Skip to content

Configurable REST API id divider #1785

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 19, 2024
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
122 changes: 76 additions & 46 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,19 @@ 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;

/**
* The charset used for URL segment values. Defaults to `a-zA-Z0-9-_~ %`. You can change it if your entity's ID values
* allow different characters. Specifically, if your models use compound IDs and the idDivider is set to a different value,
* it should be included in the charset.
*/
urlSegmentCharset?: string;
};

type RelationshipInfo = {
Expand Down Expand Up @@ -93,6 +93,8 @@ const FilterOperations = [

type FilterOperationType = (typeof FilterOperations)[number] | undefined;

const prismaIdDivider = '_';

registerCustomSerializers();

/**
Expand Down Expand Up @@ -210,8 +212,30 @@ class RequestHandler extends APIHandlerBase {
// all known types and their metadata
private typeMap: Record<string, ModelInfo>;

// divider used to separate compound ID fields
private idDivider;

private urlPatterns;

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);
}

buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) {
const options = { segmentValueCharset: urlSegmentNameCharset };
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 +269,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 +293,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 +307,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 +337,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 +351,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 +369,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 @@ -391,7 +415,7 @@ class RequestHandler extends APIHandlerBase {
return this.makeUnsupportedModelError(type);
}

const args: any = { where: this.makeIdFilter(typeInfo.idFields, resourceId) };
const args: any = { where: this.makePrismaIdFilter(typeInfo.idFields, resourceId) };

// include IDs of relation fields so that they can be serialized
this.includeRelationshipIds(type, args, 'include');
Expand Down Expand Up @@ -456,7 +480,7 @@ class RequestHandler extends APIHandlerBase {

select = select ?? { [relationship]: true };
const args: any = {
where: this.makeIdFilter(typeInfo.idFields, resourceId),
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
select,
};

Expand Down Expand Up @@ -514,7 +538,7 @@ class RequestHandler extends APIHandlerBase {
}

const args: any = {
where: this.makeIdFilter(typeInfo.idFields, resourceId),
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
select: this.makeIdSelect(typeInfo.idFields),
};

Expand Down Expand Up @@ -753,7 +777,7 @@ class RequestHandler extends APIHandlerBase {
if (relationInfo.isCollection) {
createPayload.data[key] = {
connect: enumerate(data.data).map((item: any) => ({
[this.makeIdKey(relationInfo.idFields)]: item.id,
[this.makePrismaIdKey(relationInfo.idFields)]: item.id,
})),
};
} else {
Expand All @@ -762,15 +786,15 @@ class RequestHandler extends APIHandlerBase {
}
createPayload.data[key] = {
connect: {
[this.makeIdKey(relationInfo.idFields)]: data.data.id,
[this.makePrismaIdKey(relationInfo.idFields)]: data.data.id,
},
};
}

// make sure ID fields are included for result serialization
createPayload.include = {
...createPayload.include,
[key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } },
[key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } },
};
}
}
Expand Down Expand Up @@ -807,7 +831,7 @@ class RequestHandler extends APIHandlerBase {
}

const updateArgs: any = {
where: this.makeIdFilter(typeInfo.idFields, resourceId),
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
select: {
...typeInfo.idFields.reduce((acc, field) => ({ ...acc, [field.name]: true }), {}),
[relationship]: { select: this.makeIdSelect(relationInfo.idFields) },
Expand Down Expand Up @@ -842,7 +866,7 @@ class RequestHandler extends APIHandlerBase {
updateArgs.data = {
[relationship]: {
connect: {
[this.makeIdKey(relationInfo.idFields)]: parsed.data.data.id,
[this.makePrismaIdKey(relationInfo.idFields)]: parsed.data.data.id,
},
},
};
Expand All @@ -866,7 +890,7 @@ class RequestHandler extends APIHandlerBase {
updateArgs.data = {
[relationship]: {
[relationVerb]: enumerate(parsed.data.data).map((item: any) =>
this.makeIdFilter(relationInfo.idFields, item.id)
this.makePrismaIdFilter(relationInfo.idFields, item.id)
),
},
};
Expand Down Expand Up @@ -907,7 +931,7 @@ class RequestHandler extends APIHandlerBase {
}

const updatePayload: any = {
where: this.makeIdFilter(typeInfo.idFields, resourceId),
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
data: { ...attributes },
};

Expand All @@ -926,7 +950,7 @@ class RequestHandler extends APIHandlerBase {
if (relationInfo.isCollection) {
updatePayload.data[key] = {
set: enumerate(data.data).map((item: any) => ({
[this.makeIdKey(relationInfo.idFields)]: item.id,
[this.makePrismaIdKey(relationInfo.idFields)]: item.id,
})),
};
} else {
Expand All @@ -935,13 +959,13 @@ class RequestHandler extends APIHandlerBase {
}
updatePayload.data[key] = {
set: {
[this.makeIdKey(relationInfo.idFields)]: data.data.id,
[this.makePrismaIdKey(relationInfo.idFields)]: data.data.id,
},
};
}
updatePayload.include = {
...updatePayload.include,
[key]: { select: { [this.makeIdKey(relationInfo.idFields)]: true } },
[key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } },
};
}
}
Expand All @@ -960,7 +984,7 @@ class RequestHandler extends APIHandlerBase {
}

await prisma[type].delete({
where: this.makeIdFilter(typeInfo.idFields, resourceId),
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
});
return {
status: 204,
Expand Down Expand Up @@ -1110,7 +1134,7 @@ class RequestHandler extends APIHandlerBase {
if (ids.length === 0) {
return undefined;
} else {
return data[ids.map((id) => id.name).join(idDivider)];
return data[this.makeIdKey(ids)];
}
}

Expand Down Expand Up @@ -1206,15 +1230,16 @@ class RequestHandler extends APIHandlerBase {
return r.toString();
}

private makeIdFilter(idFields: FieldInfo[], resourceId: string) {
private makePrismaIdFilter(idFields: FieldInfo[], resourceId: string) {
if (idFields.length === 1) {
return { [idFields[0].name]: this.coerce(idFields[0].type, resourceId) };
} else {
return {
[idFields.map((idf) => idf.name).join(idDivider)]: idFields.reduce(
// TODO: support `@@id` with custom name
[idFields.map((idf) => idf.name).join(prismaIdDivider)]: 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 +1255,16 @@ 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 makePrismaIdKey(idFields: FieldInfo[]) {
// TODO: support `@@id` with custom name
return idFields.map((idf) => idf.name).join(prismaIdDivider);
}

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 Expand Up @@ -1557,11 +1587,11 @@ class RequestHandler extends APIHandlerBase {
const values = value.split(',').filter((i) => i);
const filterValue =
values.length > 1
? { OR: values.map((v) => this.makeIdFilter(info.idFields, v)) }
: this.makeIdFilter(info.idFields, value);
? { OR: values.map((v) => this.makePrismaIdFilter(info.idFields, v)) }
: this.makePrismaIdFilter(info.idFields, value);
return { some: filterValue };
} else {
return { is: this.makeIdFilter(info.idFields, value) };
return { is: this.makePrismaIdFilter(info.idFields, value) };
}
} else {
const coerced = this.coerce(fieldInfo.type, value);
Expand Down
Loading
Loading