diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index 5a1344c2b..30e2607ae 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -44,6 +44,7 @@ type Policies = ReturnType; */ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { private warnings: string[] = []; + private modelNameMapping: Record; constructor(protected model: Model, protected options: PluginOptions, protected dmmf: DMMF.Document) { super(model, options, dmmf); @@ -51,6 +52,8 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { if (this.options.omitInputDetails !== undefined) { throw new PluginError(name, '"omitInputDetails" option is not supported for "rest" flavor'); } + + this.modelNameMapping = this.getOption('modelNameMapping', {} as Record); } generate() { @@ -126,6 +129,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { return result; } + private mapModelName(modelName: string): string { + return this.modelNameMapping[modelName] ?? modelName; + } + private generatePathsForModel(model: DMMF.Model, zmodel: DataModel): OAPI.PathItemObject | undefined { const result: Record = {}; @@ -139,9 +146,11 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { const resourceMeta = getModelResourceMeta(zmodel); + const modelName = this.mapModelName(model.name); + // GET /resource // POST /resource - result[`${prefix}/${lowerCaseFirst(model.name)}`] = { + result[`${prefix}/${lowerCaseFirst(modelName)}`] = { get: this.makeResourceList(zmodel, policies, resourceMeta), post: this.makeResourceCreate(zmodel, policies, resourceMeta), }; @@ -150,10 +159,10 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { // PUT /resource/{id} // PATCH /resource/{id} // DELETE /resource/{id} - result[`${prefix}/${lowerCaseFirst(model.name)}/{id}`] = { + result[`${prefix}/${lowerCaseFirst(modelName)}/{id}`] = { get: this.makeResourceFetch(zmodel, policies, resourceMeta), - put: this.makeResourceUpdate(zmodel, policies, `update-${model.name}-put`, resourceMeta), - patch: this.makeResourceUpdate(zmodel, policies, `update-${model.name}-patch`, resourceMeta), + put: this.makeResourceUpdate(zmodel, policies, `update-${modelName}-put`, resourceMeta), + patch: this.makeResourceUpdate(zmodel, policies, `update-${modelName}-patch`, resourceMeta), delete: this.makeResourceDelete(zmodel, policies, resourceMeta), }; @@ -165,14 +174,14 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { } // GET /resource/{id}/{relationship} - const relatedDataPath = `${prefix}/${lowerCaseFirst(model.name)}/{id}/${field.name}`; + const relatedDataPath = `${prefix}/${lowerCaseFirst(modelName)}/{id}/${field.name}`; let container = result[relatedDataPath]; if (!container) { container = result[relatedDataPath] = {}; } container.get = this.makeRelatedFetch(zmodel, field, relationDecl, resourceMeta); - const relationshipPath = `${prefix}/${lowerCaseFirst(model.name)}/{id}/relationships/${field.name}`; + const relationshipPath = `${prefix}/${lowerCaseFirst(modelName)}/{id}/relationships/${field.name}`; container = result[relationshipPath]; if (!container) { container = result[relationshipPath] = {}; diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 59558be98..75196b1ac 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -50,6 +50,8 @@ export type Options = { * it should be included in the charset. */ urlSegmentCharset?: string; + + modelNameMapping?: Record; }; type RelationshipInfo = { @@ -65,6 +67,19 @@ type ModelInfo = { relationships: Record; }; +type Match = { + type: string; + id: string; + relationship: string; +}; + +enum UrlPatterns { + SINGLE = 'single', + FETCH_RELATIONSHIP = 'fetchRelationship', + RELATIONSHIP = 'relationship', + COLLECTION = 'collection', +} + class InvalidValueError extends Error { constructor(public readonly message: string) { super(message); @@ -220,29 +235,71 @@ class RequestHandler extends APIHandlerBase { // divider used to separate compound ID fields private idDivider; - private urlPatterns; + private urlPatternMap: Record; + private modelNameMapping: Record; + private reverseModelNameMapping: Record; constructor(private readonly options: Options) { super(); this.idDivider = options.idDivider ?? prismaIdDivider; const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; - this.urlPatterns = this.buildUrlPatterns(this.idDivider, segmentCharset); + + this.modelNameMapping = options.modelNameMapping ?? {}; + this.modelNameMapping = Object.fromEntries( + Object.entries(this.modelNameMapping).map(([k, v]) => [lowerCaseFirst(k), v]) + ); + this.reverseModelNameMapping = Object.fromEntries( + Object.entries(this.modelNameMapping).map(([k, v]) => [v, k]) + ); + this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); } - buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) { + private buildUrlPatternMap(urlSegmentNameCharset: string): Record { const options = { segmentValueCharset: urlSegmentNameCharset }; + + const buildPath = (segments: string[]) => { + return '/' + segments.join('/'); + }; + return { - // collection operations - collection: new UrlPattern('/:type', options), - // single resource operations - single: new UrlPattern('/:type/:id', options), - // related entity fetching - fetchRelationship: new UrlPattern('/:type/:id/:relationship', options), - // relationship operations - relationship: new UrlPattern('/:type/:id/relationships/:relationship', options), + [UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options), + [UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options), + [UrlPatterns.RELATIONSHIP]: new UrlPattern( + buildPath([':type', ':id', 'relationships', ':relationship']), + options + ), + [UrlPatterns.COLLECTION]: new UrlPattern(buildPath([':type']), options), }; } + private mapModelName(modelName: string): string { + return this.modelNameMapping[modelName] ?? modelName; + } + + private matchUrlPattern(path: string, routeType: UrlPatterns): Match | undefined { + const pattern = this.urlPatternMap[routeType]; + if (!pattern) { + throw new InvalidValueError(`Unknown route type: ${routeType}`); + } + + const match = pattern.match(path); + if (!match) { + return; + } + + if (match.type in this.modelNameMapping) { + throw new InvalidValueError( + `use the mapped model name: ${this.modelNameMapping[match.type]} and not ${match.type}` + ); + } + + if (match.type in this.reverseModelNameMapping) { + match.type = this.reverseModelNameMapping[match.type]; + } + + return match; + } + async handleRequest({ prisma, method, @@ -274,19 +331,18 @@ class RequestHandler extends APIHandlerBase { try { switch (method) { case 'GET': { - let match = this.urlPatterns.single.match(path); + let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); if (match) { // single resource read return await this.processSingleRead(prisma, match.type, match.id, query); } - - match = this.urlPatterns.fetchRelationship.match(path); + match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); if (match) { // fetch related resource(s) return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query); } - match = this.urlPatterns.relationship.match(path); + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); if (match) { // read relationship return await this.processReadRelationship( @@ -298,7 +354,7 @@ class RequestHandler extends APIHandlerBase { ); } - match = this.urlPatterns.collection.match(path); + match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { // collection read return await this.processCollectionRead(prisma, match.type, query); @@ -311,8 +367,7 @@ class RequestHandler extends APIHandlerBase { if (!requestBody) { return this.makeError('invalidPayload'); } - - let match = this.urlPatterns.collection.match(path); + let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); if (match) { const body = requestBody as any; const upsertMeta = this.upsertMetaSchema.safeParse(body); @@ -338,8 +393,7 @@ class RequestHandler extends APIHandlerBase { ); } } - - match = this.urlPatterns.relationship.match(path); + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); if (match) { // relationship creation (collection relationship only) return await this.processRelationshipCRUD( @@ -362,8 +416,7 @@ class RequestHandler extends APIHandlerBase { if (!requestBody) { return this.makeError('invalidPayload'); } - - let match = this.urlPatterns.single.match(path); + let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); if (match) { // resource update return await this.processUpdate( @@ -376,8 +429,7 @@ class RequestHandler extends APIHandlerBase { zodSchemas ); } - - match = this.urlPatterns.relationship.match(path); + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); if (match) { // relationship update return await this.processRelationshipCRUD( @@ -395,13 +447,13 @@ class RequestHandler extends APIHandlerBase { } case 'DELETE': { - let match = this.urlPatterns.single.match(path); + let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); if (match) { // resource deletion return await this.processDelete(prisma, match.type, match.id); } - match = this.urlPatterns.relationship.match(path); + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); if (match) { // relationship deletion (collection relationship only) return await this.processRelationshipCRUD( @@ -531,11 +583,12 @@ class RequestHandler extends APIHandlerBase { } if (entity?.[relationship]) { + const mappedType = this.mapModelName(type); return { status: 200, body: await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { - document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/${relationship}`)), + document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${relationship}`)), paginator, }, include, @@ -582,11 +635,12 @@ class RequestHandler extends APIHandlerBase { } const entity: any = await prisma[type].findUnique(args); + const mappedType = this.mapModelName(type); if (entity?._count?.[relationship] !== undefined) { // build up paginator const total = entity?._count?.[relationship] as number; - const url = this.makeNormalizedUrl(`/${type}/${resourceId}/relationships/${relationship}`, query); + const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`, query); const { offset, limit } = this.getPagination(query); paginator = this.makePaginator(url, offset, limit, total); } @@ -595,7 +649,7 @@ class RequestHandler extends APIHandlerBase { const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { document: new Linker(() => - this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`) + this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`) ), paginator, }, @@ -680,7 +734,8 @@ class RequestHandler extends APIHandlerBase { ]); const total = count as number; - const url = this.makeNormalizedUrl(`/${type}`, query); + const mappedType = this.mapModelName(type); + const url = this.makeNormalizedUrl(`/${mappedType}`, query); const options: Partial = { include, linkers: { @@ -1009,9 +1064,13 @@ class RequestHandler extends APIHandlerBase { const entity: any = await prisma[type].update(updateArgs); + const mappedType = this.mapModelName(type); + const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { - document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)), + document: new Linker(() => + this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`) + ), }, onlyIdentifier: true, }); @@ -1156,6 +1215,7 @@ class RequestHandler extends APIHandlerBase { for (const model of Object.keys(modelMeta.models)) { const ids = getIdFields(modelMeta, model); + const mappedModel = this.mapModelName(model); if (ids.length < 1) { continue; @@ -1163,8 +1223,8 @@ class RequestHandler extends APIHandlerBase { const linker = new Linker((items) => Array.isArray(items) - ? this.makeLinkUrl(`/${model}`) - : this.makeLinkUrl(`/${model}/${this.getId(model, items, modelMeta)}`) + ? this.makeLinkUrl(`/${mappedModel}`) + : this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`) ); linkers[model] = linker; @@ -1208,6 +1268,8 @@ class RequestHandler extends APIHandlerBase { } const fieldIds = getIdFields(modelMeta, fieldMeta.type); if (fieldIds.length > 0) { + const mappedModel = this.mapModelName(model); + const relator = new Relator( async (data) => { return (data as any)[field]; @@ -1223,7 +1285,7 @@ class RequestHandler extends APIHandlerBase { ), relationship: new Linker((primary) => this.makeLinkUrl( - `/${lowerCaseFirst(model)}/${this.getId( + `/${lowerCaseFirst(mappedModel)}/${this.getId( model, primary, modelMeta diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 642b3fcf8..264a97e41 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -3013,4 +3013,65 @@ describe('REST server tests', () => { expect(r.body.data.attributes.enabled).toBe(false); }); }); + + describe('REST server tests - model name mapping', () => { + const schema = ` + model User { + id String @id @default(cuid()) + name String + posts Post[] + } + + model Post { + id String @id @default(cuid()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? + } + `; + beforeAll(async () => { + const params = await loadSchema(schema); + prisma = params.prisma; + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; + + const _handler = makeHandler({ + endpoint: 'http://localhost/api', + modelNameMapping: { + user: 'myUser', + post: 'myPost', + }, + }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('works with name mapping', async () => { + // using original model name + await expect( + handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { data: { type: 'user', attributes: { name: 'User1' } } }, + prisma, + }) + ).resolves.toMatchObject({ + status: 400, + }); + + // using mapped model name + await expect( + handler({ + method: 'post', + path: '/myUser', + query: {}, + requestBody: { data: { type: 'user', attributes: { name: 'User1' } } }, + prisma, + }) + ).resolves.toMatchObject({ + status: 201, + }); + }); + }); });