Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 171 additions & 6 deletions packages/server/src/api/rest/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
return this.handlerOptions?.queryOptions;
}

private get nestedRoutes(): boolean {
return this.handlerOptions.nestedRoutes ?? false;
}

generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document {
this.specOptions = options;
return {
Expand Down Expand Up @@ -124,14 +128,28 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
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,
Expand Down Expand Up @@ -299,17 +317,17 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
return result;
}

private buildFetchRelatedPath(
private buildRelatedPath(
modelName: string,
fieldName: string,
fieldDef: FieldDef,
tag: string,
): Record<string, any> {
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' },
Expand All @@ -318,7 +336,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
);
}

return {
const pathItem: Record<string, any> = {
get: {
tags: [tag],
summary: `Fetch related ${fieldDef.type} for ${modelName}`,
Expand All @@ -339,6 +357,153 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
},
},
};

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<string, any> {
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<string, any> = {};

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(
Expand Down
157 changes: 157 additions & 0 deletions packages/server/test/openapi/rest-openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>)) {
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 {
Expand Down
Loading