Skip to content
Merged
Changes from 1 commit
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
128 changes: 91 additions & 37 deletions packages/server/src/api/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export type Options = {
* it should be included in the charset.
*/
urlSegmentCharset?: string;

modelNameMapping?: Record<string, string>;
prefix?: string;
};

type RelationshipInfo = {
Expand All @@ -65,6 +68,19 @@ type ModelInfo = {
relationships: Record<string, RelationshipInfo>;
};

type Match = {
type: string;
id: string;
relationship: string;
};

enum UrlPatterns {
SINGLE = 'single',
FETCH_RELATIONSHIP = 'fetchRelationship',
RELATIONSHIP = 'relationship',
COLLECTION = 'collection',
}

class InvalidValueError extends Error {
constructor(public readonly message: string) {
super(message);
Expand Down Expand Up @@ -220,29 +236,60 @@ class RequestHandler extends APIHandlerBase {
// divider used to separate compound ID fields
private idDivider;

private urlPatterns;
private urlPatternMap: Record<UrlPatterns, UrlPattern>;
private modelNameMapping: Record<string, string>;
private reverseModelNameMapping: Record<string, string>;
private prefix: string | undefined;

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

this.prefix = options.prefix;
this.modelNameMapping = options.modelNameMapping ?? {};
this.reverseModelNameMapping = Object.fromEntries(
Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])
);
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
}

buildUrlPatterns(idDivider: string, urlSegmentNameCharset: string) {
private buildUrlPatternMap(urlSegmentNameCharset: string): Record<UrlPatterns, UrlPattern> {
const options = { segmentValueCharset: urlSegmentNameCharset };

const buildPath = (segments: string[]) => {
return (this.prefix ?? '') + '/' + segments.join('/');
};

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),
[UrlPatterns.SINGLE]: new UrlPattern(buildPath([':type', ':id']), options),
[UrlPatterns.FETCH_RELATIONSHIP]: new UrlPattern(buildPath([':type', ':id', ':relationship']), options),
[UrlPatterns.RELATIONSHIP]: new UrlPattern(
buildPath([':type', ':id', 'relationships', ':relationship']),
options
),
[UrlPatterns.COLLECTION]: new UrlPattern(buildPath([':type']), options),
};
}

private reverseModelNameMap(type: string): string {
return this.reverseModelNameMapping[type] ?? type;
}

private matchUrlPattern(path: string, routeType: UrlPatterns): Match {
const pattern = this.urlPatternMap[routeType];
if (!pattern) {
throw new InvalidValueError(`Unknown route type: ${routeType}`);
}

const match = pattern.match(path);
if (match) {
match.type = this.modelNameMapping[match.type] ?? match.type;
match.relationship = this.modelNameMapping[match.relationship] ?? match.relationship;
}
return match;
}

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

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

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

match = this.urlPatterns.collection.match(path);
match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
if (match) {
// collection read
return await this.processCollectionRead(prisma, match.type, query);
Expand All @@ -311,8 +357,7 @@ class RequestHandler extends APIHandlerBase {
if (!requestBody) {
return this.makeError('invalidPayload');
}

let match = this.urlPatterns.collection.match(path);
let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
if (match) {
const body = requestBody as any;
const upsertMeta = this.upsertMetaSchema.safeParse(body);
Expand All @@ -338,8 +383,7 @@ class RequestHandler extends APIHandlerBase {
);
}
}

match = this.urlPatterns.relationship.match(path);
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
if (match) {
// relationship creation (collection relationship only)
return await this.processRelationshipCRUD(
Expand All @@ -362,8 +406,7 @@ class RequestHandler extends APIHandlerBase {
if (!requestBody) {
return this.makeError('invalidPayload');
}

let match = this.urlPatterns.single.match(path);
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
if (match) {
// resource update
return await this.processUpdate(
Expand All @@ -376,8 +419,7 @@ class RequestHandler extends APIHandlerBase {
zodSchemas
);
}

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

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

match = this.urlPatterns.relationship.match(path);
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
if (match) {
// relationship deletion (collection relationship only)
return await this.processRelationshipCRUD(
Expand Down Expand Up @@ -531,11 +573,13 @@ class RequestHandler extends APIHandlerBase {
}

if (entity?.[relationship]) {
const mappedType = this.reverseModelNameMap(type);
const mappedRelationship = this.reverseModelNameMap(relationship);
return {
status: 200,
body: await this.serializeItems(relationInfo.type, entity[relationship], {
linkers: {
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/${relationship}`)),
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${mappedRelationship}`)),
paginator,
},
include,
Expand Down Expand Up @@ -582,11 +626,13 @@ class RequestHandler extends APIHandlerBase {
}

const entity: any = await prisma[type].findUnique(args);
const mappedType = this.reverseModelNameMap(type);
const mappedRelationship = this.reverseModelNameMap(relationship);

if (entity?._count?.[relationship] !== undefined) {
// build up paginator
const total = entity?._count?.[relationship] as number;
const url = this.makeNormalizedUrl(`/${type}/${resourceId}/relationships/${relationship}`, query);
const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`, query);
const { offset, limit } = this.getPagination(query);
paginator = this.makePaginator(url, offset, limit, total);
}
Expand All @@ -595,7 +641,7 @@ class RequestHandler extends APIHandlerBase {
const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
linkers: {
document: new Linker(() =>
this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)
this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)
),
paginator,
},
Expand Down Expand Up @@ -680,7 +726,8 @@ class RequestHandler extends APIHandlerBase {
]);
const total = count as number;

const url = this.makeNormalizedUrl(`/${type}`, query);
const mappedType = this.reverseModelNameMap(type);
const url = this.makeNormalizedUrl(`/${mappedType}`, query);
const options: Partial<SerializerOptions> = {
include,
linkers: {
Expand Down Expand Up @@ -1009,9 +1056,12 @@ class RequestHandler extends APIHandlerBase {

const entity: any = await prisma[type].update(updateArgs);

const mappedType = this.reverseModelNameMap(type);
const mappedRelationship = this.reverseModelNameMap(relationship);

const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], {
linkers: {
document: new Linker(() => this.makeLinkUrl(`/${type}/${resourceId}/relationships/${relationship}`)),
document: new Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${mappedRelationship}`)),
},
onlyIdentifier: true,
});
Expand Down Expand Up @@ -1147,7 +1197,7 @@ class RequestHandler extends APIHandlerBase {
}

private makeLinkUrl(path: string) {
return `${this.options.endpoint}${path}`;
return `${this.options.endpoint}${this.prefix}${path}`;
}

private buildSerializers(modelMeta: ModelMeta) {
Expand All @@ -1156,15 +1206,16 @@ class RequestHandler extends APIHandlerBase {

for (const model of Object.keys(modelMeta.models)) {
const ids = getIdFields(modelMeta, model);
const mappedModel = this.reverseModelNameMap(model);

if (ids.length < 1) {
continue;
}

const linker = new Linker((items) =>
Array.isArray(items)
? this.makeLinkUrl(`/${model}`)
: this.makeLinkUrl(`/${model}/${this.getId(model, items, modelMeta)}`)
? this.makeLinkUrl(`/${mappedModel}`)
: this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`)
);
linkers[model] = linker;

Expand Down Expand Up @@ -1208,6 +1259,9 @@ class RequestHandler extends APIHandlerBase {
}
const fieldIds = getIdFields(modelMeta, fieldMeta.type);
if (fieldIds.length > 0) {
const mappedModel = this.reverseModelNameMap(model);
const mappedField = this.reverseModelNameMap(field);

const relator = new Relator(
async (data) => {
return (data as any)[field];
Expand All @@ -1218,16 +1272,16 @@ class RequestHandler extends APIHandlerBase {
linkers: {
related: new Linker((primary) =>
this.makeLinkUrl(
`/${lowerCaseFirst(model)}/${this.getId(model, primary, modelMeta)}/${field}`
`/${lowerCaseFirst(mappedModel)}/${this.getId(model, primary, modelMeta)}/${mappedField}`
)
),
relationship: new Linker((primary) =>
this.makeLinkUrl(
`/${lowerCaseFirst(model)}/${this.getId(
`/${lowerCaseFirst(mappedModel)}/${this.getId(
model,
primary,
modelMeta
)}/relationships/${field}`
)}/relationships/${mappedField}`
)
),
},
Expand Down
Loading