diff --git a/README.md b/README.md index 868ac35b..575beee7 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ For a procedure to support OpenAPI the following _must_ be true: - Query `input` parsers extend `ZodObject<{ [string]: ZodString }>` or `ZodVoid`. - Mutation `input` parsers extend `ZodObject<{ [string]: ZodAnyType }>` or `ZodVoid`. - `meta.openapi.enabled` is set to `true`. -- `meta.openapi.method` is `GET`, `DELETE` for query OR `POST`, `PUT` or `PATCH` for mutation. +- `meta.openapi.method` is `GET`, `POST`, `PATCH`, `PUT` or `DELETE`. - `meta.openapi.path` is a string starting with `/`. - `meta.openapi.path` parameters exist in `input` parser as `ZodString` @@ -116,17 +116,15 @@ Please note: ## HTTP Requests -Query procedures accept input via URL `query parameters`. - -Mutation procedures accept input via the `request body` with a `application/json` content type. +Procedures with a `GET`/`DELETE` method will accept inputs via URL `query parameters`. Procedures with a `POST`/`PATCH`/`PUT` method will accept inputs via the `request body` with a `application/json` content type. ### Path parameters -Both queries & mutations can accept a set of their inputs via URL path parameters. You can add a path parameter to any OpenAPI enabled procedure by using curly brackets around an input name as a path segment in the `meta.openapi.path` field. +A procedure can accept a set of inputs via URL path parameters. You can add a path parameter to any OpenAPI enabled procedure by using curly brackets around an input name as a path segment in the `meta.openapi.path` field. -#### Query +#### Query parameters -Query (& path parameter) inputs are always accepted as a `string`, if you wish to support other primitives such as `number`, `boolean`, `Date` etc. please use [`z.preprocess()`](https://github.com/colinhacks/zod#preprocess). +Query & path parameter inputs are always accepted as a `string`, if you wish to support other primitives such as `number`, `boolean`, `Date` etc. please use [`z.preprocess()`](https://github.com/colinhacks/zod#preprocess). ```typescript // Router @@ -146,7 +144,7 @@ const res = await fetch('http://localhost:3000/say-hello/James?greeting=Hello' / const body = await res.json(); /* { ok: true, data: { greeting: 'Hello James!' } } */ ``` -#### Mutation +#### Request body ```typescript // Router @@ -310,16 +308,16 @@ Please see [full typings here](src/generator/index.ts). Please see [full typings here](src/types.ts). -| Property | Type | Description | Required | Default | -| ------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ | -------- | ----------- | -| `enabled` | `boolean` | Exposes this procedure to `trpc-openapi` adapters and on the OpenAPI document. | `true` | `false` | -| `method` | `HttpMethod` | Method this endpoint is exposed on. Value can be `GET`/`DELETE` for queries OR `POST`/`PUT`/`PATCH` for mutations. | `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 an `Authorization` header credential with `Bearer` scheme on OpenAPI document. | `false` | `false` | -| `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` | -| `headers` | `ParameterObject[]` | An array of custom headers to add for this endpoint in the OpenAPI document. | `false` | `undefined` | +| Property | Type | Description | Required | Default | +| ------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | -------- | ----------- | +| `enabled` | `boolean` | Exposes this procedure to `trpc-openapi` adapters and on the OpenAPI document. | `true` | `false` | +| `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 an `Authorization` header credential with `Bearer` scheme on OpenAPI document. | `false` | `false` | +| `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` | +| `headers` | `ParameterObject[]` | An array of custom headers to add for this endpoint in the OpenAPI document. | `false` | `undefined` | #### CreateOpenApiNodeHttpHandlerOptions diff --git a/examples/with-express/src/router.ts b/examples/with-express/src/router.ts index cfe72d67..d9efb42f 100644 --- a/examples/with-express/src/router.ts +++ b/examples/with-express/src/router.ts @@ -351,7 +351,7 @@ const postsProtectedRouter = createProtectedRouter() return { post }; }, }) - .query('deletePostById', { + .mutation('deletePostById', { meta: { openapi: { enabled: true, diff --git a/examples/with-nextjs/src/server/router.ts b/examples/with-nextjs/src/server/router.ts index 364e2426..50c3efc2 100644 --- a/examples/with-nextjs/src/server/router.ts +++ b/examples/with-nextjs/src/server/router.ts @@ -348,7 +348,7 @@ const postsProtectedRouter = createProtectedRouter() return { post }; }, }) - .query('deletePostById', { + .mutation('deletePostById', { meta: { openapi: { enabled: true, diff --git a/src/generator/paths.ts b/src/generator/paths.ts index 25e37cc7..f0cbe2fb 100644 --- a/src/generator/paths.ts +++ b/src/generator/paths.ts @@ -3,74 +3,55 @@ import { OpenAPIV3 } from 'openapi-types'; import { OpenApiRouter } from '../types'; import { getPathParameters, normalizePath } from '../utils/path'; -import { forEachOpenApiProcedure, getInputOutputParsers } from '../utils/procedure'; +import { + forEachOpenApiProcedure, + getInputOutputParsers, + mergeProcedureRecords, +} from '../utils/procedure'; import { getParameterObjects, getRequestBodyObject, getResponsesObject } from './schema'; +const acceptsRequestBody = (httpMethod: OpenAPIV3.HttpMethods) => { + if (httpMethod === 'get' || httpMethod === 'delete') { + return false; + } + return true; +}; + export const getOpenApiPathsObject = ( appRouter: OpenApiRouter, pathsObject: OpenAPIV3.PathsObject, ): OpenAPIV3.PathsObject => { const { queries, mutations, subscriptions } = appRouter._def; - forEachOpenApiProcedure(queries, ({ path: queryPath, procedure, openapi }) => { + forEachOpenApiProcedure(subscriptions, ({ path: subscriptionPath }) => { try { - 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', - code: 'INTERNAL_SERVER_ERROR', - }); - } - - 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({ - message: `Duplicate procedure defined for route ${method} ${path}`, - code: 'INTERNAL_SERVER_ERROR', - }); - } - - const { inputParser, outputParser } = getInputOutputParsers(procedure); - - pathsObject[path] = { - ...pathsObject[path], - [httpMethod]: { - operationId: queryPath, - summary, - description, - tags: tags ?? (tag ? [tag] : undefined), - security: protect ? [{ Authorization: [] }] : undefined, - parameters: [ - ...headerParameters, - ...(getParameterObjects(inputParser, pathParameters, 'all') || []), - ], - responses: getResponsesObject(outputParser), - }, - }; + throw new TRPCError({ + message: 'Subscriptions are not supported by OpenAPI v3', + code: 'INTERNAL_SERVER_ERROR', + }); } catch (error: any) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - error.message = `[query.${queryPath}] - ${error.message}`; + error.message = `[subscription.${subscriptionPath}] - ${error.message}`; throw error; } }); - forEachOpenApiProcedure(mutations, ({ path: mutationPath, procedure, openapi }) => { + const procedures = mergeProcedureRecords(queries, mutations); + + forEachOpenApiProcedure(procedures, ({ path: operationId, procedure, openapi }) => { try { 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', - code: 'INTERNAL_SERVER_ERROR', - }); - } const path = normalizePath(openapi.path); const pathParameters = getPathParameters(path); const headerParameters = headers?.map((header) => ({ ...header, in: 'header' })) || []; const httpMethod = OpenAPIV3.HttpMethods[method]; + if (!httpMethod) { + throw new TRPCError({ + message: 'Method must be GET, POST, PATCH, PUT or DELETE', + code: 'INTERNAL_SERVER_ERROR', + }); + } if (pathsObject[path]?.[httpMethod]) { throw new TRPCError({ message: `Duplicate procedure defined for route ${method} ${path}`, @@ -83,35 +64,31 @@ export const getOpenApiPathsObject = ( pathsObject[path] = { ...pathsObject[path], [httpMethod]: { - operationId: mutationPath, + operationId, summary, description, tags: tags ?? (tag ? [tag] : undefined), security: protect ? [{ Authorization: [] }] : undefined, - requestBody: getRequestBodyObject(inputParser, pathParameters), - parameters: [ - ...headerParameters, - ...(getParameterObjects(inputParser, pathParameters, 'path') || []), - ], + ...(acceptsRequestBody(httpMethod) + ? { + requestBody: getRequestBodyObject(inputParser, pathParameters), + parameters: [ + ...headerParameters, + ...(getParameterObjects(inputParser, pathParameters, 'path') || []), + ], + } + : { + parameters: [ + ...headerParameters, + ...(getParameterObjects(inputParser, pathParameters, 'all') || []), + ], + }), responses: getResponsesObject(outputParser), }, }; } catch (error: any) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - error.message = `[mutation.${mutationPath}] - ${error.message}`; - throw error; - } - }); - - forEachOpenApiProcedure(subscriptions, ({ path: subscriptionPath }) => { - try { - throw new TRPCError({ - message: 'Subscriptions are not supported by OpenAPI v3', - code: 'INTERNAL_SERVER_ERROR', - }); - } catch (error: any) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - error.message = `[subscription.${subscriptionPath}] - ${error.message}`; + error.message = `[${operationId}] - ${error.message}`; throw error; } }); diff --git a/src/utils/procedure.ts b/src/utils/procedure.ts index ec8d236e..baf07030 100644 --- a/src/utils/procedure.ts +++ b/src/utils/procedure.ts @@ -11,6 +11,23 @@ export const getInputOutputParsers = (procedure: Procedure { + const prefix = (procedures: OpenApiProcedureRecord, type: string) => { + const next: OpenApiProcedureRecord = {}; + Object.keys(procedures).forEach((path) => { + next[`${type}.${path}`] = procedures[path]!; + }); + return next; + }; + return { + ...prefix(queries, 'query'), + ...prefix(mutations, 'mutation'), + }; +}; + export const forEachOpenApiProcedure = ( procedureRecord: OpenApiProcedureRecord, callback: (values: { diff --git a/test/generator.test.ts b/test/generator.test.ts index 7b99bd04..16ef7bef 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -273,38 +273,21 @@ describe('generator', () => { }); test('with bad method', () => { - { - const appRouter = trpc.router().query('postQuery', { - meta: { openapi: { enabled: true, path: '/post-query', method: 'POST' } }, - input: z.object({ name: z.string() }), - output: z.object({ name: z.string() }), - resolve: ({ input }) => ({ name: input.name }), - }); + const appRouter = trpc.router().query('badMethod', { + // @ts-expect-error - bad method + meta: { openapi: { enabled: true, path: '/bad-method', method: 'BAD_METHOD' } }, + input: z.object({ name: z.string() }), + output: z.object({ name: z.string() }), + resolve: ({ input }) => ({ name: input.name }), + }); - expect(() => { - generateOpenApiDocument(appRouter, { - title: 'tRPC OpenAPI', - version: '1.0.0', - baseUrl: 'http://localhost:3000/api', - }); - }).toThrowError('[query.postQuery] - Query method must be GET or DELETE'); - } - { - const appRouter = trpc.router().mutation('getMutation', { - meta: { openapi: { enabled: true, path: '/get-mutation', method: 'GET' } }, - input: z.object({ name: z.string() }), - output: z.object({ name: z.string() }), - resolve: ({ input }) => ({ name: input.name }), + expect(() => { + generateOpenApiDocument(appRouter, { + title: 'tRPC OpenAPI', + version: '1.0.0', + baseUrl: 'http://localhost:3000/api', }); - - expect(() => { - generateOpenApiDocument(appRouter, { - title: 'tRPC OpenAPI', - version: '1.0.0', - baseUrl: 'http://localhost:3000/api', - }); - }).toThrowError('[mutation.getMutation] - Mutation method must be POST, PATCH or PUT'); - } + }).toThrowError('[query.badMethod] - Method must be GET, POST, PATCH, PUT or DELETE'); }); test('with duplicate routes', () => { @@ -547,7 +530,7 @@ describe('generator', () => { "/users": Object { "get": Object { "description": undefined, - "operationId": "readUsers", + "operationId": "query.readUsers", "parameters": Array [], "responses": Object { "200": Object { @@ -602,7 +585,7 @@ describe('generator', () => { }, "post": Object { "description": undefined, - "operationId": "createUser", + "operationId": "mutation.createUser", "parameters": Array [], "requestBody": Object { "content": Object { @@ -675,7 +658,7 @@ describe('generator', () => { "/users/{id}": Object { "delete": Object { "description": undefined, - "operationId": "deleteUser", + "operationId": "query.deleteUser", "parameters": Array [ Object { "description": undefined, @@ -720,7 +703,7 @@ describe('generator', () => { }, "get": Object { "description": undefined, - "operationId": "readUser", + "operationId": "query.readUser", "parameters": Array [ Object { "description": undefined, @@ -782,7 +765,7 @@ describe('generator', () => { }, "patch": Object { "description": undefined, - "operationId": "updateUser", + "operationId": "mutation.updateUser", "parameters": Array [ Object { "description": undefined, @@ -1013,7 +996,7 @@ describe('generator', () => { expect(openApiDocument.paths['/user']!.post!).toMatchInlineSnapshot(` Object { "description": undefined, - "operationId": "createUser", + "operationId": "mutation.createUser", "parameters": Array [], "requestBody": Object { "content": Object { @@ -1098,7 +1081,7 @@ describe('generator', () => { expect(openApiDocument.paths['/user']!.get!).toMatchInlineSnapshot(` Object { "description": undefined, - "operationId": "getUser", + "operationId": "query.getUser", "parameters": Array [ Object { "description": "User ID", @@ -2171,4 +2154,37 @@ describe('generator', () => { ] `); }); + + test('with DELETE method mutation', () => { + const appRouter = trpc.router().mutation('deleteThing', { + meta: { openapi: { enabled: true, path: '/thing/delete', method: 'DELETE' } }, + 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['/thing/delete']!.delete!.requestBody).toMatchInlineSnapshot( + `undefined`, + ); + expect(openApiDocument.paths['/thing/delete']!.delete!.parameters).toMatchInlineSnapshot(` + Array [ + Object { + "description": undefined, + "in": "query", + "name": "id", + "required": true, + "schema": Object { + "type": "string", + }, + }, + ] + `); + }); });