Skip to content

Commit 77f23a8

Browse files
authored
feat(openapi): add operationMapper option to extend OpenAPI operations (#577)
1 parent 792d7a4 commit 77f23a8

4 files changed

Lines changed: 263 additions & 7 deletions

File tree

.changeset/clever-maps-punch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/open-api': minor
3+
---
4+
5+
Add `operationMapper` option to extend OpenAPI operations

apps/docs/docs/open-api.mdx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,65 @@ This will only work for the schemas of the body, responses and individual query
9393

9494
You can see an example of its usage in the code snippet above.
9595

96+
## Extending Operations with Additional OpenAPI Fields
97+
98+
We do not provide first-party support to set all possible OpenAPI fields on the operations such as the `security` field. In addition, you may have some specific needs to modify the fields already set by `ts-rest` such as the `tags` field.
99+
100+
Therefore, we have provided an `operationMapper` option to allow you to modify the OpenAPI fields of the operations. This is a callback function, that will receive the operation object and the contract endpoint, and must return a valid OpenAPI operation object.
101+
A common way to provide data to this function is to utilize the `metadata` field of the contract endpoint. However, feel free to come up with a different solution to doing this if you would not like to include this data in your contracts.
102+
103+
```typescript
104+
const hasCustomTags = (
105+
metadata: unknown,
106+
): metadata is { openApiTags: string[] } => {
107+
return (
108+
!!metadata &&
109+
typeof metadata === 'object' &&
110+
'openApiTags' in metadata
111+
);
112+
};
113+
114+
const hasSecurity = (
115+
metadata: unknown,
116+
): metadata is { openApiSecurity: SecurityRequirementObject[] } => {
117+
return (
118+
!!metadata &&
119+
typeof metadata === 'object' &&
120+
'openApiSecurity' in metadata
121+
);
122+
};
123+
124+
const apiDoc = generateOpenApi(
125+
router,
126+
{
127+
info: { title: 'Blog API', version: '0.1' },
128+
components: {
129+
securitySchemes: {
130+
BasicAuth: {
131+
type: 'http',
132+
scheme: 'basic',
133+
},
134+
},
135+
},
136+
},
137+
{
138+
operationMapper: (operation, appRoute) => ({
139+
...operation,
140+
...(hasCustomTags(appRoute.metadata)
141+
? {
142+
tags: appRoute.metadata.openApiTags,
143+
}
144+
: {}),
145+
...(hasSecurity(appRoute.metadata)
146+
? {
147+
security: appRoute.metadata.openApiSecurity,
148+
}
149+
: {}),
150+
}),
151+
},
152+
);
153+
```
154+
96155
## Serving a Swagger UI
97156

98157
In Express use `swagger-ui-express`:
@@ -141,9 +200,14 @@ bootstrap();
141200

142201
Don't worry if you don't use express or Nest, whatever library you want to use is OK our OpenAPI returns a plain JSON object which is fully compliant with the OpenAPI spec.
143202

144-
## Enabling `operationId`'s (Recommended!)
203+
## Enabling `operationId`s (Recommended!)
204+
205+
You can set `setOperationId` to either `true` or `concatenated-path` to set `operationId`s on your endpoints.
206+
207+
In the case of setting it to `true`, it will use only the endpoint name from your contract. You have to ensure that the endpoint names are unique across the entire contract.
145208

146-
If your contract has unique names for all of your endpoints, you can enable `operationId`'s by setting `setOperationId` to `true` in the options:
209+
In the case of setting it to `concatenated-path`, it will use the endpoint name concatenated with the path through the nested contract.
210+
This is useful when you have multiple endpoints with the same name but different paths. This will result in longer but more descriptive `operationId`s.
147211

148212
```typescript
149213
const openApiSchema = generateOpenApi(
@@ -156,6 +220,7 @@ const openApiSchema = generateOpenApi(
156220
},
157221
{
158222
setOperationId: true,
223+
// setOperationId: 'concatenated-path',
159224
},
160225
);
161226
```
@@ -180,6 +245,8 @@ Below is an example of what the OpenAPI document would look like with `operation
180245
}
181246
],
182247
"operationId": "getPosts", // <--- This is the operationId
248+
// or
249+
"operationId": "posts.getPosts", // <--- If using concatenated-path
183250
"responses": {
184251
```
185252

libs/ts-rest/open-api/src/lib/ts-rest-open-api.spec.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { initContract } from '@ts-rest/core';
22
import { z } from 'zod';
33
import { generateOpenApi } from './ts-rest-open-api';
44
import { extendApi } from '@anatine/zod-openapi';
5+
import { SecurityRequirementObject } from 'openapi3-ts';
56

67
const c = initContract();
78

@@ -103,6 +104,14 @@ const router = c.router({
103104
responses: {
104105
200: c.type<{ message: string }>(),
105106
},
107+
metadata: {
108+
openApiTags: ['Custom Health Tag'],
109+
openApiSecurity: [
110+
{
111+
BasicAuth: [],
112+
},
113+
],
114+
},
106115
},
107116
mediaExamples: {
108117
method: 'POST',
@@ -557,6 +566,68 @@ describe('ts-rest-open-api', () => {
557566
});
558567
});
559568

569+
it('should generate doc with concatenated path operation ids', async () => {
570+
const apiDoc = generateOpenApi(
571+
router,
572+
{
573+
info: { title: 'Blog API', version: '0.1' },
574+
},
575+
{ setOperationId: 'concatenated-path' },
576+
);
577+
578+
expect(apiDoc).toEqual({
579+
...expectedApiDoc,
580+
paths: {
581+
'/health': {
582+
get: {
583+
...expectedApiDoc.paths['/health'].get,
584+
operationId: 'health',
585+
},
586+
},
587+
'/media-examples': {
588+
post: {
589+
...expectedApiDoc.paths['/media-examples'].post,
590+
operationId: 'mediaExamples',
591+
},
592+
},
593+
'/posts': {
594+
get: {
595+
...expectedApiDoc.paths['/posts'].get,
596+
operationId: 'posts.findPosts',
597+
},
598+
post: {
599+
...expectedApiDoc.paths['/posts'].post,
600+
operationId: 'posts.createPost',
601+
},
602+
},
603+
'/posts/{id}': {
604+
get: {
605+
...expectedApiDoc.paths['/posts/{id}'].get,
606+
operationId: 'posts.getPost',
607+
},
608+
},
609+
'/posts/{id}/comments': {
610+
get: {
611+
...expectedApiDoc.paths['/posts/{id}/comments'].get,
612+
operationId: 'posts.comments.getPostComments',
613+
},
614+
},
615+
'/posts/{id}/comments/{commentId}': {
616+
get: {
617+
...expectedApiDoc.paths['/posts/{id}/comments/{commentId}'].get,
618+
operationId: 'posts.getPostComment',
619+
},
620+
},
621+
'/auth': {
622+
post: {
623+
...expectedApiDoc.paths['/auth'].post,
624+
operationId: 'posts.auth',
625+
},
626+
},
627+
},
628+
});
629+
});
630+
560631
it('should generate doc with json query', async () => {
561632
const apiDoc = generateOpenApi(
562633
router,
@@ -689,6 +760,103 @@ describe('ts-rest-open-api', () => {
689760
).toThrowError(/getPost/);
690761
});
691762

763+
it('should not throw when duplicate operationIds with concatenated paths', async () => {
764+
const router = c.router({
765+
posts: postsRouter,
766+
getPost: {
767+
method: 'GET',
768+
path: `/posts/:id`,
769+
responses: {
770+
200: c.type<Post | null>(),
771+
},
772+
},
773+
});
774+
775+
expect(() =>
776+
generateOpenApi(
777+
router,
778+
{
779+
info: { title: 'Blog API', version: '0.1' },
780+
},
781+
{ setOperationId: 'concatenated-path' },
782+
),
783+
).not.toThrowError(/getPost/);
784+
});
785+
786+
it('should add custom fields with operationMapper', async () => {
787+
const hasCustomTags = (
788+
metadata: unknown,
789+
): metadata is { openApiTags: string[] } => {
790+
return (
791+
!!metadata &&
792+
typeof metadata === 'object' &&
793+
'openApiTags' in metadata
794+
);
795+
};
796+
797+
const hasSecurity = (
798+
metadata: unknown,
799+
): metadata is { openApiSecurity: SecurityRequirementObject[] } => {
800+
return (
801+
!!metadata &&
802+
typeof metadata === 'object' &&
803+
'openApiSecurity' in metadata
804+
);
805+
};
806+
807+
const apiDoc = generateOpenApi(
808+
router,
809+
{
810+
info: { title: 'Blog API', version: '0.1' },
811+
components: {
812+
securitySchemes: {
813+
BasicAuth: {
814+
type: 'http',
815+
scheme: 'basic',
816+
},
817+
},
818+
},
819+
},
820+
{
821+
operationMapper: (operation, appRoute) => ({
822+
...operation,
823+
...(hasCustomTags(appRoute.metadata)
824+
? {
825+
tags: appRoute.metadata.openApiTags,
826+
}
827+
: {}),
828+
...(hasSecurity(appRoute.metadata)
829+
? {
830+
security: appRoute.metadata.openApiSecurity,
831+
}
832+
: {}),
833+
}),
834+
},
835+
);
836+
expect(apiDoc).toEqual({
837+
...expectedApiDoc,
838+
paths: {
839+
...expectedApiDoc.paths,
840+
'/health': {
841+
...expectedApiDoc.paths['/health'],
842+
get: {
843+
...expectedApiDoc.paths['/health'].get,
844+
tags: router.health.metadata.openApiTags,
845+
security: router.health.metadata.openApiSecurity,
846+
},
847+
},
848+
},
849+
components: {
850+
securitySchemes: {
851+
BasicAuth: {
852+
type: 'http',
853+
scheme: 'basic',
854+
},
855+
},
856+
},
857+
});
858+
});
859+
692860
it('works with zod refine', () => {
693861
const routerWithRefine = c.router({
694862
endpointWithZodRefine: {

libs/ts-rest/open-api/src/lib/ts-rest-open-api.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,14 @@ const convertSchemaObjectToMediaTypeObject = (
195195
export const generateOpenApi = (
196196
router: AppRouter,
197197
apiDoc: Omit<OpenAPIObject, 'paths' | 'openapi'> & { info: InfoObject },
198-
options: { setOperationId?: boolean; jsonQuery?: boolean } = {},
198+
options: {
199+
setOperationId?: boolean | 'concatenated-path';
200+
jsonQuery?: boolean;
201+
operationMapper?: (
202+
operation: OperationObject,
203+
appRoute: AppRoute,
204+
) => OperationObject;
205+
} = {},
199206
): OpenAPIObject => {
200207
const paths = getPathsFromRouter(router);
201208

@@ -210,7 +217,7 @@ export const generateOpenApi = (
210217
const operationIds = new Map<string, string[]>();
211218

212219
const pathObject = paths.reduce((acc, path) => {
213-
if (options.setOperationId) {
220+
if (options.setOperationId === true) {
214221
const existingOp = operationIds.get(path.id);
215222
if (existingOp) {
216223
throw new Error(
@@ -265,13 +272,20 @@ export const generateOpenApi = (
265272
? path.route?.contentType ?? 'application/json'
266273
: 'application/json';
267274

268-
const newPath: OperationObject = {
275+
const pathOperation: OperationObject = {
269276
description: path.route.description,
270277
summary: path.route.summary,
271278
deprecated: path.route.deprecated,
272279
tags: path.paths,
273280
parameters: [...pathParams, ...headerParams, ...querySchema],
274-
...(options.setOperationId ? { operationId: path.id } : {}),
281+
...(options.setOperationId
282+
? {
283+
operationId:
284+
options.setOperationId === 'concatenated-path'
285+
? [...path.paths, path.id].join('.')
286+
: path.id,
287+
}
288+
: {}),
275289
...(bodySchema
276290
? {
277291
requestBody: {
@@ -289,7 +303,9 @@ export const generateOpenApi = (
289303

290304
acc[path.path] = {
291305
...acc[path.path],
292-
[mapMethod[path.route.method]]: newPath,
306+
[mapMethod[path.route.method]]: options.operationMapper
307+
? options.operationMapper(pathOperation, path.route)
308+
: pathOperation,
293309
};
294310

295311
return acc;

0 commit comments

Comments
 (0)