From 89aa9d2913a3101091b240789a3e00cbd8858da7 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Wed, 25 Mar 2026 18:28:20 +0100 Subject: [PATCH] add nestedRoutes support for openapi spec generation --- packages/server/src/api/rest/openapi.ts | 177 +++++++++++++++++- .../server/test/openapi/rest-openapi.test.ts | 157 ++++++++++++++++ 2 files changed, 328 insertions(+), 6 deletions(-) diff --git a/packages/server/src/api/rest/openapi.ts b/packages/server/src/api/rest/openapi.ts index 9c5bae03f..45986ceae 100644 --- a/packages/server/src/api/rest/openapi.ts +++ b/packages/server/src/api/rest/openapi.ts @@ -61,6 +61,10 @@ export class RestApiSpecGenerator { return this.handlerOptions?.queryOptions; } + private get nestedRoutes(): boolean { + return this.handlerOptions.nestedRoutes ?? false; + } + generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document { this.specOptions = options; return { @@ -124,14 +128,28 @@ export class RestApiSpecGenerator { const relIdFields = this.getIdFields(relModelDef); if (relIdFields.length === 0) continue; - // GET /{model}/{id}/{field} — fetch related - paths[`/${modelPath}/{id}/${fieldName}`] = this.buildFetchRelatedPath( + // GET /{model}/{id}/{field} — fetch related (+ nested create/update when nestedRoutes enabled) + paths[`/${modelPath}/{id}/${fieldName}`] = this.buildRelatedPath( modelName, fieldName, fieldDef, tag, ); + // Nested single resource path: /{model}/{id}/{field}/{childId} + if (this.nestedRoutes && fieldDef.array) { + const nestedSinglePath = this.buildNestedSinglePath( + modelName, + fieldName, + fieldDef, + relModelDef, + tag, + ); + if (Object.keys(nestedSinglePath).length > 0) { + paths[`/${modelPath}/{id}/${fieldName}/{childId}`] = nestedSinglePath; + } + } + // Relationship management path paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath( modelDef, @@ -299,17 +317,17 @@ export class RestApiSpecGenerator { return result; } - private buildFetchRelatedPath( + private buildRelatedPath( modelName: string, fieldName: string, fieldDef: FieldDef, tag: string, ): Record { const isCollection = !!fieldDef.array; + const relModelDef = this.schema.models[fieldDef.type]; const params: any[] = [{ $ref: '#/components/parameters/id' }, { $ref: '#/components/parameters/include' }]; - if (isCollection && this.schema.models[fieldDef.type]) { - const relModelDef = this.schema.models[fieldDef.type]!; + if (isCollection && relModelDef) { params.push( { $ref: '#/components/parameters/sort' }, { $ref: '#/components/parameters/pageOffset' }, @@ -318,7 +336,7 @@ export class RestApiSpecGenerator { ); } - return { + const pathItem: Record = { get: { tags: [tag], summary: `Fetch related ${fieldDef.type} for ${modelName}`, @@ -339,6 +357,153 @@ export class RestApiSpecGenerator { }, }, }; + + if (this.nestedRoutes && relModelDef) { + const mayDeny = this.mayDenyAccess(relModelDef, isCollection ? 'create' : 'update'); + if (isCollection && isOperationIncluded(fieldDef.type, 'create', this.queryOptions)) { + // POST /{model}/{id}/{field} — nested create + pathItem['post'] = { + tags: [tag], + summary: `Create a nested ${fieldDef.type} under ${modelName}`, + operationId: `create${modelName}_${fieldName}`, + parameters: [{ $ref: '#/components/parameters/id' }], + requestBody: { + required: true, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}CreateRequest` }, + }, + }, + }, + responses: { + '201': { + description: `Created ${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), + '422': ERROR_422, + }, + }; + } else if (!isCollection && isOperationIncluded(fieldDef.type, 'update', this.queryOptions)) { + // PATCH /{model}/{id}/{field} — nested to-one update + pathItem['patch'] = { + tags: [tag], + summary: `Update nested ${fieldDef.type} under ${modelName}`, + operationId: `update${modelName}_${fieldName}`, + parameters: [{ $ref: '#/components/parameters/id' }], + requestBody: { + required: true, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}UpdateRequest` }, + }, + }, + }, + responses: { + '200': { + description: `Updated ${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '400': ERROR_400, + ...(mayDeny && { '403': ERROR_403 }), + '404': ERROR_404, + '422': ERROR_422, + }, + }; + } + } + + return pathItem; + } + + private buildNestedSinglePath( + modelName: string, + fieldName: string, + fieldDef: FieldDef, + relModelDef: ModelDef, + tag: string, + ): Record { + const childIdParam = { name: 'childId', in: 'path', required: true, schema: { type: 'string' } }; + const idParam = { $ref: '#/components/parameters/id' }; + const mayDenyUpdate = this.mayDenyAccess(relModelDef, 'update'); + const mayDenyDelete = this.mayDenyAccess(relModelDef, 'delete'); + const result: Record = {}; + + if (isOperationIncluded(fieldDef.type, 'findUnique', this.queryOptions)) { + result['get'] = { + tags: [tag], + summary: `Get a nested ${fieldDef.type} by ID under ${modelName}`, + operationId: `get${modelName}_${fieldName}_single`, + parameters: [idParam, childIdParam, { $ref: '#/components/parameters/include' }], + responses: { + '200': { + description: `${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '404': ERROR_404, + }, + }; + } + + if (isOperationIncluded(fieldDef.type, 'update', this.queryOptions)) { + result['patch'] = { + tags: [tag], + summary: `Update a nested ${fieldDef.type} by ID under ${modelName}`, + operationId: `update${modelName}_${fieldName}_single`, + parameters: [idParam, childIdParam], + requestBody: { + required: true, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}UpdateRequest` }, + }, + }, + }, + responses: { + '200': { + description: `Updated ${fieldDef.type} resource`, + content: { + 'application/vnd.api+json': { + schema: { $ref: `#/components/schemas/${fieldDef.type}Response` }, + }, + }, + }, + '400': ERROR_400, + ...(mayDenyUpdate && { '403': ERROR_403 }), + '404': ERROR_404, + '422': ERROR_422, + }, + }; + } + + if (isOperationIncluded(fieldDef.type, 'delete', this.queryOptions)) { + result['delete'] = { + tags: [tag], + summary: `Delete a nested ${fieldDef.type} by ID under ${modelName}`, + operationId: `delete${modelName}_${fieldName}_single`, + parameters: [idParam, childIdParam], + responses: { + '200': { description: 'Deleted successfully' }, + ...(mayDenyDelete && { '403': ERROR_403 }), + '404': ERROR_404, + }, + }; + } + + return result; } private buildRelationshipPath( diff --git a/packages/server/test/openapi/rest-openapi.test.ts b/packages/server/test/openapi/rest-openapi.test.ts index 4f16c3556..8f6f3fa69 100644 --- a/packages/server/test/openapi/rest-openapi.test.ts +++ b/packages/server/test/openapi/rest-openapi.test.ts @@ -567,6 +567,163 @@ describe('REST OpenAPI spec generation - queryOptions', () => { }); }); +describe('REST OpenAPI spec generation - nestedRoutes', () => { + let handler: RestApiHandler; + let spec: any; + + beforeAll(async () => { + const client = await createTestClient(schema); + handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: true, + }); + spec = await handler.generateSpec(); + }); + + it('does not generate nested single paths when nestedRoutes is false', async () => { + const client = await createTestClient(schema); + const plainHandler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + }); + const plainSpec = await plainHandler.generateSpec(); + expect(plainSpec.paths?.['/user/{id}/posts/{childId}']).toBeUndefined(); + expect(plainSpec.paths?.['/post/{id}/comments/{childId}']).toBeUndefined(); + // fetch-related path should not have POST on plain handler + expect((plainSpec.paths as any)['/user/{id}/posts']?.post).toBeUndefined(); + // fetch-related path should not have PATCH for to-one on plain handler + expect((plainSpec.paths as any)['/post/{id}/setting']?.patch).toBeUndefined(); + }); + + it('generates nested single paths for collection relations', () => { + // User -> posts (collection) + expect(spec.paths['/user/{id}/posts/{childId}']).toBeDefined(); + // Post -> comments (collection) + expect(spec.paths['/post/{id}/comments/{childId}']).toBeDefined(); + // User -> likes (collection, compound-ID child: PostLike has @@id([postId, userId])) + expect(spec.paths['/user/{id}/likes/{childId}']).toBeDefined(); + }); + + it('does not generate nested single paths for to-one relations', () => { + // Post -> setting (to-one) + expect(spec.paths['/post/{id}/setting/{childId}']).toBeUndefined(); + // Post -> author (to-one) + expect(spec.paths['/post/{id}/author/{childId}']).toBeUndefined(); + }); + + it('nested single path has GET, PATCH, DELETE', () => { + const path = spec.paths['/user/{id}/posts/{childId}']; + expect(path.get).toBeDefined(); + expect(path.patch).toBeDefined(); + expect(path.delete).toBeDefined(); + }); + + it('nested single path GET returns single resource response', () => { + const getOp = spec.paths['/user/{id}/posts/{childId}'].get; + const schema = getOp.responses['200'].content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostResponse'); + }); + + it('nested single path PATCH uses UpdateRequest body', () => { + const patchOp = spec.paths['/user/{id}/posts/{childId}'].patch; + const schema = patchOp.requestBody.content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostUpdateRequest'); + }); + + it('nested single path has childId path parameter', () => { + const getOp = spec.paths['/user/{id}/posts/{childId}'].get; + const params = getOp.parameters; + const childIdParam = params.find((p: any) => p.name === 'childId'); + expect(childIdParam).toBeDefined(); + expect(childIdParam.in).toBe('path'); + expect(childIdParam.required).toBe(true); + }); + + it('fetch-related path has POST for collection relation when nestedRoutes enabled', () => { + const postsPath = spec.paths['/user/{id}/posts']; + expect(postsPath.get).toBeDefined(); + expect(postsPath.post).toBeDefined(); + }); + + it('fetch-related POST uses CreateRequest body', () => { + const postOp = spec.paths['/user/{id}/posts'].post; + const schema = postOp.requestBody.content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostCreateRequest'); + }); + + it('fetch-related POST returns 201 with resource response', () => { + const postOp = spec.paths['/user/{id}/posts'].post; + const schema = postOp.responses['201'].content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/PostResponse'); + }); + + it('fetch-related path has PATCH for to-one relation when nestedRoutes enabled', () => { + // Post -> setting is to-one + const settingPath = spec.paths['/post/{id}/setting']; + expect(settingPath.get).toBeDefined(); + expect(settingPath.patch).toBeDefined(); + // to-one should not get POST (no nested create for to-one) + expect(settingPath.post).toBeUndefined(); + }); + + it('fetch-related PATCH for to-one uses UpdateRequest body', () => { + const patchOp = spec.paths['/post/{id}/setting'].patch; + const schema = patchOp.requestBody.content['application/vnd.api+json'].schema; + expect(schema.$ref).toBe('#/components/schemas/SettingUpdateRequest'); + }); + + it('fetch-related path does not have PATCH for to-many (collection) relation', () => { + // User -> posts is a to-many relation; PATCH should only be generated for to-one + const postsPath = spec.paths['/user/{id}/posts']; + expect(postsPath.patch).toBeUndefined(); + }); + + it('spec passes OpenAPI 3.1 validation', async () => { + // Deep clone to avoid validate() mutating $ref strings in the shared spec object + await validate(JSON.parse(JSON.stringify(spec))); + }); + + it('operationIds are unique for nested paths', () => { + const allOperationIds: string[] = []; + for (const pathItem of Object.values(spec.paths as Record)) { + for (const method of ['get', 'post', 'patch', 'put', 'delete']) { + if (pathItem[method]?.operationId) { + allOperationIds.push(pathItem[method].operationId); + } + } + } + const unique = new Set(allOperationIds); + expect(unique.size).toBe(allOperationIds.length); + }); + + it('nestedRoutes respects queryOptions slicing excludedOperations', async () => { + const client = await createTestClient(schema); + const slicedHandler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: true, + queryOptions: { + slicing: { + models: { + post: { excludedOperations: ['create', 'delete', 'update'] }, + }, + } as any, + }, + }); + const s = await slicedHandler.generateSpec(); + + // Nested create (POST /user/{id}/posts) should be absent + expect((s.paths as any)['/user/{id}/posts']?.post).toBeUndefined(); + // Nested single GET should still exist (findUnique not excluded) + expect((s.paths as any)['/user/{id}/posts/{childId}']?.get).toBeDefined(); + // Nested single DELETE should be absent + expect((s.paths as any)['/user/{id}/posts/{childId}']?.delete).toBeUndefined(); + // Nested single PATCH (update) should be absent + expect((s.paths as any)['/user/{id}/posts/{childId}']?.patch).toBeUndefined(); + }); +}); + describe('REST OpenAPI spec generation - @meta description', () => { const metaSchema = ` model User {