Skip to content

Commit eb9332e

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 eb9332e

File tree

1 file changed

+92
-37
lines changed

1 file changed

+92
-37
lines changed

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

Lines changed: 92 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,20 @@ type ModelInfo = {
6568
relationships: Record<string, RelationshipInfo>;
6669
};
6770

71+
type Match = {
72+
type: string;
73+
id: string;
74+
relationship: string;
75+
prefix: string;
76+
};
77+
78+
enum UrlPatterns {
79+
SINGLE = 'single',
80+
FETCH_RELATIONSHIP = 'fetchRelationship',
81+
RELATIONSHIP = 'relationship',
82+
COLLECTION = 'collection',
83+
}
84+
6885
class InvalidValueError extends Error {
6986
constructor(public readonly message: string) {
7087
super(message);
@@ -220,29 +237,60 @@ class RequestHandler extends APIHandlerBase {
220237
// divider used to separate compound ID fields
221238
private idDivider;
222239

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

225245
constructor(private readonly options: Options) {
226246
super();
227247
this.idDivider = options.idDivider ?? prismaIdDivider;
228248
const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %';
229-
this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset);
249+
250+
this.prefix = options.prefix;
251+
this.modelNameMapping = options.modelNameMapping ?? {};
252+
this.reverseModelNameMapping = Object.fromEntries(
253+
Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])
254+
);
255+
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
230256
}
231257

232-
buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) {
258+
private buildUrlPatternMap(urlSegmentNameCharset: string): Record<UrlPatterns, UrlPattern> {
233259
const options = { segmentValueCharset: urlSegmentNameCharset };
260+
261+
const buildPath = (segments: string[]) => {
262+
return this.prefix + '/' + segments.join('/');
263+
};
264+
234265
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),
266+
[UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options),
267+
[UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options),
268+
[UrlPatterns.RELATIONSHIP]: new UrlPattern(
269+
buildPath([':type', ':id', 'relationships', ':relationship']),
270+
options
271+
),
272+
[UrlPatterns.COLLECTION]: new UrlPattern(buildPath([':type']), options),
243273
};
244274
}
245275

276+
private reverseModelNameMap(type: string): string {
277+
return this.reverseModelNameMapping[type] ?? type;
278+
}
279+
280+
private matchUrlPattern(path: string, routeType: UrlPatterns): Match {
281+
const pattern = this.urlPatternMap[routeType];
282+
if (!pattern) {
283+
throw new InvalidValueError(`Unknown route type: ${routeType}`);
284+
}
285+
286+
const match = pattern.match(path);
287+
if (match) {
288+
match.type = this.modelNameMapping[match.type] ?? match.type;
289+
match.relationship = this.modelNameMapping[match.relationship] ?? match.relationship;
290+
}
291+
return match;
292+
}
293+
246294
async handleRequest({
247295
prisma,
248296
method,
@@ -274,19 +322,18 @@ class RequestHandler extends APIHandlerBase {
274322
try {
275323
switch (method) {
276324
case 'GET': {
277-
let match = this.urlPatterns.single.match(path);
325+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
278326
if (match) {
279327
// single resource read
280328
return await this.processSingleRead(prisma, match.type, match.id, query);
281329
}
282-
283-
match = this.urlPatterns.fetchRelationship.match(path);
330+
match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
284331
if (match) {
285332
// fetch related resource(s)
286333
return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query);
287334
}
288335

289-
match = this.urlPatterns.relationship.match(path);
336+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
290337
if (match) {
291338
// read relationship
292339
return await this.processReadRelationship(
@@ -298,7 +345,7 @@ class RequestHandler extends APIHandlerBase {
298345
);
299346
}
300347

301-
match = this.urlPatterns.collection.match(path);
348+
match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
302349
if (match) {
303350
// collection read
304351
return await this.processCollectionRead(prisma, match.type, query);
@@ -311,8 +358,7 @@ class RequestHandler extends APIHandlerBase {
311358
if (!requestBody) {
312359
return this.makeError('invalidPayload');
313360
}
314-
315-
let match = this.urlPatterns.collection.match(path);
361+
let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
316362
if (match) {
317363
const body = requestBody as any;
318364
const upsertMeta = this.upsertMetaSchema.safeParse(body);
@@ -338,8 +384,7 @@ class RequestHandler extends APIHandlerBase {
338384
);
339385
}
340386
}
341-
342-
match = this.urlPatterns.relationship.match(path);
387+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
343388
if (match) {
344389
// relationship creation (collection relationship only)
345390
return await this.processRelationshipCRUD(
@@ -362,8 +407,7 @@ class RequestHandler extends APIHandlerBase {
362407
if (!requestBody) {
363408
return this.makeError('invalidPayload');
364409
}
365-
366-
let match = this.urlPatterns.single.match(path);
410+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
367411
if (match) {
368412
// resource update
369413
return await this.processUpdate(
@@ -376,8 +420,7 @@ class RequestHandler extends APIHandlerBase {
376420
zodSchemas
377421
);
378422
}
379-
380-
match = this.urlPatterns.relationship.match(path);
423+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
381424
if (match) {
382425
// relationship update
383426
return await this.processRelationshipCRUD(
@@ -395,13 +438,13 @@ class RequestHandler extends APIHandlerBase {
395438
}
396439

397440
case 'DELETE': {
398-
let match = this.urlPatterns.single.match(path);
441+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
399442
if (match) {
400443
// resource deletion
401444
return await this.processDelete(prisma, match.type, match.id);
402445
}
403446

404-
match = this.urlPatterns.relationship.match(path);
447+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
405448
if (match) {
406449
// relationship deletion (collection relationship only)
407450
return await this.processRelationshipCRUD(
@@ -531,11 +574,13 @@ class RequestHandler extends APIHandlerBase {
531574
}
532575

533576
if (entity?.[relationship]) {
577+
const mappedType = this.reverseModelNameMap(type);
578+
const mappedRelationship = this.reverseModelNameMap(relationship);
534579
return {
535580
status: 200,
536581
body: await this.serializeItems(relationInfo.type, entity[relationship], {
537582
linkers: {
538-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/${relationship}`)),
583+
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${mappedRelationship}`)),
539584
paginator,
540585
},
541586
include,
@@ -582,11 +627,13 @@ class RequestHandler extends APIHandlerBase {
582627
}
583628

584629
const entity: any = await prisma[type].findUnique(args);
630+
const mappedType = this.reverseModelNameMap(type);
631+
const mappedRelationship = this.reverseModelNameMap(relationship);
585632

586633
if (entity?._count?.[relationship] !== undefined) {
587634
// build up paginator
588635
const total = entity?._count?.[relationship] as number;
589-
const url = this.makeNormalizedUrl(`/${type}/${resourceId}/relationships/${relationship}`, query);
636+
const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`, query);
590637
const { offset, limit } = this.getPagination(query);
591638
paginator = this.makePaginator(url, offset, limit, total);
592639
}
@@ -595,7 +642,7 @@ class RequestHandler extends APIHandlerBase {
595642
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
596643
linkers: {
597644
document: new Linker(() =>
598-
this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)
645+
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)
599646
),
600647
paginator,
601648
},
@@ -680,7 +727,8 @@ class RequestHandler extends APIHandlerBase {
680727
]);
681728
const total = count as number;
682729

683-
const url = this.makeNormalizedUrl(`/${type}`, query);
730+
const mappedType = this.reverseModelNameMap(type);
731+
const url = this.makeNormalizedUrl(`/${mappedType}`, query);
684732
const options: Partial<SerializerOptions> = {
685733
include,
686734
linkers: {
@@ -1009,9 +1057,12 @@ class RequestHandler extends APIHandlerBase {
10091057

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

1060+
const mappedType = this.reverseModelNameMap(type);
1061+
const mappedRelationship = this.reverseModelNameMap(relationship);
1062+
10121063
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
10131064
linkers: {
1014-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)),
1065+
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)),
10151066
},
10161067
onlyIdentifier: true,
10171068
});
@@ -1147,7 +1198,7 @@ class RequestHandler extends APIHandlerBase {
11471198
}
11481199

11491200
private makeLinkUrl(path: string) {
1150-
return `${this.options.endpoint}${path}`;
1201+
return `${this.options.endpoint}${this.prefix}${path}`;
11511202
}
11521203

11531204
private buildSerializers(modelMeta: ModelMeta) {
@@ -1156,15 +1207,16 @@ class RequestHandler extends APIHandlerBase {
11561207

11571208
for (const model of Object.keys(modelMeta.models)) {
11581209
const ids = getIdFields(modelMeta, model);
1210+
const mappedModel = this.reverseModelNameMap(model);
11591211

11601212
if (ids.length < 1) {
11611213
continue;
11621214
}
11631215

11641216
const linker = new Linker((items) =>
11651217
Array.isArray(items)
1166-
? this.makeLinkUrl(`/${model}`)
1167-
: this.makeLinkUrl(`/${model}/${this.getId(model, items, modelMeta)}`)
1218+
? this.makeLinkUrl(`/${mappedModel}`)
1219+
: this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`)
11681220
);
11691221
linkers[model] = linker;
11701222

@@ -1208,6 +1260,9 @@ class RequestHandler extends APIHandlerBase {
12081260
}
12091261
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
12101262
if (fieldIds.length > 0) {
1263+
const mappedModel = this.reverseModelNameMap(model);
1264+
const mappedField = this.reverseModelNameMap(field);
1265+
12111266
const relator = new Relator(
12121267
async (data) => {
12131268
return (data as any)[field];
@@ -1218,16 +1273,16 @@ class RequestHandler extends APIHandlerBase {
12181273
linkers: {
12191274
related: new Linker((primary) =>
12201275
this.makeLinkUrl(
1221-
`/${lowerCaseFirst(model)}/${this.getId(model, primary, modelMeta)}/${field}`
1276+
`/${lowerCaseFirst(mappedModel)}/${this.getId(model, primary, modelMeta)}/${mappedField}`
12221277
)
12231278
),
12241279
relationship: new Linker((primary) =>
12251280
this.makeLinkUrl(
1226-
`/${lowerCaseFirst(model)}/${this.getId(
1281+
`/${lowerCaseFirst(mappedModel)}/${this.getId(
12271282
model,
12281283
primary,
12291284
modelMeta
1230-
)}/relationships/${field}`
1285+
)}/relationships/${mappedField}`
12311286
)
12321287
),
12331288
},

0 commit comments

Comments
 (0)