UNPKG

@zenstackhq/server

Version:

ZenStack server-side adapters

1,176 lines 68.3 kB
"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