Skip to content

Commit 8a2725b

Browse files
authored
feat: support natural keys in the API (#2188)
1 parent 277f282 commit 8a2725b

File tree

2 files changed

+115
-5
lines changed

2 files changed

+115
-5
lines changed

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PrismaErrorCode,
88
clone,
99
enumerate,
10+
requireField,
1011
getIdFields,
1112
isPrismaClientKnownRequestError,
1213
} from '@zenstackhq/runtime';
@@ -52,6 +53,8 @@ export type Options = {
5253
urlSegmentCharset?: string;
5354

5455
modelNameMapping?: Record<string, string>;
56+
57+
externalIdMapping?: Record<string, string>;
5558
};
5659

5760
type RelationshipInfo = {
@@ -238,6 +241,7 @@ class RequestHandler extends APIHandlerBase {
238241
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
239242
private modelNameMapping: Record<string, string>;
240243
private reverseModelNameMapping: Record<string, string>;
244+
private externalIdMapping: Record<string, string>;
241245

242246
constructor(private readonly options: Options) {
243247
super();
@@ -251,6 +255,12 @@ class RequestHandler extends APIHandlerBase {
251255
this.reverseModelNameMapping = Object.fromEntries(
252256
Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])
253257
);
258+
259+
this.externalIdMapping = options.externalIdMapping ?? {};
260+
this.externalIdMapping = Object.fromEntries(
261+
Object.entries(this.externalIdMapping).map(([k, v]) => [lowerCaseFirst(k), v])
262+
);
263+
254264
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
255265
}
256266

@@ -1166,11 +1176,28 @@ class RequestHandler extends APIHandlerBase {
11661176
}
11671177

11681178
//#region utilities
1179+
private getIdFields(modelMeta: ModelMeta, model: string): FieldInfo[] {
1180+
const modelLower = lowerCaseFirst(model);
1181+
if (!(modelLower in this.externalIdMapping)) {
1182+
return getIdFields(modelMeta, model);
1183+
}
1184+
1185+
const metaData = modelMeta.models[modelLower] ?? {};
1186+
const externalIdName = this.externalIdMapping[modelLower];
1187+
const uniqueConstraints = metaData.uniqueConstraints ?? {};
1188+
for (const [name, constraint] of Object.entries(uniqueConstraints)) {
1189+
if (name === externalIdName) {
1190+
return constraint.fields.map((f) => requireField(modelMeta, model, f));
1191+
}
1192+
}
1193+
1194+
throw new Error(`Model ${model} does not have unique key ${externalIdName}`);
1195+
}
11691196

11701197
private buildTypeMap(logger: LoggerConfig | undefined, modelMeta: ModelMeta): void {
11711198
this.typeMap = {};
11721199
for (const [model, { fields }] of Object.entries(modelMeta.models)) {
1173-
const idFields = getIdFields(modelMeta, model);
1200+
const idFields = this.getIdFields(modelMeta, model);
11741201
if (idFields.length === 0) {
11751202
logWarning(logger, `Not including model ${model} in the API because it has no ID field`);
11761203
continue;
@@ -1186,7 +1213,7 @@ class RequestHandler extends APIHandlerBase {
11861213
if (!fieldInfo.isDataModel) {
11871214
continue;
11881215
}
1189-
const fieldTypeIdFields = getIdFields(modelMeta, fieldInfo.type);
1216+
const fieldTypeIdFields = this.getIdFields(modelMeta, fieldInfo.type);
11901217
if (fieldTypeIdFields.length === 0) {
11911218
logWarning(
11921219
logger,
@@ -1214,7 +1241,7 @@ class RequestHandler extends APIHandlerBase {
12141241
const linkers: Record<string, Linker<any>> = {};
12151242

12161243
for (const model of Object.keys(modelMeta.models)) {
1217-
const ids = getIdFields(modelMeta, model);
1244+
const ids = this.getIdFields(modelMeta, model);
12181245
const mappedModel = this.mapModelName(model);
12191246

12201247
if (ids.length < 1) {
@@ -1266,7 +1293,7 @@ class RequestHandler extends APIHandlerBase {
12661293
if (!fieldSerializer) {
12671294
continue;
12681295
}
1269-
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
1296+
const fieldIds = this.getIdFields(modelMeta, fieldMeta.type);
12701297
if (fieldIds.length > 0) {
12711298
const mappedModel = this.mapModelName(model);
12721299

@@ -1306,7 +1333,7 @@ class RequestHandler extends APIHandlerBase {
13061333
if (!data) {
13071334
return undefined;
13081335
}
1309-
const ids = getIdFields(modelMeta, model);
1336+
const ids = this.getIdFields(modelMeta, model);
13101337
if (ids.length === 0) {
13111338
return undefined;
13121339
} else {

packages/server/tests/api/rest.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3111,4 +3111,87 @@ describe('REST server tests', () => {
31113111
});
31123112
});
31133113
});
3114+
3115+
describe('REST server tests - external id mapping', () => {
3116+
const schema = `
3117+
model User {
3118+
id Int @id @default(autoincrement())
3119+
name String
3120+
source String
3121+
posts Post[]
3122+
3123+
@@unique([name, source])
3124+
}
3125+
3126+
model Post {
3127+
id Int @id @default(autoincrement())
3128+
title String
3129+
author User? @relation(fields: [authorId], references: [id])
3130+
authorId Int?
3131+
}
3132+
`;
3133+
beforeAll(async () => {
3134+
const params = await loadSchema(schema);
3135+
prisma = params.prisma;
3136+
zodSchemas = params.zodSchemas;
3137+
modelMeta = params.modelMeta;
3138+
3139+
const _handler = makeHandler({
3140+
endpoint: 'http://localhost/api',
3141+
externalIdMapping: {
3142+
User: 'name_source',
3143+
},
3144+
});
3145+
handler = (args) =>
3146+
_handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) });
3147+
});
3148+
3149+
it('works with id mapping', async () => {
3150+
await prisma.user.create({
3151+
data: { id: 1, name: 'User1', source: 'a' },
3152+
});
3153+
3154+
// user is no longer exposed using the `id` field
3155+
let r = await handler({
3156+
method: 'get',
3157+
path: '/user/1',
3158+
query: {},
3159+
prisma,
3160+
});
3161+
3162+
expect(r.status).toBe(400);
3163+
3164+
// user is exposed using the fields from the `name__source` multi-column unique index
3165+
r = await handler({
3166+
method: 'get',
3167+
path: '/user/User1_a',
3168+
query: {},
3169+
prisma,
3170+
});
3171+
3172+
expect(r.status).toBe(200);
3173+
expect(r.body.data.attributes.source).toBe('a');
3174+
expect(r.body.data.attributes.name).toBe('User1');
3175+
3176+
await prisma.post.create({
3177+
data: { id: 1, title: 'Title1', authorId: 1 },
3178+
});
3179+
3180+
// post is exposed using the `id` field
3181+
r = await handler({
3182+
method: 'get',
3183+
path: '/post/1',
3184+
query: { include: 'author' },
3185+
prisma,
3186+
});
3187+
3188+
expect(r.status).toBe(200);
3189+
expect(r.body.data.attributes.title).toBe('Title1');
3190+
// Verify author relationship contains the external ID
3191+
expect(r.body.data.relationships.author.data).toMatchObject({
3192+
type: 'user',
3193+
id: 'User1_a',
3194+
});
3195+
});
3196+
});
31143197
});

0 commit comments

Comments
 (0)