Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactors tRPC middleware #9104

Merged
merged 6 commits into from
Sep 4, 2024
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
4 changes: 2 additions & 2 deletions common_knowledge/Discobot.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ All redirect URLs that the bot should support need to be inserted/
- `DISCORD_CLIENT_ID`: this is the client ID of the Discord app.
- For local test we use the staging Discord app/bot. The client ID can therefore be found on the [developer dashboard](https://discord.com/developers/applications/1027997517964644453/oauth2/general)
or by contacting Jake or Timothee.
- `DISCORD_BOT_TOKEN`: this is the same as the `DISCORD_TOKEN` in `/discord-bot/.env`
- `DISCORD_TOKEN`: this is the same as the `DISCORD_TOKEN` in `/discord-bot/.env`
- `CW_BOT_KEY`: this is the same as the `CW_BOT_KEY` in `/discord-bot/.env`

### Startup
Expand Down Expand Up @@ -119,7 +119,7 @@ the `CLOUDAMQP_URL` environment variable in the [`commonwealthapp` Heroku app](h
- The client ID can be found on the developer dashboard for the [staging bot](https://discord.com/developers/applications/1027997517964644453/oauth2/general)
or the [production bot](https://discord.com/developers/applications/1133050809412763719/oauth2/general).
The client ID can also be retrieved by contacting Jake or Timothee.
- `DISCORD_BOT_TOKEN`: this is the same as the `DISCORD_TOKEN` in the associated `Discobot app` above.
- `DISCORD_TOKEN`: this is the same as the `DISCORD_TOKEN` in the associated `Discobot app` above.
- `CW_BOT_KEY`: this is the same as the `CW_BOT_KEY` in the associated `Discobot app` above.

## Testing
Expand Down
5 changes: 2 additions & 3 deletions common_knowledge/Environment-Variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ If you add a new environment variable, you must add documentation here. Please d
- [DD_LOG_LEVEL](#dd_log_level)
- [DD_SITE](#dd_site)
- [DISABLE_CACHE](#disable_cache)
- [DISCORD_BOT_TOKEN](#discord_bot_token)
- [DISCORD_TOKEN](#discord_token)
- [DISCORD_BOT_URL](#discord_bot_url)
- [DISCORD_CLIENT_ID](#discord_client_id)
- [DISCORD_WEBHOOK_URL_DEV](#discord_webhook_url_dev)
Expand Down Expand Up @@ -183,7 +183,7 @@ DataDog configuration token in our Heroku pipeline, specifying our DataDog site

If `true`, disables Redis caching middleware.

## DISCORD_BOT_TOKEN
## DISCORD_TOKEN

This value should mirror the value of `DISCORD_TOKEN` in the Discobot .env file.

Expand Down Expand Up @@ -227,7 +227,6 @@ Optional. Defaults to 5 minutes (300 seconds).
This is number, in seconds. It configures the length of time we will use a community-maintained public endpoint if a given ChainNode fails.
After this time, the server will try the original DB endpoint again.


## FLAG_NEW_CREATE_COMMUNITY

Boolean toggle allowing the creation of new communities during local development.
Expand Down
156 changes: 49 additions & 107 deletions libs/adapters/src/trpc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,10 @@ import {
type EventSchemas,
type EventsHandlerMetadata,
type QueryMetadata,
type User,
} from '@hicommonwealth/core';
import { TRPCError, initTRPC } from '@trpc/server';
import { Request } from 'express';
import { type OpenApiMeta } from 'trpc-swagger';
import { TRPCError } from '@trpc/server';
import { ZodSchema, ZodUndefined, z } from 'zod';
import { OutputMiddleware, authenticate } from './middleware';

export interface Context {
req: Request;
}

const trpc = initTRPC.meta<OpenApiMeta>().context<Context>().create();

const isSecure = (md: { secure?: boolean; auth: unknown[] }) =>
md.secure !== false || md.auth.length > 0;
import { Tag, Track, buildproc, procedure } from './middleware';

const trpcerror = (error: unknown): TRPCError => {
if (error instanceof Error) {
Expand All @@ -50,63 +38,64 @@ const trpcerror = (error: unknown): TRPCError => {
});
};

export enum Tag {
User = 'User',
Community = 'Community',
Thread = 'Thread',
Comment = 'Comment',
Reaction = 'Reaction',
Integration = 'Integration',
Subscription = 'Subscription',
LoadTest = 'LoadTest',
Wallet = 'Wallet',
Webhook = 'Webhook',
}

/**
* Builds tRPC command POST endpoint
* @param factory command factory
* @param tag command tag used for OpenAPI spec grouping
* @param track analytics tracking metadata as tuple of [event, output mapper]
* @returns tRPC mutation procedure
*/
export const command = <Input extends ZodSchema, Output extends ZodSchema>(
factory: () => CommandMetadata<Input, Output>,
tag: Tag,
outputMiddleware?: OutputMiddleware<z.infer<Output>>,
track?: Track<Output>,
) => {
const md = factory();
return trpc.procedure
.meta({
openapi: {
method: 'POST',
path: `/${factory.name}`,
tags: [tag],
headers: [
return buildproc('POST', factory.name, md, tag, track).mutation(
async ({ ctx, input }) => {
try {
return await coreCommand(
md,
{
in: 'header',
name: 'address',
required: true,
schema: { type: 'string' },
actor: ctx.actor,
payload: input!,
},
],
protect: isSecure(md),
},
})
.input(md.input)
.output(md.output)
.mutation(async ({ ctx, input }) => {
// md.secure must explicitly be false if the route requires no authentication
// if we provide any authorization method we force authentication as well
if (isSecure(md)) await authenticate(ctx.req, md.authStrategy);
false,
);
} catch (error) {
throw trpcerror(error);
}
},
);
};

/**
* Builds tRPC query GET endpoint
* @param factory query factory
* @param tag query tag used for OpenAPI spec grouping
* @returns tRPC query procedure
*/
export const query = <Input extends ZodSchema, Output extends ZodSchema>(
factory: () => QueryMetadata<Input, Output>,
tag: Tag,
) => {
const md = factory();
return buildproc('GET', factory.name, md, tag).query(
async ({ ctx, input }) => {
try {
const _ctx = {
actor: {
user: ctx.req.user as User,
address: ctx.req.headers['address'] as string,
return await coreQuery(
md,
{
actor: ctx.actor,
payload: input!,
},
payload: input!,
};
const result = await coreCommand(md, _ctx, false);
outputMiddleware && (await outputMiddleware(_ctx, result!));
return result;
false,
);
} catch (error) {
throw trpcerror(error);
}
});
},
);
};

// TODO: add security options (API key, IP range, internal, etc)
Expand All @@ -118,7 +107,7 @@ export const event = <
tag: Tag.Integration,
) => {
const md = factory();
return trpc.procedure
return procedure
.meta({
openapi: {
method: 'POST',
Expand All @@ -137,50 +126,3 @@ export const event = <
}
});
};

export const query = <Input extends ZodSchema, Output extends ZodSchema>(
factory: () => QueryMetadata<Input, Output>,
tag: Tag,
) => {
const md = factory();
return trpc.procedure
.meta({
openapi: {
method: 'GET',
path: `/${factory.name}`,
tags: [tag],
headers: [
{
in: 'header',
name: 'address',
required: false,
schema: { type: 'string' },
},
],
},
protect: isSecure(md),
})
.input(md.input)
.output(md.output)
.query(async ({ ctx, input }) => {
// enable secure by default
if (isSecure(md)) await authenticate(ctx.req, md.authStrategy);
try {
return await coreQuery(
md,
{
actor: {
user: ctx.req.user as User,
address: ctx.req.headers['address'] as string,
},
payload: input!,
},
false,
);
} catch (error) {
throw trpcerror(error);
}
});
};

export const router = trpc.router;
138 changes: 113 additions & 25 deletions libs/adapters/src/trpc/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,130 @@
import {
analytics,
logger,
stats,
type Actor,
type AuthStrategies,
type CommandContext,
type User,
} from '@hicommonwealth/core';
import { TRPCError } from '@trpc/server';
import { Request } from 'express';
import { TRPCError, initTRPC } from '@trpc/server';
import { Request, Response } from 'express';
import passport from 'passport';
import { ZodSchema } from 'zod';
import { OpenApiMeta } from 'trpc-swagger';
import { ZodSchema, z } from 'zod';
import { config } from '../config';

const log = logger(import.meta);

export type OutputMiddleware<Output> = (
ctx: CommandContext<ZodSchema>,
result: Partial<Output>,
) => Promise<void>;
type Metadata<Input extends ZodSchema, Output extends ZodSchema> = {
readonly input: Input;
readonly output: Output;
auth: unknown[];
secure?: boolean;
authStrategy?: AuthStrategies;
};

export function track<Output>(
event: string,
mapper?: (result: Partial<Output>) => Record<string, unknown>,
): OutputMiddleware<Output> {
return ({ actor, payload }, result) => {
try {
analytics().track(event, {
userId: actor.user.id,
aggregateId: payload.id,
...(mapper ? mapper(result) : {}),
});
} catch (err) {
err instanceof Error && log.error(err.message, err);
}
return Promise.resolve();
};
const isSecure = (md: Metadata<ZodSchema, ZodSchema>) =>
md.secure !== false || (md.auth ?? []).length > 0;

export interface Context {
req: Request;
res: Response;
actor: Actor;
}

const trpc = initTRPC.meta<OpenApiMeta>().context<Context>().create();
export const router = trpc.router;
export const procedure = trpc.procedure;

export enum Tag {
User = 'User',
Community = 'Community',
Thread = 'Thread',
Comment = 'Comment',
Reaction = 'Reaction',
Integration = 'Integration',
Subscription = 'Subscription',
LoadTest = 'LoadTest',
Wallet = 'Wallet',
Webhook = 'Webhook',
}

export const authenticate = async (
export type Track<Output extends ZodSchema> = [
string,
mapper?: (result: z.infer<Output>) => Record<string, unknown>,
];

/**
* tRPC procedure factory with authentication, traffic stats, and analytics middleware
*/
export const buildproc = <Input extends ZodSchema, Output extends ZodSchema>(
method: 'GET' | 'POST',
name: string,
md: Metadata<Input, Output>,
tag: Tag,
track?: Track<Output>,
) => {
const secure = isSecure(md);
return trpc.procedure
.use(async ({ ctx, next }) => {
if (secure) await authenticate(ctx.req, md.authStrategy);
return next({
ctx: {
...ctx,
actor: {
user: ctx.req.user as User,
address: ctx.req.headers['address'] as string,
},
},
});
})
.use(async ({ ctx, next }) => {
const start = Date.now();
const result = await next();
const latency = Date.now() - start;
try {
const path = `${ctx.req.method.toUpperCase()} ${ctx.req.path}`;
stats().increment('cw.path.called', { path });
stats().histogram(`cw.path.latency`, latency, {
path,
statusCode: ctx.res.statusCode.toString(),
});
} catch (err) {
err instanceof Error && log.error(err.message, err);
}
if (track && result.ok) {
try {
analytics().track(track[0], {
userId: ctx.actor.user.id,
...(track[1] ? track[1](result.data) : {}),
});
} catch (err) {
err instanceof Error && log.error(err.message, err);
}
}
return result;
})
.meta({
openapi: {
method,
path: `/${name}`,
tags: [tag],
headers: [
{
in: 'header',
name: 'address',
required: false,
schema: { type: 'string' },
},
],
protect: secure,
},
})
.input(md.input)
.output(md.output);
};

const authenticate = async (
req: Request,
authStrategy: AuthStrategies = { name: 'jwt' },
) => {
Expand Down
Loading
Loading