Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PrismaErrorCode,
clone,
enumerate,
requireField,
getIdFields,
isPrismaClientKnownRequestError,
} from '@zenstackhq/runtime';
Expand Down Expand Up @@ -52,6 +53,8 @@ export type Options = {
urlSegmentCharset?: string;

modelNameMapping?: Record<string, string>;

externalIdMapping?: Record<string, string>;
};

type RelationshipInfo = {
Expand Down Expand Up @@ -238,6 +241,7 @@ class RequestHandler extends APIHandlerBase {
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
private modelNameMapping: Record<string, string>;
private reverseModelNameMapping: Record<string, string>;
private externalIdMapping: Record<string, string>;

constructor(private readonly options: Options) {
super();
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -1214,7 +1241,7 @@ class RequestHandler extends APIHandlerBase {
const linkers: Record<string, Linker<any>> = {};

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) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
83 changes: 83 additions & 0 deletions packages/server/tests/api/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
});
Loading