Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
68 changes: 40 additions & 28 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,6 @@ import { LoggerConfig, Response } from '../../types';
import { APIHandlerBase, RequestContext } from '../base';
import { logWarning, registerCustomSerializers } from '../utils';

const urlPatterns = {
// collection operations
collection: new UrlPattern('/:type'),
// single resource operations
single: new UrlPattern('/:type/:id'),
// related entity fetching
fetchRelationship: new UrlPattern('/:type/:id/:relationship'),
// relationship operations
relationship: new UrlPattern('/:type/:id/relationships/:relationship'),
};

export const idDivider = '_';

/**
* Request handler options
*/
Expand All @@ -52,6 +39,12 @@ export type Options = {
* Defaults to 100. Set to Infinity to disable pagination.
*/
pageSize?: number;

/**
* The divider used to separate compound ID fields in the URL.
* Defaults to '_'.
*/
idDivider?: string;
};

type RelationshipInfo = {
Expand Down Expand Up @@ -209,9 +202,28 @@ class RequestHandler extends APIHandlerBase {

// all known types and their metadata
private typeMap: Record<string, ModelInfo>;
public idDivider;

private urlPatterns;

constructor(private readonly options: Options) {
super();
this.idDivider = options.idDivider ?? '_';
this.urlPatterns = this.buildUrlPatterns(this.idDivider);
}

buildUrlPatterns(idDivider: string) {
const options = { segmentNameCharset: `a-zA-Z0-9-_~ %${idDivider}` };
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),
};
}

async handleRequest({
Expand Down Expand Up @@ -245,19 +257,19 @@ class RequestHandler extends APIHandlerBase {
try {
switch (method) {
case 'GET': {
let match = urlPatterns.single.match(path);
let match = this.urlPatterns.single.match(path);
if (match) {
// single resource read
return await this.processSingleRead(prisma, match.type, match.id, query);
}

match = urlPatterns.fetchRelationship.match(path);
match = this.urlPatterns.fetchRelationship.match(path);
if (match) {
// fetch related resource(s)
return await this.processFetchRelated(prisma, match.type, match.id, match.relationship, query);
}

match = urlPatterns.relationship.match(path);
match = this.urlPatterns.relationship.match(path);
if (match) {
// read relationship
return await this.processReadRelationship(
Expand All @@ -269,7 +281,7 @@ class RequestHandler extends APIHandlerBase {
);
}

match = urlPatterns.collection.match(path);
match = this.urlPatterns.collection.match(path);
if (match) {
// collection read
return await this.processCollectionRead(prisma, match.type, query);
Expand All @@ -283,13 +295,13 @@ class RequestHandler extends APIHandlerBase {
return this.makeError('invalidPayload');
}

let match = urlPatterns.collection.match(path);
let match = this.urlPatterns.collection.match(path);
if (match) {
// resource creation
return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas);
}

match = urlPatterns.relationship.match(path);
match = this.urlPatterns.relationship.match(path);
if (match) {
// relationship creation (collection relationship only)
return await this.processRelationshipCRUD(
Expand All @@ -313,7 +325,7 @@ class RequestHandler extends APIHandlerBase {
return this.makeError('invalidPayload');
}

let match = urlPatterns.single.match(path);
let match = this.urlPatterns.single.match(path);
if (match) {
// resource update
return await this.processUpdate(
Expand All @@ -327,7 +339,7 @@ class RequestHandler extends APIHandlerBase {
);
}

match = urlPatterns.relationship.match(path);
match = this.urlPatterns.relationship.match(path);
if (match) {
// relationship update
return await this.processRelationshipCRUD(
Expand All @@ -345,13 +357,13 @@ class RequestHandler extends APIHandlerBase {
}

case 'DELETE': {
let match = urlPatterns.single.match(path);
let match = this.urlPatterns.single.match(path);
if (match) {
// resource deletion
return await this.processDelete(prisma, match.type, match.id);
}

match = urlPatterns.relationship.match(path);
match = this.urlPatterns.relationship.match(path);
if (match) {
// relationship deletion (collection relationship only)
return await this.processRelationshipCRUD(
Expand Down Expand Up @@ -1110,7 +1122,7 @@ class RequestHandler extends APIHandlerBase {
if (ids.length === 0) {
return undefined;
} else {
return data[ids.map((id) => id.name).join(idDivider)];
return data[ids.map((id) => id.name).join(this.idDivider)];
}
}

Expand Down Expand Up @@ -1211,10 +1223,10 @@ class RequestHandler extends APIHandlerBase {
return { [idFields[0].name]: this.coerce(idFields[0].type, resourceId) };
} else {
return {
[idFields.map((idf) => idf.name).join(idDivider)]: idFields.reduce(
[idFields.map((idf) => idf.name).join(this.idDivider)]: idFields.reduce(
(acc, curr, idx) => ({
...acc,
[curr.name]: this.coerce(curr.type, resourceId.split(idDivider)[idx]),
[curr.name]: this.coerce(curr.type, resourceId.split(this.idDivider)[idx]),
}),
{}
),
Expand All @@ -1230,11 +1242,11 @@ class RequestHandler extends APIHandlerBase {
}

private makeIdKey(idFields: FieldInfo[]) {
return idFields.map((idf) => idf.name).join(idDivider);
return idFields.map((idf) => idf.name).join(this.idDivider);
}

private makeCompoundId(idFields: FieldInfo[], item: any) {
return idFields.map((idf) => item[idf.name]).join(idDivider);
return idFields.map((idf) => item[idf.name]).join(this.idDivider);
}

private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') {
Expand Down
97 changes: 96 additions & 1 deletion packages/server/tests/api/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { CrudFailureReason, type ModelMeta } from '@zenstackhq/runtime';
import { loadSchema, run } from '@zenstackhq/testtools';
import { Decimal } from 'decimal.js';
import SuperJSON from 'superjson';
import makeHandler, { idDivider } from '../../src/api/rest';
import makeHandler from '../../src/api/rest';

const idDivider = '_';

describe('REST server tests', () => {
let prisma: any;
Expand Down Expand Up @@ -2519,4 +2521,97 @@ describe('REST server tests', () => {
expect(Buffer.isBuffer(included.attributes.bytes)).toBeTruthy();
});
});

describe('REST server tests - compound id with custom separator', () => {
const schema = `
enum Role {
COMMON_USER
ADMIN_USER
}

model User {
email String
role Role

@@id([email, role])
}
`;
const idDivider = ':';

beforeAll(async () => {
const params = await loadSchema(schema);

prisma = params.enhanceRaw(params.prisma, params);
zodSchemas = params.zodSchemas;
modelMeta = params.modelMeta;

const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5, idDivider });
handler = (args) =>
_handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) });
});

it('POST', async () => {
const r = await handler({
method: 'post',
path: '/user',
query: {},
requestBody: {
data: {
type: 'user',
attributes: { email: '[email protected]', role: 'COMMON_USER' },
},
},
prisma,
});

expect(r.status).toBe(201);
});

it('GET', async () => {
await prisma.user.create({
data: { email: '[email protected]', role: 'COMMON_USER' },
});

const r = await handler({
method: 'get',
path: '/user',
query: {},
prisma,
});

expect(r.status).toBe(200);
expect(r.body.data).toHaveLength(1);
});

it('GET single', async () => {
await prisma.user.create({
data: { email: '[email protected]', role: 'COMMON_USER' },
});

const r = await handler({
method: 'get',
path: '/user/[email protected]:COMMON_USER',
query: {},
prisma,
});

expect(r.status).toBe(200);
expect(r.body.data.attributes.email).toBe('[email protected]');
});

it('PUT', async () => {
await prisma.user.create({
data: { email: '[email protected]', role: 'COMMON_USER' },
});

const r = await handler({
method: 'put',
path: '/user/[email protected]:COMMON_USER',
query: {},
prisma,
});

expect(r.status).toBe(200);
});
});
});
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading