From a3a41067a4a63fe8b9deda915ff593975e5ab853 Mon Sep 17 00:00:00 2001 From: Mario Campa Date: Sun, 1 Dec 2024 19:17:00 -0800 Subject: [PATCH] v2.1.0 Upgrade zod-openapi --- README.md | 17 +- jest.config.ts | 1 + package-lock.json | 37 +++- package.json | 9 +- src/generator/paths.ts | 6 +- src/generator/schema.ts | 16 +- test/generator.test.ts | 372 ++++++++++++++++++++++++++++++++++++++-- 7 files changed, 417 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 1ba8a4da..756b7ff5 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,19 @@ Note: This project is a fork of a fork, with full credit to the original authors ## Changelog -- 2.0.4 Upgrade to tRPC 11.0.0-rc.648 -- 2.0.3 Added support for array inputs for GET requests +- v2.1.0 + + - Updated the minimum version of `zod-openapi` to 4.1.0. + - Changed `zod-openapi` to a peer dependency. + - The `protect` option now defaults to `true`. + - Improved Error schema titles + +- v2.0.4 + + - Upgraded to tRPC 11.0.0-rc.648. + +- v2.0.3 + - Added support for array inputs in GET requests. ## Usage @@ -363,7 +374,7 @@ Please see [full typings here](src/types.ts). | `enabled` | `boolean` | Exposes this procedure to `trpc-to-openapi` adapters and on the OpenAPI document. | `false` | `true` | | `method` | `HttpMethod` | HTTP method this endpoint is exposed on. Value can be `GET`, `POST`, `PATCH`, `PUT` or `DELETE`. | `true` | `undefined` | | `path` | `string` | Pathname this endpoint is exposed on. Value must start with `/`, specify path parameters using `{}`. | `true` | `undefined` | -| `protect` | `boolean` | Requires this endpoint to use a security scheme. | `false` | `false` | +| `protect` | `boolean` | Requires this endpoint to use a security scheme. | `false` | `true` | | `summary` | `string` | A short summary of the endpoint included in the OpenAPI document. | `false` | `undefined` | | `description` | `string` | A verbose description of the endpoint included in the OpenAPI document. | `false` | `undefined` | | `tags` | `string[]` | A list of tags used for logical grouping of endpoints in the OpenAPI document. | `false` | `undefined` | diff --git a/jest.config.ts b/jest.config.ts index 69eed55c..d6005936 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -9,4 +9,5 @@ module.exports = { escapeString: true, printBasicPrototype: true, }, + prettierPath: require.resolve('formatter-for-jest-snapshots'), }; diff --git a/package-lock.json b/package-lock.json index 8b12f5cf..5dc6236b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trpc-to-openapi", - "version": "2.0.4", + "version": "2.1.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trpc-to-openapi", - "version": "2.0.4", + "version": "2.1.0-alpha.0", "license": "MIT", "workspaces": [ ".", @@ -22,8 +22,7 @@ "co-body": "^6.1.0", "h3": "^1.6.4", "lodash.clonedeep": "^4.5.0", - "openapi3-ts": "4.3.3", - "zod-openapi": "^2.19.0" + "openapi3-ts": "4.3.3" }, "devDependencies": { "@trpc/client": "^11.0.0-rc.648", @@ -39,6 +38,7 @@ "eslint": "^8.57.0", "express": "^4.18.2", "fastify": "^5.1.0", + "formatter-for-jest-snapshots": "npm:prettier@^2", "jest": "^29.5.0", "next": "^14.2.10", "node-fetch": "^2.6.11", @@ -56,7 +56,8 @@ }, "peerDependencies": { "@trpc/server": "^11.0.0-rc.648", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-openapi": "^4.1.0" } }, "examples/with-express": { @@ -8042,6 +8043,23 @@ "node": ">=0.4.x" } }, + "node_modules/formatter-for-jest-snapshots": { + "name": "prettier", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -16208,12 +16226,13 @@ } }, "node_modules/zod-openapi": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/zod-openapi/-/zod-openapi-2.19.0.tgz", - "integrity": "sha512-OUAAyBDPPwZ9u61i4k/LieXUzP2re8kFjqdNh2AvHjsyi/aRNz9leDAtMGcSoSzUT5xUeQoACJufBI6FzzZyxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/zod-openapi/-/zod-openapi-4.1.0.tgz", + "integrity": "sha512-bRCwRYhEO9CmFLyKgJX8h6j1dRtRiwOe+TLzMVPyV0pRW5vRIgb1rLgIGcuRZ5z3MmSVrZqbv3yva4IJrtZK4g==", "license": "MIT", + "peer": true, "engines": { - "node": ">=16.11" + "node": ">=18" }, "peerDependencies": { "zod": "^3.21.4" diff --git a/package.json b/package.json index aa9f1027..78c3bbd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trpc-to-openapi", - "version": "2.0.4", + "version": "2.1.0", "description": "tRPC OpenAPI", "author": "mcampa", "private": false, @@ -50,14 +50,14 @@ }, "peerDependencies": { "@trpc/server": "^11.0.0-rc.648", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-openapi": "^4.1.0" }, "dependencies": { "co-body": "^6.1.0", "h3": "^1.6.4", "lodash.clonedeep": "^4.5.0", - "openapi3-ts": "4.3.3", - "zod-openapi": "^2.19.0" + "openapi3-ts": "4.3.3" }, "devDependencies": { "@trpc/client": "^11.0.0-rc.648", @@ -79,6 +79,7 @@ "node-fetch": "^2.6.11", "openapi-schema-validator": "^12.1.1", "prettier": "^3.4.1", + "formatter-for-jest-snapshots": "npm:prettier@^2", "rimraf": "^6.0.1", "superjson": "^1.12.3", "ts-jest": "^29.1.0", diff --git a/src/generator/paths.ts b/src/generator/paths.ts index 59aed453..4abaf9ad 100644 --- a/src/generator/paths.ts +++ b/src/generator/paths.ts @@ -50,7 +50,6 @@ export const getOpenApiPathsObject = ( const { method, - protect, summary, description, tags, @@ -58,6 +57,7 @@ export const getOpenApiPathsObject = ( responseHeaders, successDescription, errorResponses, + protect = true, } = openapi; const path = normalizePath(openapi.path); @@ -101,7 +101,7 @@ export const getOpenApiPathsObject = ( }); } const isInputRequired = !inputParser.isOptional(); - const o = inputParser?._def?.openapi; + const o = inputParser?._def.zodOpenApi?.openapi; const inputSchema = unwrapZodType(inputParser, true).openapi({ ...(o?.title ? { title: o?.title } : {}), ...(o?.description ? { description: o?.description } : {}), @@ -150,7 +150,7 @@ export const getOpenApiPathsObject = ( outputParser, httpMethod, responseHeaders, - protect ?? false, + protect, hasInputs(inputParser), successDescription, errorResponses, diff --git a/src/generator/schema.ts b/src/generator/schema.ts index 9b0cebfa..dc52933b 100644 --- a/src/generator/schema.ts +++ b/src/generator/schema.ts @@ -9,7 +9,11 @@ import { extendZodWithOpenApi, } from 'zod-openapi'; -import { HTTP_STATUS_TRPC_ERROR_CODE, TRPC_ERROR_CODE_MESSAGE } from '../adapters/node-http/errors'; +import { + HTTP_STATUS_TRPC_ERROR_CODE, + TRPC_ERROR_CODE_HTTP_STATUS, + TRPC_ERROR_CODE_MESSAGE, +} from '../adapters/node-http/errors'; import { OpenApiContentType } from '../types'; import { instanceofZodType, @@ -114,7 +118,7 @@ export const getRequestBodyObject = ( pathParameters.forEach((pathParameter) => { mask[pathParameter] = true; }); - const o = schema._def.openapi; + const o = schema._def.zodOpenApi?.openapi; const dedupedSchema = schema.omit(mask).openapi({ ...(o?.title ? { title: o?.title } : {}), ...(o?.description ? { description: o?.description } : {}), @@ -143,7 +147,7 @@ export const hasInputs = (schema: unknown) => const errorResponseObjectByCode: Record = {}; export const errorResponseObject = ( - code = 'INTERNAL_SERVER_ERROR', + code: TRPCError['code'] = 'INTERNAL_SERVER_ERROR', message?: string, issues?: { message: string }[], ): ZodOpenApiResponseObject => { @@ -171,7 +175,7 @@ export const errorResponseObject = ( }), }) .openapi({ - title: 'Error', + title: `${message ?? 'Internal server'} error (${TRPC_ERROR_CODE_HTTP_STATUS[code] ?? 500})`, description: 'The error information', example: { code: code ?? 'INTERNAL_SERVER_ERROR', @@ -190,11 +194,11 @@ export const errorResponseObject = ( export const errorResponseFromStatusCode = (status: number) => { const code = HTTP_STATUS_TRPC_ERROR_CODE[status]; const message = code && TRPC_ERROR_CODE_MESSAGE[code]; - return errorResponseObject(code ?? 'UNKNOWN_ERROR', message ?? 'Unknown error'); + return errorResponseObject(code, message ?? 'Unknown error'); }; export const errorResponseFromMessage = (status: number, message: string) => - errorResponseObject(HTTP_STATUS_TRPC_ERROR_CODE[status] ?? 'UNKNOWN_ERROR', message); + errorResponseObject(HTTP_STATUS_TRPC_ERROR_CODE[status], message); export const getResponsesObject = ( schema: ZodTypeAny, diff --git a/test/generator.test.ts b/test/generator.test.ts index f4a74697..c1ce14f2 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -450,7 +450,49 @@ describe('generator', () => { "message", "code", ], - "title": "Error", + "title": "Invalid input data error (400)", + "type": "object", + }, + "error.FORBIDDEN": Object { + "description": "The error information", + "example": Object { + "code": "FORBIDDEN", + "issues": Array [], + "message": "Insufficient access", + }, + "properties": Object { + "code": Object { + "description": "The error code", + "example": "FORBIDDEN", + "type": "string", + }, + "issues": Object { + "description": "An array of issues that were responsible for the error", + "example": Array [], + "items": Object { + "properties": Object { + "message": Object { + "type": "string", + }, + }, + "required": Array [ + "message", + ], + "type": "object", + }, + "type": "array", + }, + "message": Object { + "description": "The error message", + "example": "Insufficient access", + "type": "string", + }, + }, + "required": Array [ + "message", + "code", + ], + "title": "Insufficient access error (403)", "type": "object", }, "error.INTERNAL_SERVER_ERROR": Object { @@ -492,7 +534,7 @@ describe('generator', () => { "message", "code", ], - "title": "Error", + "title": "Internal server error error (500)", "type": "object", }, "error.NOT_FOUND": Object { @@ -534,7 +576,49 @@ describe('generator', () => { "message", "code", ], - "title": "Error", + "title": "Not found error (404)", + "type": "object", + }, + "error.UNAUTHORIZED": Object { + "description": "The error information", + "example": Object { + "code": "UNAUTHORIZED", + "issues": Array [], + "message": "Authorization not provided", + }, + "properties": Object { + "code": Object { + "description": "The error code", + "example": "UNAUTHORIZED", + "type": "string", + }, + "issues": Object { + "description": "An array of issues that were responsible for the error", + "example": Array [], + "items": Object { + "properties": Object { + "message": Object { + "type": "string", + }, + }, + "required": Array [ + "message", + ], + "type": "object", + }, + "type": "array", + }, + "message": Object { + "description": "The error message", + "example": "Authorization not provided", + "type": "string", + }, + }, + "required": Array [ + "message", + "code", + ], + "title": "Authorization not provided error (401)", "type": "object", }, }, @@ -583,6 +667,26 @@ describe('generator', () => { }, "description": "Successful response", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "500": Object { "content": Object { "application/json": Object { @@ -594,7 +698,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -652,6 +760,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "500": Object { "content": Object { "application/json": Object { @@ -663,7 +791,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -701,6 +833,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -722,7 +874,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -772,6 +928,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -793,7 +969,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -858,6 +1038,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -879,7 +1079,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -933,10 +1137,10 @@ describe('generator', () => { expect(openApiDocument.paths!['/metadata/all']!.get!.tags).toEqual(['tagA', 'tagB']); }); - test('with security', () => { + test('secured by default', () => { const appRouter = t.router({ protectedEndpoint: t.procedure - .meta({ openapi: { method: 'POST', path: '/secure/endpoint', protect: true } }) + .meta({ openapi: { method: 'POST', path: '/secured/endpoint' } }) .input(z.object({ name: z.string() })) .output(z.object({ name: z.string() })) .query(({ input }) => ({ name: input.name })), @@ -944,11 +1148,25 @@ describe('generator', () => { const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts); - expect(openApiDocument.paths!['/secure/endpoint']!.post!.security).toEqual([ + expect(openApiDocument.paths!['/secured/endpoint']!.post!.security).toEqual([ { Authorization: [] }, ]); }); + test('with no security', () => { + const appRouter = t.router({ + protectedEndpoint: t.procedure + .meta({ openapi: { method: 'POST', path: '/unsecure/endpoint', protect: false } }) + .input(z.object({ name: z.string() })) + .output(z.object({ name: z.string() })) + .query(({ input }) => ({ name: input.name })), + }); + + const openApiDocument = generateOpenApiDocument(appRouter, defaultDocOpts); + + expect(openApiDocument.paths!['/unsecure/endpoint']!.post!.security).toBeUndefined(); + }); + test('with schema descriptions', () => { const appRouter = t.router({ createUser: t.procedure @@ -1055,6 +1273,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "500": Object { "content": Object { "application/json": Object { @@ -1066,7 +1304,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, } @@ -1125,6 +1367,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -1146,7 +1408,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, } @@ -2368,6 +2634,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -2389,7 +2675,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -2437,6 +2727,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -2458,7 +2768,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -2506,6 +2820,26 @@ describe('generator', () => { }, "description": "Invalid input data", }, + "401": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.UNAUTHORIZED", + }, + }, + }, + "description": "Authorization not provided", + }, + "403": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/error.FORBIDDEN", + }, + }, + }, + "description": "Insufficient access", + }, "404": Object { "content": Object { "application/json": Object { @@ -2527,7 +2861,11 @@ describe('generator', () => { "description": "Internal server error", }, }, - "security": undefined, + "security": Array [ + Object { + "Authorization": Array [], + }, + ], "summary": undefined, "tags": undefined, }, @@ -2935,12 +3273,14 @@ describe('generator', () => { "description": "Successful response", "headers": Object { "X-RateLimit-Limit": Object { + "required": true, "schema": Object { "description": "Request limit per hour.", "type": "integer", }, }, "X-RateLimit-Remaining": Object { + "required": true, "schema": Object { "description": "The number of requests left for the time window.", "type": "integer",