Skip to content

Commit f2c2725

Browse files
lsmith77tobiasbruggerymc9
authored
feat: add ability to map model names in the URLS/JSON response (#2187)
Co-authored-by: Tobias Brugger <[email protected]> Co-authored-by: ymc9 <[email protected]>
1 parent 037fc2c commit f2c2725

File tree

3 files changed

+172
-40
lines changed

3 files changed

+172
-40
lines changed

packages/plugins/openapi/src/rest-generator.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@ type Policies = ReturnType<typeof analyzePolicies>;
4444
*/
4545
export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
4646
private warnings: string[] = [];
47+
private modelNameMapping: Record<string, string>;
4748

4849
constructor(protected model: Model, protected options: PluginOptions, protected dmmf: DMMF.Document) {
4950
super(model, options, dmmf);
5051

5152
if (this.options.omitInputDetails !== undefined) {
5253
throw new PluginError(name, '"omitInputDetails" option is not supported for "rest" flavor');
5354
}
55+
56+
this.modelNameMapping = this.getOption('modelNameMapping', {} as Record<string, string>);
5457
}
5558

5659
generate() {
@@ -126,6 +129,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
126129
return result;
127130
}
128131

132+
private mapModelName(modelName: string): string {
133+
return this.modelNameMapping[modelName] ?? modelName;
134+
}
135+
129136
private generatePathsForModel(model: DMMF.Model, zmodel: DataModel): OAPI.PathItemObject | undefined {
130137
const result: Record<string, OAPI.PathItemObject> = {};
131138

@@ -139,9 +146,11 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
139146

140147
const resourceMeta = getModelResourceMeta(zmodel);
141148

149+
const modelName = this.mapModelName(model.name);
150+
142151
// GET /resource
143152
// POST /resource
144-
result[`${prefix}/${lowerCaseFirst(model.name)}`] = {
153+
result[`${prefix}/${lowerCaseFirst(modelName)}`] = {
145154
get: this.makeResourceList(zmodel, policies, resourceMeta),
146155
post: this.makeResourceCreate(zmodel, policies, resourceMeta),
147156
};
@@ -150,10 +159,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
150159
// PUT /resource/{id}
151160
// PATCH /resource/{id}
152161
// DELETE /resource/{id}
153-
result[`${prefix}/${lowerCaseFirst(model.name)}/{id}`] = {
162+
result[`${prefix}/${lowerCaseFirst(modelName)}/{id}`] = {
154163
get: this.makeResourceFetch(zmodel, policies, resourceMeta),
155-
put: this.makeResourceUpdate(zmodel, policies, `update-${model.name}-put`, resourceMeta),
156-
patch: this.makeResourceUpdate(zmodel, policies, `update-${model.name}-patch`, resourceMeta),
164+
put: this.makeResourceUpdate(zmodel, policies, `update-${modelName}-put`, resourceMeta),
165+
patch: this.makeResourceUpdate(zmodel, policies, `update-${modelName}-patch`, resourceMeta),
157166
delete: this.makeResourceDelete(zmodel, policies, resourceMeta),
158167
};
159168

@@ -165,14 +174,14 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
165174
}
166175

167176
// GET /resource/{id}/{relationship}
168-
const relatedDataPath = `${prefix}/${lowerCaseFirst(model.name)}/{id}/${field.name}`;
177+
const relatedDataPath = `${prefix}/${lowerCaseFirst(modelName)}/{id}/${field.name}`;
169178
let container = result[relatedDataPath];
170179
if (!container) {
171180
container = result[relatedDataPath] = {};
172181
}
173182
container.get = this.makeRelatedFetch(zmodel, field, relationDecl, resourceMeta);
174183

175-
const relationshipPath = `${prefix}/${lowerCaseFirst(model.name)}/{id}/relationships/${field.name}`;
184+
const relationshipPath = `${prefix}/${lowerCaseFirst(modelName)}/{id}/relationships/${field.name}`;
176185
container = result[relationshipPath];
177186
if (!container) {
178187
container = result[relationshipPath] = {};

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

Lines changed: 96 additions & 34 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,71 @@ 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.modelNameMapping = Object.fromEntries(
249+
Object.entries(this.modelNameMapping).map(([k, v]) => [lowerCaseFirst(k), v])
250+
);
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 '/' + 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 mapModelName(modelName: string): string {
276+
return this.modelNameMapping[modelName] ?? modelName;
277+
}
278+
279+
private matchUrlPattern(path: string, routeType: UrlPatterns): Match | undefined {
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+
return;
288+
}
289+
290+
if (match.type in this.modelNameMapping) {
291+
throw new InvalidValueError(
292+
`use the mapped model name: ${this.modelNameMapping[match.type]} and not ${match.type}`
293+
);
294+
}
295+
296+
if (match.type in this.reverseModelNameMapping) {
297+
match.type = this.reverseModelNameMapping[match.type];
298+
}
299+
300+
return match;
301+
}
302+
246303
async handleRequest({
247304
prisma,
248305
method,
@@ -274,19 +331,18 @@ class RequestHandler extends APIHandlerBase {
274331
try {
275332
switch (method) {
276333
case 'GET': {
277-
let match = this.urlPatterns.single.match(path);
334+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
278335
if (match) {
279336
// single resource read
280337
return await this.processSingleRead(prisma, match.type, match.id, query);
281338
}
282-
283-
match = this.urlPatterns.fetchRelationship.match(path);
339+
match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
284340
if (match) {
285341
// fetch related resource(s)
286342
return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query);
287343
}
288344

289-
match = this.urlPatterns.relationship.match(path);
345+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
290346
if (match) {
291347
// read relationship
292348
return await this.processReadRelationship(
@@ -298,7 +354,7 @@ class RequestHandler extends APIHandlerBase {
298354
);
299355
}
300356

301-
match = this.urlPatterns.collection.match(path);
357+
match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
302358
if (match) {
303359
// collection read
304360
return await this.processCollectionRead(prisma, match.type, query);
@@ -311,8 +367,7 @@ class RequestHandler extends APIHandlerBase {
311367
if (!requestBody) {
312368
return this.makeError('invalidPayload');
313369
}
314-
315-
let match = this.urlPatterns.collection.match(path);
370+
let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
316371
if (match) {
317372
const body = requestBody as any;
318373
const upsertMeta = this.upsertMetaSchema.safeParse(body);
@@ -338,8 +393,7 @@ class RequestHandler extends APIHandlerBase {
338393
);
339394
}
340395
}
341-
342-
match = this.urlPatterns.relationship.match(path);
396+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
343397
if (match) {
344398
// relationship creation (collection relationship only)
345399
return await this.processRelationshipCRUD(
@@ -362,8 +416,7 @@ class RequestHandler extends APIHandlerBase {
362416
if (!requestBody) {
363417
return this.makeError('invalidPayload');
364418
}
365-
366-
let match = this.urlPatterns.single.match(path);
419+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
367420
if (match) {
368421
// resource update
369422
return await this.processUpdate(
@@ -376,8 +429,7 @@ class RequestHandler extends APIHandlerBase {
376429
zodSchemas
377430
);
378431
}
379-
380-
match = this.urlPatterns.relationship.match(path);
432+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
381433
if (match) {
382434
// relationship update
383435
return await this.processRelationshipCRUD(
@@ -395,13 +447,13 @@ class RequestHandler extends APIHandlerBase {
395447
}
396448

397449
case 'DELETE': {
398-
let match = this.urlPatterns.single.match(path);
450+
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
399451
if (match) {
400452
// resource deletion
401453
return await this.processDelete(prisma, match.type, match.id);
402454
}
403455

404-
match = this.urlPatterns.relationship.match(path);
456+
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
405457
if (match) {
406458
// relationship deletion (collection relationship only)
407459
return await this.processRelationshipCRUD(
@@ -531,11 +583,12 @@ class RequestHandler extends APIHandlerBase {
531583
}
532584

533585
if (entity?.[relationship]) {
586+
const mappedType = this.mapModelName(type);
534587
return {
535588
status: 200,
536589
body: await this.serializeItems(relationInfo.type, entity[relationship], {
537590
linkers: {
538-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/${relationship}`)),
591+
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${relationship}`)),
539592
paginator,
540593
},
541594
include,
@@ -582,11 +635,12 @@ class RequestHandler extends APIHandlerBase {
582635
}
583636

584637
const entity: any = await prisma[type].findUnique(args);
638+
const mappedType = this.mapModelName(type);
585639

586640
if (entity?._count?.[relationship] !== undefined) {
587641
// build up paginator
588642
const total = entity?._count?.[relationship] as number;
589-
const url = this.makeNormalizedUrl(`/${type}/${resourceId}/relationships/${relationship}`, query);
643+
const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`, query);
590644
const { offset, limit } = this.getPagination(query);
591645
paginator = this.makePaginator(url, offset, limit, total);
592646
}
@@ -595,7 +649,7 @@ class RequestHandler extends APIHandlerBase {
595649
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
596650
linkers: {
597651
document: new Linker(() =>
598-
this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)
652+
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`)
599653
),
600654
paginator,
601655
},
@@ -680,7 +734,8 @@ class RequestHandler extends APIHandlerBase {
680734
]);
681735
const total = count as number;
682736

683-
const url = this.makeNormalizedUrl(`/${type}`, query);
737+
const mappedType = this.mapModelName(type);
738+
const url = this.makeNormalizedUrl(`/${mappedType}`, query);
684739
const options: Partial<SerializerOptions> = {
685740
include,
686741
linkers: {
@@ -1009,9 +1064,13 @@ class RequestHandler extends APIHandlerBase {
10091064

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

1067+
const mappedType = this.mapModelName(type);
1068+
10121069
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
10131070
linkers: {
1014-
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)),
1071+
document: new Linker(() =>
1072+
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`)
1073+
),
10151074
},
10161075
onlyIdentifier: true,
10171076
});
@@ -1156,15 +1215,16 @@ class RequestHandler extends APIHandlerBase {
11561215

11571216
for (const model of Object.keys(modelMeta.models)) {
11581217
const ids = getIdFields(modelMeta, model);
1218+
const mappedModel = this.mapModelName(model);
11591219

11601220
if (ids.length < 1) {
11611221
continue;
11621222
}
11631223

11641224
const linker = new Linker((items) =>
11651225
Array.isArray(items)
1166-
? this.makeLinkUrl(`/${model}`)
1167-
: this.makeLinkUrl(`/${model}/${this.getId(model, items, modelMeta)}`)
1226+
? this.makeLinkUrl(`/${mappedModel}`)
1227+
: this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`)
11681228
);
11691229
linkers[model] = linker;
11701230

@@ -1208,6 +1268,8 @@ class RequestHandler extends APIHandlerBase {
12081268
}
12091269
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
12101270
if (fieldIds.length > 0) {
1271+
const mappedModel = this.mapModelName(model);
1272+
12111273
const relator = new Relator(
12121274
async (data) => {
12131275
return (data as any)[field];
@@ -1223,7 +1285,7 @@ class RequestHandler extends APIHandlerBase {
12231285
),
12241286
relationship: new Linker((primary) =>
12251287
this.makeLinkUrl(
1226-
`/${lowerCaseFirst(model)}/${this.getId(
1288+
`/${lowerCaseFirst(mappedModel)}/${this.getId(
12271289
model,
12281290
primary,
12291291
modelMeta

0 commit comments

Comments
 (0)