Skip to content

Commit 7736ccb

Browse files
committed
add nestedRoutes support for openapi spec generation
1 parent 826cc4e commit 7736ccb

2 files changed

Lines changed: 276 additions & 4 deletions

File tree

packages/server/src/api/rest/openapi.ts

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
6161
return this.handlerOptions?.queryOptions;
6262
}
6363

64+
private get nestedRoutes(): boolean {
65+
return this.handlerOptions.nestedRoutes ?? false;
66+
}
67+
6468
generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document {
6569
this.specOptions = options;
6670
return {
@@ -124,14 +128,26 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
124128
const relIdFields = this.getIdFields(relModelDef);
125129
if (relIdFields.length === 0) continue;
126130

127-
// GET /{model}/{id}/{field} — fetch related
131+
// GET /{model}/{id}/{field} — fetch related (+ nested create/update when nestedRoutes enabled)
128132
paths[`/${modelPath}/{id}/${fieldName}`] = this.buildFetchRelatedPath(
129133
modelName,
130134
fieldName,
131135
fieldDef,
132136
tag,
133137
);
134138

139+
// Nested single resource path: /{model}/{id}/{field}/{childId}
140+
if (this.nestedRoutes && fieldDef.array) {
141+
const relModelDef = this.schema.models[fieldDef.type]!;
142+
paths[`/${modelPath}/{id}/${fieldName}/{childId}`] = this.buildNestedSinglePath(
143+
modelName,
144+
fieldName,
145+
fieldDef,
146+
relModelDef,
147+
tag,
148+
);
149+
}
150+
135151
// Relationship management path
136152
paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath(
137153
modelDef,
@@ -306,10 +322,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
306322
tag: string,
307323
): Record<string, any> {
308324
const isCollection = !!fieldDef.array;
325+
const relModelDef = this.schema.models[fieldDef.type];
309326
const params: any[] = [{ $ref: '#/components/parameters/id' }, { $ref: '#/components/parameters/include' }];
310327

311-
if (isCollection && this.schema.models[fieldDef.type]) {
312-
const relModelDef = this.schema.models[fieldDef.type]!;
328+
if (isCollection && relModelDef) {
313329
params.push(
314330
{ $ref: '#/components/parameters/sort' },
315331
{ $ref: '#/components/parameters/pageOffset' },
@@ -318,7 +334,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
318334
);
319335
}
320336

321-
return {
337+
const pathItem: Record<string, any> = {
322338
get: {
323339
tags: [tag],
324340
summary: `Fetch related ${fieldDef.type} for ${modelName}`,
@@ -339,6 +355,144 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
339355
},
340356
},
341357
};
358+
359+
if (this.nestedRoutes && relModelDef) {
360+
const mayDeny = this.mayDenyAccess(relModelDef, isCollection ? 'create' : 'update');
361+
if (isCollection) {
362+
// POST /{model}/{id}/{field} — nested create
363+
pathItem['post'] = {
364+
tags: [tag],
365+
summary: `Create a nested ${fieldDef.type} under ${modelName}`,
366+
operationId: `create${modelName}_${fieldName}`,
367+
parameters: [{ $ref: '#/components/parameters/id' }],
368+
requestBody: {
369+
required: true,
370+
content: {
371+
'application/vnd.api+json': {
372+
schema: { $ref: `#/components/schemas/${fieldDef.type}CreateRequest` },
373+
},
374+
},
375+
},
376+
responses: {
377+
'201': {
378+
description: `Created ${fieldDef.type} resource`,
379+
content: {
380+
'application/vnd.api+json': {
381+
schema: { $ref: `#/components/schemas/${fieldDef.type}Response` },
382+
},
383+
},
384+
},
385+
'400': ERROR_400,
386+
...(mayDeny && { '403': ERROR_403 }),
387+
'422': ERROR_422,
388+
},
389+
};
390+
} else {
391+
// PATCH /{model}/{id}/{field} — nested to-one update
392+
pathItem['patch'] = {
393+
tags: [tag],
394+
summary: `Update nested ${fieldDef.type} under ${modelName}`,
395+
operationId: `update${modelName}_${fieldName}`,
396+
parameters: [{ $ref: '#/components/parameters/id' }],
397+
requestBody: {
398+
required: true,
399+
content: {
400+
'application/vnd.api+json': {
401+
schema: { $ref: `#/components/schemas/${fieldDef.type}UpdateRequest` },
402+
},
403+
},
404+
},
405+
responses: {
406+
'200': {
407+
description: `Updated ${fieldDef.type} resource`,
408+
content: {
409+
'application/vnd.api+json': {
410+
schema: { $ref: `#/components/schemas/${fieldDef.type}Response` },
411+
},
412+
},
413+
},
414+
'400': ERROR_400,
415+
...(mayDeny && { '403': ERROR_403 }),
416+
'404': ERROR_404,
417+
'422': ERROR_422,
418+
},
419+
};
420+
}
421+
}
422+
423+
return pathItem;
424+
}
425+
426+
private buildNestedSinglePath(
427+
modelName: string,
428+
fieldName: string,
429+
fieldDef: FieldDef,
430+
relModelDef: ModelDef,
431+
tag: string,
432+
): Record<string, any> {
433+
const childIdParam = { name: 'childId', in: 'path', required: true, schema: { type: 'string' } };
434+
const idParam = { $ref: '#/components/parameters/id' };
435+
const mayDenyUpdate = this.mayDenyAccess(relModelDef, 'update');
436+
const mayDenyDelete = this.mayDenyAccess(relModelDef, 'delete');
437+
438+
return {
439+
get: {
440+
tags: [tag],
441+
summary: `Get a nested ${fieldDef.type} by ID under ${modelName}`,
442+
operationId: `get${modelName}_${fieldName}_single`,
443+
parameters: [idParam, childIdParam, { $ref: '#/components/parameters/include' }],
444+
responses: {
445+
'200': {
446+
description: `${fieldDef.type} resource`,
447+
content: {
448+
'application/vnd.api+json': {
449+
schema: { $ref: `#/components/schemas/${fieldDef.type}Response` },
450+
},
451+
},
452+
},
453+
'404': ERROR_404,
454+
},
455+
},
456+
patch: {
457+
tags: [tag],
458+
summary: `Update a nested ${fieldDef.type} by ID under ${modelName}`,
459+
operationId: `update${modelName}_${fieldName}_single`,
460+
parameters: [idParam, childIdParam],
461+
requestBody: {
462+
required: true,
463+
content: {
464+
'application/vnd.api+json': {
465+
schema: { $ref: `#/components/schemas/${fieldDef.type}UpdateRequest` },
466+
},
467+
},
468+
},
469+
responses: {
470+
'200': {
471+
description: `Updated ${fieldDef.type} resource`,
472+
content: {
473+
'application/vnd.api+json': {
474+
schema: { $ref: `#/components/schemas/${fieldDef.type}Response` },
475+
},
476+
},
477+
},
478+
'400': ERROR_400,
479+
...(mayDenyUpdate && { '403': ERROR_403 }),
480+
'404': ERROR_404,
481+
'422': ERROR_422,
482+
},
483+
},
484+
delete: {
485+
tags: [tag],
486+
summary: `Delete a nested ${fieldDef.type} by ID under ${modelName}`,
487+
operationId: `delete${modelName}_${fieldName}_single`,
488+
parameters: [idParam, childIdParam],
489+
responses: {
490+
'200': { description: 'Deleted successfully' },
491+
...(mayDenyDelete && { '403': ERROR_403 }),
492+
'404': ERROR_404,
493+
},
494+
},
495+
};
342496
}
343497

344498
private buildRelationshipPath(

packages/server/test/openapi/rest-openapi.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,124 @@ describe('REST OpenAPI spec generation - queryOptions', () => {
567567
});
568568
});
569569

570+
describe('REST OpenAPI spec generation - nestedRoutes', () => {
571+
let handler: RestApiHandler;
572+
let spec: any;
573+
574+
beforeAll(async () => {
575+
const client = await createTestClient(schema);
576+
handler = new RestApiHandler({
577+
schema: client.$schema,
578+
endpoint: 'http://localhost/api',
579+
nestedRoutes: true,
580+
});
581+
spec = await handler.generateSpec();
582+
});
583+
584+
it('does not generate nested single paths when nestedRoutes is false', async () => {
585+
const client = await createTestClient(schema);
586+
const plainHandler = new RestApiHandler({
587+
schema: client.$schema,
588+
endpoint: 'http://localhost/api',
589+
});
590+
const plainSpec = await plainHandler.generateSpec();
591+
expect(plainSpec.paths?.['/user/{id}/posts/{childId}']).toBeUndefined();
592+
expect(plainSpec.paths?.['/post/{id}/comments/{childId}']).toBeUndefined();
593+
// fetch-related path should not have POST on plain handler
594+
expect((plainSpec.paths as any)['/user/{id}/posts']?.post).toBeUndefined();
595+
// fetch-related path should not have PATCH for to-one on plain handler
596+
expect((plainSpec.paths as any)['/post/{id}/setting']?.patch).toBeUndefined();
597+
});
598+
599+
it('generates nested single paths for collection relations', () => {
600+
// User -> posts (collection)
601+
expect(spec.paths['/user/{id}/posts/{childId}']).toBeDefined();
602+
// Post -> comments (collection)
603+
expect(spec.paths['/post/{id}/comments/{childId}']).toBeDefined();
604+
});
605+
606+
it('does not generate nested single paths for to-one relations', () => {
607+
// Post -> setting (to-one)
608+
expect(spec.paths['/post/{id}/setting/{childId}']).toBeUndefined();
609+
// Post -> author (to-one)
610+
expect(spec.paths['/post/{id}/author/{childId}']).toBeUndefined();
611+
});
612+
613+
it('nested single path has GET, PATCH, DELETE', () => {
614+
const path = spec.paths['/user/{id}/posts/{childId}'];
615+
expect(path.get).toBeDefined();
616+
expect(path.patch).toBeDefined();
617+
expect(path.delete).toBeDefined();
618+
});
619+
620+
it('nested single path GET returns single resource response', () => {
621+
const getOp = spec.paths['/user/{id}/posts/{childId}'].get;
622+
const schema = getOp.responses['200'].content['application/vnd.api+json'].schema;
623+
expect(schema.$ref).toBe('#/components/schemas/PostResponse');
624+
});
625+
626+
it('nested single path PATCH uses UpdateRequest body', () => {
627+
const patchOp = spec.paths['/user/{id}/posts/{childId}'].patch;
628+
const schema = patchOp.requestBody.content['application/vnd.api+json'].schema;
629+
expect(schema.$ref).toBe('#/components/schemas/PostUpdateRequest');
630+
});
631+
632+
it('nested single path has childId path parameter', () => {
633+
const getOp = spec.paths['/user/{id}/posts/{childId}'].get;
634+
const params = getOp.parameters;
635+
const childIdParam = params.find((p: any) => p.name === 'childId');
636+
expect(childIdParam).toBeDefined();
637+
expect(childIdParam.in).toBe('path');
638+
expect(childIdParam.required).toBe(true);
639+
});
640+
641+
it('fetch-related path has POST for collection relation when nestedRoutes enabled', () => {
642+
const postsPath = spec.paths['/user/{id}/posts'];
643+
expect(postsPath.get).toBeDefined();
644+
expect(postsPath.post).toBeDefined();
645+
});
646+
647+
it('fetch-related POST uses CreateRequest body', () => {
648+
const postOp = spec.paths['/user/{id}/posts'].post;
649+
const schema = postOp.requestBody.content['application/vnd.api+json'].schema;
650+
expect(schema.$ref).toBe('#/components/schemas/PostCreateRequest');
651+
});
652+
653+
it('fetch-related POST returns 201 with resource response', () => {
654+
const postOp = spec.paths['/user/{id}/posts'].post;
655+
const schema = postOp.responses['201'].content['application/vnd.api+json'].schema;
656+
expect(schema.$ref).toBe('#/components/schemas/PostResponse');
657+
});
658+
659+
it('fetch-related path has PATCH for to-one relation when nestedRoutes enabled', () => {
660+
// Post -> setting is to-one
661+
const settingPath = spec.paths['/post/{id}/setting'];
662+
expect(settingPath.get).toBeDefined();
663+
expect(settingPath.patch).toBeDefined();
664+
// to-one should not get POST (no nested create for to-one)
665+
expect(settingPath.post).toBeUndefined();
666+
});
667+
668+
it('fetch-related PATCH for to-one uses UpdateRequest body', () => {
669+
const patchOp = spec.paths['/post/{id}/setting'].patch;
670+
const schema = patchOp.requestBody.content['application/vnd.api+json'].schema;
671+
expect(schema.$ref).toBe('#/components/schemas/SettingUpdateRequest');
672+
});
673+
674+
it('operationIds are unique for nested paths', () => {
675+
const allOperationIds: string[] = [];
676+
for (const pathItem of Object.values(spec.paths as Record<string, any>)) {
677+
for (const method of ['get', 'post', 'patch', 'put', 'delete']) {
678+
if (pathItem[method]?.operationId) {
679+
allOperationIds.push(pathItem[method].operationId);
680+
}
681+
}
682+
}
683+
const unique = new Set(allOperationIds);
684+
expect(unique.size).toBe(allOperationIds.length);
685+
});
686+
});
687+
570688
describe('REST OpenAPI spec generation - @meta description', () => {
571689
const metaSchema = `
572690
model User {

0 commit comments

Comments
 (0)