Skip to content

Commit c05e542

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 c05e542

File tree

1 file changed

+91
-37
lines changed

1 file changed

+91
-37
lines changed

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

Lines changed: 91 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export type Options = {
5050
* it should be included in the charset.
5151
*/
5252
urlSegmentCharset?: string;
53+
54+
modelNameMapping?: Record<string, string>;
55+
prefix?: string;
5356
};
5457

5558
type RelationshipInfo = {
@@ -65,6 +68,19 @@ type ModelInfo = {
6568
relationships: Record<string, RelationshipInfo>;
6669
};
6770

71+
type Match = {
72+
type: string;
73+
id: string;
74+
relationship: string;
75+
};
76+
77+
enum UrlPatterns {
78+
SINGLE = 'single',
79+
FETCH_RELATIONSHIP = 'fetchRelationship',
80+
RELATIONSHIP = 'relationship',
81+
COLLECTION = 'collection',
82+
}
83+
6884
class InvalidValueError extends Error {
6985
constructor(public readonly message: string) {
7086
super(message);
@@ -220,29 +236,60 @@ class RequestHandler extends APIHandlerBase {
220236
// divider used to separate compound ID fields
221237
private idDivider;
222238

223-
private urlPatterns;
239+
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
240+
private modelNameMapping: Record<string, string>;
241+
private reverseModelNameMapping: Record<string, string>;
242+
private prefix: string | undefined;
224243

225244
constructor(private readonly options: Options) {
226245
super();
227246
this.idDivider = options.idDivider ?? prismaIdDivider;
228247
const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %';
229-
this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset);
248+
249+
this.prefix = options.prefix;
250+
this.modelNameMapping = options.modelNameMapping ?? {};
251+
this.reverseModelNameMapping = Object.fromEntries(
252+
Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])
253+
);
254+
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
230255
}
231256

232-
buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) {
257+
private buildUrlPatternMap(urlSegmentNameCharset: string): Record<UrlPatterns, UrlPattern> {
233258
const options = { segmentValueCharset: urlSegmentNameCharset };
259+
260+
const buildPath = (segments: string[]) => {
261+
return (this.prefix ?? '') + '/' + segments.join('/');
262+
};
263+
234264
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),
265+
[UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options),
266+
[UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options),
267+
[UrlPatterns.RELATIONSHIP]: new UrlPattern(
268+
buildPath([':type', ':id', 'relationships', ':relationship']),
269+
options
270+
),
271+
[UrlPatterns.COLLECTION]: new UrlPattern(buildPath([':type']), options),
243272
};
244273
}
245274

275+
private reverseModelNameMap(type: string): string {
276+
return this.reverseModelNameMapping[type] ?? type;
277+
}
278+
279+
private matchUrlPattern(path: string, routeType: UrlPatterns): Match {
280+
const pattern = this.urlPatternMap[routeType];
281+
if (!pattern) {
282+
throw new InvalidValueError(`Unknown route type: ${routeType}`);
283+
}
284+
285+
const match = pattern.match(path);
286+
if (match) {
287+
match.type = this.modelNameMapping[match.type] ?? match.type;
288+
match.relationship = this.modelNameMapping[match.relationship] ?? match.relationship;
289+
}
290+
return match;
291+
}
292+
246293
async handleRequest({
247294
prisma,
248295
method,
@@ -274,19 +321,18 @@ class RequestHandler extends APIHandlerBase {
274321
try {
275322
switch (method) {
276323
case 'GET': {
277-
let match = this.urlPatterns.single.match(path);
324+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
278325
if (match) {
279326
// single resource read
280327
return await this.processSingleRead(prisma, match.type, match.id, query);
281328
}
282-
283-
match = this.urlPatterns.fetchRelationship.match(path);
329+
match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
284330
if (match) {
285331
// fetch related resource(s)
286332
return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query);
287333
}
288334

289-
match = this.urlPatterns.relationship.match(path);
335+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
290336
if (match) {
291337
// read relationship
292338
return await this.processReadRelationship(
@@ -298,7 +344,7 @@ class RequestHandler extends APIHandlerBase {
298344
);
299345
}
300346

301-
match = this.urlPatterns.collection.match(path);
347+
match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
302348
if (match) {
303349
// collection read
304350
return await this.processCollectionRead(prisma, match.type, query);
@@ -311,8 +357,7 @@ class RequestHandler extends APIHandlerBase {
311357
if (!requestBody) {
312358
return this.makeError('invalidPayload');
313359
}
314-
315-
let match = this.urlPatterns.collection.match(path);
360+
let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
316361
if (match) {
317362
const body = requestBody as any;
318363
const upsertMeta = this.upsertMetaSchema.safeParse(body);
@@ -338,8 +383,7 @@ class RequestHandler extends APIHandlerBase {
338383
);
339384
}
340385
}
341-
342-
match = this.urlPatterns.relationship.match(path);
386+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
343387
if (match) {
344388
// relationship creation (collection relationship only)
345389
return await this.processRelationshipCRUD(
@@ -362,8 +406,7 @@ class RequestHandler extends APIHandlerBase {
362406
if (!requestBody) {
363407
return this.makeError('invalidPayload');
364408
}
365-
366-
let match = this.urlPatterns.single.match(path);
409+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
367410
if (match) {
368411
// resource update
369412
return await this.processUpdate(
@@ -376,8 +419,7 @@ class RequestHandler extends APIHandlerBase {
376419
zodSchemas
377420
);
378421
}
379-
380-
match = this.urlPatterns.relationship.match(path);
422+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
381423
if (match) {
382424
// relationship update
383425
return await this.processRelationshipCRUD(
@@ -395,13 +437,13 @@ class RequestHandler extends APIHandlerBase {
395437
}
396438

397439
case 'DELETE': {
398-
let match = this.urlPatterns.single.match(path);
440+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
399441
if (match) {
400442
// resource deletion
401443
return await this.processDelete(prisma, match.type, match.id);
402444
}
403445

404-
match = this.urlPatterns.relationship.match(path);
446+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
405447
if (match) {
406448
// relationship deletion (collection relationship only)
407449
return await this.processRelationshipCRUD(
@@ -531,11 +573,13 @@ class RequestHandler extends APIHandlerBase {
531573
}
532574

533575
if (entity?.[relationship]) {
576+
const mappedType = this.reverseModelNameMap(type);
577+
const mappedRelationship = this.reverseModelNameMap(relationship);
534578
return {
535579
status: 200,
536580
body: await this.serializeItems(relationInfo.type, entity[relationship], {
537581
linkers: {
538-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/${relationship}`)),
582+
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${mappedRelationship}`)),
539583
paginator,
540584
},
541585
include,
@@ -582,11 +626,13 @@ class RequestHandler extends APIHandlerBase {
582626
}
583627

584628
const entity: any = await prisma[type].findUnique(args);
629+
const mappedType = this.reverseModelNameMap(type);
630+
const mappedRelationship = this.reverseModelNameMap(relationship);
585631

586632
if (entity?._count?.[relationship] !== undefined) {
587633
// build up paginator
588634
const total = entity?._count?.[relationship] as number;
589-
const url = this.makeNormalizedUrl(`/${type}/${resourceId}/relationships/${relationship}`, query);
635+
const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`, query);
590636
const { offset, limit } = this.getPagination(query);
591637
paginator = this.makePaginator(url, offset, limit, total);
592638
}
@@ -595,7 +641,7 @@ class RequestHandler extends APIHandlerBase {
595641
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
596642
linkers: {
597643
document: new Linker(() =>
598-
this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)
644+
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)
599645
),
600646
paginator,
601647
},
@@ -680,7 +726,8 @@ class RequestHandler extends APIHandlerBase {
680726
]);
681727
const total = count as number;
682728

683-
const url = this.makeNormalizedUrl(`/${type}`, query);
729+
const mappedType = this.reverseModelNameMap(type);
730+
const url = this.makeNormalizedUrl(`/${mappedType}`, query);
684731
const options: Partial<SerializerOptions> = {
685732
include,
686733
linkers: {
@@ -1009,9 +1056,12 @@ class RequestHandler extends APIHandlerBase {
10091056

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

1059+
const mappedType = this.reverseModelNameMap(type);
1060+
const mappedRelationship = this.reverseModelNameMap(relationship);
1061+
10121062
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
10131063
linkers: {
1014-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)),
1064+
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)),
10151065
},
10161066
onlyIdentifier: true,
10171067
});
@@ -1147,7 +1197,7 @@ class RequestHandler extends APIHandlerBase {
11471197
}
11481198

11491199
private makeLinkUrl(path: string) {
1150-
return `${this.options.endpoint}${path}`;
1200+
return `${this.options.endpoint}${this.prefix}${path}`;
11511201
}
11521202

11531203
private buildSerializers(modelMeta: ModelMeta) {
@@ -1156,15 +1206,16 @@ class RequestHandler extends APIHandlerBase {
11561206

11571207
for (const model of Object.keys(modelMeta.models)) {
11581208
const ids = getIdFields(modelMeta, model);
1209+
const mappedModel = this.reverseModelNameMap(model);
11591210

11601211
if (ids.length < 1) {
11611212
continue;
11621213
}
11631214

11641215
const linker = new Linker((items) =>
11651216
Array.isArray(items)
1166-
? this.makeLinkUrl(`/${model}`)
1167-
: this.makeLinkUrl(`/${model}/${this.getId(model, items, modelMeta)}`)
1217+
? this.makeLinkUrl(`/${mappedModel}`)
1218+
: this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`)
11681219
);
11691220
linkers[model] = linker;
11701221

@@ -1208,6 +1259,9 @@ class RequestHandler extends APIHandlerBase {
12081259
}
12091260
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
12101261
if (fieldIds.length > 0) {
1262+
const mappedModel = this.reverseModelNameMap(model);
1263+
const mappedField = this.reverseModelNameMap(field);
1264+
12111265
const relator = new Relator(
12121266
async (data) => {
12131267
return (data as any)[field];
@@ -1218,16 +1272,16 @@ class RequestHandler extends APIHandlerBase {
12181272
linkers: {
12191273
related: new Linker((primary) =>
12201274
this.makeLinkUrl(
1221-
`/${lowerCaseFirst(model)}/${this.getId(model, primary, modelMeta)}/${field}`
1275+
`/${lowerCaseFirst(mappedModel)}/${this.getId(model, primary, modelMeta)}/${mappedField}`
12221276
)
12231277
),
12241278
relationship: new Linker((primary) =>
12251279
this.makeLinkUrl(
1226-
`/${lowerCaseFirst(model)}/${this.getId(
1280+
`/${lowerCaseFirst(mappedModel)}/${this.getId(
12271281
model,
12281282
primary,
12291283
modelMeta
1230-
)}/relationships/${field}`
1284+
)}/relationships/${mappedField}`
12311285
)
12321286
),
12331287
},

0 commit comments

Comments
 (0)