Skip to content

Commit 4d9e286

Browse files
committed
Added supports PATCH /:type/:id/:relationship for to-one nested update
1 parent 371257d commit 4d9e286

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
@@ -568,7 +568,26 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
568568
requestBody,
569569
);
570570
}
571-
// /:type/:id/:relationship/:childId — nested update
571+
// /:type/:id/:relationship — nested update (to-one)
572+
const nestedToOnePatchMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
573+
if (
574+
nestedToOnePatchMatch &&
575+
this.nestedRoutes &&
576+
this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship) &&
577+
!this.resolveNestedRelation(nestedToOnePatchMatch.type, nestedToOnePatchMatch.relationship)
578+
?.isCollection
579+
) {
580+
return await this.processNestedUpdate(
581+
client,
582+
nestedToOnePatchMatch.type,
583+
nestedToOnePatchMatch.id,
584+
nestedToOnePatchMatch.relationship,
585+
undefined,
586+
query,
587+
requestBody,
588+
);
589+
}
590+
// /:type/:id/:relationship/:childId — nested update (to-many)
572591
const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
573592
if (
574593
nestedPatchMatch &&
@@ -1228,48 +1247,39 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
12281247
};
12291248
}
12301249

1231-
private async processNestedUpdate(
1232-
client: ClientContract<Schema>,
1233-
parentType: string,
1234-
parentId: string,
1235-
parentRelation: string,
1236-
childId: string,
1237-
_query: Record<string, string | string[]> | undefined,
1250+
/**
1251+
* Builds the ORM `data` payload for a nested update, shared by both to-many (childId present)
1252+
* and to-one (childId absent) variants. Returns either `{ updateData }` or `{ error }`.
1253+
*/
1254+
private buildNestedUpdatePayload(
1255+
childType: string,
1256+
typeInfo: ReturnType<RestApiHandler<Schema>['getModelInfo']>,
1257+
rev: string,
12381258
requestBody: unknown,
1239-
): Promise<Response> {
1240-
const resolved = this.resolveNestedRelation(parentType, parentRelation);
1241-
if (!resolved) {
1242-
return this.makeError('invalidPath');
1243-
}
1244-
1245-
const parentInfo = this.getModelInfo(parentType)!;
1246-
const childType = resolved.childType;
1247-
const typeInfo = this.getModelInfo(childType)!;
1248-
const rev = resolved.reverseRelation;
1249-
1259+
): { updateData: any; error?: never } | { updateData?: never; error: Response } {
12501260
const { attributes, relationships, error } = this.processRequestBody(requestBody);
1251-
if (error) return error;
1261+
if (error) return { error };
12521262

12531263
const updateData: any = { ...attributes };
12541264

12551265
// Reject attempts to change the parent relation via the nested endpoint
12561266
if (relationships && Object.prototype.hasOwnProperty.call(relationships, rev)) {
1257-
return this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`);
1267+
return { error: this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`) };
12581268
}
1259-
const fkFields = Object.values(typeInfo.fields).filter((f) => f.foreignKeyFor?.includes(rev));
1269+
const fkFields = Object.values(typeInfo!.fields).filter((f) => f.foreignKeyFor?.includes(rev));
12601270
if (fkFields.some((f) => Object.prototype.hasOwnProperty.call(updateData, f.name))) {
1261-
return this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`);
1271+
return { error: this.makeError('invalidPayload', `Relation "${rev}" cannot be changed via a nested route`) };
12621272
}
12631273

12641274
// Turn relationship payload into connect/set objects
12651275
if (relationships) {
12661276
for (const [key, data] of Object.entries<any>(relationships)) {
12671277
if (!data?.data) {
1268-
return this.makeError('invalidRelationData');
1278+
return { error: this.makeError('invalidRelationData') };
12691279
}
1270-
const relationInfo = typeInfo.relationships[key];
1280+
const relationInfo = typeInfo!.relationships[key];
12711281
if (!relationInfo) {
1272-
return this.makeUnsupportedRelationshipError(childType, key, 400);
1282+
return { error: this.makeUnsupportedRelationshipError(childType, key, 400) };
12731283
}
12741284
if (relationInfo.isCollection) {
12751285
updateData[key] = {
@@ -1279,7 +1289,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
12791289
};
12801290
} else {
12811291
if (typeof data.data !== 'object') {
1282-
return this.makeError('invalidRelationData');
1292+
return { error: this.makeError('invalidRelationData') };
12831293
}
12841294
updateData[key] = {
12851295
connect: { [this.makeDefaultIdKey(relationInfo.idFields)]: data.data.id },
@@ -1288,30 +1298,66 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
12881298
}
12891299
}
12901300

1291-
// Atomically update child scoped to parent; ORM throws NOT_FOUND if parent or child-belongs-to-parent check fails
1292-
await (client as any)[parentType].update({
1293-
where: this.makeIdFilter(parentInfo.idFields, parentId),
1294-
data: {
1295-
[parentRelation]: {
1296-
update: { where: this.makeIdFilter(typeInfo.idFields, childId), data: updateData },
1297-
},
1298-
},
1299-
});
1301+
return { updateData };
1302+
}
13001303

1301-
// Fetch the updated entity for the response
1302-
const fetchArgs: any = { where: this.makeIdFilter(typeInfo.idFields, childId) };
1303-
this.includeRelationshipIds(childType, fetchArgs, 'include');
1304-
const entity = await (client as any)[childType].findUnique(fetchArgs);
1305-
if (!entity) return this.makeError('notFound');
1304+
/**
1305+
* Handles PATCH /:type/:id/:relationship/:childId (to-many) and
1306+
* PATCH /:type/:id/:relationship (to-one, childId undefined).
1307+
*/
1308+
private async processNestedUpdate(
1309+
client: ClientContract<Schema>,
1310+
parentType: string,
1311+
parentId: string,
1312+
parentRelation: string,
1313+
childId: string | undefined,
1314+
_query: Record<string, string | string[]> | undefined,
1315+
requestBody: unknown,
1316+
): Promise<Response> {
1317+
const resolved = this.resolveNestedRelation(parentType, parentRelation);
1318+
if (!resolved) {
1319+
return this.makeError('invalidPath');
1320+
}
13061321

1307-
const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId));
1308-
const nestedLinker = new tsjapi.Linker(() => linkUrl);
1309-
return {
1310-
status: 200,
1311-
body: await this.serializeItems(childType, entity, {
1312-
linkers: { document: nestedLinker, resource: nestedLinker },
1313-
}),
1314-
};
1322+
const parentInfo = this.getModelInfo(parentType)!;
1323+
const childType = resolved.childType;
1324+
const typeInfo = this.getModelInfo(childType)!;
1325+
1326+
const { updateData, error } = this.buildNestedUpdatePayload(childType, typeInfo, resolved.reverseRelation, requestBody);
1327+
if (error) return error;
1328+
1329+
if (childId) {
1330+
// to-many: ORM requires a where filter to identify the child within the collection
1331+
await (client as any)[parentType].update({
1332+
where: this.makeIdFilter(parentInfo.idFields, parentId),
1333+
data: { [parentRelation]: { update: { where: this.makeIdFilter(typeInfo.idFields, childId), data: updateData } } },
1334+
});
1335+
const fetchArgs: any = { where: this.makeIdFilter(typeInfo.idFields, childId) };
1336+
this.includeRelationshipIds(childType, fetchArgs, 'include');
1337+
const entity = await (client as any)[childType].findUnique(fetchArgs);
1338+
if (!entity) return this.makeError('notFound');
1339+
const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation, childId));
1340+
const nestedLinker = new tsjapi.Linker(() => linkUrl);
1341+
return { status: 200, body: await this.serializeItems(childType, entity, { linkers: { document: nestedLinker, resource: nestedLinker } }) };
1342+
} else {
1343+
// to-one: no where filter needed; fetch via parent select
1344+
await (client as any)[parentType].update({
1345+
where: this.makeIdFilter(parentInfo.idFields, parentId),
1346+
data: { [parentRelation]: { update: updateData } },
1347+
});
1348+
const childIncludeArgs: any = {};
1349+
this.includeRelationshipIds(childType, childIncludeArgs, 'include');
1350+
const fetchArgs: any = {
1351+
where: this.makeIdFilter(parentInfo.idFields, parentId),
1352+
select: { [parentRelation]: childIncludeArgs.include ? { include: childIncludeArgs.include } : true },
1353+
};
1354+
const parent = await (client as any)[parentType].findUnique(fetchArgs);
1355+
const entity = parent?.[parentRelation];
1356+
if (!entity) return this.makeError('notFound');
1357+
const linkUrl = this.makeLinkUrl(this.makeNestedLinkUrl(parentType, parentId, parentRelation));
1358+
const nestedLinker = new tsjapi.Linker(() => linkUrl);
1359+
return { status: 200, body: await this.serializeItems(childType, entity, { linkers: { document: nestedLinker, resource: nestedLinker } }) };
1360+
}
13151361
}
13161362

13171363
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)