Skip to content

Commit 83416b3

Browse files
nokutuoliverbutler
andauthored
Grouping schemas by title and referencing them on @ts-rest/openapi (#727)
Co-authored-by: Oliver Butler <dev@oliverbutler.uk>
1 parent adecd09 commit 83416b3

3 files changed

Lines changed: 314 additions & 2 deletions

File tree

‎.changeset/twenty-fans-reflect.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+
Support automatic reusable "Components" in OpenAPI schemas, reduces final size of schema, uses the "Title" property from @anatine/zod-openapi

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

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,5 +1096,210 @@ describe('ts-rest-open-api', () => {
10961096
},
10971097
});
10981098
});
1099+
1100+
it('should extract references by title', () => {
1101+
const entity = extendApi(
1102+
z.object({
1103+
file: z.string(),
1104+
}),
1105+
{
1106+
title: 'Body',
1107+
},
1108+
);
1109+
const routerWithTransform = c.router({
1110+
formEndpoint: {
1111+
method: 'PUT',
1112+
path: '/title',
1113+
body: entity,
1114+
responses: {
1115+
200: entity,
1116+
},
1117+
},
1118+
});
1119+
1120+
const schema = generateOpenApi(routerWithTransform, {
1121+
info: { title: 'Title API', version: '0.1' },
1122+
});
1123+
1124+
expect(schema).toEqual({
1125+
components: {
1126+
schemas: {
1127+
Body: {
1128+
properties: {
1129+
file: {
1130+
type: 'string',
1131+
},
1132+
},
1133+
required: ['file'],
1134+
title: 'Body',
1135+
type: 'object',
1136+
},
1137+
},
1138+
},
1139+
info: {
1140+
title: 'Title API',
1141+
version: '0.1',
1142+
},
1143+
openapi: '3.0.2',
1144+
paths: {
1145+
'/title': {
1146+
put: {
1147+
parameters: [],
1148+
requestBody: {
1149+
content: {
1150+
'application/json': {
1151+
schema: {
1152+
$ref: '#/components/schemas/Body',
1153+
},
1154+
},
1155+
},
1156+
description: 'Body',
1157+
},
1158+
responses: {
1159+
'200': {
1160+
content: {
1161+
'application/json': {
1162+
schema: {
1163+
$ref: '#/components/schemas/Body',
1164+
},
1165+
},
1166+
},
1167+
description: '200',
1168+
},
1169+
},
1170+
tags: [],
1171+
},
1172+
},
1173+
},
1174+
});
1175+
});
1176+
1177+
it('should extract nested references by title', () => {
1178+
const child = extendApi(
1179+
z.object({
1180+
file: z.string(),
1181+
}),
1182+
{
1183+
title: 'Child',
1184+
},
1185+
);
1186+
const parent = extendApi(
1187+
z.object({
1188+
child: child,
1189+
}),
1190+
{
1191+
title: 'Parent',
1192+
},
1193+
);
1194+
const routerWithTransform = c.router({
1195+
formEndpoint: {
1196+
method: 'PUT',
1197+
path: '/title',
1198+
body: child,
1199+
responses: {
1200+
200: parent,
1201+
},
1202+
},
1203+
});
1204+
1205+
const schema = generateOpenApi(routerWithTransform, {
1206+
info: { title: 'Title API', version: '0.1' },
1207+
});
1208+
1209+
expect(schema).toEqual({
1210+
components: {
1211+
schemas: {
1212+
Child: {
1213+
properties: {
1214+
file: {
1215+
type: 'string',
1216+
},
1217+
},
1218+
required: ['file'],
1219+
title: 'Child',
1220+
type: 'object',
1221+
},
1222+
Parent: {
1223+
properties: {
1224+
child: {
1225+
$ref: '#/components/schemas/Child',
1226+
},
1227+
},
1228+
required: ['child'],
1229+
title: 'Parent',
1230+
type: 'object',
1231+
},
1232+
},
1233+
},
1234+
info: {
1235+
title: 'Title API',
1236+
version: '0.1',
1237+
},
1238+
openapi: '3.0.2',
1239+
paths: {
1240+
'/title': {
1241+
put: {
1242+
parameters: [],
1243+
requestBody: {
1244+
content: {
1245+
'application/json': {
1246+
schema: {
1247+
$ref: '#/components/schemas/Child',
1248+
},
1249+
},
1250+
},
1251+
description: 'Body',
1252+
},
1253+
responses: {
1254+
'200': {
1255+
content: {
1256+
'application/json': {
1257+
schema: {
1258+
$ref: '#/components/schemas/Parent',
1259+
},
1260+
},
1261+
},
1262+
description: '200',
1263+
},
1264+
},
1265+
tags: [],
1266+
},
1267+
},
1268+
},
1269+
});
1270+
});
1271+
1272+
it('should throw if duplicated title', () => {
1273+
const routerWithTransform = c.router({
1274+
formEndpoint: {
1275+
method: 'PUT',
1276+
path: '/title',
1277+
body: extendApi(
1278+
z.object({
1279+
file: z.string(),
1280+
}),
1281+
{
1282+
title: 'Body',
1283+
},
1284+
),
1285+
responses: {
1286+
200: extendApi(
1287+
z.object({
1288+
otherFile: z.string(),
1289+
}),
1290+
{
1291+
title: 'Body',
1292+
},
1293+
),
1294+
},
1295+
},
1296+
});
1297+
1298+
expect(() =>
1299+
generateOpenApi(routerWithTransform, {
1300+
info: { title: 'Title API', version: '0.1' },
1301+
}),
1302+
).toThrowError(/already exists with a different schema/);
1303+
});
10991304
});
11001305
});

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

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
OpenAPIObject,
1414
OperationObject,
1515
PathsObject,
16+
ReferenceObject,
1617
SchemaObject,
1718
} from 'openapi3-ts';
1819
import { generateSchema } from '@anatine/zod-openapi';
@@ -187,6 +188,85 @@ const convertSchemaObjectToMediaTypeObject = (
187188
};
188189
};
189190

191+
const extractReferenceSchemas = (
192+
schema: SchemaObject,
193+
referenceSchemas: { [id: string]: SchemaObject },
194+
): SchemaObject => {
195+
if (schema.allOf) {
196+
schema.allOf = schema.allOf?.map((subSchema) =>
197+
extractReferenceSchemas(subSchema, referenceSchemas),
198+
);
199+
}
200+
201+
if (schema.anyOf) {
202+
schema.anyOf = schema.anyOf?.map((subSchema) =>
203+
extractReferenceSchemas(subSchema, referenceSchemas),
204+
);
205+
}
206+
207+
if (schema.oneOf) {
208+
schema.oneOf = schema.oneOf?.map((subSchema) =>
209+
extractReferenceSchemas(subSchema, referenceSchemas),
210+
);
211+
}
212+
213+
if (schema.not) {
214+
schema.not = extractReferenceSchemas(schema.not, referenceSchemas);
215+
}
216+
217+
if (schema.items) {
218+
schema.items = extractReferenceSchemas(schema.items, referenceSchemas);
219+
}
220+
221+
if (schema.properties) {
222+
schema.properties = Object.entries(schema.properties).reduce<{
223+
[p: string]: SchemaObject | ReferenceObject;
224+
}>((prev, [propertyName, schema]) => {
225+
prev[propertyName] = extractReferenceSchemas(schema, referenceSchemas);
226+
return prev;
227+
}, {});
228+
}
229+
230+
if (schema.additionalProperties) {
231+
schema.additionalProperties =
232+
typeof schema.additionalProperties != 'boolean'
233+
? extractReferenceSchemas(schema.additionalProperties, referenceSchemas)
234+
: schema.additionalProperties;
235+
}
236+
237+
if (schema.title) {
238+
const nullable = schema.nullable;
239+
schema.nullable = undefined;
240+
if (schema.title in referenceSchemas) {
241+
if (
242+
JSON.stringify(referenceSchemas[schema.title]) !==
243+
JSON.stringify(schema)
244+
) {
245+
throw new Error(
246+
`Schema title '${schema.title}' already exists with a different schema`,
247+
);
248+
}
249+
} else {
250+
referenceSchemas[schema.title] = schema;
251+
}
252+
253+
if (nullable) {
254+
schema = {
255+
nullable: true,
256+
allOf: [
257+
{
258+
$ref: `#/components/schemas/${schema.title}`,
259+
},
260+
],
261+
};
262+
} else {
263+
schema = {
264+
$ref: `#/components/schemas/${schema.title}`,
265+
};
266+
}
267+
}
268+
return schema;
269+
};
190270
/**
191271
*
192272
* @param options.jsonQuery - Enable JSON query parameters, [see](/docs/open-api#json-query-params)
@@ -216,6 +296,8 @@ export const generateOpenApi = (
216296

217297
const operationIds = new Map<string, string[]>();
218298

299+
const referenceSchemas: { [id: string]: SchemaObject } = {};
300+
219301
const pathObject = paths.reduce((acc, path) => {
220302
if (options.setOperationId === true) {
221303
const existingOp = operationIds.get(path.id);
@@ -235,19 +317,30 @@ export const generateOpenApi = (
235317
!!options.jsonQuery,
236318
);
237319

238-
const bodySchema =
320+
let bodySchema =
239321
path.route?.method !== 'GET' && 'body' in path.route
240322
? getOpenApiSchemaFromZod(path.route.body)
241323
: null;
242324

325+
if (bodySchema?.title) {
326+
bodySchema = extractReferenceSchemas(bodySchema, referenceSchemas);
327+
}
328+
243329
const responses = Object.entries(path.route.responses).reduce(
244330
(acc, [statusCode, response]) => {
245-
const responseSchema = getOpenApiSchemaFromZod(response, true);
331+
let responseSchema = getOpenApiSchemaFromZod(response, true);
246332
const description =
247333
isZodType(response) && response.description
248334
? response.description
249335
: statusCode;
250336

337+
if (responseSchema) {
338+
responseSchema = extractReferenceSchemas(
339+
responseSchema,
340+
referenceSchemas,
341+
);
342+
}
343+
251344
return {
252345
...acc,
253346
[statusCode]: {
@@ -311,6 +404,15 @@ export const generateOpenApi = (
311404
return acc;
312405
}, {} as PathsObject);
313406

407+
if (Object.keys(referenceSchemas).length) {
408+
apiDoc['components'] = {
409+
schemas: {
410+
...referenceSchemas,
411+
},
412+
...apiDoc['components'],
413+
};
414+
}
415+
314416
return {
315417
openapi: '3.0.2',
316418
paths: pathObject,

0 commit comments

Comments
 (0)