diff --git a/src/adapters/node-http/core.ts b/src/adapters/node-http/core.ts index 31c7f990..36cd6963 100644 --- a/src/adapters/node-http/core.ts +++ b/src/adapters/node-http/core.ts @@ -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, @@ -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 = ( @@ -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; @@ -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?.({ diff --git a/src/adapters/node-http/monkeyPatch.ts b/src/adapters/node-http/monkeyPatch.ts index e07ec785..c744453a 100644 --- a/src/adapters/node-http/monkeyPatch.ts +++ b/src/adapters/node-http/monkeyPatch.ts @@ -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) + } }; diff --git a/src/adapters/node-http/procedures.ts b/src/adapters/node-http/procedures.ts index 1c90c356..00fd849f 100644 --- a/src/adapters/node-http/procedures.ts +++ b/src/adapters/node-http/procedures.ts @@ -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>(); +export const createProcedureCache = (router: OpenApiRouter) => { + const procedureCache = new Map>(); - 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 }; }; diff --git a/src/types.ts b/src/types.ts index b2e55c5c..47d06293 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 & { +type TRPCMeta = Record; + +export type OpenApiMeta = TMeta & { openapi?: { enabled: boolean; method: OpenApiMethod; @@ -31,7 +33,7 @@ export type OpenApiMeta> = TMeta & { ); }; -export type OpenApiProcedureRecord> = ProcedureRecord< +export type OpenApiProcedureRecord = ProcedureRecord< any, any, OpenApiMeta | undefined, @@ -41,7 +43,9 @@ export type OpenApiProcedureRecord> = ProcedureRecor any >; -export type OpenApiRouter> = Router< +export type OpenApiProcedure = OpenApiProcedureRecord[string]; + +export type OpenApiRouter = Router< TContext, TContext, OpenApiMeta, diff --git a/src/utils/procedure.ts b/src/utils/procedure.ts index baf07030..17ec531d 100644 --- a/src/utils/procedure.ts +++ b/src/utils/procedure.ts @@ -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) => { @@ -32,7 +32,7 @@ export const forEachOpenApiProcedure = ( procedureRecord: OpenApiProcedureRecord, callback: (values: { path: string; - procedure: OpenApiProcedureRecord[string]; + procedure: OpenApiProcedure; openapi: NonNullable; }) => void, ) => { diff --git a/test/adapters/standalone.test.ts b/test/adapters/standalone.test.ts index 5a68c88d..691ad708 100644 --- a/test/adapters/standalone.test.ts +++ b/test/adapters/standalone.test.ts @@ -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().query('invalidRoute', { meta: { openapi: { enabled: true, path: '/invalid-route', method: 'GET' } }, @@ -943,4 +944,28 @@ describe('standalone adapter', () => { close(); }); + + test('with DELETE method mutation', async () => { + const { url, close } = createHttpServerWithRouter({ + router: trpc.router().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(); + }); });