Skip to content

Commit e2573cf

Browse files
tobiasbruggerlsmith77
authored andcommitted
feat: add ability to map model names in the URLS/JSON response
primary use case is pluralization ie. model User exposed as /users it is also useful in case the internal and external names should be different. TODO: adapt openapi plugin
1 parent dc4eb4e commit e2573cf

File tree

1 file changed

+89
-36
lines changed

1 file changed

+89
-36
lines changed

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

Lines changed: 89 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export type Options = {
5050
* it should be included in the charset.
5151
*/
5252
urlSegmentCharset?: string;
53+
54+
modelNameMapping?: Record<string, string>;
5355
};
5456

5557
type RelationshipInfo = {
@@ -65,6 +67,19 @@ type ModelInfo = {
6567
relationships: Record<string, RelationshipInfo>;
6668
};
6769

70+
type Match = {
71+
type: string;
72+
id: string;
73+
relationship: string;
74+
};
75+
76+
enum UrlPatterns {
77+
SINGLE = 'single',
78+
FETCH_RELATIONSHIP = 'fetchRelationship',
79+
RELATIONSHIP = 'relationship',
80+
COLLECTION = 'collection',
81+
}
82+
6883
class InvalidValueError extends Error {
6984
constructor(public readonly message: string) {
7085
super(message);
@@ -220,29 +235,57 @@ class RequestHandler extends APIHandlerBase {
220235
// divider used to separate compound ID fields
221236
private idDivider;
222237

223-
private urlPatterns;
238+
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
239+
private modelNameMapping: Record<string, string>;
240+
private reverseModelNameMapping: Record<string, string>;
224241

225242
constructor(private readonly options: Options) {
226243
super();
227244
this.idDivider = options.idDivider ?? prismaIdDivider;
228245
const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %';
229-
this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset);
246+
247+
this.modelNameMapping = options.modelNameMapping ?? {};
248+
this.reverseModelNameMapping = Object.fromEntries(
249+
Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])
250+
);
251+
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
230252
}
231253

232-
buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) {
254+
private buildUrlPatternMap(urlSegmentNameCharset: string): Record<UrlPatterns, UrlPattern> {
233255
const options = { segmentValueCharset: urlSegmentNameCharset };
256+
257+
const buildPath = (segments: string[]) => {
258+
return '/' + segments.join('/');
259+
};
260+
234261
return {
235-
// collection operations
236-
collection: new UrlPattern('/:type', options),
237-
// single resource operations
238-
single: new UrlPattern('/:type/:id', options),
239-
// related entity fetching
240-
fetchRelationship: new UrlPattern('/:type/:id/:relationship', options),
241-
// relationship operations
242-
relationship: new UrlPattern('/:type/:id/relationships/:relationship', options),
262+
[UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options),
263+
[UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options),
264+
[UrlPatterns.RELATIONSHIP]: new UrlPattern(
265+
buildPath([':type', ':id', 'relationships', ':relationship']),
266+
options
267+
),
268+
[UrlPatterns.COLLECTION]: new UrlPattern(buildPath([':type']), options),
243269
};
244270
}
245271

272+
private reverseModelNameMap(type: string): string {
273+
return this.reverseModelNameMapping[type] ?? type;
274+
}
275+
276+
private matchUrlPattern(path: string, routeType: UrlPatterns): Match {
277+
const pattern = this.urlPatternMap[routeType];
278+
if (!pattern) {
279+
throw new InvalidValueError(`Unknown route type: ${routeType}`);
280+
}
281+
282+
const match = pattern.match(path);
283+
if (match) {
284+
match.type = this.modelNameMapping[match.type] ?? match.type;
285+
}
286+
return match;
287+
}
288+
246289
async handleRequest({
247290
prisma,
248291
method,
@@ -274,19 +317,18 @@ class RequestHandler extends APIHandlerBase {
274317
try {
275318
switch (method) {
276319
case 'GET': {
277-
let match = this.urlPatterns.single.match(path);
320+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
278321
if (match) {
279322
// single resource read
280323
return await this.processSingleRead(prisma, match.type, match.id, query);
281324
}
282-
283-
match = this.urlPatterns.fetchRelationship.match(path);
325+
match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
284326
if (match) {
285327
// fetch related resource(s)
286328
return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query);
287329
}
288330

289-
match = this.urlPatterns.relationship.match(path);
331+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
290332
if (match) {
291333
// read relationship
292334
return await this.processReadRelationship(
@@ -298,7 +340,7 @@ class RequestHandler extends APIHandlerBase {
298340
);
299341
}
300342

301-
match = this.urlPatterns.collection.match(path);
343+
match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
302344
if (match) {
303345
// collection read
304346
return await this.processCollectionRead(prisma, match.type, query);
@@ -311,8 +353,7 @@ class RequestHandler extends APIHandlerBase {
311353
if (!requestBody) {
312354
return this.makeError('invalidPayload');
313355
}
314-
315-
let match = this.urlPatterns.collection.match(path);
356+
let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
316357
if (match) {
317358
const body = requestBody as any;
318359
const upsertMeta = this.upsertMetaSchema.safeParse(body);
@@ -338,8 +379,7 @@ class RequestHandler extends APIHandlerBase {
338379
);
339380
}
340381
}
341-
342-
match = this.urlPatterns.relationship.match(path);
382+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
343383
if (match) {
344384
// relationship creation (collection relationship only)
345385
return await this.processRelationshipCRUD(
@@ -362,8 +402,7 @@ class RequestHandler extends APIHandlerBase {
362402
if (!requestBody) {
363403
return this.makeError('invalidPayload');
364404
}
365-
366-
let match = this.urlPatterns.single.match(path);
405+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
367406
if (match) {
368407
// resource update
369408
return await this.processUpdate(
@@ -376,8 +415,7 @@ class RequestHandler extends APIHandlerBase {
376415
zodSchemas
377416
);
378417
}
379-
380-
match = this.urlPatterns.relationship.match(path);
418+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
381419
if (match) {
382420
// relationship update
383421
return await this.processRelationshipCRUD(
@@ -395,13 +433,13 @@ class RequestHandler extends APIHandlerBase {
395433
}
396434

397435
case 'DELETE': {
398-
let match = this.urlPatterns.single.match(path);
436+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
399437
if (match) {
400438
// resource deletion
401439
return await this.processDelete(prisma, match.type, match.id);
402440
}
403441

404-
match = this.urlPatterns.relationship.match(path);
442+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
405443
if (match) {
406444
// relationship deletion (collection relationship only)
407445
return await this.processRelationshipCRUD(
@@ -531,11 +569,12 @@ class RequestHandler extends APIHandlerBase {
531569
}
532570

533571
if (entity?.[relationship]) {
572+
const mappedType = this.reverseModelNameMap(type);
534573
return {
535574
status: 200,
536575
body: await this.serializeItems(relationInfo.type, entity[relationship], {
537576
linkers: {
538-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/${relationship}`)),
577+
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${relationship}`)),
539578
paginator,
540579
},
541580
include,
@@ -582,11 +621,12 @@ class RequestHandler extends APIHandlerBase {
582621
}
583622

584623
const entity: any = await prisma[type].findUnique(args);
624+
const mappedType = this.reverseModelNameMap(type);
585625

586626
if (entity?._count?.[relationship] !== undefined) {
587627
// build up paginator
588628
const total = entity?._count?.[relationship] as number;
589-
const url = this.makeNormalizedUrl(`/${type}/${resourceId}/relationships/${relationship}`, query);
629+
const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`, query);
590630
const { offset, limit } = this.getPagination(query);
591631
paginator = this.makePaginator(url, offset, limit, total);
592632
}
@@ -595,7 +635,7 @@ class RequestHandler extends APIHandlerBase {
595635
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
596636
linkers: {
597637
document: new Linker(() =>
598-
this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)
638+
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`)
599639
),
600640
paginator,
601641
},
@@ -680,7 +720,8 @@ class RequestHandler extends APIHandlerBase {
680720
]);
681721
const total = count as number;
682722

683-
const url = this.makeNormalizedUrl(`/${type}`, query);
723+
const mappedType = this.reverseModelNameMap(type);
724+
const url = this.makeNormalizedUrl(`/${mappedType}`, query);
684725
const options: Partial<SerializerOptions> = {
685726
include,
686727
linkers: {
@@ -1009,9 +1050,13 @@ class RequestHandler extends APIHandlerBase {
10091050

10101051
const entity: any = await prisma[type].update(updateArgs);
10111052

1053+
const mappedType = this.reverseModelNameMap(type);
1054+
10121055
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
10131056
linkers: {
1014-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)),
1057+
document: new Linker(() =>
1058+
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`)
1059+
),
10151060
},
10161061
onlyIdentifier: true,
10171062
});
@@ -1156,15 +1201,16 @@ class RequestHandler extends APIHandlerBase {
11561201

11571202
for (const model of Object.keys(modelMeta.models)) {
11581203
const ids = getIdFields(modelMeta, model);
1204+
const mappedModel = this.reverseModelNameMap(model);
11591205

11601206
if (ids.length < 1) {
11611207
continue;
11621208
}
11631209

11641210
const linker = new Linker((items) =>
11651211
Array.isArray(items)
1166-
? this.makeLinkUrl(`/${model}`)
1167-
: this.makeLinkUrl(`/${model}/${this.getId(model, items, modelMeta)}`)
1212+
? this.makeLinkUrl(`/${mappedModel}`)
1213+
: this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`)
11681214
);
11691215
linkers[model] = linker;
11701216

@@ -1208,6 +1254,9 @@ class RequestHandler extends APIHandlerBase {
12081254
}
12091255
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
12101256
if (fieldIds.length > 0) {
1257+
const mappedModel = this.reverseModelNameMap(model);
1258+
const mappedField = this.reverseModelNameMap(field);
1259+
12111260
const relator = new Relator(
12121261
async (data) => {
12131262
return (data as any)[field];
@@ -1218,16 +1267,20 @@ class RequestHandler extends APIHandlerBase {
12181267
linkers: {
12191268
related: new Linker((primary) =>
12201269
this.makeLinkUrl(
1221-
`/${lowerCaseFirst(model)}/${this.getId(model, primary, modelMeta)}/${field}`
1270+
`/${lowerCaseFirst(mappedModel)}/${this.getId(
1271+
model,
1272+
primary,
1273+
modelMeta
1274+
)}/${mappedField}`
12221275
)
12231276
),
12241277
relationship: new Linker((primary) =>
12251278
this.makeLinkUrl(
1226-
`/${lowerCaseFirst(model)}/${this.getId(
1279+
`/${lowerCaseFirst(mappedModel)}/${this.getId(
12271280
model,
12281281
primary,
12291282
modelMeta
1230-
)}/relationships/${field}`
1283+
)}/relationships/${mappedField}`
12311284
)
12321285
),
12331286
},

0 commit comments

Comments
 (0)