diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index c2723f061..dfa4d8ea7 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 child model names. + * `relation` is the relationship field on the child model that points to the parent model. + */ + nestedRoutes?: Record< + string, + Record< + string, + { + relation: 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 = { @@ -81,6 +103,8 @@ type ModelInfo = { }; type Match = { + parentType?: string; + parentId?: string; type: string; id: string; relationship: string; @@ -88,6 +112,7 @@ type Match = { enum UrlPatterns { SINGLE = 'single', + NESTED_SINGLE = 'nestedSingle', FETCH_RELATIONSHIP = 'fetchRelationship', RELATIONSHIP = 'relationship', COLLECTION = 'collection', @@ -262,6 +287,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 +308,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 +335,15 @@ 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({ relation: z.string().min(1), requireOrphanProtection: z.boolean().optional() }), + ), + ) + .optional(), }); const parseResult = schema.safeParse(options); if (!parseResult.success) { @@ -305,6 +351,42 @@ export class RestApiHandler implements Api } } + private validateNestedRoutes() { + for (const [parentModel, children] 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 [childModel, config] of Object.entries(children)) { + const childInfo = this.getModelInfo(childModel); + if (!childInfo) { + throw new Error(`Invalid nestedRoutes: child model "${childModel}" not found in schema`); + } + const relationInfo = childInfo.relationships[config.relation]; + if (!relationInfo) { + throw new Error( + `Invalid nestedRoutes: relation "${config.relation}" not found on child model "${childModel}"`, + ); + } + if (lowerCaseFirst(relationInfo.type) !== lowerCaseFirst(parentInfo.name)) { + throw new Error( + `Invalid nestedRoutes: relation "${childModel}.${config.relation}" does not point to parent model "${parentModel}"`, + ); + } + if (config.requireOrphanProtection) { + const onDelete = this.schema.models[childInfo.name]?.fields[config.relation]?.relation?.onDelete; + const safeActions = ['Cascade', 'Restrict', 'NoAction']; + if (!onDelete || !safeActions.includes(onDelete)) { + throw new Error( + `Invalid nestedRoutes: requireOrphanProtection is enabled for "${childModel}.${config.relation}" ` + + `but its onDelete action is "${onDelete ?? 'not set'}" — must be Cascade, Restrict, or NoAction`, + ); + } + } + } + } + } + get schema() { return this.options.schema; } @@ -322,6 +404,10 @@ export class RestApiHandler implements Api return { [UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options), + [UrlPatterns.NESTED_SINGLE]: new UrlPattern( + buildPath([':parentType', ':parentId', ':type', ':id']), + options, + ), [UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options), [UrlPatterns.RELATIONSHIP]: new UrlPattern( buildPath([':type', ':id', 'relationships', ':relationship']), @@ -335,6 +421,59 @@ export class RestApiHandler implements Api return this.modelNameMapping[modelName] ?? modelName; } + private getNestedRouteConfig(parentType: string, childType: string) { + return this.nestedRoutes[lowerCaseFirst(parentType)]?.[lowerCaseFirst(childType)]; + } + + private mergeFilters(left: any, right: any) { + if (!left) { + return right; + } + if (!right) { + return left; + } + return { AND: [left, right] }; + } + + private buildNestedParentFilter(parentType: string, parentId: string, childType: string, relation: string) { + const parentInfo = this.getModelInfo(parentType); + if (!parentInfo) { + return { filter: undefined, error: this.makeUnsupportedModelError(parentType) }; + } + + const childInfo = this.getModelInfo(childType); + if (!childInfo) { + return { filter: undefined, error: this.makeUnsupportedModelError(childType) }; + } + + const relationInfo = childInfo.relationships[relation]; + if (!relationInfo) { + return { + filter: undefined, + error: this.makeError( + 'invalidPath', + `invalid nested route configuration: relation ${childType}.${relation} does not exist`, + ), + }; + } + + if (lowerCaseFirst(relationInfo.type) !== lowerCaseFirst(parentInfo.name)) { + return { + filter: undefined, + error: this.makeError( + 'invalidPath', + `invalid nested route configuration: relation ${childType}.${relation} does not point to ${parentType}`, + ), + }; + } + + const relationFilter = relationInfo.isCollection + ? { [relation]: { some: this.makeIdFilter(parentInfo.idFields, parentId, false) } } + : { [relation]: { 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) { @@ -356,6 +495,17 @@ export class RestApiHandler implements Api match.type = this.reverseModelNameMapping[match.type]; } + if (match.parentType) { + if (match.parentType in this.modelNameMapping) { + throw new InvalidValueError( + `use the mapped model name: ${this.modelNameMapping[match.parentType]} and not ${match.parentType}`, + ); + } + if (match.parentType in this.reverseModelNameMapping) { + match.parentType = this.reverseModelNameMapping[match.parentType]; + } + } + return match; } @@ -380,6 +530,19 @@ export class RestApiHandler implements Api } match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); if (match) { + const mappedRelationship = + this.reverseModelNameMapping[match.relationship] || match.relationship; + const nestedRouteConfig = this.getNestedRouteConfig(match.type, mappedRelationship); + if (nestedRouteConfig) { + return await this.processNestedCollectionRead( + client, + match.type, + match.id, + mappedRelationship, + query, + ); + } + // fetch related resource(s) return await this.processFetchRelated(client, match.type, match.id, match.relationship, query); } @@ -396,6 +559,21 @@ export class RestApiHandler implements Api ); } + match = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if (match) { + const nestedRouteConfig = this.getNestedRouteConfig(match.parentType!, match.type); + if (nestedRouteConfig) { + return await this.processNestedSingleRead( + client, + match.parentType!, + match.parentId!, + match.type, + match.id, + query, + ); + } + } + match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { // collection read @@ -409,6 +587,23 @@ export class RestApiHandler implements Api if (!requestBody) { return this.makeError('invalidPayload'); } + const nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); + if (nestedMatch) { + const mappedRelationship = + this.reverseModelNameMapping[nestedMatch.relationship] || nestedMatch.relationship; + const nestedRouteConfig = this.getNestedRouteConfig(nestedMatch.type, mappedRelationship); + if (nestedRouteConfig) { + return await this.processNestedCreate( + client, + nestedMatch.type, + nestedMatch.id, + mappedRelationship, + query, + requestBody, + ); + } + } + let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { const body = requestBody as any; @@ -444,6 +639,24 @@ export class RestApiHandler implements Api if (!requestBody) { return this.makeError('invalidPayload'); } + const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if (nestedPatchMatch) { + const nestedRouteConfig = this.getNestedRouteConfig( + nestedPatchMatch.parentType!, + nestedPatchMatch.type, + ); + if (nestedRouteConfig) { + return await this.processNestedUpdate( + client, + nestedPatchMatch.parentType!, + nestedPatchMatch.parentId!, + nestedPatchMatch.type, + nestedPatchMatch.id, + query, + requestBody, + ); + } + } let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); if (match) { // resource update @@ -467,6 +680,22 @@ export class RestApiHandler implements Api } case 'DELETE': { + const nestedDeleteMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if (nestedDeleteMatch) { + const nestedRouteConfig = this.getNestedRouteConfig( + nestedDeleteMatch.parentType!, + nestedDeleteMatch.type, + ); + if (nestedRouteConfig) { + return await this.processNestedDelete( + client, + nestedDeleteMatch.parentType!, + nestedDeleteMatch.parentId!, + nestedDeleteMatch.type, + nestedDeleteMatch.id, + ); + } + } let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); if (match) { // resource deletion @@ -598,13 +827,15 @@ export class RestApiHandler implements Api type: string, resourceId: string, query: Record | undefined, + extraFilter?: any, + pathOverride?: string, ): Promise { const typeInfo = this.getModelInfo(type); if (!typeInfo) { return this.makeUnsupportedModelError(type); } - const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) }; + const args: any = { where: this.mergeFilters(this.makeIdFilter(typeInfo.idFields, resourceId), extraFilter) }; // include IDs of relation fields so that they can be serialized this.includeRelationshipIds(type, args, 'include'); @@ -639,9 +870,18 @@ export class RestApiHandler implements Api const entity = await (client as any)[type].findUnique(args); if (entity) { + const serializeOptions: Partial = { + include, + }; + if (pathOverride) { + serializeOptions.linkers = { + document: new tsjapi.Linker(() => this.makeLinkUrl(pathOverride)), + }; + } + return { status: 200, - body: await this.serializeItems(type, entity, { include }), + body: await this.serializeItems(type, entity, serializeOptions), }; } else { return this.makeError('notFound'); @@ -807,6 +1047,9 @@ export class RestApiHandler implements Api client: ClientContract, type: string, query: Record | undefined, + extraFilter?: any, + pathOverride?: string, + resourceLinker?: Linker<[any]>, ): Promise { const typeInfo = this.getModelInfo(type); if (!typeInfo) { @@ -824,6 +1067,10 @@ export class RestApiHandler implements Api args.where = filter; } + if (extraFilter) { + args.where = this.mergeFilters(args.where, extraFilter); + } + const { sort, error: sortError } = this.buildSort(type, query); if (sortError) { return sortError; @@ -870,7 +1117,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 body = await this.serializeItems(type, entities, { + include, + linkers: { + document: new tsjapi.Linker(() => this.makeLinkUrl(pathOverride ?? `/${this.mapModelName(type)}`)), + ...(resourceLinker ? { resource: resourceLinker } : {}), + }, + }); const total = entities.length; body.meta = this.addTotalCountToMeta(body.meta, total); @@ -888,11 +1141,13 @@ export class RestApiHandler implements Api const total = count as number; const mappedType = this.mapModelName(type); - const url = this.makeNormalizedUrl(`/${mappedType}`, query); + const url = this.makeNormalizedUrl(pathOverride ?? `/${mappedType}`, query); const options: Partial = { include, linkers: { + document: new tsjapi.Linker(() => this.makeLinkUrl(pathOverride ?? `/${mappedType}`)), paginator: this.makePaginator(url, offset, limit, total), + ...(resourceLinker ? { resource: resourceLinker } : {}), }, }; const body = await this.serializeItems(type, entities, options); @@ -905,6 +1160,288 @@ export class RestApiHandler implements Api } } + private async processNestedCollectionRead( + client: ClientContract, + parentType: string, + parentId: string, + childType: string, + query: Record | undefined, + ): Promise { + const nestedRouteConfig = this.getNestedRouteConfig(parentType, childType); + if (!nestedRouteConfig) { + return this.makeError('invalidPath'); + } + + const parentInfo = this.getModelInfo(parentType); + if (!parentInfo) { + return this.makeUnsupportedModelError(parentType); + } + + const parentExists = await (client as any)[parentType].findFirst({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + }); + if (!parentExists) { + return this.makeError('notFound'); + } + + const { filter, error } = this.buildNestedParentFilter( + parentType, + parentId, + childType, + nestedRouteConfig.relation, + ); + if (error) { + return error; + } + + const childInfo = this.getModelInfo(childType)!; + const mappedParentType = this.mapModelName(parentType); + const mappedChildType = this.mapModelName(childType); + const resourceLinker = new tsjapi.Linker((item: any) => + this.makeLinkUrl(`/${mappedParentType}/${parentId}/${mappedChildType}/${this.getId(childInfo.name, item)}`), + ); + return this.processCollectionRead( + client, + childType, + query, + filter, + `/${mappedParentType}/${parentId}/${mappedChildType}`, + resourceLinker, + ); + } + + private async processNestedSingleRead( + client: ClientContract, + parentType: string, + parentId: string, + childType: string, + childId: string, + query: Record | undefined, + ): Promise { + const nestedRouteConfig = this.getNestedRouteConfig(parentType, childType); + if (!nestedRouteConfig) { + return this.makeError('invalidPath'); + } + + const typeInfo = this.getModelInfo(childType); + if (!typeInfo) { + return this.makeUnsupportedModelError(childType); + } + + const { filter: nestedFilter, error: nestedError } = this.buildNestedParentFilter( + parentType, + parentId, + childType, + nestedRouteConfig.relation, + ); + if (nestedError) { + return nestedError; + } + + 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 mappedParentType = this.mapModelName(parentType); + const mappedChildType = this.mapModelName(childType); + const nestedLinker = new tsjapi.Linker(() => + this.makeLinkUrl(`/${mappedParentType}/${parentId}/${mappedChildType}/${childId}`), + ); + return { + status: 200, + body: await this.serializeItems(childType, entity, { + include, + linkers: { + document: nestedLinker, + resource: nestedLinker, + }, + }), + }; + } + + private async processNestedCreate( + client: ClientContract, + parentType: string, + parentId: string, + childType: string, + query: Record | undefined, + requestBody: unknown, + ): Promise { + const nestedRouteConfig = this.getNestedRouteConfig(parentType, childType); + if (!nestedRouteConfig) { + return this.makeError('invalidPath'); + } + + const parentInfo = this.getModelInfo(parentType); + if (!parentInfo) { + return this.makeUnsupportedModelError(parentType); + } + + const parentExists = await (client as any)[parentType].findFirst({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + }); + if (!parentExists) { + return this.makeError('notFound'); + } + + const { error } = this.buildNestedParentFilter(parentType, parentId, childType, nestedRouteConfig.relation); + if (error) { + return error; + } + + const childInfo = this.getModelInfo(childType)!; + const mappedParentType = this.mapModelName(parentType); + const mappedChildType = this.mapModelName(childType); + const resourceLinker = new tsjapi.Linker((item: any) => + this.makeLinkUrl(`/${mappedParentType}/${parentId}/${mappedChildType}/${this.getId(childInfo.name, item)}`), + ); + return this.processCreate( + client, + childType, + query, + requestBody, + { relation: nestedRouteConfig.relation, parentId, parentIdFields: parentInfo.idFields }, + { document: resourceLinker, resource: resourceLinker }, + ); + } + + private async processNestedUpdate( + client: ClientContract, + parentType: string, + parentId: string, + childType: string, + childId: string, + query: Record | undefined, + requestBody: unknown, + ): Promise { + const nestedRouteConfig = this.getNestedRouteConfig(parentType, childType); + if (!nestedRouteConfig) { + return this.makeError('invalidPath'); + } + + const typeInfo = this.getModelInfo(childType); + if (!typeInfo) { + return this.makeUnsupportedModelError(childType); + } + + const { filter: nestedFilter, error } = this.buildNestedParentFilter( + parentType, + parentId, + childType, + nestedRouteConfig.relation, + ); + if (error) { + return error; + } + + // Verify child belongs to parent before updating + const exists = await (client as any)[childType].findFirst({ + where: this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter), + }); + if (!exists) { + return this.makeError('notFound'); + } + + // Reject attempts to change the parent relation via the nested endpoint + const body = requestBody as any; + const rel = nestedRouteConfig.relation; + if ( + typeof body?.data?.relationships === 'object' && + body.data.relationships !== null && + Object.prototype.hasOwnProperty.call(body.data.relationships, rel) + ) { + return this.makeError('invalidPayload', `Relation "${rel}" cannot be changed via a nested route`); + } + const fkFields = Object.values(typeInfo.fields).filter((f) => f.foreignKeyFor?.includes(rel)); + if ( + typeof body?.data?.attributes === 'object' && + body.data.attributes !== null && + fkFields.some((f) => Object.prototype.hasOwnProperty.call(body.data.attributes, f.name)) + ) { + return this.makeError('invalidPayload', `Relation "${rel}" cannot be changed via a nested route`); + } + + const mappedParentType = this.mapModelName(parentType); + const mappedChildType = this.mapModelName(childType); + const nestedLinker = new tsjapi.Linker(() => + this.makeLinkUrl(`/${mappedParentType}/${parentId}/${mappedChildType}/${childId}`), + ); + return this.processUpdate(client, childType, childId, query, requestBody, { + document: nestedLinker, + resource: nestedLinker, + }); + } + + private async processNestedDelete( + client: ClientContract, + parentType: string, + parentId: string, + childType: string, + childId: string, + ): Promise { + const nestedRouteConfig = this.getNestedRouteConfig(parentType, childType); + if (!nestedRouteConfig) { + return this.makeError('invalidPath'); + } + + const typeInfo = this.getModelInfo(childType); + if (!typeInfo) { + return this.makeUnsupportedModelError(childType); + } + + const { filter: nestedFilter, error } = this.buildNestedParentFilter( + parentType, + parentId, + childType, + nestedRouteConfig.relation, + ); + if (error) { + return error; + } + + // Verify child belongs to parent before deleting + const exists = await (client as any)[childType].findFirst({ + where: this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter), + }); + if (!exists) { + return this.makeError('notFound'); + } + + return this.processDelete(client, childType, childId); + } + private buildPartialSelect(type: string, query: Record | undefined) { const selectFieldsQuery = query?.[`fields[${type}]`]; if (!selectFieldsQuery) { @@ -993,6 +1530,8 @@ export class RestApiHandler implements Api type: string, _query: Record | undefined, requestBody: unknown, + forcedParentRelation?: { relation: string; parentId: string; parentIdFields: FieldDef[] }, + serializeLinkers?: SerializerOptions['linkers'], ): Promise { const typeInfo = this.getModelInfo(type); if (!typeInfo) { @@ -1041,13 +1580,41 @@ export class RestApiHandler implements Api } } + if (forcedParentRelation) { + if (relationships && forcedParentRelation.relation in relationships) { + return this.makeError( + 'invalidPayload', + `Relation "${forcedParentRelation.relation}" is controlled by the parent route and cannot be set in the request payload`, + ); + } + // Also reject scalar FK fields in attributes that could override the forced parent relation + const forcedFkFields = Object.values(typeInfo.fields).filter((f) => + f.foreignKeyFor?.includes(forcedParentRelation.relation), + ); + if (forcedFkFields.some((f) => Object.prototype.hasOwnProperty.call(createPayload.data, f.name))) { + return this.makeError( + 'invalidPayload', + `Relation "${forcedParentRelation.relation}" is controlled by the parent route and cannot be set in the request payload`, + ); + } + createPayload.data[forcedParentRelation.relation] = { + connect: this.makeIdConnect(forcedParentRelation.parentIdFields, forcedParentRelation.parentId), + }; + createPayload.include = { + ...createPayload.include, + [forcedParentRelation.relation]: { + select: { [this.makeDefaultIdKey(forcedParentRelation.parentIdFields)]: true }, + }, + }; + } + // include IDs of relation fields so that they can be serialized. this.includeRelationshipIds(type, createPayload, 'include'); const entity = await (client as any)[type].create(createPayload); return { status: 201, - body: await this.serializeItems(type, entity), + body: await this.serializeItems(type, entity, serializeLinkers ? { linkers: serializeLinkers } : undefined), }; } @@ -1245,6 +1812,7 @@ export class RestApiHandler implements Api resourceId: string, _query: Record | undefined, requestBody: unknown, + serializeLinkers?: SerializerOptions['linkers'], ): Promise { const typeInfo = this.getModelInfo(type); if (!typeInfo) { @@ -1302,7 +1870,7 @@ export class RestApiHandler implements Api const entity = await (client as any)[type].update(updatePayload); return { status: 200, - body: await this.serializeItems(type, entity), + body: await this.serializeItems(type, entity, serializeLinkers ? { linkers: serializeLinkers } : undefined), }; } @@ -2060,9 +2628,7 @@ export class RestApiHandler implements Api } } else { if (op === 'between') { - const parts = value - .split(',') - .map((v) => this.coerce(fieldDef, v)); + const parts = value.split(',').map((v) => this.coerce(fieldDef, v)); if (parts.length !== 2) { throw new InvalidValueError(`"between" expects exactly 2 comma-separated values`); } diff --git a/packages/server/test/api/options-validation.test.ts b/packages/server/test/api/options-validation.test.ts index 53f7f3680..f189db174 100644 --- a/packages/server/test/api/options-validation.test.ts +++ b/packages/server/test/api/options-validation.test.ts @@ -202,6 +202,218 @@ 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 relation is not a string', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { + User: { + relation: 123, + }, + }, + } 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: { Post: { relation: 'author' } }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should throw when child model does not exist in schema', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { NonExistent: { relation: 'author' } }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should throw when relation does not exist on child model', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { Post: { relation: 'nonExistentRelation' } }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should throw when relation does not point to the parent model', () => { + // Post.author points to User, so using it from User→Post is correct. + // But if we pretend Post is the parent and User the child, we'd need + // a relation on User that points to Post — which doesn't exist. + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + // author on Post points to User, not Post itself + Post: { Post: { relation: 'author' } }, + }, + }); + }).toThrow('Invalid nestedRoutes'); + }); + + it('should accept valid nestedRoutes configuration', () => { + expect(() => { + new RestApiHandler({ + schema: relClient.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: { + User: { Post: { relation: 'author' } }, + }, + }); + }).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: { Post: { relation: 'author', 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: { Post: { relation: 'author', 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: { Post: { relation: 'author', 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: { Post: { relation: 'author', 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: { Post: { relation: 'author' } }, + }, + }); + }).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..5222bb24f 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -3549,4 +3549,596 @@ 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: { + Post: { + relation: 'author', + }, + }, + }, + }); + 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/post', + 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/post', + 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/post/${postId}`, + client: nestedClient, + }); + expect(denied.status).toBe(404); + + const allowed = await nestedHandler({ + method: 'get', + path: `/user/u2/post/${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/post/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/post', + 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/post', + 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/post', + 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/post', + 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/post', + 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/post', + 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/post/${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/post/${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/post/${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/post/${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/post/${postId}`, + client: nestedClient, + }); + expect(denied.status).toBe(404); + + // Can delete via the correct parent + const allowed = await nestedHandler({ + method: 'delete', + path: `/user/u1/post/${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/post — nested create + const created = await nestedHandler({ + method: 'post', + path: '/user/u1/post', + 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/post/${postId}`); + expect(created.body.data.links.self).toBe(`http://localhost/api/user/u1/post/${postId}`); + + // GET /user/u1/post — nested collection read + const collection = await nestedHandler({ + method: 'get', + path: '/user/u1/post', + client: nestedClient, + }); + expect(collection.status).toBe(200); + expect(collection.body.links.self).toBe('http://localhost/api/user/u1/post'); + expect(collection.body.data[0].links.self).toBe(`http://localhost/api/user/u1/post/${postId}`); + + // GET /user/u1/post/:id — nested single read + const single = await nestedHandler({ + method: 'get', + path: `/user/u1/post/${postId}`, + client: nestedClient, + }); + expect(single.status).toBe(200); + expect(single.body.links.self).toBe(`http://localhost/api/user/u1/post/${postId}`); + expect(single.body.data.links.self).toBe(`http://localhost/api/user/u1/post/${postId}`); + + // PATCH /user/u1/post/:id — nested update + const updated = await nestedHandler({ + method: 'patch', + path: `/user/u1/post/${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/post/${postId}`); + expect(updated.body.data.links.self).toBe(`http://localhost/api/user/u1/post/${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: { Post: { relation: 'author' } }, + }, + }); + 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'); + + // Wrong parent → 404 + 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: { Post: { relation: 'author' } }, + }, + }); + 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); + }); + }); });