From c05e542923b399e45dc7295744c07bd3a8d5f37d Mon Sep 17 00:00:00 2001 From: Tobias Brugger Date: Thu, 10 Jul 2025 16:40:54 +0200 Subject: [PATCH 1/6] 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 --- packages/server/src/api/rest/index.ts | 128 ++++++++++++++++++-------- 1 file changed, 91 insertions(+), 37 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 59558be98..ec7606769 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -50,6 +50,9 @@ export type Options = { * it should be included in the charset. */ urlSegmentCharset?: string; + + modelNameMapping?: Record; + prefix?: string; }; type RelationshipInfo = { @@ -65,6 +68,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 +236,60 @@ class RequestHandler extends APIHandlerBase { // divider used to separate compound ID fields private idDivider; - private urlPatterns; + private urlPatternMap: Record; + private modelNameMapping: Record; + private reverseModelNameMapping: Record; + private prefix: string | undefined; 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.prefix = options.prefix; + this.modelNameMapping = options.modelNameMapping ?? {}; + 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 (this.prefix ?? '') + '/' + 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 reverseModelNameMap(type: string): string { + return this.reverseModelNameMapping[type] ?? type; + } + + private matchUrlPattern(path: string, routeType: UrlPatterns): Match { + const pattern = this.urlPatternMap[routeType]; + if (!pattern) { + throw new InvalidValueError(`Unknown route type: ${routeType}`); + } + + const match = pattern.match(path); + if (match) { + match.type = this.modelNameMapping[match.type] ?? match.type; + match.relationship = this.modelNameMapping[match.relationship] ?? match.relationship; + } + return match; + } + async handleRequest({ prisma, method, @@ -274,19 +321,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 +344,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 +357,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 +383,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 +406,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 +419,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 +437,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 +573,13 @@ class RequestHandler extends APIHandlerBase { } if (entity?.[relationship]) { + const mappedType = this.reverseModelNameMap(type); + const mappedRelationship = this.reverseModelNameMap(relationship); 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}/${mappedRelationship}`)), paginator, }, include, @@ -582,11 +626,13 @@ class RequestHandler extends APIHandlerBase { } const entity: any = await prisma[type].findUnique(args); + const mappedType = this.reverseModelNameMap(type); + const mappedRelationship = this.reverseModelNameMap(relationship); 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/${mappedRelationship}`, query); const { offset, limit } = this.getPagination(query); paginator = this.makePaginator(url, offset, limit, total); } @@ -595,7 +641,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/${mappedRelationship}`) ), paginator, }, @@ -680,7 +726,8 @@ class RequestHandler extends APIHandlerBase { ]); const total = count as number; - const url = this.makeNormalizedUrl(`/${type}`, query); + const mappedType = this.reverseModelNameMap(type); + const url = this.makeNormalizedUrl(`/${mappedType}`, query); const options: Partial = { include, linkers: { @@ -1009,9 +1056,12 @@ class RequestHandler extends APIHandlerBase { const entity: any = await prisma[type].update(updateArgs); + const mappedType = this.reverseModelNameMap(type); + const mappedRelationship = this.reverseModelNameMap(relationship); + 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/${mappedRelationship}`)), }, onlyIdentifier: true, }); @@ -1147,7 +1197,7 @@ class RequestHandler extends APIHandlerBase { } private makeLinkUrl(path: string) { - return `${this.options.endpoint}${path}`; + return `${this.options.endpoint}${this.prefix}${path}`; } private buildSerializers(modelMeta: ModelMeta) { @@ -1156,6 +1206,7 @@ class RequestHandler extends APIHandlerBase { for (const model of Object.keys(modelMeta.models)) { const ids = getIdFields(modelMeta, model); + const mappedModel = this.reverseModelNameMap(model); if (ids.length < 1) { continue; @@ -1163,8 +1214,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 +1259,9 @@ class RequestHandler extends APIHandlerBase { } const fieldIds = getIdFields(modelMeta, fieldMeta.type); if (fieldIds.length > 0) { + const mappedModel = this.reverseModelNameMap(model); + const mappedField = this.reverseModelNameMap(field); + const relator = new Relator( async (data) => { return (data as any)[field]; @@ -1218,16 +1272,16 @@ class RequestHandler extends APIHandlerBase { linkers: { related: new Linker((primary) => this.makeLinkUrl( - `/${lowerCaseFirst(model)}/${this.getId(model, primary, modelMeta)}/${field}` + `/${lowerCaseFirst(mappedModel)}/${this.getId(model, primary, modelMeta)}/${mappedField}` ) ), relationship: new Linker((primary) => this.makeLinkUrl( - `/${lowerCaseFirst(model)}/${this.getId( + `/${lowerCaseFirst(mappedModel)}/${this.getId( model, primary, modelMeta - )}/relationships/${field}` + )}/relationships/${mappedField}` ) ), }, From b9899318f8d67516d300eb43f5ad8a858891acae Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:07:41 +0800 Subject: [PATCH 2/6] add a sample test case --- packages/server/tests/api/rest.test.ts | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 642b3fcf8..2a5d96ed2 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: { + myUser: 'user', + myPost: 'post', + }, + }); + 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, + }); + }); + }); }); From 7d03fdbf75fd98213b81d72e0022b2886d152687 Mon Sep 17 00:00:00 2001 From: Tobias Brugger Date: Thu, 10 Jul 2025 16:40:54 +0200 Subject: [PATCH 3/6] 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 --- packages/server/src/api/rest/index.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index ec7606769..06e63826b 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -52,7 +52,6 @@ export type Options = { urlSegmentCharset?: string; modelNameMapping?: Record; - prefix?: string; }; type RelationshipInfo = { @@ -239,14 +238,12 @@ class RequestHandler extends APIHandlerBase { private urlPatternMap: Record; private modelNameMapping: Record; private reverseModelNameMapping: Record; - private prefix: string | undefined; constructor(private readonly options: Options) { super(); this.idDivider = options.idDivider ?? prismaIdDivider; const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; - this.prefix = options.prefix; this.modelNameMapping = options.modelNameMapping ?? {}; this.reverseModelNameMapping = Object.fromEntries( Object.entries(this.modelNameMapping).map(([k, v]) => [v, k]) @@ -258,7 +255,7 @@ class RequestHandler extends APIHandlerBase { const options = { segmentValueCharset: urlSegmentNameCharset }; const buildPath = (segments: string[]) => { - return (this.prefix ?? '') + '/' + segments.join('/'); + return '/' + segments.join('/'); }; return { @@ -285,7 +282,6 @@ class RequestHandler extends APIHandlerBase { const match = pattern.match(path); if (match) { match.type = this.modelNameMapping[match.type] ?? match.type; - match.relationship = this.modelNameMapping[match.relationship] ?? match.relationship; } return match; } @@ -574,12 +570,11 @@ class RequestHandler extends APIHandlerBase { if (entity?.[relationship]) { const mappedType = this.reverseModelNameMap(type); - const mappedRelationship = this.reverseModelNameMap(relationship); return { status: 200, body: await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { - document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${mappedRelationship}`)), + document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${relationship}`)), paginator, }, include, @@ -627,12 +622,11 @@ class RequestHandler extends APIHandlerBase { const entity: any = await prisma[type].findUnique(args); const mappedType = this.reverseModelNameMap(type); - const mappedRelationship = this.reverseModelNameMap(relationship); if (entity?._count?.[relationship] !== undefined) { // build up paginator const total = entity?._count?.[relationship] as number; - const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`, query); + const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`, query); const { offset, limit } = this.getPagination(query); paginator = this.makePaginator(url, offset, limit, total); } @@ -641,7 +635,7 @@ class RequestHandler extends APIHandlerBase { const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { document: new Linker(() => - this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`) + this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`) ), paginator, }, @@ -1057,11 +1051,12 @@ class RequestHandler extends APIHandlerBase { const entity: any = await prisma[type].update(updateArgs); const mappedType = this.reverseModelNameMap(type); - const mappedRelationship = this.reverseModelNameMap(relationship); const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { - document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)), + document: new Linker(() => + this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`) + ), }, onlyIdentifier: true, }); @@ -1197,7 +1192,7 @@ class RequestHandler extends APIHandlerBase { } private makeLinkUrl(path: string) { - return `${this.options.endpoint}${this.prefix}${path}`; + return `${this.options.endpoint}${path}`; } private buildSerializers(modelMeta: ModelMeta) { @@ -1260,7 +1255,6 @@ class RequestHandler extends APIHandlerBase { const fieldIds = getIdFields(modelMeta, fieldMeta.type); if (fieldIds.length > 0) { const mappedModel = this.reverseModelNameMap(model); - const mappedField = this.reverseModelNameMap(field); const relator = new Relator( async (data) => { @@ -1272,7 +1266,7 @@ class RequestHandler extends APIHandlerBase { linkers: { related: new Linker((primary) => this.makeLinkUrl( - `/${lowerCaseFirst(mappedModel)}/${this.getId(model, primary, modelMeta)}/${mappedField}` + `/${lowerCaseFirst(model)}/${this.getId(model, primary, modelMeta)}/${field}` ) ), relationship: new Linker((primary) => @@ -1281,7 +1275,7 @@ class RequestHandler extends APIHandlerBase { model, primary, modelMeta - )}/relationships/${mappedField}` + )}/relationships/${field}` ) ), }, From 92fdd9a508b70a952aa510f7378141ae09b01323 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Wed, 16 Jul 2025 13:35:01 +0200 Subject: [PATCH 4/6] update openapi plugin --- .../plugins/openapi/src/rest-generator.ts | 21 +++++++++++++----- packages/server/src/api/rest/index.ts | 22 +++++++++---------- 2 files changed, 25 insertions(+), 18 deletions(-) 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 06e63826b..653ec7680 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -245,9 +245,7 @@ class RequestHandler extends APIHandlerBase { const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; this.modelNameMapping = options.modelNameMapping ?? {}; - this.reverseModelNameMapping = Object.fromEntries( - Object.entries(this.modelNameMapping).map(([k, v]) => [v, k]) - ); + this.reverseModelNameMapping = Object.fromEntries(Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])); this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); } @@ -269,8 +267,8 @@ class RequestHandler extends APIHandlerBase { }; } - private reverseModelNameMap(type: string): string { - return this.reverseModelNameMapping[type] ?? type; + private mapModelName(modelName: string): string { + return this.modelNameMapping[modelName] ?? modelName; } private matchUrlPattern(path: string, routeType: UrlPatterns): Match { @@ -281,7 +279,7 @@ class RequestHandler extends APIHandlerBase { const match = pattern.match(path); if (match) { - match.type = this.modelNameMapping[match.type] ?? match.type; + match.type = this.reverseModelNameMapping[match.type] ?? match.type; } return match; } @@ -569,7 +567,7 @@ class RequestHandler extends APIHandlerBase { } if (entity?.[relationship]) { - const mappedType = this.reverseModelNameMap(type); + const mappedType = this.mapModelName(type); return { status: 200, body: await this.serializeItems(relationInfo.type, entity[relationship], { @@ -621,7 +619,7 @@ class RequestHandler extends APIHandlerBase { } const entity: any = await prisma[type].findUnique(args); - const mappedType = this.reverseModelNameMap(type); + const mappedType = this.mapModelName(type); if (entity?._count?.[relationship] !== undefined) { // build up paginator @@ -720,7 +718,7 @@ class RequestHandler extends APIHandlerBase { ]); const total = count as number; - const mappedType = this.reverseModelNameMap(type); + const mappedType = this.mapModelName(type); const url = this.makeNormalizedUrl(`/${mappedType}`, query); const options: Partial = { include, @@ -1050,7 +1048,7 @@ class RequestHandler extends APIHandlerBase { const entity: any = await prisma[type].update(updateArgs); - const mappedType = this.reverseModelNameMap(type); + const mappedType = this.mapModelName(type); const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { linkers: { @@ -1201,7 +1199,7 @@ class RequestHandler extends APIHandlerBase { for (const model of Object.keys(modelMeta.models)) { const ids = getIdFields(modelMeta, model); - const mappedModel = this.reverseModelNameMap(model); + const mappedModel = this.mapModelName(model); if (ids.length < 1) { continue; @@ -1254,7 +1252,7 @@ class RequestHandler extends APIHandlerBase { } const fieldIds = getIdFields(modelMeta, fieldMeta.type); if (fieldIds.length > 0) { - const mappedModel = this.reverseModelNameMap(model); + const mappedModel = this.mapModelName(model); const relator = new Relator( async (data) => { From 1c32b85a7702248113a0281f2c660de1f7c0c4c8 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 17 Jul 2025 11:30:35 +0200 Subject: [PATCH 5/6] ensure model name has the first char lower cased --- packages/server/src/api/rest/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 653ec7680..e5dc9be9c 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -245,7 +245,12 @@ class RequestHandler extends APIHandlerBase { const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; this.modelNameMapping = options.modelNameMapping ?? {}; - this.reverseModelNameMapping = Object.fromEntries(Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])); + 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); } From 5bc08d613b077229e556c923be6ff4f2d42e0e2d Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Fri, 18 Jul 2025 11:20:01 +0200 Subject: [PATCH 6/6] update tests, explicitly check for old path when using model name mapping --- packages/server/src/api/rest/index.ts | 17 ++++++++++++++--- packages/server/tests/api/rest.test.ts | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index e5dc9be9c..75196b1ac 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -276,16 +276,27 @@ class RequestHandler extends APIHandlerBase { return this.modelNameMapping[modelName] ?? modelName; } - private matchUrlPattern(path: string, routeType: UrlPatterns): Match { + 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) { - match.type = this.reverseModelNameMapping[match.type] ?? match.type; + 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; } diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 2a5d96ed2..264a97e41 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -3038,8 +3038,8 @@ describe('REST server tests', () => { const _handler = makeHandler({ endpoint: 'http://localhost/api', modelNameMapping: { - myUser: 'user', - myPost: 'post', + user: 'myUser', + post: 'myPost', }, }); handler = (args) =>