From 5af397a1732b71c4ae5f3c0d7930f89ae82d5d9b Mon Sep 17 00:00:00 2001 From: Seb Insua Date: Tue, 12 Dec 2023 15:53:21 +0000 Subject: [PATCH] Allow coercion when given union of coercible values --- package-lock.json | 137 ++++++++++++++++- package.json | 3 +- src/adapters/node-http/core.ts | 10 +- src/generator/paths.ts | 6 +- src/generator/schema.ts | 2 +- src/utils/zod.ts | 9 +- test/adapters/standalone.test.ts | 2 + test/generator.test.ts | 249 ++++++++++++++++++++----------- 8 files changed, 325 insertions(+), 93 deletions(-) diff --git a/package-lock.json b/package-lock.json index 406af490..2b512f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,8 @@ "superjson": "^1.12.3", "ts-jest": "^29.1.0", "ts-node": "^10.9.1", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "zod": "^3.22.4" }, "peerDependencies": { "@trpc/server": "^10.0.0", @@ -4296,6 +4297,126 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.3.tgz", + "integrity": "sha512-Mi8xJWh2IOjryAM1mx18vwmal9eokJ2njY4nDh04scy37F0LEGJ/diL6JL6kTXi0UfUCGbMsOItf7vpReNiD2A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.3.tgz", + "integrity": "sha512-aBvtry4bxJ1xwKZ/LVPeBGBwWVwxa4bTnNkRRw6YffJnn/f4Tv4EGDPaVeYHZGQVA56wsGbtA6nZMuWs/EIk4Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.3.tgz", + "integrity": "sha512-krT+2G3kEsEUvZoYte3/2IscscDraYPc2B+fDJFipPktJmrv088Pei/RjrhWm5TMIy5URYjZUoDZdh5k940Dyw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.3.tgz", + "integrity": "sha512-AMdFX6EKJjC0G/CM6hJvkY8wUjCcbdj3Qg7uAQJ7PVejRWaVt0sDTMavbRfgMchx8h8KsAudUCtdFkG9hlEClw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.3.tgz", + "integrity": "sha512-jySgSXE48shaLtcQbiFO9ajE9mqz7pcAVLnVLvRIlUHyQYR/WyZdK8ehLs65Mz6j9cLrJM+YdmdJPyV4WDaz2g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.3.tgz", + "integrity": "sha512-5DxHo8uYcaADiE9pHrg8o28VMt/1kR8voDehmfs9AqS0qSClxAAl+CchjdboUvbCjdNWL1MISCvEfKY2InJ3JA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.3.tgz", + "integrity": "sha512-LaqkF3d+GXRA5X6zrUjQUrXm2MN/3E2arXBtn5C7avBCNYfm9G3Xc646AmmmpN3DJZVaMYliMyCIQCMDEzk80w==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.3.tgz", + "integrity": "sha512-jglUk/x7ZWeOJWlVoKyIAkHLTI+qEkOriOOV+3hr1GyiywzcqfI7TpFSiwC7kk1scOiH7NTFKp8mA3XPNO9bDw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -16389,6 +16510,14 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/next/node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/nextjs-cors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/nextjs-cors/-/nextjs-cors-2.1.2.tgz", @@ -23790,9 +23919,9 @@ } }, "node_modules/zod": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", - "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2a295ec2..6e86152d 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "superjson": "^1.12.3", "ts-jest": "^29.1.0", "ts-node": "^10.9.1", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "zod": "^3.22.4" } } diff --git a/src/adapters/node-http/core.ts b/src/adapters/node-http/core.ts index 060c8d2a..94c6b81d 100644 --- a/src/adapters/node-http/core.ts +++ b/src/adapters/node-http/core.ts @@ -20,6 +20,7 @@ import { normalizePath } from '../../utils/path'; import { getInputOutputParsers } from '../../utils/procedure'; import { instanceofZodTypeCoercible, + instanceofZodTypeKind, instanceofZodTypeLikeVoid, instanceofZodTypeObject, unwrapZodType, @@ -118,7 +119,14 @@ export const createOpenApiNodeHttpHandler = < if (instanceofZodTypeObject(unwrappedSchema)) { Object.values(unwrappedSchema.shape).forEach((shapeSchema) => { const unwrappedShapeSchema = unwrapZodType(shapeSchema, false); - if (instanceofZodTypeCoercible(unwrappedShapeSchema)) { + + if (instanceofZodTypeKind(unwrappedShapeSchema, z.ZodFirstPartyTypeKind.ZodUnion)) { + unwrappedShapeSchema.options.forEach((option) => { + if (instanceofZodTypeCoercible(option)) { + option._def.coerce = true; + } + }); + } else if (instanceofZodTypeCoercible(unwrappedShapeSchema)) { unwrappedShapeSchema._def.coerce = true; } }); diff --git a/src/generator/paths.ts b/src/generator/paths.ts index 2d705608..d8c62dfd 100644 --- a/src/generator/paths.ts +++ b/src/generator/paths.ts @@ -94,7 +94,11 @@ export const getOpenApiPathsObject = ( ) || []), ], }), - responses: getResponsesObject(outputParser, openapi.example?.response, openapi.responseHeaders), + responses: getResponsesObject( + outputParser, + openapi.example?.response, + openapi.responseHeaders, + ), ...(openapi.deprecated ? { deprecated: openapi.deprecated } : {}), }, }; diff --git a/src/generator/schema.ts b/src/generator/schema.ts index d3f41e38..beee39c1 100644 --- a/src/generator/schema.ts +++ b/src/generator/schema.ts @@ -189,7 +189,7 @@ export const errorResponseObject: OpenAPIV3.ResponseObject = { export const getResponsesObject = ( schema: unknown, example: Record | undefined, - headers: Record | undefined + headers: Record | undefined, ): OpenAPIV3.ResponsesObject => { if (!instanceofZodType(schema)) { throw new TRPCError({ diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 653a372c..32936beb 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -7,7 +7,7 @@ export const instanceofZodType = (type: any): type is z.ZodTypeAny => { export const instanceofZodTypeKind = ( type: z.ZodTypeAny, zodTypeKind: Z, -): type is InstanceType => { +): type is InstanceType<(typeof z)[Z]> => { return type?._def?.typeName === zodTypeKind; }; @@ -106,6 +106,13 @@ export type ZodTypeCoercible = z.ZodNumber | z.ZodBoolean | z.ZodBigInt | z.ZodD export const instanceofZodTypeCoercible = (_type: z.ZodTypeAny): _type is ZodTypeCoercible => { const type = unwrapZodType(_type, false); + + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUnion)) { + return type._def.options.every( + (option) => instanceofZodTypeLikeString(option) || instanceofZodTypeCoercible(option), + ); + } + return ( instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNumber) || instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBoolean) || diff --git a/test/adapters/standalone.test.ts b/test/adapters/standalone.test.ts index 379c9f08..67633cc9 100644 --- a/test/adapters/standalone.test.ts +++ b/test/adapters/standalone.test.ts @@ -1194,6 +1194,7 @@ describe('standalone adapter', () => { // @ts-expect-error - hack to disable zodSupportsCoerce // eslint-disable-next-line import/namespace zodUtils.zodSupportsCoerce = false; + { const appRouter = t.router({ plusOne: t.procedure @@ -1225,6 +1226,7 @@ describe('standalone adapter', () => { close(); } + // @ts-expect-error - hack to re-enable zodSupportsCoerce // eslint-disable-next-line import/namespace zodUtils.zodSupportsCoerce = true; diff --git a/test/generator.test.ts b/test/generator.test.ts index c2a13c77..5456fcff 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -1061,17 +1061,17 @@ describe('generator', () => { expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); expect(openApiDocument.paths['/void']!.post!.requestBody).toMatchInlineSnapshot(`undefined`); expect(openApiDocument.paths['/void']!.post!.responses[200]).toMatchInlineSnapshot(` - Object { - "content": Object { - "application/json": Object { - "example": undefined, - "schema": Object {}, - }, - }, - "description": "Successful response", - "headers": undefined, - } - `); + Object { + "content": Object { + "application/json": Object { + "example": undefined, + "schema": Object {}, + }, + }, + "description": "Successful response", + "headers": undefined, + } + `); } }); @@ -1758,7 +1758,45 @@ describe('generator', () => { expect(() => { generateOpenApiDocument(appRouter, defaultDocOpts); - }).toThrowError('[query.union] - Input parser key: "payload" must be ZodString'); + }).toThrowError( + '[query.union] - Input parser key: "payload" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate', + ); + } + { + const appRouter = t.router({ + union: t.procedure + .meta({ openapi: { method: 'GET', path: '/union' } }) + .input(z.object({ payload: z.union([z.string(), z.number()]) })) + .output(z.string()) + .query(({ input }) => { + return typeof input.payload === 'number' ? String(input.payload) : input.payload; + }), + }); + + const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts); + + expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); + expect(openApiDocument.paths['/union']!.get!.parameters).toMatchInlineSnapshot(` + Array [ + Object { + "description": undefined, + "example": undefined, + "in": "query", + "name": "payload", + "required": true, + "schema": Object { + "anyOf": Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + }, + }, + ] + `); } { const appRouter = t.router({ @@ -1800,71 +1838,114 @@ describe('generator', () => { }); test('with intersection', () => { - const appRouter = t.router({ - intersection: t.procedure - .meta({ openapi: { method: 'GET', path: '/intersection' } }) - .input( - z.object({ - payload: z.intersection( - z.union([z.literal('a'), z.literal('b')]), - z.union([z.literal('b'), z.literal('c')]), - ), - }), - ) - .output(z.null()) - .query(() => null), - }); + { + const appRouter = t.router({ + intersection: t.procedure + .meta({ openapi: { method: 'GET', path: '/intersection' } }) + .input( + z.object({ + payload: z.intersection(z.literal('a'), z.object({ b: z.literal('b') })), + }), + ) + .output(z.null()) + .query(() => null), + }); - const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts); + expect(() => { + generateOpenApiDocument(appRouter, defaultDocOpts); + }).toThrowError( + '[query.intersection] - Input parser key: "payload" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate', + ); + } + { + const appRouter = t.router({ + intersection: t.procedure + .meta({ openapi: { method: 'GET', path: '/intersection' } }) + .input( + z.object({ + payload: z.intersection( + z.object({ a: z.literal('a') }), + z.object({ b: z.literal('b') }), + ), + }), + ) + .output(z.null()) + .query(() => null), + }); - expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); - expect(openApiDocument.paths['/intersection']!.get!.parameters).toMatchInlineSnapshot(` - Array [ - Object { - "description": undefined, - "example": undefined, - "in": "query", - "name": "payload", - "required": true, - "schema": Object { - "allOf": Array [ - Object { - "anyOf": Array [ - Object { - "enum": Array [ - "a", - ], - "type": "string", - }, - Object { - "enum": Array [ - "b", - ], - "type": "string", - }, - ], - }, - Object { - "anyOf": Array [ - Object { - "enum": Array [ - "b", - ], - "type": "string", - }, - Object { - "enum": Array [ - "c", + expect(() => { + generateOpenApiDocument(appRouter, defaultDocOpts); + }).toThrowError( + '[query.intersection] - Input parser key: "payload" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate', + ); + } + { + const appRouter = t.router({ + intersection: t.procedure + .meta({ openapi: { method: 'GET', path: '/intersection' } }) + .input( + z.object({ + payload: z.intersection( + z.union([z.literal('a'), z.literal('b')]), + z.union([z.literal('b'), z.literal('c')]), + ), + }), + ) + .output(z.null()) + .query(() => null), + }); + + const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts); + + expect(openApiSchemaValidator.validate(openApiDocument).errors).toEqual([]); + expect(openApiDocument.paths['/intersection']!.get!.parameters).toMatchInlineSnapshot(` + Array [ + Object { + "description": undefined, + "example": undefined, + "in": "query", + "name": "payload", + "required": true, + "schema": Object { + "allOf": Array [ + Object { + "anyOf": Array [ + Object { + "enum": Array [ + "a", + ], + "type": "string", + }, + Object { + "enum": Array [ + "b", + ], + "type": "string", + }, + ], + }, + Object { + "anyOf": Array [ + Object { + "enum": Array [ + "b", + ], + "type": "string", + }, + Object { + "enum": Array [ + "c", + ], + "type": "string", + }, + ], + }, ], - "type": "string", }, - ], - }, - ], - }, - }, - ] - `); + }, + ] + `); + } }); test('with lazy', () => { @@ -2807,26 +2888,26 @@ describe('generator', () => { method: 'GET', path: '/query-example/{name}', responseHeaders: { - "X-RateLimit-Limit": { - description: "Request limit per hour.", + 'X-RateLimit-Limit': { + description: 'Request limit per hour.', schema: { - type: "integer" - } + type: 'integer', + }, }, - "X-RateLimit-Remaining": { - description: "The number of requests left for the time window.", + 'X-RateLimit-Remaining': { + description: 'The number of requests left for the time window.', schema: { - type: "integer" - } - } - } + type: 'integer', + }, + }, + }, }, }) .input(z.object({ name: z.string(), greeting: z.string() })) .output(z.object({ output: z.string() })) .query(({ input }) => ({ output: `${input.greeting} ${input.name}`, - })) + })), }); const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts);