Skip to content

Commit b194d2a

Browse files
kkolstadGabrola
andauthored
feat(open-api): allow examples on OAS body and responses (#454)
Co-authored-by: Youssef Gaber <1728215+Gabrola@users.noreply.github.com>
1 parent 501deb8 commit b194d2a

3 files changed

Lines changed: 169 additions & 3 deletions

File tree

apps/docs/docs/open-api.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ export const contract = c.router({
6464
.openapi({
6565
title: 'User',
6666
description: 'A user schema',
67+
mediaExamples: {
68+
myExample: {
69+
value: {
70+
id: '123e4567-e89b-12d3-a456-426614174000',
71+
name: 'John Doe',
72+
phoneNumber: '555-555-5555',
73+
},
74+
summary: 'Example of a user',
75+
},
76+
},
6777
}),
6878
},
6979
},
@@ -76,6 +86,13 @@ See the [official `@anatine/zod-openapi` docs](https://www.npmjs.com/package/@an
7686
All Zod schemas defined in your contract can benefit from the additional OpenAPI schema, including `pathParams`, `queryParams`, and `responses`. These keys are all used when generating the OpenAPI JSON file. This could improve the quality of your generated documentation.
7787
:::
7888

89+
### Adding Examples to the Media Type
90+
91+
In order to add examples to the media type rather than to the schema itself (this is useful if you want to show multiple examples), we have added a `mediaExamples` property to the `.openapi()` method options.
92+
This will only work for the schemas of the body, responses and individual query parameters if you are using `jsonQuery` option.
93+
94+
You can see an example of its usage in the code snippet above.
95+
7996
## Serving a Swagger UI
8097

8198
In Express use `swagger-ui-express`:

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { initContract } from '@ts-rest/core';
22
import { z } from 'zod';
33
import { generateOpenApi } from './ts-rest-open-api';
4+
import { extendApi } from '@anatine/zod-openapi';
45

56
const c = initContract();
67

@@ -101,6 +102,35 @@ const router = c.router({
101102
200: c.type<{ message: string }>(),
102103
},
103104
},
105+
mediaExamples: {
106+
method: 'POST',
107+
path: '/media-examples',
108+
query: z.object({
109+
foo: extendApi(z.string(), {
110+
// this will only be added when jsonQuery is enabled
111+
mediaExamples: {
112+
one: { value: 'foo' },
113+
two: { value: 'bar' },
114+
},
115+
}),
116+
}),
117+
summary: 'Examples API',
118+
description: `Check that examples can be added to body and response types`,
119+
body: extendApi(z.object({ id: z.string() }), {
120+
mediaExamples: {
121+
one: { value: { id: 'foo' } },
122+
two: { value: { id: 'bar' } },
123+
},
124+
}),
125+
responses: {
126+
200: extendApi(z.object({ id: z.string() }), {
127+
mediaExamples: {
128+
three: { value: { id: 'foo' } },
129+
four: { value: { id: 'bar' } },
130+
},
131+
}),
132+
},
133+
},
104134
});
105135

106136
const expectedApiDoc = {
@@ -124,6 +154,66 @@ const expectedApiDoc = {
124154
tags: [],
125155
},
126156
},
157+
'/media-examples': {
158+
post: {
159+
deprecated: undefined,
160+
description: `Check that examples can be added to body and response types`,
161+
parameters: [
162+
{
163+
in: 'query',
164+
name: 'foo',
165+
required: true,
166+
schema: {
167+
type: 'string',
168+
},
169+
},
170+
],
171+
requestBody: {
172+
description: 'Body',
173+
content: {
174+
'application/json': {
175+
schema: {
176+
properties: {
177+
id: {
178+
type: 'string',
179+
},
180+
},
181+
required: ['id'],
182+
type: 'object',
183+
},
184+
examples: {
185+
one: { value: { id: 'foo' } },
186+
two: { value: { id: 'bar' } },
187+
},
188+
},
189+
},
190+
},
191+
responses: {
192+
'200': {
193+
content: {
194+
'application/json': {
195+
examples: {
196+
three: { value: { id: 'foo' } },
197+
four: { value: { id: 'bar' } },
198+
},
199+
schema: {
200+
properties: {
201+
id: {
202+
type: 'string',
203+
},
204+
},
205+
required: ['id'],
206+
type: 'object',
207+
},
208+
},
209+
},
210+
description: `200`,
211+
},
212+
},
213+
summary: 'Examples API',
214+
tags: [],
215+
},
216+
},
127217
'/posts': {
128218
get: {
129219
deprecated: undefined,
@@ -420,6 +510,12 @@ describe('ts-rest-open-api', () => {
420510
operationId: 'health',
421511
},
422512
},
513+
'/media-examples': {
514+
post: {
515+
...expectedApiDoc.paths['/media-examples'].post,
516+
operationId: 'mediaExamples',
517+
},
518+
},
423519
'/posts': {
424520
get: {
425521
...expectedApiDoc.paths['/posts'].get,
@@ -471,6 +567,34 @@ describe('ts-rest-open-api', () => {
471567
...expectedApiDoc,
472568
paths: {
473569
...expectedApiDoc.paths,
570+
'/media-examples': {
571+
...expectedApiDoc.paths['/media-examples'],
572+
post: {
573+
...expectedApiDoc.paths['/media-examples'].post,
574+
parameters: [
575+
{
576+
content: {
577+
'application/json': {
578+
examples: {
579+
one: {
580+
value: 'foo',
581+
},
582+
two: {
583+
value: 'bar',
584+
},
585+
},
586+
schema: {
587+
type: 'string',
588+
},
589+
},
590+
},
591+
in: 'query',
592+
name: 'foo',
593+
required: true,
594+
},
595+
],
596+
},
597+
},
474598
'/posts': {
475599
...expectedApiDoc.paths['/posts'],
476600
get: {

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import {
77
isZodType,
88
} from '@ts-rest/core';
99
import {
10+
ExamplesObject,
1011
InfoObject,
12+
MediaTypeObject,
1113
OpenAPIObject,
1214
OperationObject,
1315
PathsObject,
16+
SchemaObject,
1417
} from 'openapi3-ts';
1518
import { generateSchema } from '@anatine/zod-openapi';
1619
import { z } from 'zod';
@@ -129,7 +132,11 @@ const getQueryParametersFromZod = (zodObject: unknown, jsonQuery = false) => {
129132
const zodShape = extractZodObjectShape(zodObject);
130133

131134
return Object.entries(zodShape).map(([key, value]) => {
132-
const { description, ...schema } = getOpenApiSchemaFromZod(value)!;
135+
const {
136+
description,
137+
mediaExamples: examples,
138+
...schema
139+
} = getOpenApiSchemaFromZod(value)!;
133140
const isObject = (obj: z.ZodTypeAny) => {
134141
while (obj._def.innerType) {
135142
obj = obj._def.innerType;
@@ -149,6 +156,7 @@ const getQueryParametersFromZod = (zodObject: unknown, jsonQuery = false) => {
149156
content: {
150157
'application/json': {
151158
schema: schema,
159+
...(examples && { examples }),
152160
},
153161
},
154162
}
@@ -162,6 +170,23 @@ const getQueryParametersFromZod = (zodObject: unknown, jsonQuery = false) => {
162170
});
163171
};
164172

173+
declare module 'openapi3-ts' {
174+
interface SchemaObject {
175+
mediaExamples?: ExamplesObject;
176+
}
177+
}
178+
179+
const convertSchemaObjectToMediaTypeObject = (
180+
input: SchemaObject,
181+
): MediaTypeObject => {
182+
const { mediaExamples: examples, ...schema } = input;
183+
184+
return {
185+
schema,
186+
...(examples && { examples }),
187+
};
188+
};
189+
165190
/**
166191
*
167192
* @param options.jsonQuery - Enable JSON query parameters, [see](/docs/open-api#json-query-params)
@@ -224,7 +249,7 @@ export const generateOpenApi = (
224249
? {
225250
content: {
226251
'application/json': {
227-
schema: responseSchema,
252+
...convertSchemaObjectToMediaTypeObject(responseSchema),
228253
},
229254
},
230255
}
@@ -251,7 +276,7 @@ export const generateOpenApi = (
251276
description: 'Body',
252277
content: {
253278
[contentType]: {
254-
schema: bodySchema,
279+
...convertSchemaObjectToMediaTypeObject(bodySchema),
255280
},
256281
},
257282
},

0 commit comments

Comments
 (0)