Skip to content

Commit 6bb3452

Browse files
committed
add nestedRoutes support for openapi spec generation
1 parent 826cc4e commit 6bb3452

File tree

2 files changed

+312
-7
lines changed

2 files changed

+312
-7
lines changed

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

Lines changed: 170 additions & 7 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,28 @@ 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 nestedSinglePath = this.buildNestedSinglePath(
142+
modelName,
143+
fieldName,
144+
fieldDef,
145+
relModelDef,
146+
tag,
147+
);
148+
if (Object.keys(nestedSinglePath).length > 0) {
149+
paths[`/${modelPath}/{id}/${fieldName}/{childId}`] = nestedSinglePath;
150+
}
151+
}
152+
135153
// Relationship management path
136154
paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath(
137155
modelDef,
@@ -306,10 +324,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
306324
tag: string,
307325
): Record<string, any> {
308326
const isCollection = !!fieldDef.array;
327+
const relModelDef = this.schema.models[fieldDef.type];
309328
const params: any[] = [{ $ref: '#/components/parameters/id' }, { $ref: '#/components/parameters/include' }];
310329

311-
if (isCollection && this.schema.models[fieldDef.type]) {
312-
const relModelDef = this.schema.models[fieldDef.type]!;
330+
if (isCollection && relModelDef) {
313331
params.push(
314332
{ $ref: '#/components/parameters/sort' },
315333
{ $ref: '#/components/parameters/pageOffset' },
@@ -318,7 +336,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
318336
);
319337
}
320338

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

344509
private buildRelationshipPath(
@@ -1073,9 +1238,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10731238
});
10741239
if (hasEffectiveDeny) return true;
10751240

1076-
const relevantAllow = policyAttrs.filter(
1077-
(attr) => attr.name === '@@allow' && matchesOperation(attr.args),
1078-
);
1241+
const relevantAllow = policyAttrs.filter((attr) => attr.name === '@@allow' && matchesOperation(attr.args));
10791242

10801243
// If any allow rule has a constant `true` condition (and no deny), access is unconditional
10811244
const hasConstantAllow = relevantAllow.some((attr) => {

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,148 @@ 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+
it('nestedRoutes respects queryOptions slicing excludedOperations', async () => {
688+
const client = await createTestClient(schema);
689+
const slicedHandler = new RestApiHandler({
690+
schema: client.$schema,
691+
endpoint: 'http://localhost/api',
692+
nestedRoutes: true,
693+
queryOptions: {
694+
slicing: {
695+
models: {
696+
post: { excludedOperations: ['create', 'delete'] },
697+
},
698+
} as any,
699+
},
700+
});
701+
const s = await slicedHandler.generateSpec();
702+
703+
// Nested create (POST /user/{id}/posts) should be absent
704+
expect((s.paths as any)['/user/{id}/posts']?.post).toBeUndefined();
705+
// Nested single GET should still exist (findUnique not excluded)
706+
expect((s.paths as any)['/user/{id}/posts/{childId}']?.get).toBeDefined();
707+
// Nested single DELETE should be absent
708+
expect((s.paths as any)['/user/{id}/posts/{childId}']?.delete).toBeUndefined();
709+
});
710+
});
711+
570712
describe('REST OpenAPI spec generation - @meta description', () => {
571713
const metaSchema = `
572714
model User {

0 commit comments

Comments
 (0)