Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 122 additions & 117 deletions core/lib/contracts/resources/integrations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from "zod";
import { initContract } from "@ts-rest/core";
import { z } from "zod";

const contract = initContract();

Expand Down Expand Up @@ -28,127 +28,132 @@ export type PubFieldsResponse = z.infer<typeof PubFieldsSchema>;
export type PubPostBody = z.infer<typeof PubPostSchema>;
export type SuggestedMember = z.infer<typeof SuggestedMembersSchema>;

export const integrationsApi = contract.router({
auth: {
headers: z.object({
authorization: z.string(),
}),
method: "GET",
path: "/integrations/:instanceId/auth",
summary: "Authenticate a user and receive basic information about them",
description:
"Integrations can use this endpoint to exchange a PubPub community member's auth token for information about them.",
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: UserSchema,
export const integrationsApi = contract.router(
{
auth: {
method: "GET",
path: "/:instanceId/auth",
summary: "Authenticate a user and receive basic information about them",
description:
"Integrations can use this endpoint to exchange a PubPub community member's auth token for information about them.",
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: UserSchema,
},
},
},
createPub: {
method: "POST",
path: "/integrations/:instanceId/pubs",
summary: "Creates a new pub",
description: "A way to create a new pub",
body: PubPostSchema,
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: PubFieldsSchema,
404: z.object({ message: z.string() }),
createPub: {
method: "POST",
path: "/:instanceId/pubs",
summary: "Creates a new pub",
description: "A way to create a new pub",
body: PubPostSchema,
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: PubFieldsSchema,
404: z.object({ message: z.string() }),
},
},
},
getPub: {
method: "GET",
path: "/integrations/:instanceId/pubs/:pubId",
summary: "Gets a pub",
description: "A way to get pubs fields for an integration instance",
pathParams: z.object({
pubId: z.string(),
instanceId: z.string(),
}),
responses: {
200: z.array(PubFieldsSchema),
getPub: {
method: "GET",
path: "/:instanceId/pubs/:pubId",
summary: "Gets a pub",
description: "A way to get pubs fields for an integration instance",
pathParams: z.object({
pubId: z.string(),
instanceId: z.string(),
}),
responses: {
200: z.array(PubFieldsSchema),
},
},
},
getAllPubs: {
method: "GET",
path: "/integrations/:instanceId/pubs",
summary: "Gets all pubs for this instance",
description: "A way to get all pubs for an integration instance",
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: z.array(PubFieldsSchema),
getAllPubs: {
method: "GET",
path: "/:instanceId/pubs",
summary: "Gets all pubs for this instance",
description: "A way to get all pubs for an integration instance",
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: z.array(PubFieldsSchema),
},
},
},
updatePub: {
method: "PATCH",
path: "/integrations/:instanceId/pubs/:pubId",
summary: "Adds field(s) to a pub",
description: "A way to update a field for an existing pub",
body: PubFieldsSchema,
pathParams: z.object({
pubId: z.string(),
instanceId: z.string(),
}),
responses: {
200: PubFieldsSchema,
updatePub: {
method: "PATCH",
path: "/:instanceId/pubs/:pubId",
summary: "Adds field(s) to a pub",
description: "A way to update a field for an existing pub",
body: PubFieldsSchema,
pathParams: z.object({
pubId: z.string(),
instanceId: z.string(),
}),
responses: {
200: PubFieldsSchema,
},
},
},
getSuggestedMembers: {
method: "GET",
path: "/integrations/:instanceId/autosuggest/members/:memberCandidateString",
summary: "autosuggest member",
description:
"A way to autosuggest members so that integrations users can find users or verify they exist. Will return a name for ",
pathParams: z.object({
memberCandidateString: z.string(),
instanceId: z.string(),
}),
responses: {
200: z.array(SuggestedMembersSchema),
getSuggestedMembers: {
method: "GET",
path: "/:instanceId/autosuggest/members/:memberCandidateString",
summary: "autosuggest member",
description:
"A way to autosuggest members so that integrations users can find users or verify they exist. Will return a name for ",
pathParams: z.object({
memberCandidateString: z.string(),
instanceId: z.string(),
}),
responses: {
200: z.array(SuggestedMembersSchema),
},
},
},
sendEmail: {
method: "POST",
path: "/integrations/:instanceId/email",
summary: "Send an email from PubPub to a new or existing PubPub user",
description:
"Recipient can be an existing pubpub user identified by ID, or a new user who must be identified by email and name.",
body: z.object({
to: z.union([
z.object({
userId: z.string(),
}),
z.object({
email: z.string(),
name: z.string(),
}),
]),
subject: z.string(),
message: z.string(),
}),
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: z.undefined(),
sendEmail: {
method: "POST",
path: "/:instanceId/email",
summary: "Send an email from PubPub to a new or existing PubPub user",
description:
"Recipient can be an existing pubpub user identified by ID, or a new user who must be identified by email and name.",
body: z.object({
to: z.union([
z.object({
userId: z.string(),
}),
z.object({
email: z.string(),
name: z.string(),
}),
]),
subject: z.string(),
message: z.string(),
}),
pathParams: z.object({
instanceId: z.string(),
}),
responses: {
200: z.undefined(),
},
},
// TODO implement these endpoints
// getAllMembers: {
// method: "GET",
// path: "integrations/:instanceId/members",
// summary: "Gets all members for this instance",
// description: "A way to get all members for an integration instance",
// pathParams: z.object({
// instanceId: z.string(),
// }),
// responses: {
// 200: z.array(SuggestedMembersSchema),
// },
// },
},
// TODO implement these endpoints
// getAllMembers: {
// method: "GET",
// path: "integrations/:instanceId/members",
// summary: "Gets all members for this instance",
// description: "A way to get all members for an integration instance",
// pathParams: z.object({
// instanceId: z.string(),
// }),
// responses: {
// 200: z.array(SuggestedMembersSchema),
// },
// },
});
{
pathPrefix: "/integrations",
baseHeaders: z.object({
authorization: z.string(),
}),
}
);
52 changes: 45 additions & 7 deletions core/pages/api/v0/[...ts-rest].ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { createNextRoute, createNextRouter } from "@ts-rest/next";
import { type NextApiRequest, type NextApiResponse } from "next/types";
import crypto from "node:crypto";
import { api } from "~/lib/contracts";
import { getPub, getMembers, updatePub, createPub, HTTPStatusError } from "~/lib/server";
import {
BadRequestError,
HTTPStatusError,
UnauthorizedError,
createPub,
getMembers,
getPub,
updatePub,
} from "~/lib/server";
import { emailUser } from "~/lib/server/email";
import { validateToken } from "~/lib/server/token";

Expand All @@ -15,42 +24,71 @@ const handleErrors = (error: unknown, req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ message: "Internal Server Error" });
};

const getBearerToken = (authHeader: string) => {
const parts = authHeader.split("Bearer ");
if (parts.length !== 2) {
throw new BadRequestError("Unable to parse authorization header");
}
return parts[1];
};

const checkApiKey = (apiKey: string) => {
const serverKey = process.env.API_KEY;
if (!serverKey) {
return;
}
if (
serverKey &&
serverKey.length === apiKey.length &&
crypto.timingSafeEqual(Buffer.from(serverKey), Buffer.from(apiKey))
) {
return;
}

throw new UnauthorizedError("Invalid API key");
};

// TODO: verify pub belongs to integrationInstance probably in some middleware
// TODO: verify token in header
const integrationsRouter = createNextRoute(api.integrations, {
createPub: async ({ params, body }) => {
createPub: async ({ headers, params, body }) => {
checkApiKey(getBearerToken(headers.authorization));
const pub = await createPub(params.instanceId, body);
return { status: 200, body: pub };
},
getPub: async ({ params }) => {
getPub: async ({ headers, params }) => {
checkApiKey(getBearerToken(headers.authorization));
const pubFieldValuePairs = await getPub(params.pubId);
return {
status: 200,
body: pubFieldValuePairs,
};
},
getAllPubs: async ({ params }) => {
getAllPubs: async ({ headers }) => {
checkApiKey(getBearerToken(headers.authorization));
return {
status: 200,
body: [{ message: "This is not implemented" }],
};
},
updatePub: async ({ params, body }) => {
updatePub: async ({ headers, params, body }) => {
checkApiKey(getBearerToken(headers.authorization));
const updatedPub = await updatePub(params.pubId, body);
return {
status: 200,
body: updatedPub,
};
},
getSuggestedMembers: async ({ params }) => {
getSuggestedMembers: async ({ headers, params }) => {
checkApiKey(getBearerToken(headers.authorization));
const member = await getMembers(params.memberCandidateString);
return {
status: 200,
body: member,
};
},
auth: async ({ headers }) => {
const token = headers.authorization.split("Bearer ")[1];
const token = getBearerToken(headers.authorization);
const user = await validateToken(token);
return {
status: 200,
Expand Down
3 changes: 3 additions & 0 deletions integrations/evaluations/.env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
API_TOKEN=
PUBPUB_URL="http://localhost:3000"
REDIS_CONNECTION_STRING="redis://localhost:6379"
3 changes: 3 additions & 0 deletions integrations/submissions/.env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
API_TOKEN=
PUBPUB_URL="http://localhost:3000"
REDIS_CONNECTION_STRING="redis://localhost:6379"
4 changes: 2 additions & 2 deletions integrations/submissions/app/actions/submit/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { assert, expect } from "utils";
import { client } from "~/lib/pubpub";
import { findInstance } from "~/lib/instance";

export async function submit(form: FormData, token: string) {
export async function submit(form: FormData) {
try {
const { "instance-id": instanceId, ...pub } = Object.fromEntries(form);
assert(typeof instanceId === "string");
const instance = expect(await findInstance(instanceId));
return client.create(instanceId, token, pub as Pub<typeof manifest>, instance.pubTypeId);
return client.create(instanceId, pub as Pub<typeof manifest>, instance.pubTypeId);
} catch (error) {
return { error: error.message };
}
Expand Down
2 changes: 1 addition & 1 deletion integrations/submissions/app/actions/submit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default async function Page(props: Props) {
<main>
<p>Hello {user.name}</p>
<img src={`${process.env.PUBPUB_URL}/${user.avatar}`} />
<Submit instanceId={instanceId} token={token} />
<Submit instanceId={instanceId} />
</main>
);
}
Loading