Skip to content

Commit cef0e8f

Browse files
authored
Configurable REST API id divider (#1785)
2 parents 501d809 + 4fa960f commit cef0e8f

File tree

3 files changed

+203
-54
lines changed

3 files changed

+203
-54
lines changed

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

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,6 @@ import { LoggerConfig, Response } from '../../types';
2222
import { APIHandlerBase, RequestContext } from '../base';
2323
import { logWarning, registerCustomSerializers } from '../utils';
2424

25-
const urlPatterns = {
26-
// collection operations
27-
collection: new UrlPattern('/:type'),
28-
// single resource operations
29-
single: new UrlPattern('/:type/:id'),
30-
// related entity fetching
31-
fetchRelationship: new UrlPattern('/:type/:id/:relationship'),
32-
// relationship operations
33-
relationship: new UrlPattern('/:type/:id/relationships/:relationship'),
34-
};
35-
36-
export const idDivider = '_';
37-
3825
/**
3926
* Request handler options
4027
*/
@@ -52,6 +39,19 @@ export type Options = {
5239
* Defaults to 100. Set to Infinity to disable pagination.
5340
*/
5441
pageSize?: number;
42+
43+
/**
44+
* The divider used to separate compound ID fields in the URL.
45+
* Defaults to '_'.
46+
*/
47+
idDivider?: string;
48+
49+
/**
50+
* The charset used for URL segment values. Defaults to `a-zA-Z0-9-_~ %`. You can change it if your entity's ID values
51+
* allow different characters. Specifically, if your models use compound IDs and the idDivider is set to a different value,
52+
* it should be included in the charset.
53+
*/
54+
urlSegmentCharset?: string;
5555
};
5656

5757
type RelationshipInfo = {
@@ -93,6 +93,8 @@ const FilterOperations = [
9393

9494
type FilterOperationType = (typeof FilterOperations)[number] | undefined;
9595

96+
const prismaIdDivider = '_';
97+
9698
registerCustomSerializers();
9799

98100
/**
@@ -210,8 +212,30 @@ class RequestHandler extends APIHandlerBase {
210212
// all known types and their metadata
211213
private typeMap: Record<string, ModelInfo>;
212214

215+
// divider used to separate compound ID fields
216+
private idDivider;
217+
218+
private urlPatterns;
219+
213220
constructor(private readonly options: Options) {
214221
super();
222+
this.idDivider = options.idDivider ?? prismaIdDivider;
223+
const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %';
224+
this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset);
225+
}
226+
227+
buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) {
228+
const options = { segmentValueCharset: urlSegmentNameCharset };
229+
return {
230+
// collection operations
231+
collection: new UrlPattern('/:type', options),
232+
// single resource operations
233+
single: new UrlPattern('/:type/:id', options),
234+
// related entity fetching
235+
fetchRelationship: new UrlPattern('/:type/:id/:relationship', options),
236+
// relationship operations
237+
relationship: new UrlPattern('/:type/:id/relationships/:relationship', options),
238+
};
215239
}
216240

217241
async handleRequest({
@@ -245,19 +269,19 @@ class RequestHandler extends APIHandlerBase {
245269
try {
246270
switch (method) {
247271
case 'GET': {
248-
let match = urlPatterns.single.match(path);
272+
let match = this.urlPatterns.single.match(path);
249273
if (match) {
250274
// single resource read
251275
return await this.processSingleRead(prisma, match.type, match.id, query);
252276
}
253277

254-
match = urlPatterns.fetchRelationship.match(path);
278+
match = this.urlPatterns.fetchRelationship.match(path);
255279
if (match) {
256280
// fetch related resource(s)
257281
return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query);
258282
}
259283

260-
match = urlPatterns.relationship.match(path);
284+
match = this.urlPatterns.relationship.match(path);
261285
if (match) {
262286
// read relationship
263287
return await this.processReadRelationship(
@@ -269,7 +293,7 @@ class RequestHandler extends APIHandlerBase {
269293
);
270294
}
271295

272-
match = urlPatterns.collection.match(path);
296+
match = this.urlPatterns.collection.match(path);
273297
if (match) {
274298
// collection read
275299
return await this.processCollectionRead(prisma, match.type, query);
@@ -283,13 +307,13 @@ class RequestHandler extends APIHandlerBase {
283307
return this.makeError('invalidPayload');
284308
}
285309

286-
let match = urlPatterns.collection.match(path);
310+
let match = this.urlPatterns.collection.match(path);
287311
if (match) {
288312
// resource creation
289313
return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas);
290314
}
291315

292-
match = urlPatterns.relationship.match(path);
316+
match = this.urlPatterns.relationship.match(path);
293317
if (match) {
294318
// relationship creation (collection relationship only)
295319
return await this.processRelationshipCRUD(
@@ -313,7 +337,7 @@ class RequestHandler extends APIHandlerBase {
313337
return this.makeError('invalidPayload');
314338
}
315339

316-
let match = urlPatterns.single.match(path);
340+
let match = this.urlPatterns.single.match(path);
317341
if (match) {
318342
// resource update
319343
return await this.processUpdate(
@@ -327,7 +351,7 @@ class RequestHandler extends APIHandlerBase {
327351
);
328352
}
329353

330-
match = urlPatterns.relationship.match(path);
354+
match = this.urlPatterns.relationship.match(path);
331355
if (match) {
332356
// relationship update
333357
return await this.processRelationshipCRUD(
@@ -345,13 +369,13 @@ class RequestHandler extends APIHandlerBase {
345369
}
346370

347371
case 'DELETE': {
348-
let match = urlPatterns.single.match(path);
372+
let match = this.urlPatterns.single.match(path);
349373
if (match) {
350374
// resource deletion
351375
return await this.processDelete(prisma, match.type, match.id);
352376
}
353377

354-
match = urlPatterns.relationship.match(path);
378+
match = this.urlPatterns.relationship.match(path);
355379
if (match) {
356380
// relationship deletion (collection relationship only)
357381
return await this.processRelationshipCRUD(
@@ -391,7 +415,7 @@ class RequestHandler extends APIHandlerBase {
391415
return this.makeUnsupportedModelError(type);
392416
}
393417

394-
const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) };
418+
const args: any = { where: this.makePrismaIdFilter(typeInfo.idFields, resourceId) };
395419

396420
// include IDs of relation fields so that they can be serialized
397421
this.includeRelationshipIds(type, args, 'include');
@@ -456,7 +480,7 @@ class RequestHandler extends APIHandlerBase {
456480

457481
select = select ?? { [relationship]: true };
458482
const args: any = {
459-
where: this.makeIdFilter(typeInfo.idFields, resourceId),
483+
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
460484
select,
461485
};
462486

@@ -514,7 +538,7 @@ class RequestHandler extends APIHandlerBase {
514538
}
515539

516540
const args: any = {
517-
where: this.makeIdFilter(typeInfo.idFields, resourceId),
541+
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
518542
select: this.makeIdSelect(typeInfo.idFields),
519543
};
520544

@@ -753,7 +777,7 @@ class RequestHandler extends APIHandlerBase {
753777
if (relationInfo.isCollection) {
754778
createPayload.data[key] = {
755779
connect: enumerate(data.data).map((item: any) => ({
756-
[this.makeIdKey(relationInfo.idFields)]: item.id,
780+
[this.makePrismaIdKey(relationInfo.idFields)]: item.id,
757781
})),
758782
};
759783
} else {
@@ -762,15 +786,15 @@ class RequestHandler extends APIHandlerBase {
762786
}
763787
createPayload.data[key] = {
764788
connect: {
765-
[this.makeIdKey(relationInfo.idFields)]: data.data.id,
789+
[this.makePrismaIdKey(relationInfo.idFields)]: data.data.id,
766790
},
767791
};
768792
}
769793

770794
// make sure ID fields are included for result serialization
771795
createPayload.include = {
772796
...createPayload.include,
773-
[key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } },
797+
[key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } },
774798
};
775799
}
776800
}
@@ -807,7 +831,7 @@ class RequestHandler extends APIHandlerBase {
807831
}
808832

809833
const updateArgs: any = {
810-
where: this.makeIdFilter(typeInfo.idFields, resourceId),
834+
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
811835
select: {
812836
...typeInfo.idFields.reduce((acc, field) => ({ ...acc, [field.name]: true }), {}),
813837
[relationship]: { select: this.makeIdSelect(relationInfo.idFields) },
@@ -842,7 +866,7 @@ class RequestHandler extends APIHandlerBase {
842866
updateArgs.data = {
843867
[relationship]: {
844868
connect: {
845-
[this.makeIdKey(relationInfo.idFields)]: parsed.data.data.id,
869+
[this.makePrismaIdKey(relationInfo.idFields)]: parsed.data.data.id,
846870
},
847871
},
848872
};
@@ -866,7 +890,7 @@ class RequestHandler extends APIHandlerBase {
866890
updateArgs.data = {
867891
[relationship]: {
868892
[relationVerb]: enumerate(parsed.data.data).map((item: any) =>
869-
this.makeIdFilter(relationInfo.idFields, item.id)
893+
this.makePrismaIdFilter(relationInfo.idFields, item.id)
870894
),
871895
},
872896
};
@@ -907,7 +931,7 @@ class RequestHandler extends APIHandlerBase {
907931
}
908932

909933
const updatePayload: any = {
910-
where: this.makeIdFilter(typeInfo.idFields, resourceId),
934+
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
911935
data: { ...attributes },
912936
};
913937

@@ -926,7 +950,7 @@ class RequestHandler extends APIHandlerBase {
926950
if (relationInfo.isCollection) {
927951
updatePayload.data[key] = {
928952
set: enumerate(data.data).map((item: any) => ({
929-
[this.makeIdKey(relationInfo.idFields)]: item.id,
953+
[this.makePrismaIdKey(relationInfo.idFields)]: item.id,
930954
})),
931955
};
932956
} else {
@@ -935,13 +959,13 @@ class RequestHandler extends APIHandlerBase {
935959
}
936960
updatePayload.data[key] = {
937961
set: {
938-
[this.makeIdKey(relationInfo.idFields)]: data.data.id,
962+
[this.makePrismaIdKey(relationInfo.idFields)]: data.data.id,
939963
},
940964
};
941965
}
942966
updatePayload.include = {
943967
...updatePayload.include,
944-
[key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } },
968+
[key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } },
945969
};
946970
}
947971
}
@@ -960,7 +984,7 @@ class RequestHandler extends APIHandlerBase {
960984
}
961985

962986
await prisma[type].delete({
963-
where: this.makeIdFilter(typeInfo.idFields, resourceId),
987+
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
964988
});
965989
return {
966990
status: 204,
@@ -1110,7 +1134,7 @@ class RequestHandler extends APIHandlerBase {
11101134
if (ids.length === 0) {
11111135
return undefined;
11121136
} else {
1113-
return data[ids.map((id) => id.name).join(idDivider)];
1137+
return data[this.makeIdKey(ids)];
11141138
}
11151139
}
11161140

@@ -1206,15 +1230,16 @@ class RequestHandler extends APIHandlerBase {
12061230
return r.toString();
12071231
}
12081232

1209-
private makeIdFilter(idFields: FieldInfo[], resourceId: string) {
1233+
private makePrismaIdFilter(idFields: FieldInfo[], resourceId: string) {
12101234
if (idFields.length === 1) {
12111235
return { [idFields[0].name]: this.coerce(idFields[0].type, resourceId) };
12121236
} else {
12131237
return {
1214-
[idFields.map((idf) => idf.name).join(idDivider)]: idFields.reduce(
1238+
// TODO: support `@@id` with custom name
1239+
[idFields.map((idf) => idf.name).join(prismaIdDivider)]: idFields.reduce(
12151240
(acc, curr, idx) => ({
12161241
...acc,
1217-
[curr.name]: this.coerce(curr.type, resourceId.split(idDivider)[idx]),
1242+
[curr.name]: this.coerce(curr.type, resourceId.split(this.idDivider)[idx]),
12181243
}),
12191244
{}
12201245
),
@@ -1230,11 +1255,16 @@ class RequestHandler extends APIHandlerBase {
12301255
}
12311256

12321257
private makeIdKey(idFields: FieldInfo[]) {
1233-
return idFields.map((idf) => idf.name).join(idDivider);
1258+
return idFields.map((idf) => idf.name).join(this.idDivider);
1259+
}
1260+
1261+
private makePrismaIdKey(idFields: FieldInfo[]) {
1262+
// TODO: support `@@id` with custom name
1263+
return idFields.map((idf) => idf.name).join(prismaIdDivider);
12341264
}
12351265

12361266
private makeCompoundId(idFields: FieldInfo[], item: any) {
1237-
return idFields.map((idf) => item[idf.name]).join(idDivider);
1267+
return idFields.map((idf) => item[idf.name]).join(this.idDivider);
12381268
}
12391269

12401270
private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') {
@@ -1557,11 +1587,11 @@ class RequestHandler extends APIHandlerBase {
15571587
const values = value.split(',').filter((i) => i);
15581588
const filterValue =
15591589
values.length > 1
1560-
? { OR: values.map((v) => this.makeIdFilter(info.idFields, v)) }
1561-
: this.makeIdFilter(info.idFields, value);
1590+
? { OR: values.map((v) => this.makePrismaIdFilter(info.idFields, v)) }
1591+
: this.makePrismaIdFilter(info.idFields, value);
15621592
return { some: filterValue };
15631593
} else {
1564-
return { is: this.makeIdFilter(info.idFields, value) };
1594+
return { is: this.makePrismaIdFilter(info.idFields, value) };
15651595
}
15661596
} else {
15671597
const coerced = this.coerce(fieldInfo.type, value);

0 commit comments

Comments
 (0)