From ac413008b2b7efedde0089a1a0fc9d6d21e5a338 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 19 Mar 2026 11:50:22 +0100 Subject: [PATCH 1/3] Add nested routes support in the RestApiHandler --- packages/server/src/api/rest/index.ts | 521 +++++++++++++++- .../test/api/options-validation.test.ts | 196 ++++++ packages/server/test/api/rest.test.ts | 580 ++++++++++++++++++ 3 files changed, 1281 insertions(+), 16 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index c2723f061..6d5cadb49 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -64,6 +64,28 @@ export type RestApiHandlerOptions = { * Mapping from model names to unique field name to be used as resource's ID. */ externalIdMapping?: Record; + + /** + * Explicit nested route configuration. + * + * First-level keys are parent model names, second-level keys are relation field names on the parent model + * (e.g., `posts` for `User.posts`). This matches the URL segment used in nested routes: + * `/:parentType/:parentId/:relationName` and `/:parentType/:parentId/:relationName/:childId`. + */ + nestedRoutes?: Record< + string, + Record< + string, + { + /** + * When `true`, the constructor throws if the configured relation does not have an `onDelete` + * action of `Cascade`, `Restrict`, or `NoAction` in the schema. This ensures the database + * prevents orphaned child records when a parent is deleted. + */ + requireOrphanProtection?: boolean; + } + > + >; }; type RelationshipInfo = { @@ -84,10 +106,12 @@ type Match = { type: string; id: string; relationship: string; + childId?: string; }; enum UrlPatterns { SINGLE = 'single', + NESTED_SINGLE = 'nestedSingle', FETCH_RELATIONSHIP = 'fetchRelationship', RELATIONSHIP = 'relationship', COLLECTION = 'collection', @@ -262,6 +286,7 @@ export class RestApiHandler implements Api private modelNameMapping: Record; private reverseModelNameMapping: Record; private externalIdMapping: Record; + private nestedRoutes: Record>; constructor(private readonly options: RestApiHandlerOptions) { this.validateOptions(options); @@ -282,9 +307,20 @@ export class RestApiHandler implements Api Object.entries(this.externalIdMapping).map(([k, v]) => [lowerCaseFirst(k), v]), ); + this.nestedRoutes = options.nestedRoutes ?? {}; + this.nestedRoutes = Object.fromEntries( + Object.entries(this.nestedRoutes).map(([parentModel, children]) => [ + lowerCaseFirst(parentModel), + Object.fromEntries( + Object.entries(children).map(([childModel, config]) => [lowerCaseFirst(childModel), config]), + ), + ]), + ); + this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); this.buildTypeMap(); + this.validateNestedRoutes(); this.buildSerializers(); } @@ -298,6 +334,9 @@ export class RestApiHandler implements Api urlSegmentCharset: z.string().min(1).optional(), modelNameMapping: z.record(z.string(), z.string()).optional(), externalIdMapping: z.record(z.string(), z.string()).optional(), + nestedRoutes: z + .record(z.string(), z.record(z.string(), z.object({ requireOrphanProtection: z.boolean().optional() }))) + .optional(), }); const parseResult = schema.safeParse(options); if (!parseResult.success) { @@ -305,6 +344,45 @@ export class RestApiHandler implements Api } } + private validateNestedRoutes() { + for (const [parentModel, relations] of Object.entries(this.nestedRoutes)) { + const parentInfo = this.getModelInfo(parentModel); + if (!parentInfo) { + throw new Error(`Invalid nestedRoutes: parent model "${parentModel}" not found in schema`); + } + for (const [relationName, config] of Object.entries(relations)) { + const parentField: FieldDef | undefined = this.schema.models[parentInfo.name]?.fields[relationName]; + if (!parentField?.relation) { + throw new Error( + `Invalid nestedRoutes: relation "${relationName}" not found on parent model "${parentModel}"`, + ); + } + const reverseRelation = parentField.relation.opposite; + if (!reverseRelation) { + throw new Error( + `Invalid nestedRoutes: relation "${parentModel}.${relationName}" has no opposite relation defined`, + ); + } + if (!parentField.array) { + throw new Error( + `Invalid nestedRoutes: relation "${parentModel}.${relationName}" is a to-one relation — nested routes only support to-many relations`, + ); + } + if (config.requireOrphanProtection) { + const childModelName = parentField.type; + const onDelete = this.schema.models[childModelName]?.fields[reverseRelation]?.relation?.onDelete; + const safeActions = ['Cascade', 'Restrict', 'NoAction']; + if (!onDelete || !safeActions.includes(onDelete)) { + throw new Error( + `Invalid nestedRoutes: requireOrphanProtection is enabled for "${parentModel}.${relationName}" ` + + `but its onDelete action is "${onDelete ?? 'not set'}" — must be Cascade, Restrict, or NoAction`, + ); + } + } + } + } + } + get schema() { return this.options.schema; } @@ -322,6 +400,10 @@ export class RestApiHandler implements Api return { [UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options), + [UrlPatterns.NESTED_SINGLE]: new UrlPattern( + buildPath([':type', ':id', ':relationship', ':childId']), + options, + ), [UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options), [UrlPatterns.RELATIONSHIP]: new UrlPattern( buildPath([':type', ':id', 'relationships', ':relationship']), @@ -335,6 +417,74 @@ export class RestApiHandler implements Api return this.modelNameMapping[modelName] ?? modelName; } + private getNestedRouteConfig(parentType: string, parentRelation: string) { + return this.nestedRoutes[lowerCaseFirst(parentType)]?.[parentRelation]; + } + + /** + * Resolves child model type and reverse relation from a parent relation name. + * e.g. given parentType='user', parentRelation='posts', returns { childType:'post', reverseRelation:'author' } + */ + private resolveNestedRelation( + parentType: string, + parentRelation: string, + ): { childType: string; reverseRelation: string; isCollection: boolean } | undefined { + const parentInfo = this.getModelInfo(parentType); + if (!parentInfo) return undefined; + const field: FieldDef | undefined = this.schema.models[parentInfo.name]?.fields[parentRelation]; + if (!field?.relation) return undefined; + const reverseRelation = field.relation.opposite; + if (!reverseRelation) return undefined; + return { childType: lowerCaseFirst(field.type), reverseRelation, isCollection: !!field.array }; + } + + private mergeFilters(left: any, right: any) { + if (!left) { + return right; + } + if (!right) { + return left; + } + return { AND: [left, right] }; + } + + /** + * Builds a WHERE filter for the child model that constrains results to those belonging to the given parent. + * @param parentType lowercased parent model name + * @param parentId parent resource ID string + * @param parentRelation relation field name on the parent model (e.g. 'posts') + */ + private buildNestedParentFilter(parentType: string, parentId: string, parentRelation: string) { + const parentInfo = this.getModelInfo(parentType); + if (!parentInfo) { + return { filter: undefined, error: this.makeUnsupportedModelError(parentType) }; + } + + const resolved = this.resolveNestedRelation(parentType, parentRelation); + if (!resolved) { + return { + filter: undefined, + error: this.makeError( + 'invalidPath', + `invalid nested route: cannot resolve relation "${parentType}.${parentRelation}"`, + ), + }; + } + + const { reverseRelation } = resolved; + const childInfo = this.getModelInfo(resolved.childType); + if (!childInfo) { + return { filter: undefined, error: this.makeUnsupportedModelError(resolved.childType) }; + } + + const reverseRelInfo = childInfo.relationships[reverseRelation]; + const relationFilter = reverseRelInfo?.isCollection + ? { [reverseRelation]: { some: this.makeIdFilter(parentInfo.idFields, parentId, false) } } + : { [reverseRelation]: { is: this.makeIdFilter(parentInfo.idFields, parentId, false) } }; + + return { filter: relationFilter, error: undefined }; + } + private matchUrlPattern(path: string, routeType: UrlPatterns): Match | undefined { const pattern = this.urlPatternMap[routeType]; if (!pattern) { @@ -396,6 +546,18 @@ export class RestApiHandler implements Api ); } + // /:type/:id/:relationship/:childId — nested single read + match = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if (match && this.getNestedRouteConfig(match.type, match.relationship)) { + return await this.processNestedSingleRead( + client, + match.type, + match.id, + match.relationship, + match.childId!, + query, + ); + } match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { // collection read @@ -409,6 +571,18 @@ export class RestApiHandler implements Api if (!requestBody) { return this.makeError('invalidPayload'); } + // /:type/:id/:relationship — nested create + const nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); + if (nestedMatch && this.getNestedRouteConfig(nestedMatch.type, nestedMatch.relationship)) { + return await this.processNestedCreate( + client, + nestedMatch.type, + nestedMatch.id, + nestedMatch.relationship, + query, + requestBody, + ); + } let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { const body = requestBody as any; @@ -444,12 +618,8 @@ export class RestApiHandler implements Api if (!requestBody) { return this.makeError('invalidPayload'); } - let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); - if (match) { - // resource update - return await this.processUpdate(client, match.type, match.id, query, requestBody); - } - match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); + // Check RELATIONSHIP before NESTED_SINGLE to avoid ambiguity on /:type/:id/relationships/:rel + let match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); if (match) { // relationship update return await this.processRelationshipCRUD( @@ -462,18 +632,33 @@ export class RestApiHandler implements Api requestBody, ); } - + // /:type/:id/:relationship/:childId — nested update + const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if ( + nestedPatchMatch && + this.getNestedRouteConfig(nestedPatchMatch.type, nestedPatchMatch.relationship) + ) { + return await this.processNestedUpdate( + client, + nestedPatchMatch.type, + nestedPatchMatch.id, + nestedPatchMatch.relationship, + nestedPatchMatch.childId!, + query, + requestBody, + ); + } + match = this.matchUrlPattern(path, UrlPatterns.SINGLE); + if (match) { + // resource update + return await this.processUpdate(client, match.type, match.id, query, requestBody); + } return this.makeError('invalidPath'); } case 'DELETE': { - let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); - if (match) { - // resource deletion - return await this.processDelete(client, match.type, match.id); - } - - match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); + // Check RELATIONSHIP before NESTED_SINGLE to avoid ambiguity on /:type/:id/relationships/:rel + let match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); if (match) { // relationship deletion (collection relationship only) return await this.processRelationshipCRUD( @@ -486,7 +671,25 @@ export class RestApiHandler implements Api requestBody, ); } - + // /:type/:id/:relationship/:childId — nested delete (one-to-many child) + const nestedDeleteMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if ( + nestedDeleteMatch && + this.getNestedRouteConfig(nestedDeleteMatch.type, nestedDeleteMatch.relationship) + ) { + return await this.processNestedDelete( + client, + nestedDeleteMatch.type, + nestedDeleteMatch.id, + nestedDeleteMatch.relationship, + nestedDeleteMatch.childId!, + ); + } + match = this.matchUrlPattern(path, UrlPatterns.SINGLE); + if (match) { + // resource deletion + return await this.processDelete(client, match.type, match.id); + } return this.makeError('invalidPath'); } @@ -870,7 +1073,13 @@ export class RestApiHandler implements Api if (limit === Infinity) { const entities = await (client as any)[type].findMany(args); - const body = await this.serializeItems(type, entities, { include }); + const mappedType = this.mapModelName(type); + const body = await this.serializeItems(type, entities, { + include, + linkers: { + document: new tsjapi.Linker(() => this.makeLinkUrl(`/${mappedType}`)), + }, + }); const total = entities.length; body.meta = this.addTotalCountToMeta(body.meta, total); @@ -892,6 +1101,7 @@ export class RestApiHandler implements Api const options: Partial = { include, linkers: { + document: new tsjapi.Linker(() => this.makeLinkUrl(`/${mappedType}`)), paginator: this.makePaginator(url, offset, limit, total), }, }; @@ -905,6 +1115,285 @@ export class RestApiHandler implements Api } } + /** + * Builds link URL for a nested resource using parent type, parent ID, relation name, and optional child ID. + * Uses the parent model name mapping for the parent segment; the relation name is used as-is. + */ + private makeNestedLinkUrl(parentType: string, parentId: string, parentRelation: string, childId?: string) { + const mappedParentType = this.mapModelName(parentType); + const base = `/${mappedParentType}/${parentId}/${parentRelation}`; + return childId ? `${base}/${childId}` : base; + } + + private async processNestedSingleRead( + client: ClientContract, + parentType: string, + parentId: string, + parentRelation: string, + childId: string, + query: Record | undefined, + ): Promise { + const resolved = this.resolveNestedRelation(parentType, parentRelation); + if (!resolved) { + return this.makeError('invalidPath'); + } + + const { filter: nestedFilter, error: nestedError } = this.buildNestedParentFilter( + parentType, + parentId, + parentRelation, + ); + if (nestedError) return nestedError; + + const childType = resolved.childType; + const typeInfo = this.getModelInfo(childType)!; + + const args: any = { + where: this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter), + }; + this.includeRelationshipIds(childType, args, 'include'); + + let include: string[] | undefined; + if (query?.['include']) { + const { select, error, allIncludes } = this.buildRelationSelect(childType, query['include'], query); + if (error) return error; + if (select) args.include = { ...args.include, ...select }; + include = allIncludes; + } + + const { select, error } = this.buildPartialSelect(childType, query); + if (error) return error; + if (select) { + args.select = { ...select, ...args.select }; + if (args.include) { + args.select = { ...args.select, ...args.include }; + args.include = undefined; + } + } + + const entity = await (client as any)[childType].findFirst(args); + if (!entity) return this.makeError('notFound'); + + const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId)); + const nestedLinker = new tsjapi.Linker(() => linkUrl); + return { + status: 200, + body: await this.serializeItems(childType, entity, { + include, + linkers: { document: nestedLinker, resource: nestedLinker }, + }), + }; + } + + private async processNestedCreate( + client: ClientContract, + parentType: string, + parentId: string, + parentRelation: string, + _query: Record | undefined, + requestBody: unknown, + ): Promise { + const resolved = this.resolveNestedRelation(parentType, parentRelation); + if (!resolved) { + return this.makeError('invalidPath'); + } + + const parentInfo = this.getModelInfo(parentType)!; + const childType = resolved.childType; + const childInfo = this.getModelInfo(childType)!; + + const { attributes, relationships, error } = this.processRequestBody(requestBody); + if (error) return error; + + const createData: any = { ...attributes }; + + // Turn relationship payload into `connect` objects, rejecting the parent relation + if (relationships) { + for (const [key, data] of Object.entries(relationships)) { + if (!data?.data) { + return this.makeError('invalidRelationData'); + } + if (key === resolved.reverseRelation) { + return this.makeError( + 'invalidPayload', + `Relation "${key}" is controlled by the parent route and cannot be set in the request payload`, + ); + } + const relationInfo = childInfo.relationships[key]; + if (!relationInfo) { + return this.makeUnsupportedRelationshipError(childType, key, 400); + } + if (relationInfo.isCollection) { + createData[key] = { + connect: enumerate(data.data).map((item: any) => + this.makeIdConnect(relationInfo.idFields, item.id), + ), + }; + } else { + if (typeof data.data !== 'object') { + return this.makeError('invalidRelationData'); + } + createData[key] = { connect: this.makeIdConnect(relationInfo.idFields, data.data.id) }; + } + } + } + + // Reject scalar FK fields in attributes that would override the parent relation + const parentFkFields = Object.values(childInfo.fields).filter((f) => + f.foreignKeyFor?.includes(resolved.reverseRelation), + ); + if (parentFkFields.some((f) => Object.prototype.hasOwnProperty.call(createData, f.name))) { + return this.makeError( + 'invalidPayload', + `Relation "${resolved.reverseRelation}" is controlled by the parent route and cannot be set in the request payload`, + ); + } + + // Atomically create child nested in parent update; ORM throws NOT_FOUND if parent doesn't exist + await (client as any)[parentType].update({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + data: { [parentRelation]: { create: createData } }, + }); + + // Fetch the created child — most recently created for this parent + const { filter: nestedFilter, error: filterError } = this.buildNestedParentFilter( + parentType, + parentId, + parentRelation, + ); + if (filterError) return filterError; + + const fetchArgs: any = { where: nestedFilter }; + this.includeRelationshipIds(childType, fetchArgs, 'include'); + if (childInfo.idFields[0]) { + fetchArgs.orderBy = { [childInfo.idFields[0].name]: 'desc' }; + } + + const entity = await (client as any)[childType].findFirst(fetchArgs); + if (!entity) return this.makeError('notFound'); + + const collectionPath = this.makeNestedLinkUrl(parentType, parentId, parentRelation); + const resourceLinker = new tsjapi.Linker((item: any) => + this.makeLinkUrl(`${collectionPath}/${this.getId(childInfo.name, item)}`), + ); + return { + status: 201, + body: await this.serializeItems(childType, entity, { + linkers: { document: resourceLinker, resource: resourceLinker }, + }), + }; + } + + private async processNestedUpdate( + client: ClientContract, + parentType: string, + parentId: string, + parentRelation: string, + childId: string, + _query: Record | undefined, + requestBody: unknown, + ): Promise { + const resolved = this.resolveNestedRelation(parentType, parentRelation); + if (!resolved) { + return this.makeError('invalidPath'); + } + + const parentInfo = this.getModelInfo(parentType)!; + const childType = resolved.childType; + const typeInfo = this.getModelInfo(childType)!; + const rev = resolved.reverseRelation; + + const { attributes, relationships, error } = this.processRequestBody(requestBody); + if (error) return error; + + const updateData: any = { ...attributes }; + + // Reject attempts to change the parent relation via the nested endpoint + if (relationships && Object.prototype.hasOwnProperty.call(relationships, rev)) { + return this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`); + } + const fkFields = Object.values(typeInfo.fields).filter((f) => f.foreignKeyFor?.includes(rev)); + if (fkFields.some((f) => Object.prototype.hasOwnProperty.call(updateData, f.name))) { + return this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`); + } + + // Turn relationship payload into connect/set objects + if (relationships) { + for (const [key, data] of Object.entries(relationships)) { + if (!data?.data) { + return this.makeError('invalidRelationData'); + } + const relationInfo = typeInfo.relationships[key]; + if (!relationInfo) { + return this.makeUnsupportedRelationshipError(childType, key, 400); + } + if (relationInfo.isCollection) { + updateData[key] = { + set: enumerate(data.data).map((item: any) => ({ + [this.makeDefaultIdKey(relationInfo.idFields)]: item.id, + })), + }; + } else { + if (typeof data.data !== 'object') { + return this.makeError('invalidRelationData'); + } + updateData[key] = { + connect: { [this.makeDefaultIdKey(relationInfo.idFields)]: data.data.id }, + }; + } + } + } + + // Atomically update child scoped to parent; ORM throws NOT_FOUND if parent or child-belongs-to-parent check fails + await (client as any)[parentType].update({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + data: { + [parentRelation]: { + update: { where: this.makeIdFilter(typeInfo.idFields, childId), data: updateData }, + }, + }, + }); + + // Fetch the updated entity for the response + const fetchArgs: any = { where: this.makeIdFilter(typeInfo.idFields, childId) }; + this.includeRelationshipIds(childType, fetchArgs, 'include'); + const entity = await (client as any)[childType].findUnique(fetchArgs); + if (!entity) return this.makeError('notFound'); + + const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId)); + const nestedLinker = new tsjapi.Linker(() => linkUrl); + return { + status: 200, + body: await this.serializeItems(childType, entity, { + linkers: { document: nestedLinker, resource: nestedLinker }, + }), + }; + } + + private async processNestedDelete( + client: ClientContract, + parentType: string, + parentId: string, + parentRelation: string, + childId: string, + ): Promise { + const resolved = this.resolveNestedRelation(parentType, parentRelation); + if (!resolved) { + return this.makeError('invalidPath'); + } + + const parentInfo = this.getModelInfo(parentType)!; + const typeInfo = this.getModelInfo(resolved.childType)!; + + // Atomically delete child scoped to parent; ORM throws NOT_FOUND if parent or child-belongs-to-parent check fails + await (client as any)[parentType].update({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + data: { [parentRelation]: { delete: this.makeIdFilter(typeInfo.idFields, childId) } }, + }); + + return { status: 200, body: { meta: {} } }; + } + private buildPartialSelect(type: string, query: Record | undefined) { const selectFieldsQuery = query?.[`fields[${type}]`]; if (!selectFieldsQuery) { diff --git a/packages/server/test/api/options-validation.test.ts b/packages/server/test/api/options-validation.test.ts index 53f7f3680..e085c2836 100644 --- a/packages/server/test/api/options-validation.test.ts +++ b/packages/server/test/api/options-validation.test.ts @@ -202,6 +202,202 @@ describe('API Handler Options Validation', () => { }).toThrow('Invalid options'); }); + it('should throw error when nestedRoutes is not an object', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + + it('should throw error when nestedRoutes config value is invalid type', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { + posts: { + requireOrphanProtection: 'yes', + }, + }, + } as any, + }); + }).toThrow('Invalid options'); + }); + + describe('nestedRoutes semantic validation', () => { + let relClient: ClientContract; + + const relSchema = ` + model User { + id String @id @default(cuid()) + email String @unique + posts Post[] + } + model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId String + } + `; + + beforeEach(async () => { + relClient = await createTestClient(relSchema); + }); + + it('should throw when parent model does not exist in schema', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + NonExistent: { posts: {} }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should throw when relation field does not exist on parent model', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { nonExistentRelation: {} }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should throw when relation is to-one', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + Post: { author: {} }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should accept valid to-many nestedRoutes configuration', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { posts: {} }, + }, + }); + }).not.toThrow(); + }); + + describe('requireOrphanProtection', () => { + it('should throw when requireOrphanProtection is true and onDelete is not set', () => { + // relSchema has no onDelete on Post.author + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { posts: { requireOrphanProtection: true } }, + }, + }); + }).toThrow('requireOrphanProtection'); + }); + + it('should throw when requireOrphanProtection is true and onDelete is SetNull', async () => { + const c = await createTestClient(` + model User { + id String @id @default(cuid()) + posts Post[] + } + model Post { + id Int @id @default(autoincrement()) + title String + author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) + authorId String? + } + `); + expect(() => { + new RestApiHandler({ + schema: c.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { posts: { requireOrphanProtection: true } }, + }, + }); + }).toThrow('requireOrphanProtection'); + }); + + it('should accept when requireOrphanProtection is true and onDelete is Cascade', async () => { + const c = await createTestClient(` + model User { + id String @id @default(cuid()) + posts Post[] + } + model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String + } + `); + expect(() => { + new RestApiHandler({ + schema: c.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { posts: { requireOrphanProtection: true } }, + }, + }); + }).not.toThrow(); + }); + + it('should accept when requireOrphanProtection is true and onDelete is Restrict', async () => { + const c = await createTestClient(` + model User { + id String @id @default(cuid()) + posts Post[] + } + model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id], onDelete: Restrict) + authorId String + } + `); + expect(() => { + new RestApiHandler({ + schema: c.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { posts: { requireOrphanProtection: true } }, + }, + }); + }).not.toThrow(); + }); + + it('should not check orphan protection when requireOrphanProtection is not set', () => { + // relSchema has no onDelete — still fine without the flag + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { posts: {} }, + }, + }); + }).not.toThrow(); + }); + }); + }); + it('should throw error when log is invalid type', () => { expect(() => { new RestApiHandler({ diff --git a/packages/server/test/api/rest.test.ts b/packages/server/test/api/rest.test.ts index ec0a6a8a9..15e23b4d1 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -3549,4 +3549,584 @@ mutation procedure sum(a: Int, b: Int): Int expect(r.body).toMatchObject({ data: 3 }); }); }); + + describe('Nested routes', () => { + let nestedClient: ClientContract; + let nestedHandler: (any: any) => Promise<{ status: number; body: any }>; + + const nestedSchema = ` + model User { + id String @id @default(cuid()) + email String @unique + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId String + } + `; + + beforeEach(async () => { + nestedClient = await createTestClient(nestedSchema); + const api = new RestApiHandler({ + schema: nestedClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { + posts: {}, + }, + }, + }); + nestedHandler = (args) => api.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('scopes nested collection reads to parent', async () => { + await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { + create: [{ title: 'u1-post-1' }, { title: 'u1-post-2' }], + }, + }, + }); + + await nestedClient.user.create({ + data: { + id: 'u2', + email: 'u2@test.com', + posts: { + create: [{ title: 'u2-post-1' }], + }, + }, + }); + + const r = await nestedHandler({ + method: 'get', + path: '/user/u1/posts', + client: nestedClient, + }); + + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(2); + expect(r.body.data.map((item: any) => item.attributes.title).sort()).toEqual(['u1-post-1', 'u1-post-2']); + }); + + it('returns 404 for nested collection read when parent does not exist', async () => { + const r = await nestedHandler({ + method: 'get', + path: '/user/nonexistent/posts', + client: nestedClient, + }); + expect(r.status).toBe(404); + }); + + it('scopes nested single reads to parent', async () => { + await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + }, + }); + + const user2 = await nestedClient.user.create({ + data: { + id: 'u2', + email: 'u2@test.com', + posts: { + create: [{ title: 'u2-post-1' }], + }, + }, + include: { + posts: true, + }, + }); + + const postId = user2.posts[0]!.id; + + const denied = await nestedHandler({ + method: 'get', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + }); + expect(denied.status).toBe(404); + + const allowed = await nestedHandler({ + method: 'get', + path: `/user/u2/posts/${postId}`, + client: nestedClient, + }); + expect(allowed.status).toBe(200); + expect(allowed.body.data.attributes.title).toBe('u2-post-1'); + }); + + it('returns 404 for nested single read when parent does not exist', async () => { + const r = await nestedHandler({ + method: 'get', + path: '/user/nonexistent/posts/1', + client: nestedClient, + }); + expect(r.status).toBe(404); + }); + + it('returns 404 for nested create when parent does not exist', async () => { + const r = await nestedHandler({ + method: 'post', + path: '/user/nonexistent/posts', + client: nestedClient, + requestBody: { + data: { + type: 'Post', + attributes: { title: 'orphan' }, + }, + }, + }); + expect(r.status).toBe(404); + }); + + it('binds nested creates to parent relation automatically', async () => { + await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + }, + }); + + const created = await nestedHandler({ + method: 'post', + path: '/user/u1/posts', + client: nestedClient, + requestBody: { + data: { + type: 'Post', + attributes: { + title: 'nested-created', + }, + }, + }, + }); + + expect(created.status).toBe(201); + + const dbPost = await nestedClient.post.findFirst({ + where: { + title: 'nested-created', + }, + }); + + expect(dbPost?.authorId).toBe('u1'); + }); + + it('rejects nested create when payload specifies the forced parent relation', async () => { + await nestedClient.user.create({ + data: { id: 'u1', email: 'u1@test.com' }, + }); + await nestedClient.user.create({ + data: { id: 'u2', email: 'u2@test.com' }, + }); + + const r = await nestedHandler({ + method: 'post', + path: '/user/u1/posts', + client: nestedClient, + requestBody: { + data: { + type: 'Post', + attributes: { title: 'conflict' }, + relationships: { + author: { data: { type: 'User', id: 'u2' } }, + }, + }, + }, + }); + + expect(r.status).toBe(400); + }); + + it('rejects nested create when attributes contain scalar FK for the forced parent relation', async () => { + await nestedClient.user.create({ + data: { id: 'u1', email: 'u1@test.com' }, + }); + await nestedClient.user.create({ + data: { id: 'u2', email: 'u2@test.com' }, + }); + + const r = await nestedHandler({ + method: 'post', + path: '/user/u1/posts', + client: nestedClient, + requestBody: { + data: { + type: 'Post', + attributes: { title: 'conflict', authorId: 'u2' }, + }, + }, + }); + + expect(r.status).toBe(400); + }); + + it('scopes nested collection reads with filter and pagination', async () => { + await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { + create: [{ title: 'alpha' }, { title: 'beta' }, { title: 'gamma' }], + }, + }, + }); + + const filtered = await nestedHandler({ + method: 'get', + path: '/user/u1/posts', + query: { 'filter[title]': 'alpha' }, + client: nestedClient, + }); + expect(filtered.status).toBe(200); + expect(filtered.body.data).toHaveLength(1); + expect(filtered.body.data[0].attributes.title).toBe('alpha'); + + const paged = await nestedHandler({ + method: 'get', + path: '/user/u1/posts', + query: { 'page[limit]': '2', 'page[offset]': '0' }, + client: nestedClient, + }); + expect(paged.status).toBe(200); + expect(paged.body.data).toHaveLength(2); + }); + + it('updates a child scoped to parent (PATCH)', async () => { + const user1 = await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'original' }] }, + }, + include: { posts: true }, + }); + const postId = user1.posts[0]!.id; + + await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } }); + + // Cannot update a post that belongs to a different parent + const denied = await nestedHandler({ + method: 'patch', + path: `/user/u2/posts/${postId}`, + client: nestedClient, + requestBody: { + data: { type: 'Post', attributes: { title: 'denied-update' } }, + }, + }); + expect(denied.status).toBe(404); + + // Can update a post that belongs to the correct parent + const allowed = await nestedHandler({ + method: 'patch', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + requestBody: { + data: { type: 'Post', attributes: { title: 'updated' } }, + }, + }); + expect(allowed.status).toBe(200); + expect(allowed.body.data.attributes.title).toBe('updated'); + }); + + it('rejects nested PATCH when payload tries to change the parent relation', async () => { + const user1 = await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'post' }] }, + }, + include: { posts: true }, + }); + const postId = user1.posts[0]!.id; + await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } }); + + const r = await nestedHandler({ + method: 'patch', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + requestBody: { + data: { + type: 'Post', + attributes: { title: 'new' }, + relationships: { + author: { data: { type: 'User', id: 'u2' } }, + }, + }, + }, + }); + expect(r.status).toBe(400); + }); + + it('rejects nested PATCH when attributes contain camelCase FK field', async () => { + const user1 = await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'post' }] }, + }, + include: { posts: true }, + }); + const postId = user1.posts[0]!.id; + await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } }); + + const r = await nestedHandler({ + method: 'patch', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + requestBody: { + data: { + type: 'Post', + attributes: { title: 'new', authorId: 'u2' }, + }, + }, + }); + expect(r.status).toBe(400); + }); + + it('deletes a child scoped to parent (DELETE)', async () => { + const user1 = await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'to-delete' }] }, + }, + include: { posts: true }, + }); + const postId = user1.posts[0]!.id; + await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } }); + + // Cannot delete a post via the wrong parent + const denied = await nestedHandler({ + method: 'delete', + path: `/user/u2/posts/${postId}`, + client: nestedClient, + }); + expect(denied.status).toBe(404); + + // Can delete via the correct parent + const allowed = await nestedHandler({ + method: 'delete', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + }); + expect(allowed.status).toBe(200); + + const gone = await nestedClient.post.findFirst({ where: { id: postId } }); + expect(gone).toBeNull(); + }); + + it('falls back to fetchRelated for non-configured 3-segment paths', async () => { + const user1 = await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'p1' }] }, + }, + }); + + // 'author' is a relation on Post, not a nestedRoute → fetchRelated + const post = await nestedClient.post.findFirst({ where: { authorId: 'u1' } }); + const r = await nestedHandler({ + method: 'get', + path: `/post/${post!.id}/author`, + client: nestedClient, + }); + expect(r.status).toBe(200); + expect(r.body.data.id).toBe(user1.id); + }); + + it('returns nested self-links in JSON:API responses for all nested operations', async () => { + await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } }); + + // POST /user/u1/posts — nested create + const created = await nestedHandler({ + method: 'post', + path: '/user/u1/posts', + client: nestedClient, + requestBody: { data: { type: 'post', attributes: { title: 'hello' } } }, + }); + expect(created.status).toBe(201); + const postId = created.body.data.id; + expect(created.body.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`); + expect(created.body.data.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`); + + // GET /user/u1/posts/:id — nested single read + const single = await nestedHandler({ + method: 'get', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + }); + expect(single.status).toBe(200); + expect(single.body.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`); + expect(single.body.data.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`); + + // PATCH /user/u1/posts/:id — nested update + const updated = await nestedHandler({ + method: 'patch', + path: `/user/u1/posts/${postId}`, + client: nestedClient, + requestBody: { data: { type: 'post', id: String(postId), attributes: { title: 'updated' } } }, + }); + expect(updated.status).toBe(200); + expect(updated.body.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`); + expect(updated.body.data.links.self).toBe(`http://localhost/api/user/u1/posts/${postId}`); + }); + + it('works with modelNameMapping on both parent and child segments', async () => { + const mappedApi = new RestApiHandler({ + schema: nestedClient.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: { User: 'users', Post: 'posts' }, + nestedRoutes: { + User: { posts: {} }, + }, + }); + const mappedHandler = (args: any) => + mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + + await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'mapped-post' }] }, + }, + }); + await nestedClient.user.create({ + data: { id: 'u2', email: 'u2@test.com' }, + }); + + const collection = await mappedHandler({ + method: 'get', + path: '/users/u1/posts', + client: nestedClient, + }); + expect(collection.status).toBe(200); + expect(collection.body.data).toHaveLength(1); + expect(collection.body.data[0].attributes.title).toBe('mapped-post'); + + // Parent with no posts → 200 with empty collection + const denied = await mappedHandler({ + method: 'get', + path: '/users/u2/posts', + client: nestedClient, + }); + expect(denied.status).toBe(200); + expect(denied.body.data).toHaveLength(0); + }); + + it('falls back to fetchRelated for mapped child names without nestedRoutes', async () => { + const mappedApi = new RestApiHandler({ + schema: nestedClient.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: { User: 'users', Post: 'posts' }, + }); + const mappedHandler = (args: any) => + mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + + await nestedClient.user.create({ + data: { + id: 'u1', + email: 'u1@test.com', + posts: { create: [{ title: 'mapped-fallback-post' }] }, + }, + }); + await nestedClient.user.create({ + data: { id: 'u2', email: 'u2@test.com' }, + }); + + const collection = await mappedHandler({ + method: 'get', + path: '/users/u1/posts', + client: nestedClient, + }); + expect(collection.status).toBe(200); + expect(collection.body.data).toHaveLength(1); + expect(collection.body.data[0].attributes.title).toBe('mapped-fallback-post'); + + const empty = await mappedHandler({ + method: 'get', + path: '/users/u2/posts', + client: nestedClient, + }); + expect(empty.status).toBe(200); + expect(empty.body.data).toHaveLength(0); + }); + + it('exercises mapped nested-route mutations and verifies link metadata', async () => { + const mappedApi = new RestApiHandler({ + schema: nestedClient.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: { User: 'users', Post: 'posts' }, + nestedRoutes: { + User: { posts: {} }, + }, + }); + const mappedHandler = (args: any) => + mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + + await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } }); + + // POST /users/u1/posts — nested create via mapped route + const created = await mappedHandler({ + method: 'post', + path: '/users/u1/posts', + client: nestedClient, + requestBody: { data: { type: 'posts', attributes: { title: 'mapped-create' } } }, + }); + expect(created.status).toBe(201); + const postId = created.body.data.id; + expect(created.body.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`); + expect(created.body.data.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`); + + // GET /users/u1/posts — list should contain the new post + const afterCreate = await mappedHandler({ + method: 'get', + path: '/users/u1/posts', + client: nestedClient, + }); + expect(afterCreate.status).toBe(200); + expect(afterCreate.body.data).toHaveLength(1); + expect(afterCreate.body.links.self).toBe('http://localhost/api/users/u1/posts'); + + // PATCH /users/u1/posts/:id — nested update via mapped route + const updated = await mappedHandler({ + method: 'patch', + path: `/users/u1/posts/${postId}`, + client: nestedClient, + requestBody: { + data: { type: 'posts', id: String(postId), attributes: { title: 'mapped-updated' } }, + }, + }); + expect(updated.status).toBe(200); + expect(updated.body.data.attributes.title).toBe('mapped-updated'); + expect(updated.body.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`); + expect(updated.body.data.links.self).toBe(`http://localhost/api/users/u1/posts/${postId}`); + + // DELETE /users/u1/posts/:id — nested delete via mapped route + const deleted = await mappedHandler({ + method: 'delete', + path: `/users/u1/posts/${postId}`, + client: nestedClient, + }); + expect(deleted.status).toBe(200); + + // GET /users/u1/posts — list should now be empty + const afterDelete = await mappedHandler({ + method: 'get', + path: '/users/u1/posts', + client: nestedClient, + }); + expect(afterDelete.status).toBe(200); + expect(afterDelete.body.data).toHaveLength(0); + }); + }); }); From 46dfff9c7fc87adb5fa4ab04045901429f7d2643 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 19 Mar 2026 11:50:48 +0100 Subject: [PATCH 2/3] extract common helper for processNestedSingleRead and processSingleRead --- packages/server/src/api/rest/index.ts | 70 +++++++++++++-------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 6d5cadb49..5e510ea6d 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -796,18 +796,16 @@ export class RestApiHandler implements Api return resp; } - private async processSingleRead( - client: ClientContract, + /** + * Builds the ORM `args` object (include, select) shared by single-read operations. + * Returns the args to pass to findUnique/findFirst and the resolved `include` list for serialization, + * or an error response if query params are invalid. + */ + private buildSingleReadArgs( type: string, - resourceId: string, query: Record | undefined, - ): Promise { - const typeInfo = this.getModelInfo(type); - if (!typeInfo) { - return this.makeUnsupportedModelError(type); - } - - const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) }; + ): { args: any; include: string[] | undefined; error?: Response } { + const args: any = {}; // include IDs of relation fields so that they can be serialized this.includeRelationshipIds(type, args, 'include'); @@ -817,7 +815,7 @@ export class RestApiHandler implements Api if (query?.['include']) { const { select, error, allIncludes } = this.buildRelationSelect(type, query['include'], query); if (error) { - return error; + return { args, include, error }; } if (select) { args.include = { ...args.include, ...select }; @@ -827,18 +825,34 @@ export class RestApiHandler implements Api // handle partial results for requested type const { select, error } = this.buildPartialSelect(type, query); - if (error) return error; + if (error) return { args, include, error }; if (select) { args.select = { ...select, ...args.select }; if (args.include) { - args.select = { - ...args.select, - ...args.include, - }; + args.select = { ...args.select, ...args.include }; args.include = undefined; } } + return { args, include }; + } + + private async processSingleRead( + client: ClientContract, + type: string, + resourceId: string, + query: Record | undefined, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const { args, include, error } = this.buildSingleReadArgs(type, query); + if (error) return error; + + args.where = this.makeIdFilter(typeInfo.idFields, resourceId); + const entity = await (client as any)[type].findUnique(args); if (entity) { @@ -1148,28 +1162,10 @@ export class RestApiHandler implements Api const childType = resolved.childType; const typeInfo = this.getModelInfo(childType)!; - const args: any = { - where: this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter), - }; - this.includeRelationshipIds(childType, args, 'include'); - - let include: string[] | undefined; - if (query?.['include']) { - const { select, error, allIncludes } = this.buildRelationSelect(childType, query['include'], query); - if (error) return error; - if (select) args.include = { ...args.include, ...select }; - include = allIncludes; - } - - const { select, error } = this.buildPartialSelect(childType, query); + const { args, include, error } = this.buildSingleReadArgs(childType, query); if (error) return error; - if (select) { - args.select = { ...select, ...args.select }; - if (args.include) { - args.select = { ...args.select, ...args.include }; - args.include = undefined; - } - } + + args.where = this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter); const entity = await (client as any)[childType].findFirst(args); if (!entity) return this.makeError('notFound'); From 43e4e2d81e1e6a02026b82a1f33bf5bf19b35a2d Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 19 Mar 2026 12:02:32 +0100 Subject: [PATCH 3/3] use a single boolean to enable nestedRoutes --- packages/server/src/api/rest/index.ts | 93 ++------- .../test/api/options-validation.test.ts | 188 +----------------- packages/server/test/api/rest.test.ts | 14 +- 3 files changed, 18 insertions(+), 277 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 5e510ea6d..0dbc4679c 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -66,26 +66,13 @@ export type RestApiHandlerOptions = { externalIdMapping?: Record; /** - * Explicit nested route configuration. + * When `true`, enables nested route handling for all to-many relations: + * `/:parentType/:parentId/:relationName` (collection) and + * `/:parentType/:parentId/:relationName/:childId` (single). * - * First-level keys are parent model names, second-level keys are relation field names on the parent model - * (e.g., `posts` for `User.posts`). This matches the URL segment used in nested routes: - * `/:parentType/:parentId/:relationName` and `/:parentType/:parentId/:relationName/:childId`. + * Defaults to `false`. */ - nestedRoutes?: Record< - string, - Record< - string, - { - /** - * When `true`, the constructor throws if the configured relation does not have an `onDelete` - * action of `Cascade`, `Restrict`, or `NoAction` in the schema. This ensures the database - * prevents orphaned child records when a parent is deleted. - */ - requireOrphanProtection?: boolean; - } - > - >; + nestedRoutes?: boolean; }; type RelationshipInfo = { @@ -286,7 +273,7 @@ export class RestApiHandler implements Api private modelNameMapping: Record; private reverseModelNameMapping: Record; private externalIdMapping: Record; - private nestedRoutes: Record>; + private nestedRoutes: boolean; constructor(private readonly options: RestApiHandlerOptions) { this.validateOptions(options); @@ -307,20 +294,11 @@ export class RestApiHandler implements Api Object.entries(this.externalIdMapping).map(([k, v]) => [lowerCaseFirst(k), v]), ); - this.nestedRoutes = options.nestedRoutes ?? {}; - this.nestedRoutes = Object.fromEntries( - Object.entries(this.nestedRoutes).map(([parentModel, children]) => [ - lowerCaseFirst(parentModel), - Object.fromEntries( - Object.entries(children).map(([childModel, config]) => [lowerCaseFirst(childModel), config]), - ), - ]), - ); + this.nestedRoutes = options.nestedRoutes ?? false; this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); this.buildTypeMap(); - this.validateNestedRoutes(); this.buildSerializers(); } @@ -334,9 +312,7 @@ export class RestApiHandler implements Api urlSegmentCharset: z.string().min(1).optional(), modelNameMapping: z.record(z.string(), z.string()).optional(), externalIdMapping: z.record(z.string(), z.string()).optional(), - nestedRoutes: z - .record(z.string(), z.record(z.string(), z.object({ requireOrphanProtection: z.boolean().optional() }))) - .optional(), + nestedRoutes: z.boolean().optional(), }); const parseResult = schema.safeParse(options); if (!parseResult.success) { @@ -344,45 +320,6 @@ export class RestApiHandler implements Api } } - private validateNestedRoutes() { - for (const [parentModel, relations] of Object.entries(this.nestedRoutes)) { - const parentInfo = this.getModelInfo(parentModel); - if (!parentInfo) { - throw new Error(`Invalid nestedRoutes: parent model "${parentModel}" not found in schema`); - } - for (const [relationName, config] of Object.entries(relations)) { - const parentField: FieldDef | undefined = this.schema.models[parentInfo.name]?.fields[relationName]; - if (!parentField?.relation) { - throw new Error( - `Invalid nestedRoutes: relation "${relationName}" not found on parent model "${parentModel}"`, - ); - } - const reverseRelation = parentField.relation.opposite; - if (!reverseRelation) { - throw new Error( - `Invalid nestedRoutes: relation "${parentModel}.${relationName}" has no opposite relation defined`, - ); - } - if (!parentField.array) { - throw new Error( - `Invalid nestedRoutes: relation "${parentModel}.${relationName}" is a to-one relation — nested routes only support to-many relations`, - ); - } - if (config.requireOrphanProtection) { - const childModelName = parentField.type; - const onDelete = this.schema.models[childModelName]?.fields[reverseRelation]?.relation?.onDelete; - const safeActions = ['Cascade', 'Restrict', 'NoAction']; - if (!onDelete || !safeActions.includes(onDelete)) { - throw new Error( - `Invalid nestedRoutes: requireOrphanProtection is enabled for "${parentModel}.${relationName}" ` + - `but its onDelete action is "${onDelete ?? 'not set'}" — must be Cascade, Restrict, or NoAction`, - ); - } - } - } - } - } - get schema() { return this.options.schema; } @@ -417,10 +354,6 @@ export class RestApiHandler implements Api return this.modelNameMapping[modelName] ?? modelName; } - private getNestedRouteConfig(parentType: string, parentRelation: string) { - return this.nestedRoutes[lowerCaseFirst(parentType)]?.[parentRelation]; - } - /** * Resolves child model type and reverse relation from a parent relation name. * e.g. given parentType='user', parentRelation='posts', returns { childType:'post', reverseRelation:'author' } @@ -548,7 +481,7 @@ export class RestApiHandler implements Api // /:type/:id/:relationship/:childId — nested single read match = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); - if (match && this.getNestedRouteConfig(match.type, match.relationship)) { + if (match && this.nestedRoutes && this.resolveNestedRelation(match.type, match.relationship)?.isCollection) { return await this.processNestedSingleRead( client, match.type, @@ -573,7 +506,7 @@ export class RestApiHandler implements Api } // /:type/:id/:relationship — nested create const nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); - if (nestedMatch && this.getNestedRouteConfig(nestedMatch.type, nestedMatch.relationship)) { + if (nestedMatch && this.nestedRoutes && this.resolveNestedRelation(nestedMatch.type, nestedMatch.relationship)?.isCollection) { return await this.processNestedCreate( client, nestedMatch.type, @@ -636,7 +569,8 @@ export class RestApiHandler implements Api const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); if ( nestedPatchMatch && - this.getNestedRouteConfig(nestedPatchMatch.type, nestedPatchMatch.relationship) + this.nestedRoutes && + this.resolveNestedRelation(nestedPatchMatch.type, nestedPatchMatch.relationship)?.isCollection ) { return await this.processNestedUpdate( client, @@ -675,7 +609,8 @@ export class RestApiHandler implements Api const nestedDeleteMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); if ( nestedDeleteMatch && - this.getNestedRouteConfig(nestedDeleteMatch.type, nestedDeleteMatch.relationship) + this.nestedRoutes && + this.resolveNestedRelation(nestedDeleteMatch.type, nestedDeleteMatch.relationship)?.isCollection ) { return await this.processNestedDelete( client, diff --git a/packages/server/test/api/options-validation.test.ts b/packages/server/test/api/options-validation.test.ts index e085c2836..e902896f4 100644 --- a/packages/server/test/api/options-validation.test.ts +++ b/packages/server/test/api/options-validation.test.ts @@ -202,7 +202,7 @@ describe('API Handler Options Validation', () => { }).toThrow('Invalid options'); }); - it('should throw error when nestedRoutes is not an object', () => { + it('should throw error when nestedRoutes is not a boolean', () => { expect(() => { new RestApiHandler({ schema: client.$schema, @@ -212,192 +212,6 @@ describe('API Handler Options Validation', () => { }).toThrow('Invalid options'); }); - it('should throw error when nestedRoutes config value is invalid type', () => { - expect(() => { - new RestApiHandler({ - schema: client.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { - posts: { - requireOrphanProtection: 'yes', - }, - }, - } as any, - }); - }).toThrow('Invalid options'); - }); - - describe('nestedRoutes semantic validation', () => { - let relClient: ClientContract; - - const relSchema = ` - model User { - id String @id @default(cuid()) - email String @unique - posts Post[] - } - model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId String - } - `; - - beforeEach(async () => { - relClient = await createTestClient(relSchema); - }); - - it('should throw when parent model does not exist in schema', () => { - expect(() => { - new RestApiHandler({ - schema: relClient.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - NonExistent: { posts: {} }, - }, - }); - }).toThrow('Invalid nestedRoutes'); - }); - - it('should throw when relation field does not exist on parent model', () => { - expect(() => { - new RestApiHandler({ - schema: relClient.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { nonExistentRelation: {} }, - }, - }); - }).toThrow('Invalid nestedRoutes'); - }); - - it('should throw when relation is to-one', () => { - expect(() => { - new RestApiHandler({ - schema: relClient.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - Post: { author: {} }, - }, - }); - }).toThrow('Invalid nestedRoutes'); - }); - - it('should accept valid to-many nestedRoutes configuration', () => { - expect(() => { - new RestApiHandler({ - schema: relClient.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { posts: {} }, - }, - }); - }).not.toThrow(); - }); - - describe('requireOrphanProtection', () => { - it('should throw when requireOrphanProtection is true and onDelete is not set', () => { - // relSchema has no onDelete on Post.author - expect(() => { - new RestApiHandler({ - schema: relClient.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { posts: { requireOrphanProtection: true } }, - }, - }); - }).toThrow('requireOrphanProtection'); - }); - - it('should throw when requireOrphanProtection is true and onDelete is SetNull', async () => { - const c = await createTestClient(` - model User { - id String @id @default(cuid()) - posts Post[] - } - model Post { - id Int @id @default(autoincrement()) - title String - author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) - authorId String? - } - `); - expect(() => { - new RestApiHandler({ - schema: c.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { posts: { requireOrphanProtection: true } }, - }, - }); - }).toThrow('requireOrphanProtection'); - }); - - it('should accept when requireOrphanProtection is true and onDelete is Cascade', async () => { - const c = await createTestClient(` - model User { - id String @id @default(cuid()) - posts Post[] - } - model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - authorId String - } - `); - expect(() => { - new RestApiHandler({ - schema: c.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { posts: { requireOrphanProtection: true } }, - }, - }); - }).not.toThrow(); - }); - - it('should accept when requireOrphanProtection is true and onDelete is Restrict', async () => { - const c = await createTestClient(` - model User { - id String @id @default(cuid()) - posts Post[] - } - model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id], onDelete: Restrict) - authorId String - } - `); - expect(() => { - new RestApiHandler({ - schema: c.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { posts: { requireOrphanProtection: true } }, - }, - }); - }).not.toThrow(); - }); - - it('should not check orphan protection when requireOrphanProtection is not set', () => { - // relSchema has no onDelete — still fine without the flag - expect(() => { - new RestApiHandler({ - schema: relClient.$schema, - endpoint: 'http://localhost/api', - nestedRoutes: { - User: { posts: {} }, - }, - }); - }).not.toThrow(); - }); - }); - }); - it('should throw error when log is invalid type', () => { expect(() => { new RestApiHandler({ diff --git a/packages/server/test/api/rest.test.ts b/packages/server/test/api/rest.test.ts index 15e23b4d1..8ca916faa 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -3574,11 +3574,7 @@ mutation procedure sum(a: Int, b: Int): Int const api = new RestApiHandler({ schema: nestedClient.$schema, endpoint: 'http://localhost/api', - nestedRoutes: { - User: { - posts: {}, - }, - }, + nestedRoutes: true, }); nestedHandler = (args) => api.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); }); @@ -3986,9 +3982,7 @@ mutation procedure sum(a: Int, b: Int): Int schema: nestedClient.$schema, endpoint: 'http://localhost/api', modelNameMapping: { User: 'users', Post: 'posts' }, - nestedRoutes: { - User: { posts: {} }, - }, + nestedRoutes: true, }); const mappedHandler = (args: any) => mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); @@ -4066,9 +4060,7 @@ mutation procedure sum(a: Int, b: Int): Int schema: nestedClient.$schema, endpoint: 'http://localhost/api', modelNameMapping: { User: 'users', Post: 'posts' }, - nestedRoutes: { - User: { posts: {} }, - }, + nestedRoutes: true, }); const mappedHandler = (args: any) => mappedApi.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });