diff --git a/src/generator/schema.ts b/src/generator/schema.ts index 721def46..f167006e 100644 --- a/src/generator/schema.ts +++ b/src/generator/schema.ts @@ -6,6 +6,7 @@ import zodToJsonSchema from 'zod-to-json-schema'; import { instanceofZod, instanceofZodTypeLikeObject, + instanceofZodTypeLikeOptional, instanceofZodTypeLikeString, instanceofZodTypeLikeVoid, } from '../utils'; @@ -60,7 +61,9 @@ export const getParameterObjects = ( return true; }) .map((shapeKey) => { - const shapeSchema = shape[shapeKey]!; + let shapeSchema = shape[shapeKey]!; + const isRequired = !shapeSchema.isOptional(); + const isPathParameter = pathParameters.includes(shapeKey); if (!instanceofZodTypeLikeString(shapeSchema)) { throw new TRPCError({ @@ -69,13 +72,22 @@ export const getParameterObjects = ( }); } - const isPathParameter = pathParameters.includes(shapeKey); + if (instanceofZodTypeLikeOptional(shapeSchema)) { + if (isPathParameter) { + throw new TRPCError({ + message: `Path parameter: "${shapeKey}" must not be optional`, + code: 'INTERNAL_SERVER_ERROR', + }); + } + shapeSchema = shapeSchema.unwrap(); + } + const { description, ...schema } = zodSchemaToOpenApiSchemaObject(shapeSchema); return { name: shapeKey, in: isPathParameter ? 'path' : 'query', - required: isPathParameter || !shapeSchema.isOptional(), + required: isPathParameter || isRequired, schema: schema, description: description, }; diff --git a/src/utils.ts b/src/utils.ts index 796b8df6..a0596a72 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -72,3 +72,9 @@ export const instanceofZodTypeLikeString = (type: z.ZodTypeAny): boolean => { } return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodString); }; + +export const instanceofZodTypeLikeOptional = ( + type: z.ZodTypeAny, +): type is z.ZodOptional => { + return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodOptional); +}; diff --git a/test/generator.test.ts b/test/generator.test.ts index 2389aa89..ccc7986e 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -1,6 +1,5 @@ import * as trpc from '@trpc/server'; import { Subscription } from '@trpc/server'; -import e from 'express'; import openAPISchemaValidator from 'openapi-schema-validator'; import { z } from 'zod'; @@ -620,6 +619,23 @@ describe('generator', () => { }).toThrowError('[query.pathParameters] - Input parser must be a ZodObject'); }); + test('with optional path parameters', () => { + const appRouter = trpc.router().query('pathParameters', { + meta: { openapi: { enabled: true, path: '/path-parameters/{name}', method: 'GET' } }, + input: z.object({ name: z.string().optional() }), + output: z.object({ name: z.string() }), + resolve: () => ({ name: 'asdf' }), + }); + + expect(() => { + generateOpenApiDocument(appRouter, { + title: 'tRPC OpenAPI', + version: '1.0.0', + baseUrl: 'http://localhost:3000/api', + }); + }).toThrowError('[query.pathParameters] - Path parameter: "name" must not be optional'); + }); + test('with missing path parameters', () => { const appRouter = trpc.router().query('pathParameters', { meta: { openapi: { enabled: true, path: '/path-parameters/{name}', method: 'GET' } }, @@ -1577,4 +1593,61 @@ describe('generator', () => { } `); }); + + test('with optional query input parameter', () => { + const appRouter = trpc.router().query('optional', { + meta: { openapi: { enabled: true, path: '/optional', method: 'GET' } }, + input: z.object({ payload: z.string().optional() }), + output: z.string().optional(), + resolve: ({ input }) => input.payload, + }); + + 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['/optional']!.get!.parameters).toMatchInlineSnapshot(` + Array [ + Object { + "description": undefined, + "in": "query", + "name": "payload", + "required": false, + "schema": Object { + "type": "string", + }, + }, + ] + `); + expect(openApiDocument.paths['/optional']!.get!.responses[200]).toMatchInlineSnapshot(` + Object { + "content": Object { + "application/json": Object { + "schema": Object { + "additionalProperties": false, + "properties": Object { + "data": Object { + "type": "string", + }, + "ok": Object { + "enum": Array [ + true, + ], + "type": "boolean", + }, + }, + "required": Array [ + "ok", + ], + "type": "object", + }, + }, + }, + "description": "Successful response", + } + `); + }); });