@zenstackhq/server
Version:
ZenStack server-side adapters
1,176 lines • 68.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = makeHandler;
exports.RestApiHandler = makeHandler;
const runtime_1 = require("@zenstackhq/runtime");
const local_helpers_1 = require("@zenstackhq/runtime/local-helpers");
const superjson_1 = __importDefault(require("superjson"));
const ts_japi_1 = require("ts-japi");
const url_pattern_1 = __importDefault(require("url-pattern"));
const zod_1 = __importDefault(require("zod"));
const base_1 = require("../base");
const utils_1 = require("../utils");
var UrlPatterns;
(function (UrlPatterns) {
UrlPatterns["SINGLE"] = "single";
UrlPatterns["FETCH_RELATIONSHIP"] = "fetchRelationship";
UrlPatterns["RELATIONSHIP"] = "relationship";
UrlPatterns["COLLECTION"] = "collection";
})(UrlPatterns || (UrlPatterns = {}));
class InvalidValueError extends Error {
constructor(message) {
super(message);
this.message = message;
}
}
const DEFAULT_PAGE_SIZE = 100;
const FilterOperations = [
'lt',
'lte',
'gt',
'gte',
'contains',
'icontains',
'search',
'startsWith',
'endsWith',
'has',
'hasEvery',
'hasSome',
'isEmpty',
];
const prismaIdDivider = '_';
(0, utils_1.registerCustomSerializers)();
/**
* RESTful-style API request handler (compliant with JSON:API)
*/
class RequestHandler extends base_1.APIHandlerBase {
constructor(options) {
super();
this.options = options;
// error responses
this.errors = {
unsupportedModel: {
status: 404,
title: 'Unsupported model type',
detail: 'The model type is not supported',
},
unsupportedRelationship: {
status: 400,
title: 'Unsupported relationship',
detail: 'The relationship is not supported',
},
invalidPath: {
status: 400,
title: 'The request path is invalid',
},
invalidVerb: {
status: 400,
title: 'The HTTP verb is not supported',
},
notFound: {
status: 404,
title: 'Resource not found',
},
noId: {
status: 400,
title: 'Model without an ID field is not supported',
},
invalidId: {
status: 400,
title: 'Resource ID is invalid',
},
invalidPayload: {
status: 400,
title: 'Invalid payload',
},
invalidRelationData: {
status: 400,
title: 'Invalid payload',
detail: 'Invalid relationship data',
},
invalidRelation: {
status: 400,
title: 'Invalid payload',
detail: 'Invalid relationship',
},
invalidFilter: {
status: 400,
title: 'Invalid filter',
},
invalidSort: {
status: 400,
title: 'Invalid sort',
},
invalidValue: {
status: 400,
title: 'Invalid value for type',
},
duplicatedFieldsParameter: {
status: 400,
title: 'Fields Parameter Duplicated',
},
forbidden: {
status: 403,
title: 'Operation is forbidden',
},
validationError: {
status: 422,
title: 'Operation is unprocessable due to validation errors',
},
unknownError: {
status: 400,
title: 'Unknown error',
},
};
this.filterParamPattern = new RegExp(/^filter(?<match>(\[[^[\]]+\])+)$/);
// zod schema for payload of creating and updating a resource
this.createUpdatePayloadSchema = zod_1.default
.object({
data: zod_1.default.object({
type: zod_1.default.string(),
attributes: zod_1.default.object({}).passthrough().optional(),
relationships: zod_1.default
.record(zod_1.default.string(), zod_1.default.object({
data: zod_1.default.union([
zod_1.default.object({ type: zod_1.default.string(), id: zod_1.default.union([zod_1.default.string(), zod_1.default.number()]) }),
zod_1.default.array(zod_1.default.object({ type: zod_1.default.string(), id: zod_1.default.union([zod_1.default.string(), zod_1.default.number()]) })),
]),
}))
.optional(),
}),
meta: zod_1.default.object({}).passthrough().optional(),
})
.strict();
// zod schema for updating a single relationship
this.updateSingleRelationSchema = zod_1.default.object({
data: zod_1.default.object({ type: zod_1.default.string(), id: zod_1.default.union([zod_1.default.string(), zod_1.default.number()]) }).nullable(),
});
// zod schema for updating collection relationship
this.updateCollectionRelationSchema = zod_1.default.object({
data: zod_1.default.array(zod_1.default.object({ type: zod_1.default.string(), id: zod_1.default.union([zod_1.default.string(), zod_1.default.number()]) })),
});
this.upsertMetaSchema = zod_1.default.object({
meta: zod_1.default.object({
operation: zod_1.default.literal('upsert'),
matchFields: zod_1.default.array(zod_1.default.string()).min(1),
}),
});
this.idDivider = options.idDivider ?? prismaIdDivider;
const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %';
this.modelNameMapping = options.modelNameMapping ?? {};
this.modelNameMapping = Object.fromEntries(Object.entries(this.modelNameMapping).map(([k, v]) => [(0, local_helpers_1.lowerCaseFirst)(k), v]));
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]) => [(0, local_helpers_1.lowerCaseFirst)(k), v]));
this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
}
buildUrlPatternMap(urlSegmentNameCharset) {
const options = { segmentValueCharset: urlSegmentNameCharset };
const buildPath = (segments) => {
return '/' + segments.join('/');
};
return {
[UrlPatterns.SINGLE]: new url_pattern_1.default(buildPath([':type', ':id']), options),
[UrlPatterns.FETCH_RELATIONSHIP]: new url_pattern_1.default(buildPath([':type', ':id', ':relationship']), options),
[UrlPatterns.RELATIONSHIP]: new url_pattern_1.default(buildPath([':type', ':id', 'relationships', ':relationship']), options),
[UrlPatterns.COLLECTION]: new url_pattern_1.default(buildPath([':type']), options),
};
}
mapModelName(modelName) {
return this.modelNameMapping[modelName] ?? modelName;
}
matchUrlPattern(path, routeType) {
const pattern = this.urlPatternMap[routeType];
if (!pattern) {
throw new InvalidValueError(`Unknown route type: ${routeType}`);
}
const match = pattern.match(path);
if (!match) {
return;
}
if (match.type in this.modelNameMapping) {
throw new InvalidValueError(`use the mapped model name: ${this.modelNameMapping[match.type]} and not ${match.type}`);
}
if (match.type in this.reverseModelNameMapping) {
match.type = this.reverseModelNameMapping[match.type];
}
return match;
}
async handleRequest({ prisma, method, path, query, requestBody, logger, modelMeta, zodSchemas, }) {
modelMeta = modelMeta ?? this.defaultModelMeta;
if (!modelMeta) {
throw new Error('Model metadata is not provided or loaded from default location');
}
if (!this.serializers) {
this.buildSerializers(modelMeta);
}
if (!this.typeMap) {
this.buildTypeMap(logger, modelMeta);
}
method = method.toUpperCase();
if (!path.startsWith('/')) {
path = '/' + path;
}
try {
switch (method) {
case 'GET': {
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
if (match) {
// single resource read
return await this.processSingleRead(prisma, match.type, match.id, query);
}
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.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
if (match) {
// read relationship
return await this.processReadRelationship(prisma, match.type, match.id, match.relationship, query);
}
match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
if (match) {
// collection read
return await this.processCollectionRead(prisma, match.type, query);
}
return this.makeError('invalidPath');
}
case 'POST': {
if (!requestBody) {
return this.makeError('invalidPayload');
}
let match = this.matchUrlPattern(path, UrlPatterns.COLLECTION);
if (match) {
const body = requestBody;
const upsertMeta = this.upsertMetaSchema.safeParse(body);
if (upsertMeta.success) {
// resource upsert
return await this.processUpsert(prisma, match.type, query, requestBody, modelMeta, zodSchemas);
}
else {
// resource creation
return await this.processCreate(prisma, match.type, query, requestBody, modelMeta, zodSchemas);
}
}
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
if (match) {
// relationship creation (collection relationship only)
return await this.processRelationshipCRUD(prisma, 'create', match.type, match.id, match.relationship, query, requestBody);
}
return this.makeError('invalidPath');
}
// TODO: PUT for full update
case 'PUT':
case 'PATCH': {
if (!requestBody) {
return this.makeError('invalidPayload');
}
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
if (match) {
// resource update
return await this.processUpdate(prisma, match.type, match.id, query, requestBody, modelMeta, zodSchemas);
}
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
if (match) {
// relationship update
return await this.processRelationshipCRUD(prisma, 'update', match.type, match.id, match.relationship, query, requestBody);
}
return this.makeError('invalidPath');
}
case 'DELETE': {
let match = this.matchUrlPattern(path, UrlPatterns.SINGLE);
if (match) {
// resource deletion
return await this.processDelete(prisma, match.type, match.id);
}
match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP);
if (match) {
// relationship deletion (collection relationship only)
return await this.processRelationshipCRUD(prisma, 'delete', match.type, match.id, match.relationship, query, requestBody);
}
return this.makeError('invalidPath');
}
default:
return this.makeError('invalidPath');
}
}
catch (err) {
if (err instanceof InvalidValueError) {
return this.makeError('invalidValue', err.message);
}
else {
return this.handlePrismaError(err);
}
}
}
async processSingleRead(prisma, type, resourceId, query) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const args = { where: this.makePrismaIdFilter(typeInfo.idFields, resourceId) };
// include IDs of relation fields so that they can be serialized
this.includeRelationshipIds(type, args, 'include');
// handle "include" query parameter
let include;
if (query?.include) {
const { select, error, allIncludes } = this.buildRelationSelect(type, query.include, query);
if (error) {
return error;
}
if (select) {
args.include = { ...args.include, ...select };
}
include = allIncludes;
}
// handle partial results for requested type
const { select, error } = this.buildPartialSelect(type, query);
if (error)
return error;
if (select) {
args.select = { ...select, ...args.select };
if (args.include) {
args.select = {
...args.select,
...args.include,
};
args.include = undefined;
}
}
const entity = await prisma[type].findUnique(args);
if (entity) {
return {
status: 200,
body: await this.serializeItems(type, entity, { include }),
};
}
else {
return this.makeError('notFound');
}
}
async processFetchRelated(prisma, type, resourceId, relationship, query) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const relationInfo = typeInfo.relationships[relationship];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, relationship, 404);
}
let select;
// handle "include" query parameter
let include;
if (query?.include) {
const { select: relationSelect, error, allIncludes } = this.buildRelationSelect(type, query.include, query);
if (error) {
return error;
}
// trim the leading `$relationship.` from the include paths
include = allIncludes
.filter((i) => i.startsWith(`${relationship}.`))
.map((i) => i.substring(`${relationship}.`.length));
select = relationSelect;
}
// handle partial results for requested type
if (!select) {
const { select: partialFields, error } = this.buildPartialSelect((0, local_helpers_1.lowerCaseFirst)(relationInfo.type), query);
if (error)
return error;
select = partialFields ? { [relationship]: { select: { ...partialFields } } } : { [relationship]: true };
}
const args = {
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
select,
};
if (relationInfo.isCollection) {
// if related data is a collection, it can be filtered, sorted, and paginated
const error = this.injectRelationQuery(relationInfo.type, select, relationship, query);
if (error) {
return error;
}
}
const entity = await prisma[type].findUnique(args);
let paginator;
if (entity?._count?.[relationship] !== undefined) {
// build up paginator
const total = entity?._count?.[relationship];
const url = this.makeNormalizedUrl(`/${type}/${resourceId}/${relationship}`, query);
const { offset, limit } = this.getPagination(query);
paginator = this.makePaginator(url, offset, limit, total);
}
if (entity?.[relationship]) {
const mappedType = this.mapModelName(type);
return {
status: 200,
body: await this.serializeItems(relationInfo.type, entity[relationship], {
linkers: {
document: new ts_japi_1.Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${relationship}`)),
paginator,
},
include,
}),
};
}
else {
return this.makeError('notFound');
}
}
async processReadRelationship(prisma, type, resourceId, relationship, query) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const relationInfo = typeInfo.relationships[relationship];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, relationship, 404);
}
const args = {
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
select: this.makeIdSelect(typeInfo.idFields),
};
// include IDs of relation fields so that they can be serialized
args.select = { ...args.select, [relationship]: { select: this.makeIdSelect(relationInfo.idFields) } };
let paginator;
if (relationInfo.isCollection) {
// if related data is a collection, it can be filtered, sorted, and paginated
const error = this.injectRelationQuery(relationInfo.type, args.select, relationship, query);
if (error) {
return error;
}
}
const entity = await prisma[type].findUnique(args);
const mappedType = this.mapModelName(type);
if (entity?._count?.[relationship] !== undefined) {
// build up paginator
const total = entity?._count?.[relationship];
const url = this.makeNormalizedUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`, query);
const { offset, limit } = this.getPagination(query);
paginator = this.makePaginator(url, offset, limit, total);
}
if (entity?.[relationship]) {
const serialized = await this.serializeItems(relationInfo.type, entity[relationship], {
linkers: {
document: new ts_japi_1.Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`)),
paginator,
},
onlyIdentifier: true,
});
return {
status: 200,
body: serialized,
};
}
else {
return this.makeError('notFound');
}
}
async processCollectionRead(prisma, type, query) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const args = {};
// add filter
const { filter, error: filterError } = this.buildFilter(type, query);
if (filterError) {
return filterError;
}
if (filter) {
args.where = filter;
}
const { sort, error: sortError } = this.buildSort(type, query);
if (sortError) {
return sortError;
}
if (sort) {
args.orderBy = sort;
}
// include IDs of relation fields so that they can be serialized
this.includeRelationshipIds(type, args, 'include');
// handle "include" query parameter
let include;
if (query?.include) {
const { select, error, allIncludes } = this.buildRelationSelect(type, query.include, query);
if (error) {
return error;
}
if (select) {
args.include = { ...args.include, ...select };
}
include = allIncludes;
}
// handle partial results for requested type
const { select, error } = this.buildPartialSelect(type, query);
if (error)
return error;
if (select) {
args.select = { ...select, ...args.select };
if (args.include) {
args.select = {
...args.select,
...args.include,
};
args.include = undefined;
}
}
const { offset, limit } = this.getPagination(query);
if (offset > 0) {
args.skip = offset;
}
if (limit === Infinity) {
const entities = await prisma[type].findMany(args);
const body = await this.serializeItems(type, entities, { include });
const total = entities.length;
body.meta = this.addTotalCountToMeta(body.meta, total);
return {
status: 200,
body: body,
};
}
else {
args.take = limit;
const [entities, count] = await Promise.all([
prisma[type].findMany(args),
prisma[type].count({ where: args.where ?? {} }),
]);
const total = count;
const mappedType = this.mapModelName(type);
const url = this.makeNormalizedUrl(`/${mappedType}`, query);
const options = {
include,
linkers: {
paginator: this.makePaginator(url, offset, limit, total),
},
};
const body = await this.serializeItems(type, entities, options);
body.meta = this.addTotalCountToMeta(body.meta, total);
return {
status: 200,
body: body,
};
}
}
buildPartialSelect(type, query) {
const selectFieldsQuery = query?.[`fields[${type}]`];
if (!selectFieldsQuery) {
return { select: undefined, error: undefined };
}
if (Array.isArray(selectFieldsQuery)) {
return {
select: undefined,
error: this.makeError('duplicatedFieldsParameter', `duplicated fields query for type ${type}`),
};
}
const typeInfo = this.typeMap[(0, local_helpers_1.lowerCaseFirst)(type)];
if (!typeInfo) {
return { select: undefined, error: this.makeUnsupportedModelError(type) };
}
const selectFieldNames = selectFieldsQuery.split(',').filter((i) => i);
const fields = selectFieldNames.reduce((acc, curr) => ({ ...acc, [curr]: true }), {});
return {
select: { ...this.makeIdSelect(typeInfo.idFields), ...fields },
};
}
addTotalCountToMeta(meta, total) {
return meta ? Object.assign(meta, { total }) : Object.assign({}, { total });
}
makePaginator(baseUrl, offset, limit, total) {
if (limit === Infinity) {
return undefined;
}
const totalPages = Math.ceil(total / limit);
return new ts_japi_1.Paginator(() => ({
first: this.replaceURLSearchParams(baseUrl, { 'page[limit]': limit }),
last: this.replaceURLSearchParams(baseUrl, {
'page[offset]': (totalPages - 1) * limit,
}),
prev: offset - limit >= 0 && offset - limit <= total - 1
? this.replaceURLSearchParams(baseUrl, {
'page[offset]': offset - limit,
'page[limit]': limit,
})
: null,
next: offset + limit <= total - 1
? this.replaceURLSearchParams(baseUrl, {
'page[offset]': offset + limit,
'page[limit]': limit,
})
: null,
}));
}
processRequestBody(type, requestBody, zodSchemas, mode) {
let body = requestBody;
if (body.meta?.serialization) {
// superjson deserialize body if a serialization meta is provided
body = superjson_1.default.deserialize({ json: body, meta: body.meta.serialization });
}
const parsed = this.createUpdatePayloadSchema.parse(body);
const attributes = parsed.data.attributes;
if (attributes) {
// use the zod schema (that only contains non-relation fields) to validate the payload,
// if available
const schemaName = `${(0, local_helpers_1.upperCaseFirst)(type)}${(0, local_helpers_1.upperCaseFirst)(mode)}ScalarSchema`;
const payloadSchema = zodSchemas?.models?.[schemaName];
if (payloadSchema) {
const parsed = payloadSchema.safeParse(attributes);
if (!parsed.success) {
return {
error: this.makeError('invalidPayload', (0, local_helpers_1.getZodErrorMessage)(parsed.error), 422, runtime_1.CrudFailureReason.DATA_VALIDATION_VIOLATION, parsed.error),
};
}
}
}
return { attributes, relationships: parsed.data.relationships };
}
async processCreate(prisma, type, _query, requestBody, modelMeta, zodSchemas) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create');
if (error) {
return error;
}
const createPayload = { data: { ...attributes } };
// turn relationship payload into Prisma connect objects
if (relationships) {
for (const [key, data] of Object.entries(relationships)) {
if (!data?.data) {
return this.makeError('invalidRelationData');
}
const relationInfo = typeInfo.relationships[key];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, key, 400);
}
if (relationInfo.isCollection) {
createPayload.data[key] = {
connect: (0, runtime_1.enumerate)(data.data).map((item) => this.makeIdConnect(relationInfo.idFields, item.id)),
};
}
else {
if (typeof data.data !== 'object') {
return this.makeError('invalidRelationData');
}
createPayload.data[key] = {
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
};
}
// make sure ID fields are included for result serialization
createPayload.include = {
...createPayload.include,
[key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } },
};
}
}
// include IDs of relation fields so that they can be serialized.
this.includeRelationshipIds(type, createPayload, 'include');
const entity = await prisma[type].create(createPayload);
return {
status: 201,
body: await this.serializeItems(type, entity),
};
}
async processUpsert(prisma, type, _query, requestBody, modelMeta, zodSchemas) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'create');
if (error) {
return error;
}
const matchFields = this.upsertMetaSchema.parse(requestBody).meta.matchFields;
const uniqueFields = Object.values(modelMeta.models[type].uniqueConstraints || {}).map((uf) => uf.fields);
if (!uniqueFields.some((uniqueCombination) => uniqueCombination.every((field) => matchFields.includes(field)))) {
return this.makeError('invalidPayload', 'Match fields must be unique fields', 400);
}
const upsertPayload = {
where: this.makeUpsertWhere(matchFields, attributes, typeInfo),
create: { ...attributes },
update: {
...Object.fromEntries(Object.entries(attributes).filter((e) => !matchFields.includes(e[0]))),
},
};
if (relationships) {
for (const [key, data] of Object.entries(relationships)) {
if (!data?.data) {
return this.makeError('invalidRelationData');
}
const relationInfo = typeInfo.relationships[key];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, key, 400);
}
if (relationInfo.isCollection) {
upsertPayload.create[key] = {
connect: (0, runtime_1.enumerate)(data.data).map((item) => this.makeIdConnect(relationInfo.idFields, item.id)),
};
upsertPayload.update[key] = {
set: (0, runtime_1.enumerate)(data.data).map((item) => this.makeIdConnect(relationInfo.idFields, item.id)),
};
}
else {
if (typeof data.data !== 'object') {
return this.makeError('invalidRelationData');
}
upsertPayload.create[key] = {
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
};
upsertPayload.update[key] = {
connect: this.makeIdConnect(relationInfo.idFields, data.data.id),
};
}
}
}
// include IDs of relation fields so that they can be serialized.
this.includeRelationshipIds(type, upsertPayload, 'include');
const entity = await prisma[type].upsert(upsertPayload);
return {
status: 201,
body: await this.serializeItems(type, entity),
};
}
async processRelationshipCRUD(prisma, mode, type, resourceId, relationship, query, requestBody) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const relationInfo = typeInfo.relationships[relationship];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, relationship, 404);
}
if (!relationInfo.isCollection && mode !== 'update') {
// to-one relation can only be updated
return this.makeError('invalidVerb');
}
const updateArgs = {
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
select: {
...typeInfo.idFields.reduce((acc, field) => ({ ...acc, [field.name]: true }), {}),
[relationship]: { select: this.makeIdSelect(relationInfo.idFields) },
},
};
if (!relationInfo.isCollection) {
// zod-parse payload
const parsed = this.updateSingleRelationSchema.safeParse(requestBody);
if (!parsed.success) {
return this.makeError('invalidPayload', (0, local_helpers_1.getZodErrorMessage)(parsed.error), undefined, runtime_1.CrudFailureReason.DATA_VALIDATION_VIOLATION, parsed.error);
}
if (parsed.data.data === null) {
if (!relationInfo.isOptional) {
// cannot disconnect a required relation
return this.makeError('invalidPayload');
}
// set null -> disconnect
updateArgs.data = {
[relationship]: {
disconnect: true,
},
};
}
else {
updateArgs.data = {
[relationship]: {
connect: this.makeIdConnect(relationInfo.idFields, parsed.data.data.id),
},
};
}
}
else {
// zod-parse payload
const parsed = this.updateCollectionRelationSchema.safeParse(requestBody);
if (!parsed.success) {
return this.makeError('invalidPayload', (0, local_helpers_1.getZodErrorMessage)(parsed.error), undefined, runtime_1.CrudFailureReason.DATA_VALIDATION_VIOLATION, parsed.error);
}
// create -> connect, delete -> disconnect, update -> set
const relationVerb = mode === 'create' ? 'connect' : mode === 'delete' ? 'disconnect' : 'set';
updateArgs.data = {
[relationship]: {
[relationVerb]: (0, runtime_1.enumerate)(parsed.data.data).map((item) => this.makePrismaIdFilter(relationInfo.idFields, item.id)),
},
};
}
const entity = await prisma[type].update(updateArgs);
const mappedType = this.mapModelName(type);
const serialized = await this.serializeItems(relationInfo.type, entity[relationship], {
linkers: {
document: new ts_japi_1.Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`)),
},
onlyIdentifier: true,
});
return {
status: 200,
body: serialized,
};
}
async processUpdate(prisma, type, resourceId, _query, requestBody, modelMeta, zodSchemas) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
const { error, attributes, relationships } = this.processRequestBody(type, requestBody, zodSchemas, 'update');
if (error) {
return error;
}
const updatePayload = {
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
data: { ...attributes },
};
// turn relationships into prisma payload
if (relationships) {
for (const [key, data] of Object.entries(relationships)) {
if (!data?.data) {
return this.makeError('invalidRelationData');
}
const relationInfo = typeInfo.relationships[key];
if (!relationInfo) {
return this.makeUnsupportedRelationshipError(type, key, 400);
}
if (relationInfo.isCollection) {
updatePayload.data[key] = {
set: (0, runtime_1.enumerate)(data.data).map((item) => ({
[this.makePrismaIdKey(relationInfo.idFields)]: item.id,
})),
};
}
else {
if (typeof data.data !== 'object') {
return this.makeError('invalidRelationData');
}
updatePayload.data[key] = {
connect: {
[this.makePrismaIdKey(relationInfo.idFields)]: data.data.id,
},
};
}
updatePayload.include = {
...updatePayload.include,
[key]: { select: { [this.makePrismaIdKey(relationInfo.idFields)]: true } },
};
}
}
// include IDs of relation fields so that they can be serialized.
this.includeRelationshipIds(type, updatePayload, 'include');
const entity = await prisma[type].update(updatePayload);
return {
status: 200,
body: await this.serializeItems(type, entity),
};
}
async processDelete(prisma, type, resourceId) {
const typeInfo = this.typeMap[type];
if (!typeInfo) {
return this.makeUnsupportedModelError(type);
}
await prisma[type].delete({
where: this.makePrismaIdFilter(typeInfo.idFields, resourceId),
});
return {
status: 200,
body: { meta: {} },
};
}
//#region utilities
getIdFields(modelMeta, model) {
const modelLower = (0, local_helpers_1.lowerCaseFirst)(model);
if (!(modelLower in this.externalIdMapping)) {
return (0, runtime_1.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) => (0, runtime_1.requireField)(modelMeta, model, f));
}
}
throw new Error(`Model ${model} does not have unique key ${externalIdName}`);
}
buildTypeMap(logger, modelMeta) {
this.typeMap = {};
for (const [model, { fields }] of Object.entries(modelMeta.models)) {
const idFields = this.getIdFields(modelMeta, model);
if (idFields.length === 0) {
(0, utils_1.logWarning)(logger, `Not including model ${model} in the API because it has no ID field`);
continue;
}
this.typeMap[model] = {
idFields,
relationships: {},
fields,
};
for (const [field, fieldInfo] of Object.entries(fields)) {
if (!fieldInfo.isDataModel) {
continue;
}
const fieldTypeIdFields = this.getIdFields(modelMeta, fieldInfo.type);
if (fieldTypeIdFields.length === 0) {
(0, utils_1.logWarning)(logger, `Not including relation ${model}.${field} in the API because it has no ID field`);
continue;
}
this.typeMap[model].relationships[field] = {
type: fieldInfo.type,
idFields: fieldTypeIdFields,
isCollection: !!fieldInfo.isArray,
isOptional: !!fieldInfo.isOptional,
};
}
}
}
makeLinkUrl(path) {
return `${this.options.endpoint}${path}`;
}
buildSerializers(modelMeta) {
this.serializers = new Map();
const linkers = {};
for (const model of Object.keys(modelMeta.models)) {
const ids = this.getIdFields(modelMeta, model);
const mappedModel = this.mapModelName(model);
if (ids.length < 1) {
continue;
}
const linker = new ts_japi_1.Linker((items) => Array.isArray(items)
? this.makeLinkUrl(`/${mappedModel}`)
: this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items, modelMeta)}`));
linkers[model] = linker;
let projection = {};
for (const [field, fieldMeta] of Object.entries(modelMeta.models[model].fields)) {
if (fieldMeta.isDataModel) {
projection[field] = 0;
}
}
if (Object.keys(projection).length === 0) {
projection = null;
}
const serializer = new ts_japi_1.Serializer(model, {
version: '1.1',
idKey: this.makeIdKey(ids),
linkers: {
resource: linker,
document: linker,
},
projection,
});
this.serializers.set(model, serializer);
}
// set relators
for (const model of Object.keys(modelMeta.models)) {
const serializer = this.serializers.get(model);
if (!serializer) {
continue;
}
const relators = {};
for (const [field, fieldMeta] of Object.entries(modelMeta.models[model].fields)) {
if (!fieldMeta.isDataModel) {
continue;
}
const fieldSerializer = this.serializers.get((0, local_helpers_1.lowerCaseFirst)(fieldMeta.type));
if (!fieldSerializer) {
continue;
}
const fieldIds = this.getIdFields(modelMeta, fieldMeta.type);
if (fieldIds.length > 0) {
const mappedModel = this.mapModelName(model);
const relator = new ts_japi_1.Relator(async (data) => {
return data[field];
}, fieldSerializer, {
relatedName: field,
linkers: {
related: new ts_japi_1.Linker((primary) => this.makeLinkUrl(`/${(0, local_helpers_1.lowerCaseFirst)(model)}/${this.getId(model, primary, modelMeta)}/${field}`)),
relationship: new ts_japi_1.Linker((primary) => this.makeLinkUrl(`/${(0, local_helpers_1.lowerCaseFirst)(mappedModel)}/${this.getId(model, primary, modelMeta)}/relationships/${field}`)),
},
});
relators[field] = relator;
}
}
serializer.setRelators(relators);
}
}
getId(model, data, modelMeta) {
if (!data) {
return undefined;
}
const ids = this.getIdFields(modelMeta, model);
if (ids.length === 0) {
return undefined;
}
else {
return data[this.makeIdKey(ids)];
}
}
async serializeItems(model, items, options) {
model = (0, local_helpers_1.lowerCaseFirst)(model);
const serializer = this.serializers.get(model);
if (!serializer) {
throw new Error(`serializer not found for model ${model}`);
}
const itemsWithId = (0, runtime_1.clone)(items);
this.injectCompoundId(model, itemsWithId);
// serialize to JSON:API structure
const serialized = await serializer.serialize(itemsWithId, options);
// convert the serialization result to plain object otherwise SuperJSON won't work
const plainResult = this.toPlainObject(serialized);
// superjson serialize the result
const { json, meta } = superjson_1.default.serialize(plainResult);
const result = json;
if (meta) {
result.meta = { ...result.meta, serialization: meta };
}
return result;
}
injectCompoundId(model, items) {
const typeInfo = this.typeMap[(0, local_helpers_1.lowerCaseFirst)(model)];
if (!typeInfo) {
return;
}
// recursively traverse the entity to create synthetic ID field for models with compound ID
(0, runtime_1.enumerate)(items).forEach((item) => {
if (!item) {
return;
}
if (typeInfo.idFields.length > 1) {
item[this.makeIdKey(typeInfo.idFields)] = this.makeCompoundId(typeInfo.idFields, item);
}
for (const [key, value] of Object.entries(item)) {
if (typeInfo.relationships[key]) {
// field is a relationship, recurse
this.injectCompoundId(typeInfo.relationships[key].type, value);
}
}
});
}
toPlainObject(data) {
if (data === undefined || data === null) {
return data;
}
if (Array.isArray(data)) {
return data.map((item) => this.toPlainObject(item));
}
if (typeof data === 'object') {
if (typeof data.toJSON === 'function') {
// custom toJSON function
return data.toJSON();
}
const result = {};
for (const [field, value] of Object.entries(data)) {
if (value === undefined || typeof value === 'function') {
// trim undefined and functions
continue;
}
else if (field === 'attributes') {
// don't visit into entity data
result[field] = value;
}
else {
result[field] = this.toPlainObject(value);
}
}
return result;
}
return data;
}
replaceURLSearchParams(url, params) {
const r = new URL(url);
for (const [key, value] of Object.entries(params)) {
r.searchParams.set(key, value.toString());
}
return r.toString();
}
makePrismaIdFilter(idFields, resourceId, nested = true) {
const decodedId = decodeURIComponent(resourceId);
if (idFields.length === 1) {
return { [idFields[0].name]: this.coerce(idFields[0], decodedId) };
}
else if (nested) {
return {
// TODO: support `@@id` with custom name
[idFields.map((idf) => idf.name).join(prismaIdDivider)]: idFields.reduce((acc, curr, idx) => ({
...acc,
[curr.name]: this.coerce(curr, decodedId.split(this.idDivider)[idx]),
}), {}),
};
}
else {
return idFields.reduce((acc, curr, idx) => ({
...acc,
[curr.name]: this.coerce(curr, decodedId.split(this.idDivider)[idx]),
}), {});
}
}
makeIdSelect(idFields) {
if (idFields.length === 0) {
throw this.errors.noId;
}
return idFields.reduce((acc, curr) => ({ ...acc, [curr.name]: true }), {});
}
makeIdConnect(idFields, id) {
if (idFields.length === 1) {
return { [idFields[0].name]: this.coerce(idFields[0], id) };
}
else {
return {
[this.makePrismaIdKey(idFields)]: idFields.reduce((acc, curr, idx) => ({
...acc,
[curr.name]: this.coerce(curr, `${id}`.split(this.idDivider)[idx]),
}), {}),
};
}
}
makeIdKey(idFields) {
return idFields.map((idf) => idf.name).join(this.idDivider);
}
makePrismaIdKey(idFields) {
// TODO: support `@@id` with custom name
return idFields.map((idf) => idf.name).join(prismaIdDivider);
}
makeCompoundId(idFields, item) {
return idFields.map((idf) => item[idf.name]).join(this.idDivider);
}
makeUpsertWhere(matchFields, attributes, typeInfo) {
const where = matchFields.reduce((acc, field) => {
acc[field] = attributes[field] ?? null;
return acc;
}, {});
if (typeInfo.idFields.length > 1 &&
matchFields.some((mf) => typeInfo.idFields.map((idf) => idf.name).includes(mf))) {
return {
[this.makePrismaIdKey(typeInfo.idFields)]: where,
};
}
return where;
}
includeRelationshipIds(model, args, mode) {
const typeInfo = this.typeMap[model];
if (!typeInfo) {
return;
}
for (const [relation, relationInfo] of Object.entries(typeInfo.relationships)) {
args[mode] = { ...args[mode], [relation]: { select: this.makeIdSelect(relationInfo.idFields) } };
}
}
coerce(fieldInfo, value) {
if (typeof value === 'string') {
if (fieldInfo.isTypeDef || fieldInfo.type === 'Json') {
try {
return JSON.parse(value);
}
catch {
throw new InvalidValueError(`invalid JSON value: ${value}`);
}
}
const type = fieldInfo.type;
if (type === 'Int' || type === 'BigInt') {
const parsed = parseInt(value);
if (isNaN(parsed)) {
throw new InvalidValueError(`invalid ${type} value: ${value}`);
}
return parsed;
}
else if (type === 'Float' || type === 'Decimal') {
const parsed = parseFloat(value);
if (isNaN(parsed)) {
thro