From 567d370124cc4a17431fed01116fc89a2101a8fa Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 17 Jul 2025 17:16:02 +0200 Subject: [PATCH 1/2] add ability to define any unique ID to be used as the ID within a REST API this makes it possible to use a natural key even while also using surrogate primary keys --- packages/server/src/api/rest/index.ts | 37 +++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 75196b1ac..0bf02762c 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -7,6 +7,7 @@ import { PrismaErrorCode, clone, enumerate, + requireField, getIdFields, isPrismaClientKnownRequestError, } from '@zenstackhq/runtime'; @@ -52,6 +53,8 @@ export type Options = { urlSegmentCharset?: string; modelNameMapping?: Record; + + externalIdMapping?: Record; }; type RelationshipInfo = { @@ -238,6 +241,7 @@ class RequestHandler extends APIHandlerBase { private urlPatternMap: Record; private modelNameMapping: Record; private reverseModelNameMapping: Record; + private externalIdMapping: Record; constructor(private readonly options: Options) { super(); @@ -251,6 +255,12 @@ class RequestHandler extends APIHandlerBase { this.reverseModelNameMapping = Object.fromEntries( Object.entries(this.modelNameMapping).map(([k, v]) => [v, k]) ); + + this.externalIdMapping = options.externalIdMapping ?? {}; + this.externalIdMapping = Object.fromEntries( + Object.entries(this.externalIdMapping).map(([k, v]) => [lowerCaseFirst(k), v]) + ); + this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); } @@ -1166,11 +1176,28 @@ class RequestHandler extends APIHandlerBase { } //#region utilities + private getIdFields(modelMeta: ModelMeta, model: string): FieldInfo[] { + const modelLower = lowerCaseFirst(model); + if (!(modelLower in this.externalIdMapping)) { + return getIdFields(modelMeta, model); + } + + const metaData = modelMeta.models[modelLower] ?? {}; + const externalIdName = this.externalIdMapping[modelLower]; + const uniqueConstraints = metaData.uniqueConstraints ?? {}; + for (const [name, constraint] of Object.entries(uniqueConstraints)) { + if (name === externalIdName) { + return constraint.fields.map((f) => requireField(modelMeta, model, f)); + } + } + + throw new Error(`Model ${model} does not have unique key ${externalIdName}`); + } private buildTypeMap(logger: LoggerConfig | undefined, modelMeta: ModelMeta): void { this.typeMap = {}; for (const [model, { fields }] of Object.entries(modelMeta.models)) { - const idFields = getIdFields(modelMeta, model); + const idFields = this.getIdFields(modelMeta, model); if (idFields.length === 0) { logWarning(logger, `Not including model ${model} in the API because it has no ID field`); continue; @@ -1186,7 +1213,7 @@ class RequestHandler extends APIHandlerBase { if (!fieldInfo.isDataModel) { continue; } - const fieldTypeIdFields = getIdFields(modelMeta, fieldInfo.type); + const fieldTypeIdFields = this.getIdFields(modelMeta, fieldInfo.type); if (fieldTypeIdFields.length === 0) { logWarning( logger, @@ -1214,7 +1241,7 @@ class RequestHandler extends APIHandlerBase { const linkers: Record> = {}; for (const model of Object.keys(modelMeta.models)) { - const ids = getIdFields(modelMeta, model); + const ids = this.getIdFields(modelMeta, model); const mappedModel = this.mapModelName(model); if (ids.length < 1) { @@ -1266,7 +1293,7 @@ class RequestHandler extends APIHandlerBase { if (!fieldSerializer) { continue; } - const fieldIds = getIdFields(modelMeta, fieldMeta.type); + const fieldIds = this.getIdFields(modelMeta, fieldMeta.type); if (fieldIds.length > 0) { const mappedModel = this.mapModelName(model); @@ -1306,7 +1333,7 @@ class RequestHandler extends APIHandlerBase { if (!data) { return undefined; } - const ids = getIdFields(modelMeta, model); + const ids = this.getIdFields(modelMeta, model); if (ids.length === 0) { return undefined; } else { From 582a83688c8b1a9b71706edec1de193581e2b8be Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Wed, 6 Aug 2025 13:18:55 +0200 Subject: [PATCH 2/2] add tests for 'externalIdMapping' --- packages/server/tests/api/rest.test.ts | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index c0023873a..116da8c59 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -3111,4 +3111,87 @@ describe('REST server tests', () => { }); }); }); + + describe('REST server tests - external id mapping', () => { + const schema = ` + model User { + id Int @id @default(autoincrement()) + name String + source String + posts Post[] + + @@unique([name, source]) + } + + model Post { + id Int @id @default(autoincrement()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + } + `; + beforeAll(async () => { + const params = await loadSchema(schema); + prisma = params.prisma; + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; + + const _handler = makeHandler({ + endpoint: 'http://localhost/api', + externalIdMapping: { + User: 'name_source', + }, + }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('works with id mapping', async () => { + await prisma.user.create({ + data: { id: 1, name: 'User1', source: 'a' }, + }); + + // user is no longer exposed using the `id` field + let r = await handler({ + method: 'get', + path: '/user/1', + query: {}, + prisma, + }); + + expect(r.status).toBe(400); + + // user is exposed using the fields from the `name__source` multi-column unique index + r = await handler({ + method: 'get', + path: '/user/User1_a', + query: {}, + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.source).toBe('a'); + expect(r.body.data.attributes.name).toBe('User1'); + + await prisma.post.create({ + data: { id: 1, title: 'Title1', authorId: 1 }, + }); + + // post is exposed using the `id` field + r = await handler({ + method: 'get', + path: '/post/1', + query: { include: 'author' }, + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.title).toBe('Title1'); + // Verify author relationship contains the external ID + expect(r.body.data.relationships.author.data).toMatchObject({ + type: 'user', + id: 'User1_a', + }); + }); + }); });