diff --git a/src/generator/paths.ts b/src/generator/paths.ts index 52dce093..25e37cc7 100644 --- a/src/generator/paths.ts +++ b/src/generator/paths.ts @@ -14,7 +14,7 @@ export const getOpenApiPathsObject = ( forEachOpenApiProcedure(queries, ({ path: queryPath, procedure, openapi }) => { try { - const { method, protect, summary, description, tags, tag } = openapi; + const { method, protect, summary, description, tags, tag, headers } = openapi; if (method !== 'GET' && method !== 'DELETE') { throw new TRPCError({ message: 'Query method must be GET or DELETE', @@ -24,6 +24,7 @@ export const getOpenApiPathsObject = ( const path = normalizePath(openapi.path); const pathParameters = getPathParameters(path); + const headerParameters = headers?.map((header) => ({ ...header, in: 'header' })) || []; const httpMethod = OpenAPIV3.HttpMethods[method]; if (pathsObject[path]?.[httpMethod]) { throw new TRPCError({ @@ -42,7 +43,10 @@ export const getOpenApiPathsObject = ( description, tags: tags ?? (tag ? [tag] : undefined), security: protect ? [{ Authorization: [] }] : undefined, - parameters: getParameterObjects(inputParser, pathParameters, 'all'), + parameters: [ + ...headerParameters, + ...(getParameterObjects(inputParser, pathParameters, 'all') || []), + ], responses: getResponsesObject(outputParser), }, }; @@ -55,7 +59,7 @@ export const getOpenApiPathsObject = ( forEachOpenApiProcedure(mutations, ({ path: mutationPath, procedure, openapi }) => { try { - const { method, protect, summary, description, tags, tag } = openapi; + const { method, protect, summary, description, tags, tag, headers } = openapi; if (method !== 'POST' && method !== 'PATCH' && method !== 'PUT') { throw new TRPCError({ message: 'Mutation method must be POST, PATCH or PUT', @@ -65,6 +69,7 @@ export const getOpenApiPathsObject = ( const path = normalizePath(openapi.path); const pathParameters = getPathParameters(path); + const headerParameters = headers?.map((header) => ({ ...header, in: 'header' })) || []; const httpMethod = OpenAPIV3.HttpMethods[method]; if (pathsObject[path]?.[httpMethod]) { throw new TRPCError({ @@ -84,7 +89,10 @@ export const getOpenApiPathsObject = ( tags: tags ?? (tag ? [tag] : undefined), security: protect ? [{ Authorization: [] }] : undefined, requestBody: getRequestBodyObject(inputParser, pathParameters), - parameters: getParameterObjects(inputParser, pathParameters, 'path'), + parameters: [ + ...headerParameters, + ...(getParameterObjects(inputParser, pathParameters, 'path') || []), + ], responses: getResponsesObject(outputParser), }, }; diff --git a/src/types.ts b/src/types.ts index 94852c97..b2e55c5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import { ProcedureRecord } from '@trpc/server'; import { DefaultErrorShape, Router } from '@trpc/server/dist/declarations/src/router'; // 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'; export type OpenApiMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; @@ -15,6 +16,7 @@ export type OpenApiMeta> = TMeta & { summary?: string; description?: string; protect?: boolean; + headers?: (OpenAPIV3.ParameterBaseObject & { name: string; in?: 'header' })[]; } & ( | { tags?: never; diff --git a/test/generator.test.ts b/test/generator.test.ts index d90051a1..7b99bd04 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -548,7 +548,7 @@ describe('generator', () => { "get": Object { "description": undefined, "operationId": "readUsers", - "parameters": undefined, + "parameters": Array [], "responses": Object { "200": Object { "content": Object { @@ -888,7 +888,7 @@ describe('generator', () => { expect(Object.keys(openApiDocument.paths).length).toBe(0); }); - test('with summary, description & single tag', () => { + test('with summary, description & multiple tags', () => { const appRouter = trpc.router().query('all.metadata', { meta: { openapi: { @@ -897,7 +897,7 @@ describe('generator', () => { method: 'GET', summary: 'Short summary', description: 'Verbose description', - tag: 'tag', + tags: ['tagA', 'tagB'], }, }, input: z.object({ name: z.string() }), @@ -914,19 +914,18 @@ describe('generator', () => { expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); expect(openApiDocument.paths['/metadata/all']!.get!.summary).toBe('Short summary'); expect(openApiDocument.paths['/metadata/all']!.get!.description).toBe('Verbose description'); - expect(openApiDocument.paths['/metadata/all']!.get!.tags).toEqual(['tag']); + expect(openApiDocument.paths['/metadata/all']!.get!.tags).toEqual(['tagA', 'tagB']); }); - test('with summary, description & multiple tags', () => { + // @deprecated + test('with single tag', () => { const appRouter = trpc.router().query('all.metadata', { meta: { openapi: { enabled: true, path: '/metadata/all', method: 'GET', - summary: 'Short summary', - description: 'Verbose description', - tags: ['tagA', 'tagB'], + tag: 'tag', }, }, input: z.object({ name: z.string() }), @@ -941,9 +940,7 @@ describe('generator', () => { }); expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); - expect(openApiDocument.paths['/metadata/all']!.get!.summary).toBe('Short summary'); - expect(openApiDocument.paths['/metadata/all']!.get!.description).toBe('Verbose description'); - expect(openApiDocument.paths['/metadata/all']!.get!.tags).toEqual(['tagA', 'tagB']); + expect(openApiDocument.paths['/metadata/all']!.get!.tags).toEqual(['tag']); }); test('with security', () => { @@ -1185,7 +1182,7 @@ describe('generator', () => { }); expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); - expect(openApiDocument.paths['/void']!.get!.parameters).toMatchInlineSnapshot(`undefined`); + expect(openApiDocument.paths['/void']!.get!.parameters).toEqual([]); expect(openApiDocument.paths['/void']!.get!.responses[200]).toMatchInlineSnapshot(` Object { "content": Object { @@ -2125,4 +2122,53 @@ describe('generator', () => { } `); }); + + test('with custom header', () => { + const appRouter = trpc.router().query('echo', { + meta: { + openapi: { + enabled: true, + path: '/echo', + method: 'GET', + headers: [ + { + name: 'x-custom-header', + required: true, + description: 'Some custom header', + }, + ], + }, + }, + input: z.object({ id: z.string() }), + output: z.object({ id: z.string() }), + resolve: ({ input }) => ({ id: input.id }), + }); + + const openApiDocument = generateOpenApiDocument(appRouter, { + title: 'tRPC OpenAPI', + version: '1.0.0', + baseUrl: 'http://localhost:3000/api', + }); + + expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); + expect(openApiDocument.paths['/echo']!.get!.parameters).toMatchInlineSnapshot(` + Array [ + Object { + "description": "Some custom header", + "in": "header", + "name": "x-custom-header", + "required": true, + }, + Object { + "description": undefined, + "in": "query", + "name": "id", + "required": true, + "schema": Object { + "type": "string", + }, + }, + ] + `); + }); });