Skip to content
This repository was archived by the owner on Mar 10, 2024. It is now read-only.
Draft
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
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"docs"
],
"scripts": {
"preinstall": "npx only-allow yarn",
"postinstall": "husky install",
"test": "turbo run test",
"test:watch": "turbo run test:watch",
Expand All @@ -35,8 +36,8 @@
"@types/eslint": "^8",
"@types/jest": "^29.5.5",
"@types/prettier": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"eslint": "^8.34.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
Expand All @@ -51,7 +52,7 @@
"ts-jest": "^29.1.1",
"tsx": "^3.12.3",
"turbo": "^1.11.1",
"typescript": "^4.9.5"
"typescript": "^5.3.3"
},
"engines": {
"node": "18.x"
Expand Down
92 changes: 92 additions & 0 deletions packages/api/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { cryptoHash, decrypt } from '@supaglue/core/lib';
import prisma from '@supaglue/db';
import { createEnv } from '@t3-oss/env-core';
import { TRPCError, initTRPC } from '@trpc/server';
import type { OpenApiMeta } from '@usevenice/trpc-openapi';
import { z } from 'zod';
import { extendZodWithOpenApi } from 'zod-openapi';
import { createApolloProvider } from './providers/apollo';
import type { EngagementProvider } from './routers/engagement';

extendZodWithOpenApi(z);

export { z };

export const env = createEnv({
server: {
/** Reqruired for prisma */
SUPAGLUE_DATABASE_URL: z.string().url(),
/* Required for encryption & decryption */
SUPAGLUE_API_ENCRYPTION_SECRET: z.string().min(1),
},
runtimeEnv: process.env,
});

export function createContext(opts: { headers: unknown }) {
const headers = z
.object({
'x-api-key': z.string().optional(),
'x-customer-id': z.string().optional(),
'x-provider-name': z.string().optional(),
})
.parse(opts.headers);

return { headers, env, prisma };
}

/**
* Initialization of tRPC backend
* Should be done only once per backend!
*/
export const t = initTRPC.context<ReturnType<typeof createContext>>().meta<OpenApiMeta>().create();

export const authedProcedure = t.procedure.use(async ({ next, ctx }) => {
if (!ctx.headers['x-api-key']) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'x-api-key header is required' });
}
const { hashed: hashedApiKey } = await cryptoHash(ctx.headers['x-api-key']);
const applications = await prisma.application.findMany({
where: { config: { path: ['apiKey'], equals: hashedApiKey } },
});
const applicationId = applications[0]?.id;

if (!applicationId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: `Can't find application by api key` });
}

return next({ ctx: { ...ctx, applicationId } });
});

export const remoteProcedure = authedProcedure.use(async ({ next, ctx }) => {
const { 'x-customer-id': customerId, 'x-provider-name': providerName } = ctx.headers;
if (!customerId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'x-customer-id header is required' });
}
if (!providerName) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'x-provider-name header is required' });
}

const conn = await prisma.connection.findFirst({
where: { customerId: { equals: `${ctx.applicationId}:${customerId}` }, providerName: { equals: providerName } },
});
if (!conn) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Can't find connection for customer:${customerId} and provider:${providerName}`,
});
}

const decrypted = JSON.parse(await decrypt(conn.credentials));

const provider = ((): EngagementProvider => {
switch (providerName) {
case 'apollo':
return createApolloProvider(decrypted);
default:
throw new TRPCError({ code: 'NOT_IMPLEMENTED', message: `Provider ${providerName} is not implemented` });
}
})();

return next({ ctx: { ...ctx, provider, providerName, customerId } });
});
18 changes: 18 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@supaglue/api",
"packageManager": "[email protected]",
"dependencies": {
"@supaglue/core": "workspace:*",
"@supaglue/db": "workspace:*",
"@t3-oss/env-core": "^0.7.1",
"@trpc/server": "^10.44.1",
"@usevenice/trpc-openapi": "^1.3.8",
"express": ">=5.0.0-beta.1",
"trpc-panel": "^1.3.4",
"zod": "^3.22.4",
"zod-openapi": "^2.11.0"
},
"devDependencies": {
"@types/express": "^4.17.17"
}
}
21 changes: 21 additions & 0 deletions packages/api/providers/apollo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* eslint-disable no-console */
import { createApolloClient } from '@supaglue/core/remotes/impl/apollo/apollo.client';
import { z } from '../context';
import type { EngagementProvider } from '../routers/engagement';

export const createApolloProvider = z
.function()
.args(z.object({ apiKey: z.string() }))
.implement((opts) => {
const apollo = createApolloClient(opts);
return {
contacts: {
get: (id) =>
apollo.GET('/v1/contacts/{id}', { params: { path: { id } } }).then(({ data: { contact } }) => contact),
},
logCall: async (input) => {
console.log('log call input:', input);
return { id: '1', note: 'test', contact_id: '1' };
},
} satisfies EngagementProvider;
});
60 changes: 60 additions & 0 deletions packages/api/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { createOpenApiExpressMiddleware, generateOpenApiDocument } from '@usevenice/trpc-openapi';
import express from 'express';
import { renderTrpcPanel } from 'trpc-panel';
import { z } from 'zod';
import { createContext, t } from './context';
import { engagementRouter } from './routers/engagement';

export const metaRouter = t.router({
getOpenApiSpec: t.procedure
.meta({ openapi: { method: 'GET', path: '/openapi.json' } })
.input(z.void())
.output(z.unknown())
.query(() => openApiSpec),
});

export const appRouter = t.mergeRouters(t.router({ engagement: engagementRouter }), metaRouter);

const port = 3000;

export const openApiSpec = generateOpenApiDocument(appRouter, {
title: 'Supaglue OpenAPI',
openApiVersion: '3.1.0',
version: '0.0.1',
baseUrl: `http://localhost:${port}`,
});

if (require.main === module) {
// openapi server, running in the Docker container with express
const app = express();
app.get('/', (_req, res) => {
res.send('Our current API routes');
});
app.use(
createOpenApiExpressMiddleware({
router: appRouter,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createContext: ({ req }) => createContext({ headers: req.headers }),
})
);

app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Server is running on port ${port}`);
});

// trpc server, running in api routes in next.js for our internal management ui use
createHTTPServer({
router: appRouter,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createContext: ({ req }) => createContext({ headers: req.headers }),
middleware(req, res, next) {
if (req.url === '/_panel') {
res.end(renderTrpcPanel(appRouter, { url: 'http://localhost:3001' }));
return;
}
next();
},
}).listen(3001);
}
53 changes: 53 additions & 0 deletions packages/api/routers/engagement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { TRPCError } from '@trpc/server';
import { remoteProcedure, t, z } from '../context';

const schemas = {
logCallInput: z.object({
contact_id: z.string(),
}),
call: z
.object({
id: z.string(),
note: z.string(),
contact_id: z.string(),
})
.openapi({ ref: 'Call' }),
contact: z
.object({
id: z.string(),
email: z.string(),
first_name: z.string(),
last_name: z.string(),
})
.openapi({ ref: 'Contact' }),
};

export interface EngagementProvider {
contacts: {
get: (id: string) => Promise<z.infer<typeof schemas.contact>>;
};
logCall: (input: z.infer<typeof schemas.logCallInput>) => Promise<z.infer<typeof schemas.call>>;
}

export const engagementRouter = t.router({
logCall: remoteProcedure
.meta({ openapi: { method: 'POST', path: '/engagement/v2/calls' } })
.input(schemas.logCallInput)
.output(z.object({ record: schemas.call }))
.mutation(async ({ input, ctx }) => {
if (!ctx.provider?.logCall) {
throw new TRPCError({ code: 'NOT_IMPLEMENTED' });
}
return { record: await ctx.provider?.logCall(input) };
}),
getContact: remoteProcedure
.meta({ openapi: { method: 'GET', path: '/engagement/v2/contacts/{id}' } })
.input(z.object({ id: z.string() }))
.output(z.object({ record: schemas.contact }))
.mutation(async ({ input, ctx }) => {
if (!ctx.provider?.contacts.get) {
throw new TRPCError({ code: 'NOT_IMPLEMENTED' });
}
return { record: await ctx.provider?.contacts.get(input.id) };
}),
});
3 changes: 3 additions & 0 deletions packages/core/remotes/impl/apollo/apollo.openapi.gen.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion packages/core/remotes/impl/apollo/apollo.openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/core/remotes/impl/apollo/apollo.openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export type ApolloContact = z.infer<typeof apolloContact>;
export const apolloContact = z
.object({
id: z.string(),
first_name: z.string(),
last_name: z.string(),
email: z.string(),
emailer_campaign_ids: z.array(z.string()).optional(),
contact_campaign_statuses: z.array(
z
Expand Down
Loading