diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 651a98736..cd8d0f66a 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -66,6 +66,15 @@ export type RestApiHandlerOptions = { * Mapping from model names to unique field name to be used as resource's ID. */ externalIdMapping?: Record; + + /** + * When `true`, enables nested route handling for all to-many relations: + * `/:parentType/:parentId/:relationName` (collection) and + * `/:parentType/:parentId/:relationName/:childId` (single). + * + * Defaults to `false`. + */ + nestedRoutes?: boolean; } & CommonHandlerOptions; type RelationshipInfo = { @@ -86,10 +95,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', @@ -264,6 +275,7 @@ export class RestApiHandler implements Api private modelNameMapping: Record; private reverseModelNameMapping: Record; private externalIdMapping: Record; + private nestedRoutes: boolean; constructor(private readonly options: RestApiHandlerOptions) { this.validateOptions(options); @@ -284,6 +296,8 @@ export class RestApiHandler implements Api Object.entries(this.externalIdMapping).map(([k, v]) => [lowerCaseFirst(k), v]), ); + this.nestedRoutes = options.nestedRoutes ?? false; + this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); this.buildTypeMap(); @@ -301,6 +315,7 @@ export class RestApiHandler implements Api modelNameMapping: z.record(z.string(), z.string()).optional(), externalIdMapping: z.record(z.string(), z.string()).optional(), queryOptions: queryOptionsSchema.optional(), + nestedRoutes: z.boolean().optional(), }); const parseResult = schema.safeParse(options); if (!parseResult.success) { @@ -325,6 +340,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']), @@ -338,6 +357,70 @@ export class RestApiHandler implements Api return this.modelNameMapping[modelName] ?? modelName; } + /** + * 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) { @@ -399,6 +482,18 @@ export class RestApiHandler implements Api ); } + // /:type/:id/:relationship/:childId — nested single read + match = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if (match && this.nestedRoutes && this.resolveNestedRelation(match.type, match.relationship)?.isCollection) { + return await this.processNestedSingleRead( + client, + match.type, + match.id, + match.relationship, + match.childId!, + query, + ); + } match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { // collection read @@ -412,6 +507,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.nestedRoutes && this.resolveNestedRelation(nestedMatch.type, nestedMatch.relationship)?.isCollection) { + 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; @@ -447,12 +554,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( @@ -465,18 +568,53 @@ export class RestApiHandler implements Api requestBody, ); } - + // /:type/:id/:relationship — nested update (to-one) + const nestedToOnePatchMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); + if ( + nestedToOnePatchMatch && + this.nestedRoutes && + this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship) && + !this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship) + ?.isCollection + ) { + return await this.processNestedUpdate( + client, + nestedToOnePatchMatch.type, + nestedToOnePatchMatch.id, + nestedToOnePatchMatch.relationship, + undefined, + query, + requestBody, + ); + } + // /:type/:id/:relationship/:childId — nested update (to-many) + const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE); + if ( + nestedPatchMatch && + this.nestedRoutes && + this.resolveNestedRelation(nestedPatchMatch.type, nestedPatchMatch.relationship)?.isCollection + ) { + 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( @@ -489,7 +627,26 @@ 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.nestedRoutes && + this.resolveNestedRelation(nestedDeleteMatch.type, nestedDeleteMatch.relationship)?.isCollection + ) { + 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'); } @@ -606,18 +763,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'); @@ -627,7 +782,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 }; @@ -637,18 +792,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) { @@ -883,7 +1054,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); @@ -905,6 +1082,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), }, }; @@ -918,6 +1096,294 @@ 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, include, error } = this.buildSingleReadArgs(childType, query); + if (error) return error; + + 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'); + + 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 }, + }), + }; + } + + /** + * Builds the ORM `data` payload for a nested update, shared by both to-many (childId present) + * and to-one (childId absent) variants. Returns either `{ updateData }` or `{ error }`. + */ + private buildNestedUpdatePayload( + childType: string, + typeInfo: ReturnType['getModelInfo']>, + rev: string, + requestBody: unknown, + ): { updateData: any; error?: never } | { updateData?: never; error: Response } { + 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 { error: 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 { error: 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 { error: this.makeError('invalidRelationData') }; + } + const relationInfo = typeInfo!.relationships[key]; + if (!relationInfo) { + return { error: 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 { error: this.makeError('invalidRelationData') }; + } + updateData[key] = { + connect: { [this.makeDefaultIdKey(relationInfo.idFields)]: data.data.id }, + }; + } + } + } + + return { updateData }; + } + + /** + * Handles PATCH /:type/:id/:relationship/:childId (to-many) and + * PATCH /:type/:id/:relationship (to-one, childId undefined). + */ + private async processNestedUpdate( + client: ClientContract, + parentType: string, + parentId: string, + parentRelation: string, + childId: string | undefined, + _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 { updateData, error } = this.buildNestedUpdatePayload(childType, typeInfo, resolved.reverseRelation, requestBody); + if (error) return error; + + if (childId) { + // to-many: ORM requires a where filter to identify the child within the collection + await (client as any)[parentType].update({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + data: { [parentRelation]: { update: { where: this.makeIdFilter(typeInfo.idFields, childId), data: updateData } } }, + }); + 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 } }) }; + } else { + // to-one: no where filter needed; fetch via parent select + await (client as any)[parentType].update({ + where: this.makeIdFilter(parentInfo.idFields, parentId), + data: { [parentRelation]: { update: updateData } }, + }); + const childIncludeArgs: any = {}; + this.includeRelationshipIds(childType, childIncludeArgs, 'include'); + const fetchArgs: any = { + where: this.makeIdFilter(parentInfo.idFields, parentId), + select: { [parentRelation]: childIncludeArgs.include ? { include: childIncludeArgs.include } : true }, + }; + const parent = await (client as any)[parentType].findUnique(fetchArgs); + const entity = parent?.[parentRelation]; + if (!entity) return this.makeError('notFound'); + const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation)); + 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..e902896f4 100644 --- a/packages/server/test/api/options-validation.test.ts +++ b/packages/server/test/api/options-validation.test.ts @@ -202,6 +202,16 @@ describe('API Handler Options Validation', () => { }).toThrow('Invalid options'); }); + it('should throw error when nestedRoutes is not a boolean', () => { + expect(() => { + new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + nestedRoutes: 'invalid' as any, + }); + }).toThrow('Invalid options'); + }); + 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..52d7242b2 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -3549,4 +3549,640 @@ 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: true, + }); + 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('supports PATCH /:type/:id/:relationship for to-one nested update', async () => { + await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } }); + await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } }); + const post = await nestedClient.post.create({ + data: { title: 'my-post', author: { connect: { id: 'u1' } } }, + }); + + // PATCH /post/:id/author — update the to-one related author's attributes + const updated = await nestedHandler({ + method: 'patch', + path: `/post/${post.id}/author`, + client: nestedClient, + requestBody: { + data: { type: 'user', id: 'u1', attributes: { email: 'u1-new@test.com' } }, + }, + }); + expect(updated.status).toBe(200); + expect(updated.body.data.attributes.email).toBe('u1-new@test.com'); + expect(updated.body.links.self).toBe(`http://localhost/api/post/${post.id}/author`); + expect(updated.body.data.links.self).toBe(`http://localhost/api/post/${post.id}/author`); + + // Verify the DB was actually updated + const dbUser = await nestedClient.user.findUnique({ where: { id: 'u1' } }); + expect(dbUser?.email).toBe('u1-new@test.com'); + + // Attempting to change the back-relation (posts) via the nested route should be rejected + const rejected = await nestedHandler({ + method: 'patch', + path: `/post/${post.id}/author`, + client: nestedClient, + requestBody: { + data: { + type: 'user', + id: 'u1', + relationships: { posts: { data: [{ type: 'post', id: String(post.id) }] } }, + }, + }, + }); + expect(rejected.status).toBe(400); + }); + + it('returns 400 for PATCH /:type/:id/:relationship to-one when nestedRoutes is not enabled', async () => { + const api = new RestApiHandler({ + schema: nestedClient.$schema, + endpoint: 'http://localhost/api', + // nestedRoutes not enabled + }); + const plainHandler = (args: any) => + api.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + + await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } }); + const post = await nestedClient.post.create({ + data: { title: 'my-post', author: { connect: { id: 'u1' } } }, + }); + + const r = await plainHandler({ + method: 'patch', + path: `/post/${post.id}/author`, + client: nestedClient, + requestBody: { data: { type: 'user', id: 'u1', attributes: { email: 'x@test.com' } } }, + }); + expect(r.status).toBe(400); + }); + + 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: true, + }); + 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: true, + }); + 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); + }); + }); });