diff --git a/BREAKINGCHANGES.md b/BREAKINGCHANGES.md index cbca9b8d..0b84de03 100644 --- a/BREAKINGCHANGES.md +++ b/BREAKINGCHANGES.md @@ -1,3 +1,4 @@ 1. `update` and `delete` policy rejection throws `NotFoundError` 1. `check()` ORM api has been removed 1. non-optional to-one relation doesn't automatically filter parent read when evaluating access policies +1. `@omit` and `@password` attributes have been removed diff --git a/packages/orm/src/utils/clone.ts b/packages/common-helpers/src/clone.ts similarity index 90% rename from packages/orm/src/utils/clone.ts rename to packages/common-helpers/src/clone.ts index 3160d319..0d21bffc 100644 --- a/packages/orm/src/utils/clone.ts +++ b/packages/common-helpers/src/clone.ts @@ -1,4 +1,4 @@ -import { isPlainObject } from '@zenstackhq/common-helpers'; +import { isPlainObject } from './is-plain-object'; /** * Clones the given object. Only arrays and plain objects are cloned. Other values are returned as is. diff --git a/packages/orm/src/utils/enumerate.ts b/packages/common-helpers/src/enumerable.ts similarity index 100% rename from packages/orm/src/utils/enumerate.ts rename to packages/common-helpers/src/enumerable.ts diff --git a/packages/common-helpers/src/index.ts b/packages/common-helpers/src/index.ts index 9a3b3678..07c4fff5 100644 --- a/packages/common-helpers/src/index.ts +++ b/packages/common-helpers/src/index.ts @@ -1,3 +1,5 @@ +export * from './clone'; +export * from './enumerable'; export * from './is-plain-object'; export * from './lower-case-first'; export * from './param-case'; diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 258cf9fb..b4173c22 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1,9 +1,8 @@ -import { invariant, isPlainObject } from '@zenstackhq/common-helpers'; +import { enumerate, invariant, isPlainObject } from '@zenstackhq/common-helpers'; import type { Expression, ExpressionBuilder, ExpressionWrapper, SqlBool, ValueNode } from 'kysely'; import { expressionBuilder, sql, type SelectQueryBuilder } from 'kysely'; import { match, P } from 'ts-pattern'; import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, ModelDef, SchemaDef } from '../../../schema'; -import { enumerate } from '../../../utils/enumerate'; import type { OrArray } from '../../../utils/type-utils'; import { AGGREGATE_OPERATORS, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants'; import type { @@ -755,7 +754,7 @@ export abstract class BaseCrudDialect { : this.fieldRef(model, field, modelAlias); }; - enumerate(orderBy).forEach((orderBy) => { + enumerate(orderBy).forEach((orderBy, index) => { for (const [field, value] of Object.entries(orderBy)) { if (!value) { continue; @@ -841,15 +840,16 @@ export abstract class BaseCrudDialect { } } else { // order by to-one relation - result = result.leftJoin(relationModel, (join) => { - const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, relationModel); + const joinAlias = `${modelAlias}$orderBy$${index}`; + result = result.leftJoin(`${relationModel} as ${joinAlias}`, (join) => { + const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, joinAlias); return join.on((eb) => this.and( ...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right))), ), ); }); - result = this.buildOrderBy(result, fieldDef.type, relationModel, value, negated); + result = this.buildOrderBy(result, relationModel, joinAlias, value, negated); } } } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index a1fe784a..82eeb9a8 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -58,6 +58,14 @@ export class PostgresCrudDialect extends BaseCrudDiale : value, ) .with('Decimal', () => (value !== null ? value.toString() : value)) + .with('Json', () => { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + // postgres requires simple JSON values to be stringified + return JSON.stringify(value); + } else { + return value; + } + }) .otherwise(() => value); } } diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index f27001fa..a309b268 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -1,5 +1,5 @@ import { createId } from '@paralleldrive/cuid2'; -import { invariant, isPlainObject } from '@zenstackhq/common-helpers'; +import { clone, enumerate, invariant, isPlainObject } from '@zenstackhq/common-helpers'; import { DeleteResult, expressionBuilder, @@ -17,8 +17,6 @@ import * as uuid from 'uuid'; import type { ClientContract } from '../..'; import type { BuiltinType, Expression, FieldDef } from '../../../schema'; import { ExpressionUtils, type GetModels, type ModelDef, type SchemaDef } from '../../../schema'; -import { clone } from '../../../utils/clone'; -import { enumerate } from '../../../utils/enumerate'; import { extractFields, fieldsToSelectObject } from '../../../utils/object-utils'; import { NUMERIC_FIELD_TYPES } from '../../constants'; import { TransactionIsolationLevel, type CRUD } from '../../contract'; diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 0c997ec1..f76a8a4e 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -1,4 +1,4 @@ -import { invariant } from '@zenstackhq/common-helpers'; +import { enumerate, invariant } from '@zenstackhq/common-helpers'; import Decimal from 'decimal.js'; import stableStringify from 'json-stable-stringify'; import { match, P } from 'ts-pattern'; @@ -12,7 +12,6 @@ import { type ModelDef, type SchemaDef, } from '../../../schema'; -import { enumerate } from '../../../utils/enumerate'; import { extractFields } from '../../../utils/object-utils'; import { formatError } from '../../../utils/zod-utils'; import { AGGREGATE_OPERATORS, LOGICAL_COMBINATORS, NUMERIC_FIELD_TYPES } from '../../constants'; diff --git a/packages/server/package.json b/packages/server/package.json index 60ae9748..ef1e1b10 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -55,7 +55,10 @@ "@zenstackhq/orm": "workspace:*", "decimal.js": "catalog:", "superjson": "^2.2.3", - "ts-pattern": "catalog:" + "ts-japi": "^1.12.0", + "ts-pattern": "catalog:", + "url-pattern": "^1.0.3", + "zod-validation-error": "catalog:" }, "devDependencies": { "@types/body-parser": "^1.19.6", @@ -66,13 +69,15 @@ "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*", "body-parser": "^2.2.0", - "supertest": "^7.1.4", "express": "^5.0.0", - "next": "^15.0.0" + "next": "^15.0.0", + "supertest": "^7.1.4", + "zod": "~3.25.0" }, "peerDependencies": { "express": "^5.0.0", - "next": "^15.0.0" + "next": "^15.0.0", + "zod": "catalog:" }, "peerDependenciesMeta": { "express": { diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index b415bd06..09d9700e 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -1 +1,2 @@ -export { RPCApiHandler } from './rpc'; +export { RestApiHandler, type RestApiHandlerOptions } from './rest'; +export { RPCApiHandler, type RPCApiHandlerOptions } from './rpc'; diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts new file mode 100644 index 00000000..bcf27829 --- /dev/null +++ b/packages/server/src/api/rest/index.ts @@ -0,0 +1,2075 @@ +import { clone, enumerate, lowerCaseFirst, paramCase } from '@zenstackhq/common-helpers'; +import { + InputValidationError, + NotFoundError, + QueryError, + RejectedByPolicyError, + ZenStackError, + type ClientContract, +} from '@zenstackhq/orm'; +import type { FieldDef, ModelDef, SchemaDef } from '@zenstackhq/orm/schema'; +import { Decimal } from 'decimal.js'; +import SuperJSON from 'superjson'; +import { Linker, Paginator, Relator, Serializer, type SerializerOptions } from 'ts-japi'; +import UrlPattern from 'url-pattern'; +import z from 'zod'; +import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types'; +import { getZodErrorMessage, log, registerCustomSerializers } from '../utils'; + +/** + * Options for {@link RestApiHandler} + */ +export type RestApiHandlerOptions = { + /** + * The schema + */ + schema: Schema; + + /** + * Logging configuration + */ + log?: LogConfig; + + /** + * The base endpoint of the RESTful API, must be a valid URL + */ + endpoint: string; + + /** + * The default page size for limiting the number of results returned + * from collection queries, including resource collection, related data + * of collection types, and relationship of collection types. + * + * 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; + + modelNameMapping?: Record; + + externalIdMapping?: Record; +}; + +type RelationshipInfo = { + type: string; + idFields: FieldDef[]; + isCollection: boolean; + isOptional: boolean; +}; + +type ModelInfo = { + name: string; + idFields: FieldDef[]; + fields: Record; + relationships: Record; +}; + +type Match = { + type: string; + id: string; + relationship: string; +}; + +enum UrlPatterns { + SINGLE = 'single', + FETCH_RELATIONSHIP = 'fetchRelationship', + RELATIONSHIP = 'relationship', + COLLECTION = 'collection', +} + +class InvalidValueError extends Error { + constructor(message: string) { + super(message); + } +} + +const DEFAULT_PAGE_SIZE = 100; + +const FilterOperations = [ + 'lt', + 'lte', + 'gt', + 'gte', + 'contains', + 'icontains', + 'search', + 'startsWith', + 'endsWith', + 'has', + 'hasEvery', + 'hasSome', + 'isEmpty', +] as const; + +type FilterOperationType = (typeof FilterOperations)[number] | undefined; + +const DEFAULT_ID_DIVIDER = '_'; + +registerCustomSerializers(); + +/** + * RESTful-style API request handler (compliant with JSON:API) + */ +export class RestApiHandler implements ApiHandler { + // resource serializers + private serializers = new Map(); + + // error responses + private readonly errors: Record = { + 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 relation data', + detail: 'Invalid relationship data', + }, + invalidRelation: { + status: 400, + title: 'Invalid relation', + 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', + }, + queryError: { + status: 400, + title: 'Error occurred while executing the query', + }, + unknownError: { + status: 500, + title: 'Unknown error', + }, + }; + + private filterParamPattern = new RegExp(/^filter(?(\[[^[\]]+\])+)$/); + + // zod schema for payload of creating and updating a resource + private createUpdatePayloadSchema = z + .object({ + data: z.object({ + type: z.string(), + attributes: z.object({}).passthrough().optional(), + relationships: z + .record( + z.string(), + z.object({ + data: z.union([ + z.object({ type: z.string(), id: z.union([z.string(), z.number()]) }), + z.array(z.object({ type: z.string(), id: z.union([z.string(), z.number()]) })), + ]), + }), + ) + .optional(), + }), + meta: z.object({}).passthrough().optional(), + }) + .strict(); + + // zod schema for updating a single relationship + private updateSingleRelationSchema = z.object({ + data: z.object({ type: z.string(), id: z.union([z.string(), z.number()]) }).nullable(), + }); + + // zod schema for updating collection relationship + private updateCollectionRelationSchema = z.object({ + data: z.array(z.object({ type: z.string(), id: z.union([z.string(), z.number()]) })), + }); + + private upsertMetaSchema = z.object({ + meta: z.object({ + operation: z.literal('upsert'), + matchFields: z.array(z.string()).min(1), + }), + }); + + // all known types and their metadata + private typeMap: Record = {}; + + // divider used to separate compound ID fields + private idDivider; + + private urlPatternMap: Record; + private modelNameMapping: Record; + private reverseModelNameMapping: Record; + private externalIdMapping: Record; + + constructor(private readonly options: RestApiHandlerOptions) { + this.idDivider = options.idDivider ?? DEFAULT_ID_DIVIDER; + const segmentCharset = options.urlSegmentCharset ?? 'a-zA-Z0-9-_~ %'; + + this.modelNameMapping = options.modelNameMapping ?? {}; + this.modelNameMapping = Object.fromEntries( + Object.entries(this.modelNameMapping).map(([k, v]) => [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]) => [lowerCaseFirst(k), v]), + ); + + this.urlPatternMap = this.buildUrlPatternMap(segmentCharset); + + this.buildTypeMap(); + this.buildSerializers(); + } + + get schema() { + return this.options.schema; + } + + private buildUrlPatternMap(urlSegmentNameCharset: string): Record { + const options = { segmentValueCharset: urlSegmentNameCharset }; + + const buildPath = (segments: string[]) => { + return '/' + segments.join('/'); + }; + + return { + [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 mapModelName(modelName: string): string { + return this.modelNameMapping[modelName] ?? modelName; + } + + private matchUrlPattern(path: string, routeType: UrlPatterns): Match | undefined { + 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({ client, method, path, query, requestBody }: RequestContext): Promise { + 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(client, match.type, match.id, query); + } + match = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP); + if (match) { + // fetch related resource(s) + return await this.processFetchRelated(client, match.type, match.id, match.relationship, query); + } + + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); + if (match) { + // read relationship + return await this.processReadRelationship( + client, + match.type, + match.id, + match.relationship, + query, + ); + } + + match = this.matchUrlPattern(path, UrlPatterns.COLLECTION); + if (match) { + // collection read + return await this.processCollectionRead(client, 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 as any; + const upsertMeta = this.upsertMetaSchema.safeParse(body); + if (upsertMeta.success) { + // resource upsert + return await this.processUpsert(client, match.type, query, requestBody); + } else { + // resource creation + return await this.processCreate(client, match.type, query, requestBody); + } + } + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); + if (match) { + // relationship creation (collection relationship only) + return await this.processRelationshipCRUD( + client, + '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(client, match.type, match.id, query, requestBody); + } + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); + if (match) { + // relationship update + return await this.processRelationshipCRUD( + client, + 'update', + match.type, + match.id, + match.relationship as string, + query, + requestBody, + ); + } + + return this.makeError('invalidPath'); + } + + case 'DELETE': { + let match = this.matchUrlPattern(path, UrlPatterns.SINGLE); + if (match) { + // resource deletion + return await this.processDelete(client, match.type, match.id); + } + + match = this.matchUrlPattern(path, UrlPatterns.RELATIONSHIP); + if (match) { + // relationship deletion (collection relationship only) + return await this.processRelationshipCRUD( + client, + 'delete', + match.type, + match.id, + match.relationship as string, + query, + requestBody, + ); + } + + return this.makeError('invalidPath'); + } + + default: + return this.makeError('invalidPath'); + } + } catch (err) { + if (err instanceof InvalidValueError) { + return this.makeError('invalidValue', err.message); + } else if (err instanceof ZenStackError) { + return this.handleZenStackError(err); + } else { + return this.handleGenericError(err); + } + } + } + + private handleGenericError(err: unknown): Response | PromiseLike { + return this.makeError('unknownError', err instanceof Error ? `${err.message}\n${err.stack}` : 'Unknown error'); + } + + private async processSingleRead( + client: ClientContract, + type: string, + resourceId: string, + query: Record | undefined, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const args: any = { where: this.makeIdFilter(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: string[] | undefined; + 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 (client as any)[type].findUnique(args); + + if (entity) { + return { + status: 200, + body: await this.serializeItems(type, entity, { include }), + }; + } else { + return this.makeError('notFound'); + } + } + + private async processFetchRelated( + client: ClientContract, + type: string, + resourceId: string, + relationship: string, + query: Record | undefined, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const relationInfo = typeInfo.relationships[relationship]; + if (!relationInfo) { + return this.makeUnsupportedRelationshipError(type, relationship, 404); + } + + let select: any; + + // handle "include" query parameter + let include: string[] | undefined; + 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(lowerCaseFirst(relationInfo.type), query); + if (error) return error; + + select = partialFields ? { [relationship]: { select: { ...partialFields } } } : { [relationship]: true }; + } + + const args: any = { + where: this.makeIdFilter(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: any = await (client as any)[type].findUnique(args); + + let paginator: Paginator | undefined; + + if (entity?._count?.[relationship] !== undefined) { + // build up paginator + const total = entity?._count?.[relationship] as number; + 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 Linker(() => this.makeLinkUrl(`/${mappedType}/${resourceId}/${relationship}`)), + paginator, + }, + include, + }), + }; + } else { + return this.makeError('notFound'); + } + } + + private async processReadRelationship( + client: ClientContract, + type: string, + resourceId: string, + relationship: string, + query: Record | undefined, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const relationInfo = typeInfo.relationships[relationship]; + if (!relationInfo) { + return this.makeUnsupportedRelationshipError(type, relationship, 404); + } + + const args: any = { + where: this.makeIdFilter(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: Paginator | undefined; + + 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: any = await (client as any)[type].findUnique(args); + const mappedType = this.mapModelName(type); + + if (entity?._count?.[relationship] !== undefined) { + // build up paginator + const total = entity?._count?.[relationship] as number; + 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: any = await this.serializeItems(relationInfo.type, entity[relationship], { + linkers: { + document: new Linker(() => + this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`), + ), + paginator, + }, + onlyIdentifier: true, + }); + + return { + status: 200, + body: serialized, + }; + } else { + return this.makeError('notFound'); + } + } + + private async processCollectionRead( + client: ClientContract, + type: string, + query: Record | undefined, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const args: any = {}; + + // 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: string[] | undefined; + 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 (client as any)[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([ + (client as any)[type].findMany(args), + (client as any)[type].count({ where: args.where ?? {} }), + ]); + const total = count as number; + + const mappedType = this.mapModelName(type); + const url = this.makeNormalizedUrl(`/${mappedType}`, query); + const options: Partial = { + 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, + }; + } + } + + private buildPartialSelect(type: string, query: Record | undefined) { + 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.getModelInfo(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 }, + }; + } + + private addTotalCountToMeta(meta: any, total: any) { + return meta ? Object.assign(meta, { total }) : Object.assign({}, { total }); + } + + private makePaginator(baseUrl: string, offset: number, limit: number, total: number) { + if (limit === Infinity) { + return undefined; + } + + const totalPages = Math.ceil(total / limit); + + return new 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, + })); + } + + private processRequestBody(requestBody: unknown) { + let body: any = requestBody; + if (body.meta?.serialization) { + // superjson deserialize body if a serialization meta is provided + body = SuperJSON.deserialize({ json: body, meta: body.meta.serialization }); + } + + const parseResult = this.createUpdatePayloadSchema.safeParse(body); + if (!parseResult.success) { + return { + attributes: undefined, + relationships: undefined, + error: this.makeError('invalidPayload', getZodErrorMessage(parseResult.error)), + }; + } + + return { + attributes: parseResult.data.data.attributes, + relationships: parseResult.data.data.relationships, + error: undefined, + }; + } + + private async processCreate( + client: ClientContract, + type: string, + _query: Record | undefined, + requestBody: unknown, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const { attributes, relationships, error } = this.processRequestBody(requestBody); + if (error) { + return error; + } + + const createPayload: any = { data: { ...attributes } }; + + // turn relationship payload into `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: enumerate(data.data).map((item: any) => + 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.makeDefaultIdKey(relationInfo.idFields)]: true } }, + }; + } + } + + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, createPayload, 'include'); + + const entity = await (client as any)[type].create(createPayload); + return { + status: 201, + body: await this.serializeItems(type, entity), + }; + } + + private async processUpsert( + client: ClientContract, + type: string, + _query: Record | undefined, + requestBody: unknown, + ) { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const modelName = typeInfo.name; + const { attributes, relationships, error } = this.processRequestBody(requestBody); + if (error) { + return error; + } + + const parseResult = this.upsertMetaSchema.safeParse(requestBody); + if (parseResult.error) { + return this.makeError('invalidPayload', getZodErrorMessage(parseResult.error)); + } + const matchFields = parseResult.data.meta.matchFields; + const uniqueFieldSets = this.getUniqueFieldSets(modelName); + + if (!uniqueFieldSets.some((set) => set.every((field) => matchFields.includes(field)))) { + return this.makeError('invalidPayload', 'Match fields must be unique fields', 400); + } + + const upsertPayload: any = { + 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(modelName, key, 400); + } + + if (relationInfo.isCollection) { + upsertPayload.create[key] = { + connect: enumerate(data.data).map((item: any) => + this.makeIdConnect(relationInfo.idFields, item.id), + ), + }; + upsertPayload.update[key] = { + set: enumerate(data.data).map((item: any) => + 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(modelName, upsertPayload, 'include'); + + const entity = await (client as any)[modelName].upsert(upsertPayload); + + return { + status: 201, + body: await this.serializeItems(modelName, entity), + }; + } + + private getUniqueFieldSets(type: string) { + const modelDef = this.requireModel(type); + return Object.entries(modelDef.uniqueFields).map( + ([k, v]) => + typeof v.type === 'string' + ? [k] // single unique field + : Object.keys(v), // compound unique fields + ); + } + + private async processRelationshipCRUD( + client: ClientContract, + mode: 'create' | 'update' | 'delete', + type: string, + resourceId: string, + relationship: string, + _query: Record | undefined, + requestBody: unknown, + ): Promise { + const typeInfo = this.getModelInfo(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: any = { + where: this.makeIdFilter(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', getZodErrorMessage(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', getZodErrorMessage(parsed.error)); + } + + // create -> connect, delete -> disconnect, update -> set + const relationVerb = mode === 'create' ? 'connect' : mode === 'delete' ? 'disconnect' : 'set'; + + updateArgs.data = { + [relationship]: { + [relationVerb]: enumerate(parsed.data.data).map((item: any) => + this.makeIdFilter(relationInfo.idFields, item.id), + ), + }, + }; + } + + const entity: any = await (client as any)[type].update(updateArgs); + + const mappedType = this.mapModelName(type); + + const serialized: any = await this.serializeItems(relationInfo.type, entity[relationship], { + linkers: { + document: new Linker(() => + this.makeLinkUrl(`/${mappedType}/${resourceId}/relationships/${relationship}`), + ), + }, + onlyIdentifier: true, + }); + + return { + status: 200, + body: serialized, + }; + } + + private async processUpdate( + client: ClientContract, + type: any, + resourceId: string, + _query: Record | undefined, + requestBody: unknown, + ): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + const { attributes, relationships, error } = this.processRequestBody(requestBody); + if (error) { + return error; + } + + const updatePayload: any = { + where: this.makeIdFilter(typeInfo.idFields, resourceId), + data: { ...attributes }, + }; + + // turn relationships into query 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: enumerate(data.data).map((item: any) => ({ + [this.makeDefaultIdKey(relationInfo.idFields)]: item.id, + })), + }; + } else { + if (typeof data.data !== 'object') { + return this.makeError('invalidRelationData'); + } + updatePayload.data[key] = { + connect: { + [this.makeDefaultIdKey(relationInfo.idFields)]: data.data.id, + }, + }; + } + updatePayload.include = { + ...updatePayload.include, + [key]: { select: { [this.makeDefaultIdKey(relationInfo.idFields)]: true } }, + }; + } + } + + // include IDs of relation fields so that they can be serialized. + this.includeRelationshipIds(type, updatePayload, 'include'); + + const entity = await (client as any)[type].update(updatePayload); + return { + status: 200, + body: await this.serializeItems(type, entity), + }; + } + + private async processDelete(client: ClientContract, type: any, resourceId: string): Promise { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return this.makeUnsupportedModelError(type); + } + + await (client as any)[type].delete({ + where: this.makeIdFilter(typeInfo.idFields, resourceId), + }); + return { + status: 200, + body: { meta: {} }, + }; + } + + //#region utilities + + private requireModel(model: string): ModelDef { + const modelDef = this.schema.models[model]; + if (!modelDef) { + throw new Error(`Model ${model} is not defined in the schema`); + } + return modelDef; + } + + private getIdFields(model: string): FieldDef[] { + const modelDef = this.requireModel(model); + const modelLower = lowerCaseFirst(model); + if (!(modelLower in this.externalIdMapping)) { + return Object.values(modelDef.fields).filter((f) => modelDef.idFields.includes(f.name)); + } + + // map external ID name to unique constraint field + const externalIdName = this.externalIdMapping[modelLower]; + for (const [name, info] of Object.entries(modelDef.uniqueFields)) { + if (name === externalIdName) { + if (typeof info.type === 'string') { + // single unique field + return [this.requireField(model, info.type)]; + } else { + // compound unique fields + return Object.keys(info).map((f) => this.requireField(model, f)); + } + } + } + + throw new Error(`Model ${model} does not have unique key ${externalIdName}`); + } + + private requireField(model: string, field: string): FieldDef { + const modelDef = this.requireModel(model); + const fieldDef = modelDef.fields[field]; + if (!fieldDef) { + throw new Error(`Field ${field} is not defined in model ${model}`); + } + return fieldDef; + } + + private buildTypeMap() { + this.typeMap = {}; + for (const [model, { fields }] of Object.entries(this.schema.models)) { + const idFields = this.getIdFields(model); + if (idFields.length === 0) { + log(this.options.log, 'warn', `Not including model ${model} in the API because it has no ID field`); + continue; + } + + const modelInfo: ModelInfo = (this.typeMap[lowerCaseFirst(model)] = { + name: model, + idFields, + relationships: {}, + fields, + }); + + for (const [field, fieldInfo] of Object.entries(fields)) { + if (!fieldInfo.relation) { + continue; + } + const fieldTypeIdFields = this.getIdFields(fieldInfo.type); + if (fieldTypeIdFields.length === 0) { + log( + this.options.log, + 'warn', + `Not including relation ${model}.${field} in the API because it has no ID field`, + ); + continue; + } + + modelInfo.relationships[field] = { + type: fieldInfo.type, + idFields: fieldTypeIdFields, + isCollection: !!fieldInfo.array, + isOptional: !!fieldInfo.optional, + }; + } + } + } + + private getModelInfo(model: string): ModelInfo | undefined { + return this.typeMap[lowerCaseFirst(model)]; + } + + private makeLinkUrl(path: string) { + return `${this.options.endpoint}${path}`; + } + + private buildSerializers() { + const linkers: Record> = {}; + + for (const model of Object.keys(this.schema.models)) { + const ids = this.getIdFields(model); + const modelLower = lowerCaseFirst(model); + const mappedModel = this.mapModelName(modelLower); + + if (ids.length < 1) { + continue; + } + + const linker = new Linker((items) => + Array.isArray(items) + ? this.makeLinkUrl(`/${mappedModel}`) + : this.makeLinkUrl(`/${mappedModel}/${this.getId(model, items)}`), + ); + linkers[modelLower] = linker; + + let projection: Record | null = {}; + const modelDef = this.requireModel(model); + for (const [field, fieldDef] of Object.entries(modelDef.fields)) { + if (fieldDef.relation) { + projection[field] = 0; + } + } + if (Object.keys(projection).length === 0) { + projection = null; + } + + const serializer = new Serializer(model, { + version: '1.1', + idKey: this.makeIdKey(ids), + linkers: { + resource: linker, + document: linker, + }, + projection, + }); + this.serializers.set(modelLower, serializer); + } + + // set relators + for (const model of Object.keys(this.schema.models)) { + const modelLower = lowerCaseFirst(model); + const serializer = this.serializers.get(modelLower); + if (!serializer) { + continue; + } + + const relators: Record> = {}; + const modelDef = this.requireModel(model); + for (const [field, fieldDef] of Object.entries(modelDef.fields)) { + if (!fieldDef.relation) { + continue; + } + const fieldSerializer = this.serializers.get(lowerCaseFirst(fieldDef.type)); + if (!fieldSerializer) { + continue; + } + const fieldIds = this.getIdFields(fieldDef.type); + if (fieldIds.length > 0) { + const mappedModel = this.mapModelName(modelLower); + + const relator = new Relator( + async (data) => { + return (data as any)[field]; + }, + fieldSerializer, + { + relatedName: field, + linkers: { + related: new Linker((primary) => + this.makeLinkUrl(`/${mappedModel}/${this.getId(model, primary)}/${field}`), + ), + relationship: new Linker((primary) => + this.makeLinkUrl( + `/${mappedModel}/${this.getId(model, primary)}/relationships/${field}`, + ), + ), + }, + }, + ); + relators[field] = relator; + } + } + serializer.setRelators(relators); + } + } + + private getId(model: string, data: any) { + if (!data) { + return undefined; + } + const ids = this.getIdFields(model); + if (ids.length === 0) { + return undefined; + } else { + return data[this.makeIdKey(ids)]; + } + } + + private async serializeItems(model: string, items: unknown, options?: Partial>) { + model = lowerCaseFirst(model); + const serializer = this.serializers.get(model); + if (!serializer) { + throw new Error(`serializer not found for model ${model}`); + } + + const itemsWithId = 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.serialize(plainResult); + + const result: any = json; + if (meta) { + result.meta = { ...result.meta, serialization: meta }; + } + + return result; + } + + private injectCompoundId(model: string, items: unknown) { + const typeInfo = this.getModelInfo(model); + if (!typeInfo) { + return; + } + + // recursively traverse the entity to create synthetic ID field for models with compound ID + enumerate(items).forEach((item: any) => { + 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); + } + } + }); + } + + private toPlainObject(data: any): any { + if (data === undefined || data === null) { + return data; + } + + if (Array.isArray(data)) { + return data.map((item: any) => this.toPlainObject(item)); + } + + if (typeof data === 'object') { + if (typeof data.toJSON === 'function') { + // custom toJSON function + return data.toJSON(); + } + const result: any = {}; + 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; + } + + private replaceURLSearchParams(url: string, params: Record) { + const r = new URL(url); + for (const [key, value] of Object.entries(params)) { + r.searchParams.set(key, value.toString()); + } + return r.toString(); + } + + private makeIdFilter(idFields: FieldDef[], resourceId: string, nested: boolean = 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(DEFAULT_ID_DIVIDER)]: 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]), + }), + {}, + ); + } + } + + private makeIdSelect(idFields: FieldDef[]) { + if (idFields.length === 0) { + throw this.errors['noId']; + } + return idFields.reduce((acc, curr) => ({ ...acc, [curr.name]: true }), {}); + } + + private makeIdConnect(idFields: FieldDef[], id: string | number) { + if (idFields.length === 1) { + return { [idFields[0]!.name]: this.coerce(idFields[0]!, id) }; + } else { + return { + [this.makeDefaultIdKey(idFields)]: idFields.reduce( + (acc, curr, idx) => ({ + ...acc, + [curr.name]: this.coerce(curr, `${id}`.split(this.idDivider)[idx]), + }), + {}, + ), + }; + } + } + + private makeIdKey(idFields: FieldDef[]) { + return idFields.map((idf) => idf.name).join(this.idDivider); + } + + private makeDefaultIdKey(idFields: FieldDef[]) { + // TODO: support `@@id` with custom name + return idFields.map((idf) => idf.name).join(DEFAULT_ID_DIVIDER); + } + + private makeCompoundId(idFields: FieldDef[], item: any) { + return idFields.map((idf) => item[idf.name]).join(this.idDivider); + } + + private makeUpsertWhere(matchFields: any[], attributes: any, typeInfo: ModelInfo) { + const where = matchFields.reduce((acc: any, field: string) => { + 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.makeDefaultIdKey(typeInfo.idFields)]: where, + }; + } + + return where; + } + + private includeRelationshipIds(model: string, args: any, mode: 'select' | 'include') { + const typeInfo = this.getModelInfo(model); + if (!typeInfo) { + return; + } + for (const [relation, relationInfo] of Object.entries(typeInfo.relationships)) { + args[mode] = { ...args[mode], [relation]: { select: this.makeIdSelect(relationInfo.idFields) } }; + } + } + + private coerce(fieldDef: FieldDef, value: any) { + if (typeof value === 'string') { + if (fieldDef.attributes?.some((attr) => attr.name === '@json')) { + try { + return JSON.parse(value); + } catch { + throw new InvalidValueError(`invalid JSON value: ${value}`); + } + } + + const type = fieldDef.type; + if (type === 'Int') { + const parsed = parseInt(value); + if (isNaN(parsed)) { + throw new InvalidValueError(`invalid ${type} value: ${value}`); + } + return parsed; + } else if (type === 'BigInt') { + try { + return BigInt(value); + } catch { + throw new InvalidValueError(`invalid ${type} value: ${value}`); + } + } else if (type === 'Float') { + const parsed = parseFloat(value); + if (isNaN(parsed)) { + throw new InvalidValueError(`invalid ${type} value: ${value}`); + } + return parsed; + } else if (type === 'Decimal') { + try { + return new Decimal(value); + } catch { + throw new InvalidValueError(`invalid ${type} value: ${value}`); + } + } else if (type === 'Boolean') { + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } else { + throw new InvalidValueError(`invalid ${type} value: ${value}`); + } + } + } + return value; + } + + private makeNormalizedUrl(path: string, query: Record | undefined) { + const url = new URL(this.makeLinkUrl(path)); + for (const [key, value] of Object.entries(query ?? {})) { + if ( + key.startsWith('filter[') || + key.startsWith('sort[') || + key === 'include' || + key.startsWith('include[') || + key.startsWith('fields[') + ) { + for (const v of enumerate(value)) { + url.searchParams.append(key, v); + } + } + } + return url.toString(); + } + + private getPagination(query: Record | undefined) { + if (!query) { + return { offset: 0, limit: this.options.pageSize ?? DEFAULT_PAGE_SIZE }; + } + + let offset = 0; + if (query['page[offset]']) { + const value = query['page[offset]']; + const offsetText = Array.isArray(value) ? value[value.length - 1]! : value; + offset = parseInt(offsetText); + if (isNaN(offset) || offset < 0) { + offset = 0; + } + } + + let pageSizeOption = this.options.pageSize ?? DEFAULT_PAGE_SIZE; + if (pageSizeOption <= 0) { + pageSizeOption = DEFAULT_PAGE_SIZE; + } + + let limit = pageSizeOption; + if (query['page[limit]']) { + const value = query['page[limit]']; + const limitText = Array.isArray(value) ? value[value.length - 1]! : value; + limit = parseInt(limitText); + if (isNaN(limit) || limit <= 0) { + limit = pageSizeOption; + } + limit = Math.min(pageSizeOption, limit); + } + + return { offset, limit }; + } + + private buildFilter( + type: string, + query: Record | undefined, + ): { filter: any; error: any } { + if (!query) { + return { filter: undefined, error: undefined }; + } + + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return { filter: undefined, error: this.makeUnsupportedModelError(type) }; + } + + const items: any[] = []; + + for (const [key, value] of Object.entries(query)) { + if (!value) { + continue; + } + + // try matching query parameter key as "filter[x][y]..." + const match = key.match(this.filterParamPattern); + if (!match || !match.groups || !match.groups['match']) { + continue; + } + + const filterKeys = match.groups['match'] + .replaceAll(/[[\]]/g, ' ') + .split(' ') + .filter((i) => i); + + if (!filterKeys.length) { + continue; + } + + // turn filter into a nested query object + + const item: any = {}; + let curr = item; + let currType = typeInfo; + + for (const filterValue of enumerate(value)) { + for (let i = 0; i < filterKeys.length; i++) { + // extract filter operation from (optional) trailing $op + let filterKey = filterKeys[i]!; + let filterOp: FilterOperationType | undefined; + const pos = filterKey.indexOf('$'); + if (pos > 0) { + filterOp = filterKey.substring(pos + 1) as FilterOperationType; + filterKey = filterKey.substring(0, pos); + } + + if (!!filterOp && !FilterOperations.includes(filterOp)) { + return { + filter: undefined, + error: this.makeError('invalidFilter', `invalid filter operation: ${filterOp}`), + }; + } + + const idFields = this.getIdFields(currType.name); + const fieldDef = + filterKey === 'id' + ? Object.values(currType.fields).find((f) => idFields.some((idf) => idf.name === f.name)) + : currType.fields[filterKey]; + if (!fieldDef) { + return { filter: undefined, error: this.makeError('invalidFilter') }; + } + + if (!fieldDef.relation) { + // regular field + if (i !== filterKeys.length - 1) { + // must be the last segment of a filter + return { filter: undefined, error: this.makeError('invalidFilter') }; + } + curr[fieldDef.name] = this.makeFilterValue(fieldDef, filterValue, filterOp); + } else { + // relation field + if (i === filterKeys.length - 1) { + curr[fieldDef.name] = this.makeFilterValue(fieldDef, filterValue, filterOp); + } else { + // keep going + if (fieldDef.array) { + // collection filtering implies "some" operation + curr[fieldDef.name] = { some: {} }; + curr = curr[fieldDef.name].some; + } else { + curr = curr[fieldDef.name] = {}; + } + currType = this.getModelInfo(fieldDef.type)!; + } + } + } + items.push(item); + } + } + + if (items.length === 0) { + return { filter: undefined, error: undefined }; + } else { + // combine filters with AND + return { filter: items.length === 1 ? items[0] : { AND: items }, error: undefined }; + } + } + + private buildSort(type: string, query: Record | undefined) { + if (!query?.['sort']) { + return { sort: undefined, error: undefined }; + } + + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return { sort: undefined, error: this.makeUnsupportedModelError(type) }; + } + + const result: any[] = []; + + for (const sortSpec of enumerate(query['sort'])) { + const sortFields = sortSpec.split(',').filter((i) => i); + + for (const sortField of sortFields) { + const dir = sortField.startsWith('-') ? 'desc' : 'asc'; + const cleanedSortField = sortField.startsWith('-') ? sortField.substring(1) : sortField; + const parts = cleanedSortField.split('.').filter((i) => i); + + const sortItem: any = {}; + let curr = sortItem; + let currType = typeInfo; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + + const fieldInfo = currType.fields[part]; + if (!fieldInfo || fieldInfo.array) { + return { + sort: undefined, + error: this.makeError('invalidSort', 'sorting by array field is not supported'), + }; + } + + if (i === parts.length - 1) { + if (fieldInfo.relation) { + // relation field: sort by id + const relationType = this.getModelInfo(fieldInfo.type); + if (!relationType) { + return { sort: undefined, error: this.makeUnsupportedModelError(fieldInfo.type) }; + } + curr[fieldInfo.name] = relationType.idFields.reduce((acc: any, idField: FieldDef) => { + acc[idField.name] = dir; + return acc; + }, {}); + } else { + // regular field + curr[fieldInfo.name] = dir; + } + } else { + if (!fieldInfo.relation) { + // must be a relation field + return { + sort: undefined, + error: this.makeError( + 'invalidSort', + 'intermediate sort segments must be relationships', + ), + }; + } + // keep going + curr = curr[fieldInfo.name] = {}; + currType = this.getModelInfo(fieldInfo.type)!; + if (!currType) { + return { sort: undefined, error: this.makeUnsupportedModelError(fieldInfo.type) }; + } + } + } + + result.push(sortItem); + } + } + + return { sort: result, error: undefined }; + } + + private buildRelationSelect( + type: string, + include: string | string[], + query: Record | undefined, + ) { + const typeInfo = this.getModelInfo(type); + if (!typeInfo) { + return { select: undefined, error: this.makeUnsupportedModelError(type) }; + } + + const result: any = {}; + const allIncludes: string[] = []; + + for (const includeItem of enumerate(include)) { + const inclusions = includeItem.split(',').filter((i) => i); + for (const inclusion of inclusions) { + allIncludes.push(inclusion); + + const parts = inclusion.split('.'); + let currPayload = result; + let currType = typeInfo; + + for (let i = 0; i < parts.length; i++) { + const relation = parts[i]!; + const relationInfo = currType.relationships[relation]; + if (!relationInfo) { + return { select: undefined, error: this.makeUnsupportedRelationshipError(type, relation, 400) }; + } + + currType = this.getModelInfo(relationInfo.type)!; + if (!currType) { + return { select: undefined, error: this.makeUnsupportedModelError(relationInfo.type) }; + } + + // handle partial results for requested type + const { select, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query); + if (error) return { select: undefined, error }; + + if (i !== parts.length - 1) { + if (select) { + currPayload[relation] = { select: { ...select } }; + currPayload = currPayload[relation].select; + } else { + currPayload[relation] = { include: { ...currPayload[relation]?.include } }; + currPayload = currPayload[relation].include; + } + } else { + currPayload[relation] = select + ? { + select: { ...select }, + } + : true; + } + } + } + } + + return { select: result, error: undefined, allIncludes }; + } + + private makeFilterValue(fieldDef: FieldDef, value: string, op: FilterOperationType): any { + // TODO: inequality filters? + if (fieldDef.relation) { + // relation filter is converted to an ID filter + const info = this.getModelInfo(fieldDef.type)!; + if (fieldDef.array) { + // filtering a to-many relation, imply 'some' operator + const values = value.split(',').filter((i) => i); + const filterValue = + values.length > 1 + ? { OR: values.map((v) => this.makeIdFilter(info.idFields, v, false)) } + : this.makeIdFilter(info.idFields, value, false); + return { some: filterValue }; + } else { + const values = value.split(',').filter((i) => i); + if (values.length > 1) { + return { OR: values.map((v) => this.makeIdFilter(info.idFields, v, false)) }; + } else { + return { is: this.makeIdFilter(info.idFields, value, false) }; + } + } + } else { + const coerced = this.coerce(fieldDef, value); + switch (op) { + case 'icontains': + return { contains: coerced, mode: 'insensitive' }; + case 'hasSome': + case 'hasEvery': { + const values = value + .split(',') + .filter((i) => i) + .map((v) => this.coerce(fieldDef, v)); + return { [op]: values }; + } + case 'isEmpty': + if (value !== 'true' && value !== 'false') { + throw new InvalidValueError(`Not a boolean: ${value}`); + } + return { isEmpty: value === 'true' ? true : false }; + default: + if (op === undefined) { + if (fieldDef.attributes?.some((attr) => attr.name === '@json')) { + // handle JSON value equality filter + return { equals: coerced }; + } + + // regular filter, split value by comma + const values = value + .split(',') + .filter((i) => i) + .map((v) => this.coerce(fieldDef, v)); + return values.length > 1 ? { in: values } : { equals: values[0] }; + } else { + return { [op]: coerced }; + } + } + } + } + + private injectRelationQuery( + type: string, + injectTarget: any, + injectKey: string, + query: Record | undefined, + ) { + const { filter, error: filterError } = this.buildFilter(type, query); + if (filterError) { + return filterError; + } + + if (filter) { + injectTarget[injectKey] = { ...injectTarget[injectKey], where: filter }; + } + + const { sort, error: sortError } = this.buildSort(type, query); + if (sortError) { + return sortError; + } + if (sort) { + injectTarget[injectKey] = { ...injectTarget[injectKey], orderBy: sort }; + } + + const pagination = this.getPagination(query); + const offset = pagination.offset; + if (offset > 0) { + // inject skip + injectTarget[injectKey] = { ...injectTarget[injectKey], skip: offset }; + } + const limit = pagination.limit; + if (limit !== Infinity) { + // inject take + injectTarget[injectKey] = { ...injectTarget[injectKey], take: limit }; + + // include a count query for the relationship + injectTarget._count = { select: { [injectKey]: true } }; + } + } + + private handleZenStackError(err: ZenStackError) { + if (err instanceof InputValidationError) { + return this.makeError( + 'validationError', + err.message, + 422, + err.cause instanceof Error ? err.cause.message : undefined, + ); + } else if (err instanceof RejectedByPolicyError) { + return this.makeError('forbidden', err.message, 403, err.reason); + } else if (err instanceof NotFoundError) { + return this.makeError('notFound', err.message); + } else if (err instanceof QueryError) { + return this.makeError( + 'queryError', + err.message, + 400, + err.cause instanceof Error ? err.cause.message : undefined, + ); + } else { + return this.makeError('unknownError', err.message); + } + } + + private makeError(code: keyof typeof this.errors, detail?: string, status?: number, reason?: string) { + status = status ?? this.errors[code]?.status ?? 500; + const error: any = { + status, + code: paramCase(code), + title: this.errors[code]?.title, + }; + + if (detail) { + error.detail = detail; + } + + if (reason) { + error.reason = reason; + } + + return { + status, + body: { + errors: [error], + }, + }; + } + + private makeUnsupportedModelError(model: string) { + return this.makeError('unsupportedModel', `Model ${model} doesn't exist`); + } + + private makeUnsupportedRelationshipError(model: string, relationship: string, status: number) { + return this.makeError('unsupportedRelationship', `Relationship ${model}.${relationship} doesn't exist`, status); + } + + //#endregion +} diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index c454615b..e9d73fc0 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -17,7 +17,14 @@ registerCustomSerializers(); * Options for {@link RPCApiHandler} */ export type RPCApiHandlerOptions = { + /** + * The schema + */ schema: Schema; + + /** + * Logging configuration + */ log?: LogConfig; }; @@ -173,9 +180,13 @@ export class RPCApiHandler implements ApiHandler `sending error response: ${safeJSONStringify(resp)}`); + log( + this.options.log, + 'debug', + () => `sending error response: ${safeJSONStringify(resp)}${err instanceof Error ? '\n' + err.stack : ''}`, + ); return resp; } diff --git a/packages/server/src/api/utils.ts b/packages/server/src/api/utils.ts index a31a50a8..51b604a5 100644 --- a/packages/server/src/api/utils.ts +++ b/packages/server/src/api/utils.ts @@ -1,6 +1,9 @@ import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import { match } from 'ts-pattern'; +import { ZodError } from 'zod'; +import { fromError as fromError3 } from 'zod-validation-error/v3'; +import { fromError as fromError4 } from 'zod-validation-error/v4'; import type { LogConfig, LogLevel } from '../types'; export function log(logger: LogConfig | undefined, level: LogLevel, message: string | (() => string), error?: unknown) { @@ -48,3 +51,14 @@ export function registerCustomSerializers() { ); } } + +/** + * Format ZodError into a readable string + */ +export function getZodErrorMessage(error: ZodError): string { + if ('_zod' in error) { + return fromError4(error).toString(); + } else { + return fromError3(error).toString(); + } +} diff --git a/packages/server/test/adapter/express.test.ts b/packages/server/test/adapter/express.test.ts index 314288ca..d1448d2b 100644 --- a/packages/server/test/adapter/express.test.ts +++ b/packages/server/test/adapter/express.test.ts @@ -5,6 +5,7 @@ import request from 'supertest'; import { describe, expect, it } from 'vitest'; import { ZenStackMiddleware } from '../../src/adapter/express'; import { RPCApiHandler } from '../../src/api'; +import { RestApiHandler } from '../../src/api/rest'; import { makeUrl, schema } from '../utils'; describe('Express adapter tests - rpc handler', () => { @@ -91,86 +92,83 @@ describe('Express adapter tests - rpc handler', () => { }); }); -// describe('Express adapter tests - rest handler', () => { -// it('run middleware', async () => { -// const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); - -// const app = express(); -// app.use(bodyParser.json()); -// app.use( -// '/api', -// ZenStackMiddleware({ -// getPrisma: () => prisma, -// modelMeta, -// zodSchemas, -// handler: RESTAPIHandler({ endpoint: 'http://localhost/api' }), -// }), -// ); - -// let r = await request(app).get(makeUrl('/api/post/1')); -// expect(r.status).toBe(404); - -// r = await request(app) -// .post('/api/user') -// .send({ -// data: { -// type: 'user', -// attributes: { -// id: 'user1', -// email: 'user1@abc.com', -// }, -// }, -// }); -// expect(r.status).toBe(201); -// expect(r.body).toMatchObject({ -// jsonapi: { version: '1.1' }, -// data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, -// }); - -// r = await request(app).get('/api/user?filter[id]=user1'); -// expect(r.body.data).toHaveLength(1); - -// r = await request(app).get('/api/user?filter[id]=user2'); -// expect(r.body.data).toHaveLength(0); - -// r = await request(app).get('/api/user?filter[id]=user1&filter[email]=xyz'); -// expect(r.body.data).toHaveLength(0); - -// r = await request(app) -// .put('/api/user/user1') -// .send({ data: { type: 'user', attributes: { email: 'user1@def.com' } } }); -// expect(r.status).toBe(200); -// expect(r.body.data.attributes.email).toBe('user1@def.com'); - -// r = await request(app).delete(makeUrl('/api/user/user1')); -// expect(r.status).toBe(200); -// expect(await prisma.user.findMany()).toHaveLength(0); -// }); -// }); - -// describe('Express adapter tests - rest handler with custom middleware', () => { -// it('run middleware', async () => { -// const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); - -// const app = express(); -// app.use(bodyParser.json()); -// app.use( -// '/api', -// ZenStackMiddleware({ -// getPrisma: () => prisma, -// modelMeta, -// zodSchemas, -// handler: RESTAPIHandler({ endpoint: 'http://localhost/api' }), -// sendResponse: false, -// }), -// ); - -// app.use((req, res) => { -// res.status(res.locals.status).json({ message: res.locals.body }); -// }); - -// const r = await request(app).get(makeUrl('/api/post/1')); -// expect(r.status).toBe(404); -// expect(r.body.message).toHaveProperty('errors'); -// }); -// }); +describe('Express adapter tests - rest handler', () => { + it('works with sending response', async () => { + const client = await createPolicyTestClient(schema); + + const app = express(); + app.use(bodyParser.json()); + app.use( + '/api', + ZenStackMiddleware({ + apiHandler: new RestApiHandler({ schema: client.$schema, endpoint: 'http://localhost/api' }), + getClient: () => client.$unuseAll(), + }), + ); + + let r = await request(app).get(makeUrl('/api/post/1')); + expect(r.status).toBe(404); + + r = await request(app) + .post('/api/user') + .send({ + data: { + type: 'User', + attributes: { + id: 'user1', + email: 'user1@abc.com', + }, + }, + }); + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { type: 'User', id: 'user1', attributes: { email: 'user1@abc.com' } }, + }); + + r = await request(app).get('/api/user?filter[id]=user1'); + expect(r.body.data).toHaveLength(1); + + r = await request(app).get('/api/user?filter[id]=user2'); + expect(r.body.data).toHaveLength(0); + + r = await request(app).get('/api/user?filter[id]=user1&filter[email]=xyz'); + expect(r.body.data).toHaveLength(0); + + r = await request(app) + .put('/api/user/user1') + .send({ data: { type: 'User', attributes: { email: 'user1@def.com' } } }); + expect(r.status).toBe(200); + expect(r.body.data.attributes.email).toBe('user1@def.com'); + + r = await request(app).delete(makeUrl('/api/user/user1')); + expect(r.status).toBe(200); + expect(await client.$unuseAll().user.findMany()).toHaveLength(0); + }); +}); + +describe('Express adapter tests - rest handler with custom middleware', () => { + it('run middleware', async () => { + const client = await createPolicyTestClient(schema); + + const app = express(); + app.use(bodyParser.json()); + app.use( + '/api', + ZenStackMiddleware({ + getClient: () => client.$unuseAll(), + apiHandler: new RestApiHandler({ schema: client.$schema, endpoint: 'http://localhost/api' }), + sendResponse: false, + }), + ); + + app.use((_req, res) => { + const zenstack = res.locals['zenstack']; + res.status(zenstack.status).json({ message: zenstack.body }); + }); + + const r = await request(app).get(makeUrl('/api/post/1')); + expect(r.status).toBe(404); + expect(r.body.message).toHaveProperty('errors'); + }); +}); diff --git a/packages/server/test/adapter/next.test.ts b/packages/server/test/adapter/next.test.ts index def6a1d3..ee335038 100644 --- a/packages/server/test/adapter/next.test.ts +++ b/packages/server/test/adapter/next.test.ts @@ -5,7 +5,7 @@ import { apiResolver } from 'next/dist/server/api-utils/node/api-resolver'; import request from 'supertest'; import { describe, expect, it } from 'vitest'; import { NextRequestHandler, type PageRouteRequestHandlerOptions } from '../../src/adapter/next'; -import { RPCApiHandler } from '../../src/api'; +import { RestApiHandler, RPCApiHandler } from '../../src/api'; function makeTestClient( apiPath: string, @@ -232,67 +232,60 @@ model M { }); }); -// describe('Next.js adapter tests - rest handler', () => { -// let origDir: string; - -// beforeEach(() => { -// origDir = process.cwd(); -// }); - -// afterEach(() => { -// process.chdir(origDir); -// }); - -// it('adapter test - rest', async () => { -// const model = ` -// model M { -// id String @id @default(cuid()) -// value Int -// } -// `; - -// const { prisma, modelMeta } = await loadSchema(model); - -// const options = { getPrisma: () => prisma, handler: Rest({ endpoint: 'http://localhost/api' }), modelMeta }; - -// await makeTestClient('/m', options) -// .post('/') -// .send({ data: { type: 'm', attributes: { id: '1', value: 1 } } }) -// .expect(201) -// .expect((resp) => { -// expect(resp.body.data.attributes.value).toBe(1); -// }); - -// await makeTestClient('/m/1', options) -// .get('/') -// .expect(200) -// .expect((resp) => { -// expect(resp.body.data.id).toBe('1'); -// }); - -// await makeTestClient('/m', options, undefined, { 'filter[value]': '1' }) -// .get('/') -// .expect(200) -// .expect((resp) => { -// expect(resp.body.data).toHaveLength(1); -// }); - -// await makeTestClient('/m', options, undefined, { 'filter[value]': '2' }) -// .get('/') -// .expect(200) -// .expect((resp) => { -// expect(resp.body.data).toHaveLength(0); -// }); - -// await makeTestClient('/m/1', options) -// .put('/') -// .send({ data: { type: 'm', attributes: { value: 2 } } }) -// .expect(200) -// .expect((resp) => { -// expect(resp.body.data.attributes.value).toBe(2); -// }); - -// await makeTestClient('/m/1', options).del('/').expect(200); -// expect(await prisma.m.count()).toBe(0); -// }); -// }); +describe('Next.js adapter tests - rest handler', () => { + it('adapter test - rest', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int +} + `; + + const client = await createTestClient(model); + + const options = { + getClient: () => client, + apiHandler: new RestApiHandler({ schema: client.$schema, endpoint: 'http://localhost/api' }), + }; + + await makeTestClient('/m', options) + .post('/') + .send({ data: { type: 'm', attributes: { id: '1', value: 1 } } }) + .expect(201) + .expect((resp) => { + expect(resp.body.data.attributes.value).toBe(1); + }); + + await makeTestClient('/m/1', options) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.data.id).toBe('1'); + }); + + await makeTestClient('/m', options, undefined, { 'filter[value]': '1' }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.data).toHaveLength(1); + }); + + await makeTestClient('/m', options, undefined, { 'filter[value]': '2' }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.data).toHaveLength(0); + }); + + await makeTestClient('/m/1', options) + .put('/') + .send({ data: { type: 'm', attributes: { value: 2 } } }) + .expect(200) + .expect((resp) => { + expect(resp.body.data.attributes.value).toBe(2); + }); + + await makeTestClient('/m/1', options).del('/').expect(200); + expect(await client.m.count()).toBe(0); + }); +}); diff --git a/packages/server/test/api/rest.test.ts b/packages/server/test/api/rest.test.ts new file mode 100644 index 00000000..b309b5ae --- /dev/null +++ b/packages/server/test/api/rest.test.ts @@ -0,0 +1,3167 @@ +import { ClientContract } from '@zenstackhq/orm'; +import { SchemaDef } from '@zenstackhq/orm/schema'; +import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools'; +import { Decimal } from 'decimal.js'; +import SuperJSON from 'superjson'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { RestApiHandler } from '../../src/api/rest'; + +const idDivider = '_'; + +describe('REST server tests', () => { + let client: ClientContract; + let handler: (any: any) => Promise<{ status: number; body: any }>; + + describe('REST server tests - regular client', () => { + const schema = ` + type Address { + city String + } + + model User { + myId String @id @default(cuid()) + createdAt DateTime @default (now()) + updatedAt DateTime @updatedAt + email String @unique @email + posts Post[] + likes PostLike[] + profile Profile? + address Address? @json + someJson Json? + } + + model Profile { + id Int @id @default(autoincrement()) + gender String + user User @relation(fields: [userId], references: [myId]) + userId String @unique + } + + model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default (now()) + updatedAt DateTime @updatedAt + title String @length(1, 10) + author User? @relation(fields: [authorId], references: [myId]) + authorId String? + published Boolean @default(false) + publishedAt DateTime? + viewCount Int @default(0) + comments Comment[] + likes PostLike[] + setting Setting? + } + + model Comment { + id Int @id @default(autoincrement()) + post Post @relation(fields: [postId], references: [id]) + postId Int + content String + } + + model Setting { + id Int @id @default(autoincrement()) + boost Int + post Post @relation(fields: [postId], references: [id]) + postId Int @unique + } + + model PostLike { + postId Int + userId String + superLike Boolean + post Post @relation(fields: [postId], references: [id]) + user User @relation(fields: [userId], references: [myId]) + likeInfos PostLikeInfo[] + @@id([postId, userId]) + } + + model PostLikeInfo { + id Int @id @default(autoincrement()) + text String + postId Int + userId String + postLike PostLike @relation(fields: [postId, userId], references: [postId, userId]) + } + `; + + beforeEach(async () => { + client = await createTestClient(schema); + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 5, + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + describe('CRUD', () => { + describe('GET', () => { + it('invalid type, id, relationship', async () => { + let r = await handler({ + method: 'get', + path: '/foo', + client, + }); + expect(r.status).toBe(404); + + r = await handler({ + method: 'get', + path: '/user/user1/posts', + client, + }); + expect(r.status).toBe(404); + + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, + }, + }); + + r = await handler({ + method: 'get', + path: '/user/user1/relationships/foo', + client, + }); + expect(r.status).toBe(404); + + r = await handler({ + method: 'get', + path: '/user/user1/foo', + client, + }); + expect(r.status).toBe(404); + }); + + it('returns an empty array when no item exists', async () => { + const r = await handler({ + method: 'get', + path: '/user', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: [], + links: { + self: 'http://localhost/api/user', + }, + }); + }); + + it('returns all items when there are some in the database', async () => { + // Create users first + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, + }, + }); + await client.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { title: 'Post2' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user', + }, + meta: { + total: 2, + }, + data: [ + { + type: 'User', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'Post', id: 1 }], + }, + }, + }, + { + type: 'User', + id: 'user2', + attributes: { email: 'user2@abc.com' }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [{ type: 'Post', id: 2 }], + }, + }, + }, + ], + }); + }); + + it('returns a single item when the ID is specified', async () => { + // Create a user first + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { + type: 'User', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'Post', id: 1 }], + }, + }, + }, + }); + }); + + it('fetch a related resource', async () => { + // Create a user first + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1/posts', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/posts', + }, + data: [ + { + type: 'Post', + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, + }, + }, + ], + }); + }); + + it('returns an empty data array when loading empty related resources', async () => { + // Create a user first + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { + type: 'User', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [], + }, + }, + }, + }); + }); + + it('fetches a related resource with a compound ID', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await client.postLike.create({ + data: { postId: 1, userId: 'user1', superLike: true }, + }); + + const r = await handler({ + method: 'get', + path: '/post/1/relationships/likes', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1/relationships/likes', + }, + data: [{ type: 'PostLike', id: `1${idDivider}user1` }], + }); + }); + + it('fetch a relationship', async () => { + // Create a user first + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'Post', id: 1 }], + }); + }); + + it('returns 404 if the specified ID does not exist', async () => { + const r = await handler({ + method: 'get', + path: '/user/nonexistentuser', + client, + }); + + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', + }, + ], + }); + }); + + it('toplevel filtering', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + address: { city: 'Seattle' }, + someJson: 'foo', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await client.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, + }, + }); + + // id filter + let r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user2' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + // multi-id filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1,user2' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(2); + + // String filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email]']: 'user1@abc.com' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@abc' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@bc' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'user1' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'ser1' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc.com' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + // Int filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: '1' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gt]']: '0' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gte]']: '1' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lt]']: '0' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lte]']: '0' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // Boolean filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[published]']: 'true' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // deep to-one filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author][email]']: 'user1@abc.com' }, + client, + }); + expect(r.body.data).toHaveLength(1); + + // deep to-many filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts][published]']: 'true' }, + client, + }); + expect(r.body.data).toHaveLength(1); + + // filter to empty + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user3' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + // to-many relation collection filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '2' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '1,2,3' }, + client, + }); + expect(r.body.data).toHaveLength(2); + + // multi filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, + client, + }); + expect(r.body.data).toHaveLength(0); + r = await handler({ + method: 'get', + path: '/post', + query: { + ['filter[author][email]']: 'user1@abc.com', + ['filter[title]']: 'Post1', + }, + client, + }); + expect(r.body.data).toHaveLength(1); + r = await handler({ + method: 'get', + path: '/post', + query: { + ['filter[author][email]']: 'user1@abc.com', + ['filter[title]']: 'Post2', + }, + client, + }); + expect(r.body.data).toHaveLength(0); + + // to-one relation filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author]']: 'user1' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // relation filter with multiple values + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author]']: 'user1,user2' }, + client, + }); + expect(r.body.data).toHaveLength(2); + + // invalid filter field + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[foo]']: '1' }, + client, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); + + // invalid filter value + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: 'a' }, + client, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-value', + title: 'Invalid value for type', + }, + ], + }); + + // invalid filter operation + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$foo]']: '1' }, + client, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); + + // TODO: JSON filter + // // typedef equality filter + // r = await handler({ + // method: 'get', + // path: '/user', + // query: { ['filter[address]']: JSON.stringify({ city: 'Seattle' }) }, + // client, + // }); + // expect(r.body.data).toHaveLength(1); + // r = await handler({ + // method: 'get', + // path: '/user', + // query: { ['filter[address]']: JSON.stringify({ city: 'Tokyo' }) }, + // client, + // }); + // expect(r.body.data).toHaveLength(0); + + // // plain json equality filter + // r = await handler({ + // method: 'get', + // path: '/user', + // query: { ['filter[someJson]']: JSON.stringify('foo') }, + // client, + // }); + // expect(r.body.data).toHaveLength(1); + // r = await handler({ + // method: 'get', + // path: '/user', + // query: { ['filter[someJson]']: JSON.stringify('bar') }, + // client, + // }); + // expect(r.body.data).toHaveLength(0); + + // // invalid json + // r = await handler({ + // method: 'get', + // path: '/user', + // query: { ['filter[someJson]']: '{ hello: world }' }, + // client, + // }); + // expect(r.body).toMatchObject({ + // errors: [ + // { + // status: 400, + // code: 'invalid-value', + // title: 'Invalid value for type', + // }, + // ], + // }); + }); + + it('related data filtering', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await client.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, + }, + }); + + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['filter[viewCount]']: '1' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user/user2/posts', + query: { ['filter[viewCount]']: '1' }, + client, + }); + expect(r.body.data).toHaveLength(1); + }); + + it('relationship filtering', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await client.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, + }, + }); + + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['filter[viewCount]']: '1' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user/user2/relationships/posts', + query: { ['filter[viewCount]']: '1' }, + client, + }); + expect(r.body.data).toHaveLength(1); + }); + + it('toplevel sorting', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', viewCount: 1, published: true }, + }, + }, + }); + await client.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 2, published: false }, + }, + }, + }); + + // basic sorting + let r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // basic sorting desc + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation id + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author.email' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // multi-field sorting + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'published,viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount,published' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount,-published' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // invalid field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'foo' }, + client, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); + + // sort with collection + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'comments' }, + client, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); + + // sort with regular field in the middle + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount.foo' }, + client, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); + }); + + it('related data sorting', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { + id: 1, + title: 'Post1', + viewCount: 1, + published: true, + setting: { create: { boost: 1 } }, + }, + { + id: 2, + title: 'Post2', + viewCount: 2, + published: false, + setting: { create: { boost: 2 } }, + }, + ], + }, + }, + }); + + // asc + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: 'viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // desc + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: '-viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // relation field + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: '-setting.boost' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + }); + + it('relationship sorting', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { + id: 1, + title: 'Post1', + viewCount: 1, + published: true, + setting: { create: { boost: 1 } }, + }, + { + id: 2, + title: 'Post2', + viewCount: 2, + published: false, + setting: { create: { boost: 2 } }, + }, + ], + }, + }, + }); + + // asc + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: 'viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // desc + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: '-viewCount' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // relation field + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: '-setting.boost' }, + client, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + }); + + it('including', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, + }, + profile: { + create: { gender: 'male' }, + }, + }, + }); + await client.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { + id: 2, + title: 'Post2', + viewCount: 1, + published: true, + comments: { create: { content: 'Comment2' } }, + }, + }, + }, + }); + + // collection query include + let r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts' }, + client, + }); + expect(r.body.included).toHaveLength(2); + expect(r.body.included[0]).toMatchObject({ + type: 'Post', + id: 1, + attributes: { title: 'Post1' }, + }); + + // single query include + r = await handler({ + method: 'get', + path: '/user/user1', + query: { include: 'posts' }, + client, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'Post', + id: 1, + attributes: { title: 'Post1' }, + }); + + // related query include + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments' }, + client, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'Comment', + attributes: { content: 'Comment1' }, + }); + + // related query include with filter + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments', ['filter[published]']: 'true' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + // deep include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments' }, + client, + }); + expect(r.body.included).toHaveLength(4); + expect(r.body.included[2]).toMatchObject({ + type: 'Comment', + attributes: { content: 'Comment1' }, + }); + + // multiple include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments,profile' }, + client, + }); + expect(r.body.included).toHaveLength(5); + const profile = r.body.included.find((item: any) => item.type === 'Profile'); + expect(profile).toMatchObject({ + type: 'Profile', + attributes: { gender: 'male' }, + }); + + // invalid include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'foo' }, + client, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [{ status: 400, code: 'unsupported-relationship' }], + }); + }); + + it('toplevel pagination', async () => { + for (const i of Array(5).keys()) { + await client.user.create({ + data: { + myId: `user${i}`, + email: `user${i}@abc.com`, + }, + }); + } + + // limit only + let r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3' }, + client, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: null, + next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); + + // limit & offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3', ['page[offset]']: '3' }, + client, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', + next: null, + }); + + // limit trimmed + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '10' }, + client, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + + // offset overflow + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '10' }, + client, + }); + expect(r.body.data).toHaveLength(0); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + + // minus offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '-1' }, + client, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + + // zero limit + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '0' }, + client, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + }); + + it('related data pagination', async () => { + await client.user.create({ + data: { + myId: `user1`, + email: `user1@abc.com`, + posts: { + create: [...Array(10).keys()].map((i) => ({ + id: i, + title: `Post${i}`, + })), + }, + }, + }); + + // default limiting + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + client, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=5', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5', + prev: null, + next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', + }); + + // explicit limiting + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['page[limit]']: '3' }, + client, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', + prev: null, + next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); + + // offset + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['page[limit]']: '3', ['page[offset]']: '8' }, + client, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', + prev: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', + next: null, + }); + }); + + it('relationship pagination', async () => { + await client.user.create({ + data: { + myId: `user1`, + email: `user1@abc.com`, + posts: { + create: [...Array(10).keys()].map((i) => ({ + id: i, + title: `Post${i}`, + })), + }, + }, + }); + + // default limiting + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + client, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=5', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5', + prev: null, + next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', + }); + + // explicit limiting + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['page[limit]']: '3' }, + client, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', + prev: null, + next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); + + // offset + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['page[limit]']: '3', ['page[offset]']: '8' }, + client, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', + prev: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', + next: null, + }); + }); + + describe('compound id', () => { + beforeEach(async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, + }); + await client.user.create({ + data: { myId: 'user2', email: 'user2@abc.com' }, + }); + await client.postLike.create({ + data: { userId: 'user2', postId: 1, superLike: false }, + }); + }); + + it('get all', async () => { + const r = await handler({ + method: 'get', + path: '/postLike', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: [ + { + type: 'PostLike', + id: `1${idDivider}user2`, + attributes: { userId: 'user2', postId: 1, superLike: false }, + }, + ], + }); + }); + + it('get single', async () => { + const r = await handler({ + method: 'get', + path: `/postLike/1${idDivider}user2`, // Order of ids is same as in the model @@id + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { + type: 'PostLike', + id: `1${idDivider}user2`, + attributes: { userId: 'user2', postId: 1, superLike: false }, + }, + }); + }); + + it('get as relationship', async () => { + const r = await handler({ + method: 'get', + path: `/post/1`, + query: { include: 'likes' }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { + relationships: { + likes: { + data: [{ type: 'PostLike', id: `1${idDivider}user2` }], + }, + }, + }, + included: [ + expect.objectContaining({ + type: 'PostLike', + id: '1_user2', + attributes: { + postId: 1, + userId: 'user2', + superLike: false, + }, + links: { + self: 'http://localhost/api/postLike/1_user2', + }, + }), + ], + }); + }); + }); + }); + + describe('POST', () => { + it('creates an item without relation', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'User', attributes: { myId: 'user1', email: 'user1@abc.com' } }, + }, + client, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { + type: 'User', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [], + }, + }, + links: { self: 'http://localhost/api/user/user1' }, + }, + }); + }); + + it('creates an item with date coercion', async () => { + const r = await handler({ + method: 'post', + path: '/post', + query: {}, + requestBody: { + data: { + type: 'Post', + attributes: { + id: 1, + title: 'Post1', + published: true, + publishedAt: '2024-03-02T05:00:00.000Z', + }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + }); + + it('creates an item with zod violation', async () => { + const r = await handler({ + method: 'post', + path: '/post', + query: {}, + requestBody: { + data: { + type: 'Post', + attributes: { + id: 1, + title: 'a very very long long title', + }, + }, + }, + client, + }); + + expect(r.status).toBe(422); + expect(r.body.errors[0].code).toBe('validation-error'); + }); + + it('creates an item with collection relations', async () => { + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await client.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { myId: 'user1', email: 'user1@abc.com' }, + relationships: { + posts: { + data: [ + { type: 'Post', id: 1 }, + { type: 'Post', id: 2 }, + ], + }, + }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { + type: 'User', + id: 'user1', + attributes: { + email: 'user1@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [ + { type: 'Post', id: 1 }, + { type: 'Post', id: 2 }, + ], + }, + }, + }, + }); + }); + + it('creates an item with single relation', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/post', + query: {}, + requestBody: { + data: { + type: 'Post', + attributes: { title: 'Post1' }, + relationships: { + author: { + data: { type: 'User', id: 'user1' }, + }, + }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1', + }, + data: { + type: 'Post', + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + data: { type: 'User', id: 'user1' }, + }, + }, + }, + }); + }); + + it('create single relation disallowed', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + const r = await handler({ + method: 'post', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { type: 'User', id: 'user1' }, + }, + client, + }); + + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-verb', + title: 'The HTTP verb is not supported', + }, + ], + }); + }); + + it('create a collection of relations', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await client.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [ + { type: 'Post', id: 1 }, + { type: 'Post', id: 2 }, + ], + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [ + { type: 'Post', id: 1 }, + { type: 'Post', id: 2 }, + ], + }); + }); + + it('create relation for nonexistent entity', async () => { + let r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'Post', id: 1 }], + }, + client, + }); + + expect(r.status).toBe(404); + + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { data: [{ type: 'Post', id: 1 }] }, + client, + }); + + expect(r.status).toBe(404); + }); + + it('create relation with compound id', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ data: { id: 1, title: 'Post1' } }); + + const r = await handler({ + method: 'post', + path: '/postLike', + query: {}, + requestBody: { + data: { + type: 'postLike', + attributes: { userId: 'user1', postId: 1, superLike: false }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + }); + + it('compound id create single', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + const r = await handler({ + method: 'post', + path: '/postLike', + query: {}, + requestBody: { + data: { + type: 'postLike', + id: `1${idDivider}user1`, + attributes: { userId: 'user1', postId: 1, superLike: false }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + }); + + it('create an entity related to an entity with compound id', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ data: { id: 1, title: 'Post1' } }); + await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'post', + path: '/postLikeInfo', + query: {}, + requestBody: { + data: { + type: 'postLikeInfo', + attributes: { text: 'LikeInfo1' }, + relationships: { + postLike: { + data: { type: 'postLike', id: `1${idDivider}user1` }, + }, + }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + }); + + it('upsert a new entity', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { myId: 'user1', email: 'user1@abc.com' }, + }, + meta: { + operation: 'upsert', + matchFields: ['myId'], + }, + }, + client, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { + type: 'User', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [], + }, + }, + }, + }); + }); + + it('upsert an existing entity', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { myId: 'user1', email: 'user2@abc.com' }, + }, + meta: { + operation: 'upsert', + matchFields: ['myId'], + }, + }, + client, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { + type: 'User', + id: 'user1', + attributes: { email: 'user2@abc.com' }, + }, + }); + }); + + it('upsert fails if matchFields are not unique', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/profile', + query: {}, + requestBody: { + data: { + type: 'profile', + attributes: { gender: 'male' }, + relationships: { + user: { + data: { type: 'User', id: 'user1' }, + }, + }, + }, + meta: { + operation: 'upsert', + matchFields: ['gender'], + }, + }, + client, + }); + + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-payload', + }, + ], + }); + }); + + it('upsert works with compound id', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ data: { id: 1, title: 'Post1' } }); + + const r = await handler({ + method: 'post', + path: '/postLike', + query: {}, + requestBody: { + data: { + type: 'postLike', + id: `1${idDivider}user1`, + attributes: { userId: 'user1', postId: 1, superLike: false }, + }, + meta: { + operation: 'upsert', + matchFields: ['userId', 'postId'], + }, + }, + client, + }); + + expect(r.status).toBe(201); + }); + }); + + describe('PUT', () => { + it('updates an item if it exists', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + }, + }); + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await client.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'put', + path: '/user/user1', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { email: 'user2@abc.com' }, + relationships: { + posts: { + data: [ + { type: 'Post', id: 1 }, + { type: 'Post', id: 2 }, + ], + }, + }, + }, + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1', + }, + data: { + type: 'User', + id: 'user1', + attributes: { + email: 'user2@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [ + { type: 'Post', id: 1 }, + { type: 'Post', id: 2 }, + ], + }, + }, + }, + }); + }); + + it("returns an empty data list in relationships if it's empty", async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + }, + }); + + const r = await handler({ + method: 'put', + path: '/user/user1', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { email: 'user2@abc.com' }, + }, + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1', + }, + data: { + type: 'User', + id: 'user1', + attributes: { + email: 'user2@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [], + }, + }, + }, + }); + }); + + it('returns 404 if the user does not exist', async () => { + const r = await handler({ + method: 'put', + path: '/user/nonexistentuser', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { email: 'user2@abc.com' }, + }, + }, + client, + }); + + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + expect.objectContaining({ + code: 'not-found', + status: 404, + title: 'Resource not found', + }), + ], + }); + }); + + it('update an item with date coercion', async () => { + await client.post.create({ data: { id: 1, title: 'Post1' } }); + + const r = await handler({ + method: 'put', + path: '/post/1', + query: {}, + requestBody: { + data: { + type: 'Post', + attributes: { + published: true, + publishedAt: '2024-03-02T05:00:00.000Z', + }, + }, + }, + client, + }); + + expect(r.status).toBe(200); + }); + + it('update an item with zod violation', async () => { + await client.post.create({ data: { id: 1, title: 'Post1' } }); + + const r = await handler({ + method: 'put', + path: '/post/1', + query: {}, + requestBody: { + data: { + type: 'Post', + attributes: { + publishedAt: '2024-13-01', + }, + }, + }, + client, + }); + + expect(r.status).toBe(422); + expect(r.body.errors[0].code).toBe('validation-error'); + }); + + it('update item with compound id', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ data: { id: 1, title: 'Post1' } }); + await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'put', + path: `/postLike/1${idDivider}user1`, + query: {}, + requestBody: { + data: { + type: 'PostLike', + attributes: { superLike: true }, + }, + }, + client, + }); + + expect(r.status).toBe(200); + }); + + it('update the id of an item with compound id', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ data: { id: 1, title: 'Post1' } }); + await client.post.create({ data: { id: 2, title: 'Post2' } }); + await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'put', + path: `/postLike/1${idDivider}user1`, + query: {}, + requestBody: { + data: { + type: 'PostLike', + relationships: { + post: { data: { type: 'Post', id: 2 } }, + }, + }, + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body.data.id).toBe(`2${idDivider}user1`); + }); + + it('update a single relation', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'User', + id: 'user1', + }, + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + jsonapi: { + version: '1.1', + }, + links: { + self: 'http://localhost/api/post/1/relationships/author', + }, + data: { + type: 'User', + id: 'user1', + }, + }); + }); + + it('remove a single relation', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { data: null }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1/relationships/author', + }, + data: null, + }); + }); + + it('update a collection of relations', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + await client.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'patch', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'Post', id: 2 }], + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'Post', id: 2 }], + }); + }); + + it('update a collection of relations with compound id', async () => { + await client.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await client.post.create({ data: { id: 1, title: 'Post1' } }); + await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/likes', + query: {}, + requestBody: { + data: [{ type: 'PostLike', id: `1${idDivider}user1`, attributes: { superLike: true } }], + }, + client, + }); + + expect(r.status).toBe(200); + }); + + it('update a collection of relations to empty', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'patch', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { data: [] }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [], + }); + }); + + it('update relation for nonexistent entity', async () => { + let r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'User', + id: 'user1', + }, + }, + client, + }); + expect(r.status).toBe(404); + + await client.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'User', + id: 'user1', + }, + }, + client, + }); + + expect(r.status).toBe(404); + }); + }); + + describe('DELETE', () => { + it('deletes an item if it exists', async () => { + // Create a user first + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + }, + }); + + const r = await handler({ + method: 'delete', + path: '/user/user1', + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ meta: {} }); + }); + + it('deletes an item with compound id', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + await client.postLike.create({ data: { userId: 'user1', postId: 1, superLike: false } }); + + const r = await handler({ + method: 'delete', + path: `/postLike/1${idDivider}user1`, + client, + }); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ meta: {} }); + }); + + it('returns 404 if the user does not exist', async () => { + const r = await handler({ + method: 'delete', + path: '/user/nonexistentuser', + client, + }); + + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + expect.objectContaining({ + code: 'not-found', + status: 404, + title: 'Resource not found', + }), + ], + }); + }); + + it('delete single relation disallowed', async () => { + await client.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'delete', + path: '/post/1/relationships/author', + query: {}, + client, + }); + + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-verb', + title: 'The HTTP verb is not supported', + }, + ], + }); + }); + + it('delete a collection of relations', async () => { + await client.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, + }, + }); + + const r = await handler({ + method: 'delete', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'Post', id: 1 }], + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + jsonapi: { + version: '1.1', + }, + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'Post', id: 2 }], + }); + }); + + it('delete relations for nonexistent entity', async () => { + const r = await handler({ + method: 'delete', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'Post', id: 1 }], + }, + client, + }); + expect(r.status).toBe(404); + }); + }); + + describe('validation error', () => { + it('creates an item without relation', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'User', attributes: { myId: 'user1', email: 'user1.com' } }, + }, + client, + }); + + expect(r.status).toBe(422); + expect(r.body.errors[0].code).toBe('validation-error'); + expect(r.body.errors[0].detail).toContain('Invalid email'); + expect(r.body.errors[0].reason).toContain('Invalid email'); + }); + }); + }); + }); + + describe('REST server tests - access policy', () => { + const schema = ` + model Foo { + id Int @id + value Int + + @@allow('create,read', true) + @@allow('update', value > 0) + } + + model Bar { + id Int @id + value Int + + @@allow('create', true) + } + `; + + beforeEach(async () => { + client = await createPolicyTestClient(schema); + + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 5, + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('update policy rejection test', async () => { + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { id: 1, value: 0 } }, + }, + client, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { value: 1 } }, + }, + client, + }); + expect(r.status).toBe(404); + expect(r.body.errors[0].code).toBe('not-found'); + }); + + it('read-back policy rejection test', async () => { + const r = await handler({ + method: 'post', + path: '/bar', + query: {}, + requestBody: { + data: { type: 'bar', attributes: { id: 1, value: 0 } }, + }, + client, + }); + expect(r.status).toBe(403); + expect(r.body.errors[0].reason).toBe('cannot-read-back'); + }); + }); + + describe('REST server tests - NextAuth project regression', () => { + const schema = ` + model Post { + id String @id @default(cuid()) + title String + content String + + // full access for all + @@allow('all', true) + } + + model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + } + + model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + } + + model User { + id String @id @default(cuid()) + name String? + email String @email @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + + @@allow('create,read', true) + @@allow('delete,update', auth() != null) + } + + model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) + } + `; + + beforeEach(async () => { + client = await createPolicyTestClient(schema); + + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 5, + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('crud test', async () => { + let r = await handler({ + method: 'get', + path: '/user', + client, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'User', attributes: { email: 'user1@abc.com' } }, + }, + client, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'get', + path: '/user', + client, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'User', attributes: { email: 'user1@abc.com' } }, + }, + client, + }); + expect(r.status).toBe(400); + expect(r.body.errors[0].code).toBe('query-error'); + }); + }); + + describe('REST server tests - field type coverage', () => { + const schema = ` + model Foo { + id Int @id + string String + int Int + bigInt BigInt + date DateTime + float Float + decimal Decimal + boolean Boolean + bytes Bytes + bars Bar[] + } + + model Bar { + id Int @id + bytes Bytes + foo Foo? @relation(fields: [fooId], references: [id]) + fooId Int? @unique + } + `; + + it('field types', async () => { + const client = await createTestClient(schema, { provider: 'postgresql' }); + + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 5, + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + + await client.bar.create({ data: { id: 1, bytes: Buffer.from([7, 8, 9]) } }); + + const decimalValue1 = new Decimal('0.046875'); + const decimalValue2 = new Decimal('0.0146875'); + + const createAttrs = { + string: 'string', + int: 123, + bigInt: BigInt(534543543534), + date: new Date(), + float: 1.23, + decimal: decimalValue1, + boolean: true, + bytes: new Uint8Array([1, 2, 3, 4]), + }; + + const { json: createPayload, meta: createMeta } = SuperJSON.serialize({ + data: { + type: 'foo', + attributes: { id: 1, ...createAttrs }, + relationships: { + bars: { + data: [{ type: 'bar', id: 1 }], + }, + }, + }, + }); + + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + ...(createPayload as any), + meta: { + serialization: createMeta, + }, + }, + client, + }); + expect(r.status).toBe(201); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + let serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + let deserialized: any = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + let data = deserialized.data.attributes; + expect(typeof data.bigInt).toBe('bigint'); + expect(data.bytes).toBeInstanceOf(Uint8Array); + expect(data.date instanceof Date).toBeTruthy(); + expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); + + const updateAttrs = { + bigInt: BigInt(1534543543534), + date: new Date(), + decimal: decimalValue2, + bytes: new Uint8Array([5, 2, 3, 4]), + }; + const { json: updatePayload, meta: updateMeta } = SuperJSON.serialize({ + data: { + type: 'foo', + attributes: updateAttrs, + }, + }); + + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + ...(updatePayload as any), + meta: { + serialization: updateMeta, + }, + }, + client, + }); + expect(r.status).toBe(200); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + data = deserialized.data.attributes; + expect(data.bigInt).toEqual(updateAttrs.bigInt); + expect(data.date).toEqual(updateAttrs.date); + expect(data.decimal.equals(updateAttrs.decimal)).toBeTruthy(); + expect(data.bytes.toString()).toEqual(updateAttrs.bytes.toString()); + + r = await handler({ + method: 'get', + path: '/foo/1', + query: {}, + client, + }); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + data = deserialized.data.attributes; + expect(typeof data.bigInt).toBe('bigint'); + expect(data.bytes).toBeInstanceOf(Uint8Array); + expect(data.date instanceof Date).toBeTruthy(); + expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); + + r = await handler({ + method: 'get', + path: '/foo', + query: { include: 'bars' }, + client, + }); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + const included = deserialized.included[0]; + expect(included.attributes.bytes).toBeInstanceOf(Uint8Array); + }); + }); + + describe('REST server tests - compound id with custom separator', () => { + const schema = ` + enum Role { + COMMON_USER + ADMIN_USER + } + + model User { + email String + role Role + enabled Boolean @default(true) + + @@id([email, role]) + } + `; + const idDivider = ':'; + + beforeEach(async () => { + client = await createTestClient(schema); + + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + pageSize: 5, + idDivider, + urlSegmentCharset: 'a-zA-Z0-9-_~ %@.:', + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('POST', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }, + }, + client, + }); + + expect(r.status).toBe(201); + }); + + it('GET', async () => { + await client.user.create({ + data: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + query: {}, + client, + }); + + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + }); + + it('GET single', async () => { + await client.user.create({ + data: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1@abc.com:COMMON_USER', + query: {}, + client, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.email).toBe('user1@abc.com'); + }); + + it('PUT', async () => { + await client.user.create({ + data: { email: 'user1@abc.com', role: 'COMMON_USER' }, + }); + + const r = await handler({ + method: 'put', + path: '/user/user1@abc.com:COMMON_USER', + query: {}, + requestBody: { + data: { + type: 'User', + attributes: { enabled: false }, + }, + }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.enabled).toBe(false); + }); + }); + + describe('REST server tests - model name mapping', () => { + const schema = ` + model User { + id String @id @default(cuid()) + name String + posts Post[] + } + + model Post { + id String @id @default(cuid()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? + } + `; + beforeEach(async () => { + client = await createTestClient(schema); + + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + modelNameMapping: { + User: 'myUser', + }, + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('works with name mapping', async () => { + // using original model name + await expect( + handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { data: { type: 'User', attributes: { id: '1', name: 'User1' } } }, + client, + }), + ).resolves.toMatchObject({ + status: 400, + }); + + // using mapped model name + await expect( + handler({ + method: 'post', + path: '/myUser', + query: {}, + requestBody: { data: { type: 'User', attributes: { id: '1', name: 'User1' } } }, + client, + }), + ).resolves.toMatchObject({ + status: 201, + body: { + links: { self: 'http://localhost/api/myUser/1' }, + }, + }); + + await expect( + handler({ + method: 'get', + path: '/myUser/1', + query: {}, + client, + }), + ).resolves.toMatchObject({ + status: 200, + body: { + links: { self: 'http://localhost/api/myUser/1' }, + }, + }); + + // works with unmapped model name + await expect( + handler({ + method: 'post', + path: '/post', + query: {}, + requestBody: { + data: { + type: 'Post', + attributes: { id: '1', title: 'Post1' }, + relationships: { + author: { data: { type: 'User', id: '1' } }, + }, + }, + }, + client, + }), + ).resolves.toMatchObject({ + status: 201, + }); + }); + }); + + describe('REST server tests - external id mapping', () => { + const schema = ` + model User { + id Int @id @default(autoincrement()) + name String + source String + posts Post[] + + @@unique([name, source]) + } + + model Post { + id Int @id @default(autoincrement()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + } + `; + beforeEach(async () => { + client = await createTestClient(schema); + + const _handler = new RestApiHandler({ + schema: client.$schema, + endpoint: 'http://localhost/api', + externalIdMapping: { + User: 'name_source', + }, + }); + handler = (args) => _handler.handleRequest({ ...args, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('works with id mapping', async () => { + await client.user.create({ + data: { id: 1, name: 'User1', source: 'a' }, + }); + + // user is no longer exposed using the `id` field + let r = await handler({ + method: 'get', + path: '/user/1', + query: {}, + client, + }); + + expect(r.status).toBe(422); + expect(r.body.errors[0].code).toBe('validation-error'); + + // user is exposed using the fields from the `name__source` multi-column unique index + r = await handler({ + method: 'get', + path: '/user/User1_a', + query: {}, + client, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.source).toBe('a'); + expect(r.body.data.attributes.name).toBe('User1'); + + await client.post.create({ + data: { id: 1, title: 'Title1', authorId: 1 }, + }); + + // post is exposed using the `id` field + r = await handler({ + method: 'get', + path: '/post/1', + query: { include: 'author' }, + client, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes.title).toBe('Title1'); + // Verify author relationship contains the external ID + expect(r.body.data.relationships.author.data).toMatchObject({ + type: 'User', + id: 'User1_a', + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfb4ed92..c446d606 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -426,9 +426,18 @@ importers: superjson: specifier: ^2.2.3 version: 2.2.3 + ts-japi: + specifier: ^1.12.0 + version: 1.12.0 ts-pattern: specifier: 'catalog:' version: 5.7.1 + url-pattern: + specifier: ^1.0.3 + version: 1.0.3 + zod-validation-error: + specifier: 'catalog:' + version: 4.0.1(zod@3.25.76) devDependencies: '@types/body-parser': specifier: ^1.19.6 @@ -463,6 +472,9 @@ importers: supertest: specifier: ^7.1.4 version: 7.1.4 + zod: + specifier: ~3.25.0 + version: 3.25.76 packages/tanstack-query: dependencies: @@ -3010,6 +3022,10 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-japi@1.12.0: + resolution: {integrity: sha512-mmATC0x9lYXbD5ziSdi1c+E+7tmzh/F5dJlZTpvXYjMyI4LJa0WMtAH/Go4Dsnm8vxSLTct1gdkk2b83MxfHZg==} + engines: {node: '>=10'} + ts-pattern@5.7.1: resolution: {integrity: sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==} @@ -3118,6 +3134,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-pattern@1.0.3: + resolution: {integrity: sha512-uQcEj/2puA4aq1R3A2+VNVBgaWYR24FdWjl7VNW83rnWftlhyzOZ/tBjezRiC2UkIzuxC8Top3IekN3vUf1WxA==} + engines: {node: '>=0.12.0'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5582,6 +5602,8 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-japi@1.12.0: {} + ts-pattern@5.7.1: {} tslib@2.8.1: {} @@ -5689,6 +5711,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-pattern@1.0.3: {} + util-deprecate@1.0.2: {} uuid@11.0.5: {} diff --git a/tests/e2e/orm/client-api/find.test.ts b/tests/e2e/orm/client-api/find.test.ts index ab896869..7e2194d4 100644 --- a/tests/e2e/orm/client-api/find.test.ts +++ b/tests/e2e/orm/client-api/find.test.ts @@ -59,6 +59,7 @@ describe('Client find tests ', () => { // skip await expect(client.user.findMany({ skip: 1 })).resolves.toHaveLength(2); await expect(client.user.findMany({ skip: 2 })).resolves.toHaveLength(1); + // explicit sort await expect( client.user.findFirst({ @@ -69,6 +70,16 @@ describe('Client find tests ', () => { email: 'u02@test.com', }); + // allows duplicate sort fields + await expect( + client.user.findFirst({ + skip: 2, + orderBy: [{ email: 'desc' }, { email: 'desc' }], + }), + ).resolves.toMatchObject({ + email: 'u02@test.com', + }); + // take + skip await expect(client.user.findMany({ take: 1, skip: 1 })).resolves.toHaveLength(1); await expect(client.user.findMany({ take: 3, skip: 2 })).resolves.toHaveLength(1); @@ -162,6 +173,11 @@ describe('Client find tests ', () => { orderBy: { profile: { bio: 'asc' } }, }), ).resolves.toMatchObject(user1); + await expect( + client.user.findFirst({ + orderBy: [{ profile: { bio: 'asc' } }, { profile: { bio: 'asc' } }], + }), + ).resolves.toMatchObject(user1); await expect( client.user.findFirst({ orderBy: { profile: { bio: 'desc' } },