Skip to content

Commit

Permalink
perf/lazy-monkey-patch (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
jlalmes authored Aug 25, 2022
1 parent 1403dac commit 08742f2
Show file tree
Hide file tree
Showing 6 changed files with 83 additions and 50 deletions.
17 changes: 11 additions & 6 deletions src/adapters/node-http/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import { acceptsRequestBody } from '../../utils/method';
import { normalizePath } from '../../utils/path';
import { TRPC_ERROR_CODE_HTTP_STATUS, getErrorFromUnknown } from './errors';
import { getBody, getQuery } from './input';
import { monkeyPatchVoidInputs } from './monkeyPatch';
import { createMatchProcedureFn } from './procedures';
import { monkeyPatchProcedure } from './monkeyPatch';
import { createProcedureCache } from './procedures';

export type CreateOpenApiNodeHttpHandlerOptions<
TRouter extends OpenApiRouter,
Expand All @@ -43,11 +43,12 @@ export const createOpenApiNodeHttpHandler = <
const router = cloneDeep(opts.router);

// Validate router
generateOpenApiDocument(router, { title: '-', version: '-', baseUrl: '-' });
monkeyPatchVoidInputs(router);
if (process.env.NODE_ENV !== 'production') {
generateOpenApiDocument(router, { title: '', version: '', baseUrl: '' });
}

const { createContext, responseMeta, onError, teardown, maxBodySize } = opts;
const matchProcedure = createMatchProcedureFn(router);
const getProcedure = createProcedureCache(router);

return async (req: TRequest, res: TResponse, next?: OpenApiNextFunction) => {
const sendResponse = (
Expand All @@ -69,7 +70,7 @@ export const createOpenApiNodeHttpHandler = <
const reqUrl = req.url!;
const url = new URL(reqUrl.startsWith('/') ? `http://127.0.0.1${reqUrl}` : reqUrl);
const path = normalizePath(url.pathname);
const { procedure, pathInput } = matchProcedure(method, path) ?? {};
const { procedure, pathInput } = getProcedure(method, path) ?? {};

let input: any;
let ctx: any;
Expand Down Expand Up @@ -97,8 +98,12 @@ export const createOpenApiNodeHttpHandler = <
...(acceptsRequestBody(method) ? await getBody(req, maxBodySize) : getQuery(req, url)),
...pathInput,
};

ctx = await createContext?.({ req, res });
const caller = router.createCaller(ctx);

monkeyPatchProcedure(procedure.procedure);

data = await caller[procedure.type](procedure.path, input);

const meta = responseMeta?.({
Expand Down
25 changes: 13 additions & 12 deletions src/adapters/node-http/monkeyPatch.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { z } from 'zod';

import { OpenApiProcedureRecord, OpenApiRouter } from '../../types';
import { forEachOpenApiProcedure, getInputOutputParsers } from '../../utils/procedure';
import { OpenApiProcedure } from '../../types';
import { getInputOutputParsers } from '../../utils/procedure';
import { instanceofZodType, instanceofZodTypeLikeVoid } from '../../utils/zod';

export const monkeyPatchVoidInputs = (appRouter: OpenApiRouter) => {
const { queries, mutations } = appRouter._def;
const zObject = z.object({});
type MonkeyPatchedOpenApiProcedure = OpenApiProcedure & { __MONKEY_PATCHED__?: boolean };

const voidInputPatcher = (procedure: OpenApiProcedureRecord[string]) => {
const { inputParser } = getInputOutputParsers(procedure);
if (instanceofZodType(inputParser) && instanceofZodTypeLikeVoid(inputParser)) {
export const monkeyPatchProcedure = (procedure: MonkeyPatchedOpenApiProcedure) => {
if (procedure.__MONKEY_PATCHED__) return;
procedure.__MONKEY_PATCHED__ = true;

const { inputParser } = getInputOutputParsers(procedure);
if (instanceofZodType(inputParser)) {
if (instanceofZodTypeLikeVoid(inputParser)) {
const zObject = z.object({});
(procedure as any).parseInputFn = zObject.parseAsync.bind(zObject);
}
};

forEachOpenApiProcedure(queries, ({ procedure }) => voidInputPatcher(procedure));
forEachOpenApiProcedure(mutations, ({ procedure }) => voidInputPatcher(procedure));
// TODO: add out of box support for number/boolean/date etc. (https://github.com/jlalmes/trpc-openapi/issues/44)
}
};
50 changes: 24 additions & 26 deletions src/adapters/node-http/procedures.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,59 @@
import { OpenApiRouter } from '../../types';
import { OpenApiMethod, OpenApiProcedure, OpenApiRouter } from '../../types';
import { getPathRegExp, normalizePath } from '../../utils/path';
import { forEachOpenApiProcedure } from '../../utils/procedure';

type Procedure = { type: 'query' | 'mutation'; path: string };
type CachedProcedure = {
type: 'query' | 'mutation';
path: string;
procedure: OpenApiProcedure;
};

const getMethodPathProcedureMap = (appRouter: OpenApiRouter) => {
const map = new Map<string, Map<RegExp, Procedure>>();
export const createProcedureCache = (router: OpenApiRouter) => {
const procedureCache = new Map<OpenApiMethod, Map<RegExp, CachedProcedure>>();

const { queries, mutations } = appRouter._def;
const { queries, mutations } = router._def;

forEachOpenApiProcedure(queries, ({ path: queryPath, procedure, openapi }) => {
const { method } = openapi;
if (!map.has(method)) {
map.set(method, new Map());
if (!procedureCache.has(method)) {
procedureCache.set(method, new Map());
}
const path = normalizePath(openapi.path);
const pathRegExp = getPathRegExp(path);
map.get(method)!.set(pathRegExp, {
procedureCache.get(method)!.set(pathRegExp, {
type: 'query',
path: queryPath,
procedure,
});
});

forEachOpenApiProcedure(mutations, ({ path: mutationPath, procedure, openapi }) => {
const { method } = openapi;
if (!map.has(method)) {
map.set(method, new Map());
if (!procedureCache.has(method)) {
procedureCache.set(method, new Map());
}
const path = normalizePath(openapi.path);
const pathRegExp = getPathRegExp(path);
map.get(method)!.set(pathRegExp, {
procedureCache.get(method)!.set(pathRegExp, {
type: 'mutation',
path: mutationPath,
procedure,
});
});

return map;
};

export const createMatchProcedureFn = (router: OpenApiRouter) => {
const methodPathProcedureMap = getMethodPathProcedureMap(router);

return (method: string, path: string) => {
const pathProcedureMap = methodPathProcedureMap.get(method);
if (!pathProcedureMap) {
return (method: OpenApiMethod, path: string) => {
const procedureMethodCache = procedureCache.get(method);
if (!procedureMethodCache) {
return undefined;
}

const matchingRegExp = Array.from(pathProcedureMap.keys()).find((regExp) => {
return regExp.test(path);
});
if (!matchingRegExp) {
const procedureRegExp = Array.from(procedureMethodCache.keys()).find((re) => re.test(path));
if (!procedureRegExp) {
return undefined;
}

const procedure = pathProcedureMap.get(matchingRegExp)!;
const pathInput = matchingRegExp.exec(path)?.groups ?? {};
const procedure = procedureMethodCache.get(procedureRegExp)!;
const pathInput = procedureRegExp.exec(path)?.groups ?? {};

return { procedure, pathInput };
};
Expand Down
12 changes: 8 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { DefaultErrorShape, Router } from '@trpc/server/dist/declarations/src/ro
// eslint-disable-next-line import/no-unresolved
import { TRPC_ERROR_CODE_KEY } from '@trpc/server/dist/declarations/src/rpc';
import { OpenAPIV3 } from 'openapi-types';
import { ZodIssue, z } from 'zod';
import { ZodIssue } from 'zod';

export type OpenApiMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';

export type OpenApiMeta<TMeta = Record<string, any>> = TMeta & {
type TRPCMeta = Record<string, any>;

export type OpenApiMeta<TMeta = TRPCMeta> = TMeta & {
openapi?: {
enabled: boolean;
method: OpenApiMethod;
Expand All @@ -31,7 +33,7 @@ export type OpenApiMeta<TMeta = Record<string, any>> = TMeta & {
);
};

export type OpenApiProcedureRecord<TMeta = Record<string, any>> = ProcedureRecord<
export type OpenApiProcedureRecord<TMeta = TRPCMeta> = ProcedureRecord<
any,
any,
OpenApiMeta<TMeta> | undefined,
Expand All @@ -41,7 +43,9 @@ export type OpenApiProcedureRecord<TMeta = Record<string, any>> = ProcedureRecor
any
>;

export type OpenApiRouter<TContext = any, TMeta = Record<string, any>> = Router<
export type OpenApiProcedure<TMeta = TRPCMeta> = OpenApiProcedureRecord<TMeta>[string];

export type OpenApiRouter<TContext = any, TMeta = TRPCMeta> = Router<
TContext,
TContext,
OpenApiMeta<TMeta>,
Expand Down
4 changes: 2 additions & 2 deletions src/utils/procedure.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// eslint-disable-next-line import/no-unresolved
import { Procedure, ProcedureParser } from '@trpc/server/dist/declarations/src/internals/procedure';

import { OpenApiMeta, OpenApiProcedureRecord } from '../types';
import { OpenApiMeta, OpenApiProcedure, OpenApiProcedureRecord } from '../types';

// `inputParser` & `outputParser` are private so this is a hack to access it
export const getInputOutputParsers = (procedure: Procedure<any, any, any, any, any, any, any>) => {
Expand Down Expand Up @@ -32,7 +32,7 @@ export const forEachOpenApiProcedure = (
procedureRecord: OpenApiProcedureRecord,
callback: (values: {
path: string;
procedure: OpenApiProcedureRecord[string];
procedure: OpenApiProcedure;
openapi: NonNullable<OpenApiMeta['openapi']>;
}) => void,
) => {
Expand Down
25 changes: 25 additions & 0 deletions test/adapters/standalone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe('standalone adapter', () => {
teardownMock.mockClear();
});

// Please note: validating router does not happen in `production`.
test('with invalid router', () => {
const appRouter = trpc.router<any, OpenApiMeta>().query('invalidRoute', {
meta: { openapi: { enabled: true, path: '/invalid-route', method: 'GET' } },
Expand Down Expand Up @@ -943,4 +944,28 @@ describe('standalone adapter', () => {

close();
});

test('with DELETE method mutation', async () => {
const { url, close } = createHttpServerWithRouter({
router: trpc.router<any, OpenApiMeta>().mutation('echoDelete', {
meta: { openapi: { enabled: true, method: 'DELETE', path: '/echo-delete' } },
input: z.object({ payload: z.string() }),
output: z.object({ payload: z.string() }),
resolve: ({ input }) => input,
}),
});

const res = await fetch(`${url}/echo-delete?payload=jlalmes`, { method: 'DELETE' });
const body = (await res.json()) as OpenApiSuccessResponse;

expect(res.status).toBe(200);
expect(body).toEqual({
ok: true,
data: {
payload: 'jlalmes',
},
});

close();
});
});

0 comments on commit 08742f2

Please sign in to comment.