Skip to content

Commit 09f28fa

Browse files
committed
Added supports PATCH /:type/:id/:relationship for to-one nested update
1 parent 43e4e2d commit 09f28fa

File tree

2 files changed

+159
-49
lines changed

2 files changed

+159
-49
lines changed

packages/server/src/api/rest/index.ts

Lines changed: 95 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,26 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
565565
requestBody,
566566
);
567567
}
568-
// /:type/:id/:relationship/:childId — nested update
568+
// /:type/:id/:relationship — nested update (to-one)
569+
const nestedToOnePatchMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
570+
if (
571+
nestedToOnePatchMatch &&
572+
this.nestedRoutes &&
573+
this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship) &&
574+
!this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship)
575+
?.isCollection
576+
) {
577+
return await this.processNestedUpdate(
578+
client,
579+
nestedToOnePatchMatch.type,
580+
nestedToOnePatchMatch.id,
581+
nestedToOnePatchMatch.relationship,
582+
undefined,
583+
query,
584+
requestBody,
585+
);
586+
}
587+
// /:type/:id/:relationship/:childId — nested update (to-many)
569588
const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
570589
if (
571590
nestedPatchMatch &&
@@ -1215,48 +1234,39 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
12151234
};
12161235
}
12171236

1218-
private async processNestedUpdate(
1219-
client: ClientContract<Schema>,
1220-
parentType: string,
1221-
parentId: string,
1222-
parentRelation: string,
1223-
childId: string,
1224-
_query: Record<string, string | string[]> | undefined,
1237+
/**
1238+
* Builds the ORM `data` payload for a nested update, shared by both to-many (childId present)
1239+
* and to-one (childId absent) variants. Returns either `{ updateData }` or `{ error }`.
1240+
*/
1241+
private buildNestedUpdatePayload(
1242+
childType: string,
1243+
typeInfo: ReturnType<RestApiHandler<Schema>['getModelInfo']>,
1244+
rev: string,
12251245
requestBody: unknown,
1226-
): Promise<Response> {
1227-
const resolved = this.resolveNestedRelation(parentType, parentRelation);
1228-
if (!resolved) {
1229-
return this.makeError('invalidPath');
1230-
}
1231-
1232-
const parentInfo = this.getModelInfo(parentType)!;
1233-
const childType = resolved.childType;
1234-
const typeInfo = this.getModelInfo(childType)!;
1235-
const rev = resolved.reverseRelation;
1236-
1246+
): { updateData: any; error?: never } | { updateData?: never; error: Response } {
12371247
const { attributes, relationships, error } = this.processRequestBody(requestBody);
1238-
if (error) return error;
1248+
if (error) return { error };
12391249

12401250
const updateData: any = { ...attributes };
12411251

12421252
// Reject attempts to change the parent relation via the nested endpoint
12431253
if (relationships && Object.prototype.hasOwnProperty.call(relationships, rev)) {
1244-
return this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`);
1254+
return { error: this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`) };
12451255
}
1246-
const fkFields = Object.values(typeInfo.fields).filter((f) => f.foreignKeyFor?.includes(rev));
1256+
const fkFields = Object.values(typeInfo!.fields).filter((f) => f.foreignKeyFor?.includes(rev));
12471257
if (fkFields.some((f) => Object.prototype.hasOwnProperty.call(updateData, f.name))) {
1248-
return this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`);
1258+
return { error: this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`) };
12491259
}
12501260

12511261
// Turn relationship payload into connect/set objects
12521262
if (relationships) {
12531263
for (const [key, data] of Object.entries<any>(relationships)) {
12541264
if (!data?.data) {
1255-
return this.makeError('invalidRelationData');
1265+
return { error: this.makeError('invalidRelationData') };
12561266
}
1257-
const relationInfo = typeInfo.relationships[key];
1267+
const relationInfo = typeInfo!.relationships[key];
12581268
if (!relationInfo) {
1259-
return this.makeUnsupportedRelationshipError(childType, key, 400);
1269+
return { error: this.makeUnsupportedRelationshipError(childType, key, 400) };
12601270
}
12611271
if (relationInfo.isCollection) {
12621272
updateData[key] = {
@@ -1266,7 +1276,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
12661276
};
12671277
} else {
12681278
if (typeof data.data !== 'object') {
1269-
return this.makeError('invalidRelationData');
1279+
return { error: this.makeError('invalidRelationData') };
12701280
}
12711281
updateData[key] = {
12721282
connect: { [this.makeDefaultIdKey(relationInfo.idFields)]: data.data.id },
@@ -1275,30 +1285,66 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
12751285
}
12761286
}
12771287

1278-
// Atomically update child scoped to parent; ORM throws NOT_FOUND if parent or child-belongs-to-parent check fails
1279-
await (client as any)[parentType].update({
1280-
where: this.makeIdFilter(parentInfo.idFields, parentId),
1281-
data: {
1282-
[parentRelation]: {
1283-
update: { where: this.makeIdFilter(typeInfo.idFields, childId), data: updateData },
1284-
},
1285-
},
1286-
});
1288+
return { updateData };
1289+
}
12871290

1288-
// Fetch the updated entity for the response
1289-
const fetchArgs: any = { where: this.makeIdFilter(typeInfo.idFields, childId) };
1290-
this.includeRelationshipIds(childType, fetchArgs, 'include');
1291-
const entity = await (client as any)[childType].findUnique(fetchArgs);
1292-
if (!entity) return this.makeError('notFound');
1291+
/**
1292+
* Handles PATCH /:type/:id/:relationship/:childId (to-many) and
1293+
* PATCH /:type/:id/:relationship (to-one, childId undefined).
1294+
*/
1295+
private async processNestedUpdate(
1296+
client: ClientContract<Schema>,
1297+
parentType: string,
1298+
parentId: string,
1299+
parentRelation: string,
1300+
childId: string | undefined,
1301+
_query: Record<string, string | string[]> | undefined,
1302+
requestBody: unknown,
1303+
): Promise<Response> {
1304+
const resolved = this.resolveNestedRelation(parentType, parentRelation);
1305+
if (!resolved) {
1306+
return this.makeError('invalidPath');
1307+
}
12931308

1294-
const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId));
1295-
const nestedLinker = new tsjapi.Linker(() => linkUrl);
1296-
return {
1297-
status: 200,
1298-
body: await this.serializeItems(childType, entity, {
1299-
linkers: { document: nestedLinker, resource: nestedLinker },
1300-
}),
1301-
};
1309+
const parentInfo = this.getModelInfo(parentType)!;
1310+
const childType = resolved.childType;
1311+
const typeInfo = this.getModelInfo(childType)!;
1312+
1313+
const { updateData, error } = this.buildNestedUpdatePayload(childType, typeInfo, resolved.reverseRelation, requestBody);
1314+
if (error) return error;
1315+
1316+
if (childId) {
1317+
// to-many: ORM requires a where filter to identify the child within the collection
1318+
await (client as any)[parentType].update({
1319+
where: this.makeIdFilter(parentInfo.idFields, parentId),
1320+
data: { [parentRelation]: { update: { where: this.makeIdFilter(typeInfo.idFields, childId), data: updateData } } },
1321+
});
1322+
const fetchArgs: any = { where: this.makeIdFilter(typeInfo.idFields, childId) };
1323+
this.includeRelationshipIds(childType, fetchArgs, 'include');
1324+
const entity = await (client as any)[childType].findUnique(fetchArgs);
1325+
if (!entity) return this.makeError('notFound');
1326+
const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId));
1327+
const nestedLinker = new tsjapi.Linker(() => linkUrl);
1328+
return { status: 200, body: await this.serializeItems(childType, entity, { linkers: { document: nestedLinker, resource: nestedLinker } }) };
1329+
} else {
1330+
// to-one: no where filter needed; fetch via parent select
1331+
await (client as any)[parentType].update({
1332+
where: this.makeIdFilter(parentInfo.idFields, parentId),
1333+
data: { [parentRelation]: { update: updateData } },
1334+
});
1335+
const childIncludeArgs: any = {};
1336+
this.includeRelationshipIds(childType, childIncludeArgs, 'include');
1337+
const fetchArgs: any = {
1338+
where: this.makeIdFilter(parentInfo.idFields, parentId),
1339+
select: { [parentRelation]: childIncludeArgs.include ? { include: childIncludeArgs.include } : true },
1340+
};
1341+
const parent = await (client as any)[parentType].findUnique(fetchArgs);
1342+
const entity = parent?.[parentRelation];
1343+
if (!entity) return this.makeError('notFound');
1344+
const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation));
1345+
const nestedLinker = new tsjapi.Linker(() => linkUrl);
1346+
return { status: 200, body: await this.serializeItems(childType, entity, { linkers: { document: nestedLinker, resource: nestedLinker } }) };
1347+
}
13021348
}
13031349

13041350
private async processNestedDelete(

packages/server/test/api/rest.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3940,6 +3940,70 @@ mutation procedure sum(a: Int, b: Int): Int
39403940
expect(r.body.data.id).toBe(user1.id);
39413941
});
39423942

3943+
it('supports PATCH /:type/:id/:relationship for to-one nested update', async () => {
3944+
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
3945+
await nestedClient.user.create({ data: { id: 'u2', email: 'u2@test.com' } });
3946+
const post = await nestedClient.post.create({
3947+
data: { title: 'my-post', author: { connect: { id: 'u1' } } },
3948+
});
3949+
3950+
// PATCH /post/:id/author — update the to-one related author's attributes
3951+
const updated = await nestedHandler({
3952+
method: 'patch',
3953+
path: `/post/${post.id}/author`,
3954+
client: nestedClient,
3955+
requestBody: {
3956+
data: { type: 'user', id: 'u1', attributes: { email: 'u1-new@test.com' } },
3957+
},
3958+
});
3959+
expect(updated.status).toBe(200);
3960+
expect(updated.body.data.attributes.email).toBe('u1-new@test.com');
3961+
expect(updated.body.links.self).toBe(`http://localhost/api/post/${post.id}/author`);
3962+
expect(updated.body.data.links.self).toBe(`http://localhost/api/post/${post.id}/author`);
3963+
3964+
// Verify the DB was actually updated
3965+
const dbUser = await nestedClient.user.findUnique({ where: { id: 'u1' } });
3966+
expect(dbUser?.email).toBe('u1-new@test.com');
3967+
3968+
// Attempting to change the back-relation (posts) via the nested route should be rejected
3969+
const rejected = await nestedHandler({
3970+
method: 'patch',
3971+
path: `/post/${post.id}/author`,
3972+
client: nestedClient,
3973+
requestBody: {
3974+
data: {
3975+
type: 'user',
3976+
id: 'u1',
3977+
relationships: { posts: { data: [{ type: 'post', id: String(post.id) }] } },
3978+
},
3979+
},
3980+
});
3981+
expect(rejected.status).toBe(400);
3982+
});
3983+
3984+
it('returns 400 for PATCH /:type/:id/:relationship to-one when nestedRoutes is not enabled', async () => {
3985+
const api = new RestApiHandler({
3986+
schema: nestedClient.$schema,
3987+
endpoint: 'http://localhost/api',
3988+
// nestedRoutes not enabled
3989+
});
3990+
const plainHandler = (args: any) =>
3991+
api.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) });
3992+
3993+
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
3994+
const post = await nestedClient.post.create({
3995+
data: { title: 'my-post', author: { connect: { id: 'u1' } } },
3996+
});
3997+
3998+
const r = await plainHandler({
3999+
method: 'patch',
4000+
path: `/post/${post.id}/author`,
4001+
client: nestedClient,
4002+
requestBody: { data: { type: 'user', id: 'u1', attributes: { email: 'x@test.com' } } },
4003+
});
4004+
expect(r.status).toBe(400);
4005+
});
4006+
39434007
it('returns nested self-links in JSON:API responses for all nested operations', async () => {
39444008
await nestedClient.user.create({ data: { id: 'u1', email: 'u1@test.com' } });
39454009

0 commit comments

Comments
 (0)