diff --git a/package.json b/package.json index b0126df..18261f9 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "version": "0.6.1", "description": "Generate MSW request handlers from OpenAPI documents, HAR files, and other sources.", "exports": { + "./data": { + "types": "./lib/data/from-collection.d.ts", + "default": "./lib/data/from-collection.js" + }, "./open-api": { "types": "./lib/open-api/from-open-api.d.ts", "default": "./lib/open-api/from-open-api.js" @@ -49,15 +53,23 @@ }, "packageManager": "pnpm@9.15.0", "peerDependencies": { + "@msw/data": "^1.1.6", "msw": "^2.10.0" }, + "peerDependenciesMeta": { + "@msw/data": { + "optional": true + } + }, "devDependencies": { "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", + "@msw/data": "^1.1.6", "@open-draft/test-server": "^0.7.2", "@ossjs/release": "^0.10.1", "@types/compression": "^1.8.1", "@types/node": "^18.19.33", + "@types/pluralize": "^0.0.33", "@vitest/browser": "^4.0.8", "@vitest/browser-playwright": "^4.0.8", "compression": "^1.8.1", @@ -69,15 +81,19 @@ "simple-git-hooks": "^2.13.1", "tsx": "^4.20.6", "typescript": "^5.9.3", - "vitest": "^4.0.8" + "vitest": "^4.0.8", + "zod": "^4.4.3" }, "dependencies": { "@mswjs/interceptors": "^0.40.0", + "@standard-schema/spec": "^1.1.0", "@stoplight/json": "^3.21.7", "@types/har-format": "^1.2.16", "@yellow-ticket/seed-json-schema": "^0.1.7", + "graphql": "^16.8.1", "openapi-types": "^12.1.3", "outvariant": "^1.4.3", + "pluralize": "^8.0.0", "yaml": "^2.8.2" }, "pnpm": { @@ -86,4 +102,4 @@ "braces@<3.0.3": ">=3.0.3" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c0787..357db90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: '@mswjs/interceptors': specifier: ^0.40.0 version: 0.40.0 + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 '@stoplight/json': specifier: ^3.21.7 version: 3.21.7 @@ -24,12 +27,18 @@ importers: '@yellow-ticket/seed-json-schema': specifier: ^0.1.7 version: 0.1.7 + graphql: + specifier: ^16.8.1 + version: 16.8.1 openapi-types: specifier: ^12.1.3 version: 12.1.3 outvariant: specifier: ^1.4.3 version: 1.4.3 + pluralize: + specifier: ^8.0.0 + version: 8.0.0 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -40,6 +49,9 @@ importers: '@commitlint/config-conventional': specifier: ^20.0.0 version: 20.0.0 + '@msw/data': + specifier: ^1.1.6 + version: 1.1.6 '@open-draft/test-server': specifier: ^0.7.2 version: 0.7.2 @@ -52,6 +64,9 @@ importers: '@types/node': specifier: ^18.19.33 version: 18.19.33 + '@types/pluralize': + specifier: ^0.0.33 + version: 0.0.33 '@vitest/browser': specifier: ^4.0.8 version: 4.0.8(msw@2.12.1(@types/node@18.19.33)(typescript@5.9.3))(vite@7.2.2(@types/node@18.19.33)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.2))(vitest@4.0.8) @@ -88,6 +103,9 @@ importers: vitest: specifier: ^4.0.8 version: 4.0.8(@types/node@18.19.33)(@vitest/browser-playwright@4.0.8)(happy-dom@20.0.10)(jiti@2.6.1)(msw@2.12.1(@types/node@18.19.33)(typescript@5.9.3))(tsx@4.20.6)(yaml@2.8.2) + zod: + specifier: ^4.4.3 + version: 4.4.3 packages: @@ -373,6 +391,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@msw/data@1.1.6': + resolution: {integrity: sha512-Kp0JhuaQBgMR4C6sXdoDYUNeaF/JhhzLToumYiV9flciOwSzZe7FSnlMNcL9Yj1tm1R/3f8mktDR4M6y1xVS8Q==} + '@mswjs/interceptors@0.40.0': resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -523,8 +544,8 @@ packages: cpu: [x64] os: [win32] - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@stoplight/json@3.21.7': resolution: {integrity: sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==} @@ -596,6 +617,9 @@ packages: '@types/node@24.10.9': resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} + '@types/pluralize@0.0.33': + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/qs@6.9.15': resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} @@ -967,6 +991,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.25.5: resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} engines: {node: '>=18'} @@ -1353,6 +1380,10 @@ packages: typescript: optional: true + mutative@1.3.0: + resolution: {integrity: sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ==} + engines: {node: '>=14.0'} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1481,6 +1512,10 @@ packages: engines: {node: '>=18'} hasBin: true + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -1580,6 +1615,9 @@ packages: resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} engines: {node: '>=4'} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rettime@0.7.0: resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} @@ -2015,6 +2053,9 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@babel/code-frame@7.24.2': @@ -2259,6 +2300,14 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@msw/data@1.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + es-toolkit: 1.49.0 + mutative: 1.3.0 + outvariant: 1.4.3 + rettime: 0.11.11 + '@mswjs/interceptors@0.40.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -2398,7 +2447,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@stoplight/json@3.21.7': dependencies: @@ -2480,6 +2529,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pluralize@0.0.33': {} + '@types/qs@6.9.15': {} '@types/range-parser@1.2.7': {} @@ -2543,7 +2594,7 @@ snapshots: '@vitest/expect@4.0.8': dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 '@vitest/spy': 4.0.8 '@vitest/utils': 4.0.8 @@ -2872,6 +2923,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-toolkit@1.49.0: {} + esbuild@0.25.5: optionalDependencies: '@esbuild/aix-ppc64': 0.25.5 @@ -3267,6 +3320,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + mutative@1.3.0: {} + mute-stream@2.0.0: {} nano-spawn@2.0.0: {} @@ -3387,6 +3442,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pluralize@8.0.0: {} + pngjs@7.0.0: {} postcss@8.5.6: @@ -3486,6 +3543,8 @@ snapshots: ret@0.2.2: {} + rettime@0.11.11: {} + rettime@0.7.0: {} rfdc@1.4.1: {} @@ -3874,3 +3933,5 @@ snapshots: yocto-queue@1.2.1: {} yoctocolors-cjs@2.1.2: {} + + zod@4.4.3: {} diff --git a/src/data/from-collection.ts b/src/data/from-collection.ts new file mode 100644 index 0000000..a248d8b --- /dev/null +++ b/src/data/from-collection.ts @@ -0,0 +1,438 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import { type Collection, OperationError, Query } from '@msw/data' +import { + type DefaultBodyType, + type GraphQLHandler, + type HttpResponseResolver, + type PathParams, + http, + HttpHandler, + HttpResponse, +} from 'msw' +import { getValueAtPath } from './utils/get-value-at-path.js' +import { toSerializable } from './utils/to-serializable.js' +import { + generateGraphQLHandlers, + type FromCollectionGraphQLOptions, +} from './graphql/generate-graphql-handlers.js' + +export type { FromCollectionGraphQLOptions } + +export interface FromCollectionHttpOptions { + /** + * Format of the generated handlers. + * @default "http" + */ + format?: 'http' + /** + * The URL of the resource represented by this collection. + * Can be a path or an absolute URL. All generated handlers + * are nested under this URL. + * @default "/" + * + * @example + * fromCollection(users, { baseUrl: '/api/users' }) + * // GET /api/users + * // GET /api/users/:id + * // ... + */ + baseUrl?: string + /** + * The record property to match against the path parameter + * in the record-scoped routes (e.g. `GET /users/:id`). + * @default "id" + */ + primaryKey?: string +} + +export type FromCollectionOptions = + | FromCollectionHttpOptions + | FromCollectionGraphQLOptions + +const paginationParameterNames = ['skip', 'take'] + +const responseStatusByOperationErrorCode: Record = { + STRICT_QUERY_WITHOUT_RESULTS: 404, + INVALID_INITIAL_VALUES: 400, +} + +class GeneratedHandlerError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + this.name = 'GeneratedHandlerError' + } +} + +/** + * Generates request handlers representing a RESTful API + * for the given collection. + * + * The following handlers are generated: + * + * - `GET {baseUrl}`, returns all records. Supports offset-based + * pagination with the `skip` and `take` query parameters. Any other + * query parameters are treated as record property filters + * (e.g. `GET /users?name=John`). + * - `GET {baseUrl}/:key`, returns a record by its `primaryKey`. + * - `POST {baseUrl}`, creates a new record from the request body. + * - `PUT {baseUrl}/:key`, replaces a record with the request body. + * - `PATCH {baseUrl}/:key`, merges the request body into a record. + * - `DELETE {baseUrl}/:key`, deletes a record by its `primaryKey`. + * + * Provide the `format: 'graphql'` option to generate GraphQL + * handlers instead (see {@link FromCollectionGraphQLOptions}). + * + * @example + * import { Collection } from '@msw/data' + * import { fromCollection } from '@msw/source/data' + * + * const users = new Collection({ schema: userSchema }) + * const handlers = fromCollection(users, { baseUrl: '/api/users' }) + * + * @example + * // Generate GraphQL handlers instead. + * fromCollection(users, { format: 'graphql', name: 'user' }) + */ +export function fromCollection( + collection: Collection, + options?: FromCollectionHttpOptions, +): Array +export function fromCollection( + collection: Collection, + options: FromCollectionGraphQLOptions, +): Array +export function fromCollection( + collection: Collection, + options: FromCollectionOptions = {}, +): Array | Array { + if (options.format === 'graphql') { + return generateGraphQLHandlers(collection, options) + } + + const { baseUrl = '/', primaryKey = 'id' } = options + + const collectionUrl = baseUrl.replace(/\/+$/, '') + const listUrl = collectionUrl === '' ? '/' : collectionUrl + const recordUrl = `${collectionUrl}/:${primaryKey}` + + const createPrimaryKeyQuery = ( + expectedValue: string, + ): Query> => { + return new Query((record: unknown) => { + const actualValue = getValueAtPath(record, [primaryKey]) + + /** + * @note Path parameters are always strings so compare + * the stringified property value. This supports non-string + * primary keys, like numbers. + */ + return actualValue != null && String(actualValue) === expectedValue + }) + } + + const updateRecord = async ( + primaryKeyValue: string, + produceNextRecord: (draftRecord: Record) => void, + ) => { + return collection + .update(createPrimaryKeyQuery(primaryKeyValue), { + strict: true, + data(draft) { + /** + * @note The draft is opaque for an arbitrary schema but the + * record-scoped routes only make sense for object records. + */ + produceNextRecord(draft as Record) + }, + }) + .catch((error) => { + if (error instanceof OperationError) { + throw error + } + + // Failing to produce the next record means the request body + // has resulted in a record that violates the collection schema. + throw new GeneratedHandlerError( + 400, + `Failed to update a record with "${primaryKey}" equal to "${primaryKeyValue}": the updated record does not match the collection schema.`, + ) + }) + } + + return [ + http.get( + listUrl, + withErrorResponses(({ request }) => { + const url = new URL(request.url) + const pagination = parsePaginationParameters(url.searchParams) + const filterQuery = new Query>( + createSearchParametersPredicate(url.searchParams), + ) + + const records = collection.findMany(filterQuery, pagination) + + return jsonResponse(records) + }), + ), + http.get( + recordUrl, + withErrorResponses(({ params }) => { + const primaryKeyValue = getRequiredPathParameter(params, primaryKey) + const record = collection.findFirst( + createPrimaryKeyQuery(primaryKeyValue), + { strict: true }, + ) + + return jsonResponse(record) + }), + ), + http.post( + listUrl, + withErrorResponses(async ({ request }) => { + const initialValues = await parseJsonBody(request) + const createdRecord = await collection.create( + initialValues as StandardSchemaV1.InferInput, + ) + + return jsonResponse(createdRecord, { status: 201 }) + }), + ), + http.put( + recordUrl, + withErrorResponses(async ({ request, params }) => { + const primaryKeyValue = getRequiredPathParameter(params, primaryKey) + const nextValues = await parseJsonObjectBody(request) + + const nextRecord = await updateRecord( + primaryKeyValue, + (draftRecord) => { + for (const key of Object.keys(draftRecord)) { + const isReplaced = Object.prototype.hasOwnProperty.call( + nextValues, + key, + ) + + /** + * @note The primary key is never removed nor replaced: + * the record identity is described by the request URL. + */ + if (key !== primaryKey && !isReplaced) { + delete draftRecord[key] + } + } + + for (const [key, value] of Object.entries(nextValues)) { + if (key !== primaryKey) { + draftRecord[key] = value + } + } + }, + ) + + return jsonResponse(nextRecord) + }), + ), + http.patch( + recordUrl, + withErrorResponses(async ({ request, params }) => { + const primaryKeyValue = getRequiredPathParameter(params, primaryKey) + const nextValues = await parseJsonObjectBody(request) + + const nextRecord = await updateRecord( + primaryKeyValue, + (draftRecord) => { + for (const [key, value] of Object.entries(nextValues)) { + if (key !== primaryKey) { + draftRecord[key] = value + } + } + }, + ) + + return jsonResponse(nextRecord) + }), + ), + http.delete( + recordUrl, + withErrorResponses(({ params }) => { + const primaryKeyValue = getRequiredPathParameter(params, primaryKey) + const deletedRecord = collection.delete( + createPrimaryKeyQuery(primaryKeyValue), + { strict: true }, + ) + + return jsonResponse(deletedRecord) + }), + ), + ] +} + +/** + * Wraps the given response resolver, translating the errors + * it throws to the respective error responses. + */ +function withErrorResponses( + resolver: HttpResponseResolver, +): HttpResponseResolver { + return async (info) => { + try { + return await resolver(info) + } catch (error) { + return createErrorResponse(error) + } + } +} + +function createErrorResponse(error: unknown): Response { + if (error instanceof GeneratedHandlerError) { + return HttpResponse.json( + { message: error.message }, + { status: error.status }, + ) + } + + if (error instanceof OperationError) { + const status = responseStatusByOperationErrorCode[String(error.code)] ?? 500 + + return HttpResponse.json({ message: error.message }, { status }) + } + + if (error instanceof Error) { + return HttpResponse.json({ message: error.message }, { status: 500 }) + } + + return HttpResponse.json({ message: 'Unexpected error' }, { status: 500 }) +} + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + /** + * @note Serialize the body manually to resolve relations + * and handle circular references between related records. + */ + return HttpResponse.json(toSerializable(body) as DefaultBodyType, init) +} + +function getRequiredPathParameter( + params: PathParams, + parameterName: string, +): string { + const parameterValue = params[parameterName] + + if (typeof parameterValue !== 'string') { + throw new GeneratedHandlerError( + 400, + `Failed to handle the request: expected the "${parameterName}" path parameter to be a string.`, + ) + } + + return parameterValue +} + +async function parseJsonBody(request: Request): Promise { + return request.json().catch(() => { + throw new GeneratedHandlerError( + 400, + 'Failed to handle the request: expected a valid JSON request body.', + ) + }) +} + +async function parseJsonObjectBody( + request: Request, +): Promise> { + const body = await parseJsonBody(request) + + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + throw new GeneratedHandlerError( + 400, + 'Failed to handle the request: expected the request body to be a JSON object.', + ) + } + + return body as Record +} + +function parsePaginationParameters(searchParams: URLSearchParams): { + skip?: number + take?: number +} { + const pagination: { skip?: number; take?: number } = {} + const rawSkip = searchParams.get('skip') + const rawTake = searchParams.get('take') + + if (rawSkip != null) { + const skip = Number(rawSkip) + + if (!Number.isInteger(skip) || skip < 0) { + throw new GeneratedHandlerError( + 400, + `Failed to query the collection: expected the "skip" query parameter to be a non-negative integer but got "${rawSkip}".`, + ) + } + + pagination.skip = skip + } + + if (rawTake != null) { + const take = Number(rawTake) + + if (!Number.isInteger(take)) { + throw new GeneratedHandlerError( + 400, + `Failed to query the collection: expected the "take" query parameter to be an integer but got "${rawTake}".`, + ) + } + + pagination.take = take + } + + return pagination +} + +/** + * Creates a record predicate from the given search parameters. + * Every search parameter (except the pagination parameters) is + * treated as an equality filter on the record property. Nested + * properties are expressed with dots (e.g. `?address.city=NY`). + */ +function createSearchParametersPredicate( + searchParams: URLSearchParams, +): (record: unknown) => boolean { + const filters: Array<(record: unknown) => boolean> = [] + + for (const parameterName of new Set(searchParams.keys())) { + if (paginationParameterNames.includes(parameterName)) { + continue + } + + const expectedValues = searchParams.getAll(parameterName) + const propertyPath = parameterName.split('.') + + filters.push((record) => { + const actualValue = getValueAtPath(record, propertyPath) + + if (actualValue == null) { + return false + } + + // Match records whose array property contains + // any of the expected values (e.g. `?roles=admin`). + if (Array.isArray(actualValue)) { + return actualValue.some((item) => { + return expectedValues.includes(String(item)) + }) + } + + return expectedValues.includes(String(actualValue)) + }) + } + + return (record) => { + return filters.every((filter) => { + return filter(record) + }) + } +} diff --git a/src/data/graphql/generate-graphql-handlers.ts b/src/data/graphql/generate-graphql-handlers.ts new file mode 100644 index 0000000..32a56a8 --- /dev/null +++ b/src/data/graphql/generate-graphql-handlers.ts @@ -0,0 +1,417 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import { type Collection, Query } from '@msw/data' +import { + graphql as executeGraphQL, + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + parse, + valueFromASTUntyped, + visit, +} from 'graphql' +import { + graphql, + HttpResponse, + type GraphQLHandler, + type GraphQLQuery, + type GraphQLResponseBody, +} from 'msw' +import { invariant } from 'outvariant' +import pluralize from 'pluralize' +import { getValueAtPath } from '../utils/get-value-at-path.js' +import { toSerializable } from '../utils/to-serializable.js' +import { capitalize, inferCollectionTypes } from './infer-graphql-types.js' +import { matchesWhere, type WhereArgument } from './where-filter.js' + +export interface FromCollectionGraphQLOptions { + format: 'graphql' + /** + * Name of the resource represented by this collection (singular). + * Used to derive the operation and type names, including + * the pluralized ones (e.g. `person` -> `person`, `people`, + * `createPerson`, `Person`). + */ + name: string + /** + * The URL of the GraphQL endpoint to scope the handlers to. + * If not provided, the handlers match GraphQL operations + * against any endpoint. + */ + baseUrl?: string + /** + * The record property representing its identity. + * Typed as `ID` and used to resolve the `cursor` pagination argument. + * @default "id" + */ + primaryKey?: string +} + +interface PaginationArguments { + skip?: number | null + take?: number | null + cursor?: string | null +} + +/** + * Generates GraphQL request handlers representing + * a GraphQL API for the given collection. + * + * The following operations are supported (for `name: 'user'`): + * + * - `user(where)`, returns the first matching record or null. + * - `users(where, skip, take, cursor)`, returns all matching records. + * - `createUser(data)`, creates a new record. + * - `updateUser(where, data)` / `updateUsers(where, data)`, merges + * `data` into the matching record(s). + * - `deleteUser(where)` / `deleteUsers(where)`, deletes the matching record(s). + * + * The GraphQL types are inferred from the collection records + * on each request since the collection schema (a Standard Schema) + * cannot be introspected. + */ +export function generateGraphQLHandlers( + collection: Collection, + options: FromCollectionGraphQLOptions, +): Array { + const { name, primaryKey = 'id' } = options + + invariant( + typeof name === 'string' && name.length > 0, + 'Failed to generate GraphQL handlers from collection: expected the "name" option to be a non-empty string but got %j. The resource name is required to derive the GraphQL operation and type names.', + name, + ) + + const pluralName = pluralize(name) + + invariant( + pluralName !== name, + 'Failed to generate GraphQL handlers from collection: the plural form of the "name" option ("%s") equals its singular form, making the list operations indistinguishable. Please choose a different resource name.', + name, + ) + + const target = + options.baseUrl == null ? graphql : graphql.link(options.baseUrl) + + return [ + target.operation( + async ({ request, query, variables, operationName }) => { + const schema = generateGraphQLSchema(collection, { + name, + pluralName, + primaryKey, + /** + * @note Provide the "data" mutation arguments as the fallback + * type inference samples. When the collection has no records + * to infer from yet, the first create mutation bootstraps + * the types from its own input, making its properties both + * accepted and selectable. + */ + fallbackRecordSamples: extractDataArgumentValues(query, variables), + }) + + const result = await executeGraphQL({ + schema, + source: query, + variableValues: variables, + /** + * @note Read the operation name from the request itself. + * The resolver argument is derived from the first operation + * in the document, which is wrong for the requests that + * execute one of multiple defined operations. + */ + operationName: + (await getRequestedOperationName(request)) ?? operationName, + }) + + return HttpResponse.json( + toSerializable(result) as GraphQLResponseBody, + ) + }, + ), + ] +} + +/** + * Returns the name of the operation this GraphQL request executes. + */ +async function getRequestedOperationName( + request: Request, +): Promise { + try { + if (request.method === 'GET') { + const url = new URL(request.url) + + return url.searchParams.get('operationName') ?? undefined + } + + if (request.headers.get('content-type')?.includes('application/json')) { + const body: unknown = await request.clone().json() + + if ( + body != null && + typeof body === 'object' && + 'operationName' in body && + typeof body.operationName === 'string' + ) { + return body.operationName + } + } + } catch { + // Ignore the request body errors and defer + // to the operation name derived from the document. + } + + return undefined +} + +/** + * Extracts the values of all `data` arguments from the given + * GraphQL operation (e.g. `createUser(data: { name: "John" })`). + */ +function extractDataArgumentValues( + source: string, + variables?: Record, +): Array { + const dataValues: Array = [] + + try { + visit(parse(source), { + Argument(node) { + if (node.name.value === 'data') { + dataValues.push(valueFromASTUntyped(node.value, variables)) + } + }, + }) + } catch { + // Ignore the operations that cannot be parsed. The parsing error + // will surface in the operation execution result. + } + + return dataValues +} + +/** + * Generates a GraphQL schema for the given collection, + * inferring the entity types from the collection records. + */ +export function generateGraphQLSchema( + collection: Collection, + options: { + name: string + pluralName: string + primaryKey: string + fallbackRecordSamples?: Array + }, +): GraphQLSchema { + const { name, pluralName, primaryKey } = options + const typeName = capitalize(name) + + const serializedRecords = collection.all().map((record) => { + return toSerializable(record) + }) + + /** + * @note The records are the source of truth for the type inference. + * The fallback samples only bootstrap the types for empty collections. + */ + const recordSamples = + serializedRecords.length > 0 + ? serializedRecords + : (options.fallbackRecordSamples ?? []) + + const { entityType, entityInputType, whereInputType } = inferCollectionTypes( + recordSamples, + { typeName, primaryKey }, + ) + + const createWhereQuery = (where: WhereArgument | null | undefined) => { + return new Query>( + (record: unknown) => { + return where == null || matchesWhere(record, where) + }, + ) + } + + const createPrimaryKeyQuery = (expectedValue: string) => { + return new Query>( + (record: unknown) => { + const actualValue = getValueAtPath(record, [primaryKey]) + + return actualValue != null && String(actualValue) === expectedValue + }, + ) + } + + const updateData = (data: unknown) => { + invariant( + data != null && typeof data === 'object' && !Array.isArray(data), + 'Failed to update the collection record: expected the "data" argument to be an object but got %j.', + data, + ) + + return (draft: unknown) => { + Object.assign(draft as Record, data) + } + } + + const queryType = new GraphQLObjectType({ + name: 'Query', + fields: { + // Get the first record matching the query. + [name]: { + type: entityType, + args: { + where: { type: whereInputType }, + }, + resolve(_: unknown, args: { where?: WhereArgument | null }) { + const record = collection.findFirst(createWhereQuery(args.where)) + + if (record == null) { + return null + } + + return toSerializable(record) + }, + }, + // Get all records matching the query. + [pluralName]: { + type: new GraphQLList(entityType), + args: { + where: { type: whereInputType }, + skip: { type: GraphQLInt }, + take: { type: GraphQLInt }, + cursor: { type: GraphQLID }, + }, + resolve( + _: unknown, + args: { where?: WhereArgument | null } & PaginationArguments, + ) { + const cursorRecord = + args.cursor == null + ? undefined + : collection.findFirst(createPrimaryKeyQuery(args.cursor)) + + if (args.cursor != null && cursorRecord == null) { + return [] + } + + const records = collection.findMany(createWhereQuery(args.where), { + skip: args.skip ?? undefined, + take: args.take ?? undefined, + cursor: cursorRecord, + }) + + return records.map((record) => { + return toSerializable(record) + }) + }, + }, + }, + }) + + const mutationType = new GraphQLObjectType({ + name: 'Mutation', + fields: { + // Create a new record. + [`create${typeName}`]: { + type: entityType, + args: { + data: { type: new GraphQLNonNull(entityInputType) }, + }, + async resolve(_: unknown, args: { data: unknown }) { + const createdRecord = await collection.create( + args.data as StandardSchemaV1.InferInput, + ) + + return toSerializable(createdRecord) + }, + }, + // Update the first record matching the query. + [`update${typeName}`]: { + type: entityType, + args: { + where: { type: new GraphQLNonNull(whereInputType) }, + data: { type: new GraphQLNonNull(entityInputType) }, + }, + async resolve( + _: unknown, + args: { where: WhereArgument; data: unknown }, + ) { + const updatedRecord = await collection.update( + createWhereQuery(args.where), + { + strict: true, + data: updateData(args.data), + }, + ) + + return toSerializable(updatedRecord) + }, + }, + // Update all records matching the query. + [`update${capitalize(pluralName)}`]: { + type: new GraphQLList(entityType), + args: { + where: { type: new GraphQLNonNull(whereInputType) }, + data: { type: new GraphQLNonNull(entityInputType) }, + }, + async resolve( + _: unknown, + args: { where: WhereArgument; data: unknown }, + ) { + const updatedRecords = await collection.updateMany( + createWhereQuery(args.where), + { + data: updateData(args.data), + }, + ) + + return updatedRecords.map((record) => { + return toSerializable(record) + }) + }, + }, + // Delete the first record matching the query. + [`delete${typeName}`]: { + type: entityType, + args: { + where: { type: new GraphQLNonNull(whereInputType) }, + }, + resolve(_: unknown, args: { where: WhereArgument }) { + const deletedRecord = collection.delete( + createWhereQuery(args.where), + { + strict: true, + }, + ) + + return toSerializable(deletedRecord) + }, + }, + // Delete all records matching the query. + [`delete${capitalize(pluralName)}`]: { + type: new GraphQLList(entityType), + args: { + where: { type: new GraphQLNonNull(whereInputType) }, + }, + resolve(_: unknown, args: { where: WhereArgument }) { + const deletedRecords = collection.deleteMany( + createWhereQuery(args.where), + ) + + return deletedRecords.map((record) => { + return toSerializable(record) + }) + }, + }, + }, + }) + + return new GraphQLSchema({ + query: queryType, + mutation: mutationType, + }) +} diff --git a/src/data/graphql/infer-graphql-types.ts b/src/data/graphql/infer-graphql-types.ts new file mode 100644 index 0000000..97b8c9c --- /dev/null +++ b/src/data/graphql/infer-graphql-types.ts @@ -0,0 +1,342 @@ +import { + type GraphQLFieldConfigMap, + type GraphQLInputFieldConfigMap, + type GraphQLInputType, + type GraphQLOutputType, + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + GraphQLScalarType, + GraphQLString, + valueFromASTUntyped, +} from 'graphql' + +/** + * An arbitrary JSON value. Used as the type for the mutation + * inputs (the collection schema performs the actual validation) + * and as the fallback type for the fields whose type cannot + * be inferred from the collection records. + */ +export const GraphQLJson = new GraphQLScalarType({ + name: 'JSON', + description: 'An arbitrary JSON value.', + serialize(value) { + return value + }, + parseValue(value) { + return value + }, + parseLiteral(ast, variables) { + return valueFromASTUntyped(ast, variables) + }, +}) + +function createComparatorInputType( + name: string, + scalarType: GraphQLScalarType, + comparatorNames: Array, +): GraphQLInputObjectType { + const listComparatorNames = ['in', 'notIn', 'between', 'notBetween'] + const fields: GraphQLInputFieldConfigMap = {} + + for (const comparatorName of comparatorNames) { + const isListComparator = listComparatorNames.includes(comparatorName) + + fields[comparatorName] = { + type: isListComparator ? new GraphQLList(scalarType) : scalarType, + } + } + + return new GraphQLInputObjectType({ name, fields }) +} + +const equalityComparators = ['equals', 'notEquals', 'in', 'notIn'] +const stringComparators = equalityComparators.concat([ + 'contains', + 'notContains', +]) +const numberComparators = equalityComparators.concat([ + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + 'notBetween', +]) + +export const comparatorInputTypes = { + id: createComparatorInputType( + 'IdComparatorInput', + GraphQLID, + stringComparators, + ), + string: createComparatorInputType( + 'StringComparatorInput', + GraphQLString, + stringComparators, + ), + int: createComparatorInputType( + 'IntComparatorInput', + GraphQLInt, + numberComparators, + ), + float: createComparatorInputType( + 'FloatComparatorInput', + GraphQLFloat, + numberComparators, + ), + boolean: createComparatorInputType('BooleanComparatorInput', GraphQLBoolean, [ + 'equals', + 'notEquals', + ]), +} + +export interface InferredCollectionTypes { + entityType: GraphQLObjectType + entityInputType: GraphQLInputObjectType + whereInputType: GraphQLInputObjectType +} + +export interface InferCollectionTypesOptions { + /** + * Name of the GraphQL object type describing a collection record. + */ + typeName: string + /** + * The record property representing its identity (typed as `ID`). + */ + primaryKey: string +} + +/** + * Infers the GraphQL runtime types for the collection from + * the given (serialized) collection records. The collection schema + * is a Standard Schema and cannot be introspected, so the types + * are derived from the actual record values instead. + */ +export function inferCollectionTypes( + records: Array, + options: InferCollectionTypesOptions, +): InferredCollectionTypes { + const { typeName, primaryKey } = options + const recordSamples = records.filter(isPlainObject) + + const entityFields = inferObjectFields(typeName, recordSamples, primaryKey) + + // An object type must have at least one field. Assume the identity + // field for the collections that have no records to infer from. + if (Object.keys(entityFields).length === 0) { + entityFields[primaryKey] = { type: GraphQLID } + } + + const entityType = new GraphQLObjectType({ + name: typeName, + fields: entityFields, + }) + + const whereInputFields: GraphQLInputFieldConfigMap = {} + + for (const [fieldName, fieldConfig] of Object.entries(entityFields)) { + const comparatorInputType = getComparatorInputType(fieldConfig.type) + + if (comparatorInputType != null) { + whereInputFields[fieldName] = { type: comparatorInputType } + } + } + + if (Object.keys(whereInputFields).length === 0) { + whereInputFields[primaryKey] = { type: comparatorInputTypes.id } + } + + const whereInputType = new GraphQLInputObjectType({ + name: `${typeName}WhereInput`, + fields: whereInputFields, + }) + + const entityInputType = toInputObjectType(entityType) + + return { entityType, entityInputType, whereInputType } +} + +/** + * Derives a GraphQL input object type from the given object type, + * mirroring its fields (e.g. the `UserInput` type for `User`). + * The input types describe the `data` argument of the mutations, + * making them strictly typed. + */ +function toInputObjectType( + objectType: GraphQLObjectType, +): GraphQLInputObjectType { + return new GraphQLInputObjectType({ + name: `${objectType.name}Input`, + fields: () => { + const inputFields: GraphQLInputFieldConfigMap = {} + + for (const [fieldName, field] of Object.entries(objectType.getFields())) { + inputFields[fieldName] = { type: toInputType(field.type) } + } + + return inputFields + }, + }) +} + +function toInputType(outputType: GraphQLOutputType): GraphQLInputType { + if (outputType instanceof GraphQLList) { + return new GraphQLList(toInputType(outputType.ofType)) + } + + if (outputType instanceof GraphQLObjectType) { + return toInputObjectType(outputType) + } + + if (outputType instanceof GraphQLScalarType) { + return outputType + } + + // The inferred types consist of scalars, lists, and object types + // only, so any other type is a sign of a mistake. + throw new TypeError( + `Failed to derive an input type from "${String(outputType)}": unsupported type`, + ) +} + +function inferObjectFields( + typeName: string, + samples: Array>, + primaryKey?: string, +): GraphQLFieldConfigMap { + const fields: GraphQLFieldConfigMap = {} + const fieldNames = new Set() + + for (const sample of samples) { + for (const fieldName of Object.keys(sample)) { + fieldNames.add(fieldName) + } + } + + for (const fieldName of fieldNames) { + const fieldValues: Array = [] + + for (const sample of samples) { + if (sample[fieldName] !== undefined) { + fieldValues.push(sample[fieldName]) + } + } + + fields[fieldName] = { + type: inferValueType( + `${typeName}${capitalize(fieldName)}`, + fieldValues, + fieldName === primaryKey, + ), + } + } + + return fields +} + +/** + * Infers a GraphQL output type from the given value samples. + * Falls back to the `JSON` scalar for the values whose type + * cannot be represented (mixed types, empty objects, no samples). + */ +function inferValueType( + typeName: string, + values: Array, + isPrimaryKey: boolean, +): GraphQLOutputType { + const definedValues = values.filter((value) => { + return value != null + }) + + if (definedValues.length === 0) { + return GraphQLJson + } + + if (isPrimaryKey && definedValues.every(isScalar)) { + return GraphQLID + } + + if (definedValues.every((value) => typeof value === 'boolean')) { + return GraphQLBoolean + } + + if (definedValues.every((value) => typeof value === 'number')) { + const isIntegerOnly = definedValues.every((value) => { + return Number.isInteger(value) + }) + + return isIntegerOnly ? GraphQLInt : GraphQLFloat + } + + if (definedValues.every((value) => typeof value === 'string')) { + return GraphQLString + } + + if (definedValues.every(Array.isArray)) { + const itemValues = definedValues.flat(1) + + return new GraphQLList(inferValueType(typeName, itemValues, false)) + } + + if (definedValues.every(isPlainObject)) { + const objectFields = inferObjectFields(typeName, definedValues) + + if (Object.keys(objectFields).length === 0) { + return GraphQLJson + } + + return new GraphQLObjectType({ + name: typeName, + fields: objectFields, + }) + } + + return GraphQLJson +} + +function getComparatorInputType( + fieldType: GraphQLOutputType, +): GraphQLInputType | undefined { + switch (fieldType) { + case GraphQLID: { + return comparatorInputTypes.id + } + case GraphQLString: { + return comparatorInputTypes.string + } + case GraphQLInt: { + return comparatorInputTypes.int + } + case GraphQLFloat: { + return comparatorInputTypes.float + } + case GraphQLBoolean: { + return comparatorInputTypes.boolean + } + default: { + return undefined + } + } +} + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function isScalar(value: unknown): boolean { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) +} + +export function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} diff --git a/src/data/graphql/where-filter.ts b/src/data/graphql/where-filter.ts new file mode 100644 index 0000000..6f88022 --- /dev/null +++ b/src/data/graphql/where-filter.ts @@ -0,0 +1,114 @@ +/** + * The `where` argument of the generated GraphQL operations: + * a map of record properties to their comparators + * (e.g. `{ name: { contains: "John" } }`). + */ +export type WhereArgument = Record | null> + +type ComparatorFunction = ( + actualValue: unknown, + expectedValue: unknown, +) => boolean + +const looseEquals: ComparatorFunction = (actualValue, expectedValue) => { + if (actualValue === expectedValue) { + return true + } + + // Support comparing values of different types, like the number 123 + // and the string "123" coming from an `ID` input. + if (actualValue == null || expectedValue == null) { + return false + } + + return String(actualValue) === String(expectedValue) +} + +const includedIn: ComparatorFunction = (actualValue, expectedValues) => { + return ( + Array.isArray(expectedValues) && + expectedValues.some((expectedValue) => { + return looseEquals(actualValue, expectedValue) + }) + ) +} + +const withinRange: ComparatorFunction = (actualValue, expectedRange) => { + if (!Array.isArray(expectedRange) || expectedRange.length !== 2) { + return false + } + + const [minValue, maxValue] = expectedRange + + return ( + Number(actualValue) >= Number(minValue) && + Number(actualValue) <= Number(maxValue) + ) +} + +const comparatorFunctions: Record = { + equals: looseEquals, + notEquals: (actualValue, expectedValue) => { + return !looseEquals(actualValue, expectedValue) + }, + contains: (actualValue, expectedValue) => { + return String(actualValue).includes(String(expectedValue)) + }, + notContains: (actualValue, expectedValue) => { + return !String(actualValue).includes(String(expectedValue)) + }, + in: includedIn, + notIn: (actualValue, expectedValues) => { + return !includedIn(actualValue, expectedValues) + }, + gt: (actualValue, expectedValue) => { + return Number(actualValue) > Number(expectedValue) + }, + gte: (actualValue, expectedValue) => { + return Number(actualValue) >= Number(expectedValue) + }, + lt: (actualValue, expectedValue) => { + return Number(actualValue) < Number(expectedValue) + }, + lte: (actualValue, expectedValue) => { + return Number(actualValue) <= Number(expectedValue) + }, + between: withinRange, + notBetween: (actualValue, expectedRange) => { + return !withinRange(actualValue, expectedRange) + }, +} + +/** + * Returns true if the given record matches all the comparators + * described by the given `where` argument. + */ +export function matchesWhere(record: unknown, where: WhereArgument): boolean { + if (record == null || typeof record !== 'object') { + return false + } + + return Object.entries(where).every(([propertyName, comparators]) => { + if (comparators == null) { + return true + } + + const actualValue = (record as Record)[propertyName] + + return Object.entries(comparators).every( + ([comparatorName, expectedValue]) => { + if (expectedValue == null) { + return true + } + + const comparator = comparatorFunctions[comparatorName] + + if (comparator == null) { + return false + } + + return comparator(actualValue, expectedValue) + }, + ) + }) +} diff --git a/src/data/utils/get-value-at-path.ts b/src/data/utils/get-value-at-path.ts new file mode 100644 index 0000000..c09e9ae --- /dev/null +++ b/src/data/utils/get-value-at-path.ts @@ -0,0 +1,13 @@ +export function getValueAtPath(target: unknown, path: Array): unknown { + let currentValue = target + + for (const segment of path) { + if (currentValue == null || typeof currentValue !== 'object') { + return undefined + } + + currentValue = (currentValue as Record)[segment] + } + + return currentValue +} diff --git a/src/data/utils/to-serializable.test.ts b/src/data/utils/to-serializable.test.ts new file mode 100644 index 0000000..d79a654 --- /dev/null +++ b/src/data/utils/to-serializable.test.ts @@ -0,0 +1,55 @@ +import { toSerializable } from './to-serializable.js' + +it('returns primitives as-is', () => { + expect(toSerializable('hello')).toBe('hello') + expect(toSerializable(123)).toBe(123) + expect(toSerializable(false)).toBe(false) + expect(toSerializable(null)).toBe(null) + expect(toSerializable(undefined)).toBe(undefined) +}) + +it('serializes plain objects and arrays', () => { + expect( + toSerializable({ id: 1, tags: ['a', 'b'], nested: { value: true } }), + ).toEqual({ id: 1, tags: ['a', 'b'], nested: { value: true } }) +}) + +it('resolves enumerable getter properties', () => { + const object = {} + Object.defineProperty(object, 'computed', { + enumerable: true, + get() { + return 'resolved' + }, + }) + + expect(toSerializable(object)).toEqual({ computed: 'resolved' }) +}) + +it('respects custom "toJSON" methods', () => { + const date = new Date('2000-01-01T00:00:00.000Z') + + expect(toSerializable({ createdAt: date })).toEqual({ + createdAt: '2000-01-01T00:00:00.000Z', + }) +}) + +it('omits circular references', () => { + const user: Record = { id: 1 } + const post: Record = { title: 'First', author: user } + user.posts = [post] + + expect(toSerializable(user)).toEqual({ + id: 1, + posts: [{ title: 'First' }], + }) +}) + +it('preserves repeated non-circular references', () => { + const author = { name: 'John' } + const library = { books: [{ author }, { author }] } + + expect(toSerializable(library)).toEqual({ + books: [{ author: { name: 'John' } }, { author: { name: 'John' } }], + }) +}) diff --git a/src/data/utils/to-serializable.ts b/src/data/utils/to-serializable.ts new file mode 100644 index 0000000..4c634c4 --- /dev/null +++ b/src/data/utils/to-serializable.ts @@ -0,0 +1,59 @@ +/** + * Returns a JSON-serializable copy of the given value. + * + * Unlike `JSON.stringify`, this resolves enumerable getter properties + * (e.g. collection relations) and omits circular references instead + * of throwing. Circular references occur in two-way relations, such as + * `user.posts[0].author` pointing back to `user`. + */ +export function toSerializable( + value: unknown, + ancestors: Set = new Set(), +): unknown { + if (value === null || typeof value !== 'object') { + return value + } + + // Omit values that reference their own ancestor (a circular reference). + if (ancestors.has(value)) { + return undefined + } + + const valueWithToJson = value as { toJSON?: () => unknown } + + if (typeof valueWithToJson.toJSON === 'function') { + return valueWithToJson.toJSON() + } + + ancestors.add(value) + + try { + if (Array.isArray(value)) { + return value.map((item) => { + const serializableItem = toSerializable(item, ancestors) + + // Mirror `JSON.stringify`, which serializes + // non-serializable array items as `null`. + if (serializableItem === undefined) { + return null + } + + return serializableItem + }) + } + + const serializableObject: Record = {} + + for (const [key, propertyValue] of Object.entries(value)) { + const serializablePropertyValue = toSerializable(propertyValue, ancestors) + + if (serializablePropertyValue !== undefined) { + serializableObject[key] = serializablePropertyValue + } + } + + return serializableObject + } finally { + ancestors.delete(value) + } +} diff --git a/test/data/graphql.test.ts b/test/data/graphql.test.ts new file mode 100644 index 0000000..d4f60b3 --- /dev/null +++ b/test/data/graphql.test.ts @@ -0,0 +1,827 @@ +import { Collection } from '@msw/data' +import { GraphQLHandler } from 'msw' +import { z } from 'zod' +import { fromCollection } from '../../src/data/from-collection.js' +import { withHandlers } from '../support/with-handlers.js' + +const graphqlUrl = 'https://api.example.com/graphql' + +const userSchema = z.object({ + id: z.string(), + name: z.string(), + age: z.number(), + subscribed: z.boolean().default(false), +}) + +function createUsersCollection() { + return new Collection({ schema: userSchema }) +} + +async function seedUsers(users: ReturnType) { + await users.create({ id: 'abc-123', name: 'John', age: 32 }) + await users.create({ id: 'def-456', name: 'Kate', age: 27, subscribed: true }) + await users.create({ id: 'ghi-789', name: 'Alice', age: 40 }) +} + +interface GraphQLOperationInput { + query: string + variables?: Record + operationName?: string +} + +async function executeOperation( + input: GraphQLOperationInput, + url: string = graphqlUrl, +) { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }) + + return response.json() +} + +it('generates a single graphql handler', () => { + const users = createUsersCollection() + const handlers = fromCollection(users, { format: 'graphql', name: 'user' }) + + expect(handlers).toHaveLength(1) + expect(handlers[0]).toBeInstanceOf(GraphQLHandler) +}) + +it('throws when the "name" option is missing', () => { + const users = createUsersCollection() + + expect(() => { + // @ts-expect-error Intentionally missing the required "name" option. + fromCollection(users, { format: 'graphql' }) + }).toThrow( + 'Failed to generate GraphQL handlers from collection: expected the "name" option to be a non-empty string', + ) +}) + +it('throws when the derived plural name equals the singular name', () => { + const users = createUsersCollection() + + expect(() => { + fromCollection(users, { + format: 'graphql', + name: 'series', + }) + }).toThrow( + 'Failed to generate GraphQL handlers from collection: the plural form of the "name" option ("series") equals its singular form', + ) +}) + +it('derives irregular plural operation names', async () => { + const people = new Collection({ + schema: z.object({ + id: z.string(), + name: z.string(), + }), + }) + await people.create({ id: 'abc-123', name: 'John' }) + + const result = await withHandlers( + fromCollection(people, { + format: 'graphql', + name: 'person', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListPeople { people { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + people: [{ name: 'John' }], + }, + }) +}) + +describe('queries', () => { + it('returns all records with the selected fields only', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users { id name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [ + { id: 'abc-123', name: 'John' }, + { id: 'def-456', name: 'Kate' }, + { id: 'ghi-789', name: 'Alice' }, + ], + }, + }) + }) + + it('supports the "equals" comparator in the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(where: { name: { equals: "Kate" } }) { id } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ id: 'def-456' }], + }, + }) + }) + + it('supports the "contains" comparator in the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(where: { name: { contains: "o" } }) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'John' }], + }, + }) + }) + + it('supports the number comparators in the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(where: { age: { gt: 30, lt: 40 } }) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'John' }], + }, + }) + }) + + it('supports the "in" comparator in the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(where: { id: { in: ["abc-123", "ghi-789"] } }) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'John' }, { name: 'Alice' }], + }, + }) + }) + + it('supports the boolean comparators in the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(where: { subscribed: { equals: true } }) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'Kate' }], + }, + }) + }) + + it('supports the "where" argument provided as a typed variable', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers($where: UserWhereInput) { users(where: $where) { name } }`, + variables: { + where: { age: { gte: 40 } }, + }, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'Alice' }], + }, + }) + }) + + it('returns the first matching record for the singular query', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query GetUser { user(where: { id: { equals: "def-456" } }) { id name } }`, + }), + ) + + expect(result).toEqual({ + data: { + user: { id: 'def-456', name: 'Kate' }, + }, + }) + }) + + it('returns null for the singular query without matches', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query GetUser { user(where: { id: { equals: "unknown" } }) { id } }`, + }), + ) + + expect(result).toEqual({ + data: { + user: null, + }, + }) + }) + + it('supports offset-based pagination', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(skip: 1, take: 1) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'Kate' }], + }, + }) + }) + + it('supports cursor-based pagination', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(cursor: "def-456", take: 2) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [{ name: 'Kate' }, { name: 'Alice' }], + }, + }) + }) + + it('returns an empty list for an unknown cursor', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users(cursor: "unknown") { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [], + }, + }) + }) + + it('executes the operation matching the "operationName"', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: ` +query ListUsers { users { id } } +query GetKate { user(where: { name: { equals: "Kate" } }) { name } } +`, + operationName: 'GetKate', + }), + ) + + expect(result).toEqual({ + data: { + user: { name: 'Kate' }, + }, + }) + }) + + it('returns validation errors for fields absent from all records', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users { unknownField } }`, + }), + ) + + expect(result.data).toBeUndefined() + expect(result.errors).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('Cannot query field "unknownField"'), + }), + ]) + }) + + it('returns an empty list for an empty collection', async () => { + const users = createUsersCollection() + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query ListUsers { users { id } }`, + }), + ) + + expect(result).toEqual({ + data: { + users: [], + }, + }) + }) +}) + +describe('mutations', () => { + it('creates a new record from an inline "data" literal', async () => { + const users = createUsersCollection() + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + /** + * @note The properties of the "data" argument are selectable + * even though the collection is empty: the argument values + * are used as additional type inference samples. + */ + query: `mutation CreateUser { createUser(data: { id: "abc-123", name: "John", age: 32 }) { id name age } }`, + }), + ) + + expect(result).toEqual({ + data: { + createUser: { id: 'abc-123', name: 'John', age: 32 }, + }, + }) + expect(users.count()).toBe(1) + }) + + it('creates a new record from the "data" variable', async () => { + const users = createUsersCollection() + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation CreateUser($data: UserInput!) { createUser(data: $data) { id name } }`, + variables: { + data: { id: 'abc-123', name: 'John', age: 32 }, + }, + }), + ) + + expect(result).toEqual({ + data: { + createUser: { id: 'abc-123', name: 'John' }, + }, + }) + expect(users.count()).toBe(1) + }) + + it('rejects the "data" argument with unknown properties', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation CreateUser { createUser(data: { id: "xyz-000", unknownProperty: true }) { id } }`, + }), + ) + + expect(result.data).toBeUndefined() + expect(result.errors).toEqual([ + expect.objectContaining({ + message: expect.stringContaining( + 'Field "unknownProperty" is not defined by type "UserInput"', + ), + }), + ]) + expect(users.count()).toBe(3) + }) + + it('returns errors for initial values not matching the schema', async () => { + const users = createUsersCollection() + vi.spyOn(console, 'error').mockImplementation(() => void 0) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation CreateUser { createUser(data: { id: "abc-123" }) { id } }`, + }), + ) + + expect(result.data).toEqual({ createUser: null }) + expect(result.errors).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('does not match the schema'), + }), + ]) + expect(users.count()).toBe(0) + }) + + it('updates the first record matching the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation UpdateUser { updateUser(where: { id: { equals: "abc-123" } }, data: { name: "Johnatan" }) { id name age } }`, + }), + ) + + expect(result).toEqual({ + data: { + updateUser: { id: 'abc-123', name: 'Johnatan', age: 32 }, + }, + }) + expect(users.findFirst((query) => query.where({ id: 'abc-123' }))).toEqual({ + id: 'abc-123', + name: 'Johnatan', + age: 32, + subscribed: false, + }) + }) + + it('returns errors when updating a non-existing record', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation UpdateUser { updateUser(where: { id: { equals: "unknown" } }, data: { name: "Nobody" }) { id } }`, + }), + ) + + expect(result.data).toEqual({ updateUser: null }) + expect(result.errors).toEqual([ + expect.objectContaining({ + message: + 'Failed to execute "update" on collection: no record found matching the query', + }), + ]) + }) + + it('updates all records matching the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation UpdateUsers { updateUsers(where: { age: { gte: 30 } }, data: { subscribed: true }) { name subscribed } }`, + }), + ) + + expect(result).toEqual({ + data: { + updateUsers: [ + { name: 'John', subscribed: true }, + { name: 'Alice', subscribed: true }, + ], + }, + }) + }) + + it('deletes the first record matching the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation DeleteUser { deleteUser(where: { id: { equals: "def-456" } }) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + deleteUser: { name: 'Kate' }, + }, + }) + expect(users.count()).toBe(2) + }) + + it('returns errors when deleting a non-existing record', async () => { + const users = createUsersCollection() + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation DeleteUser { deleteUser(where: { id: { equals: "unknown" } }) { id } }`, + }), + ) + + expect(result.data).toEqual({ deleteUser: null }) + expect(result.errors).toEqual([ + expect.objectContaining({ + message: + 'Failed to execute "delete" on collection: no record found matching the query', + }), + ]) + }) + + it('deletes all records matching the "where" argument', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `mutation DeleteUsers { deleteUsers(where: { age: { lt: 40 } }) { name } }`, + }), + ) + + expect(result).toEqual({ + data: { + deleteUsers: [{ name: 'John' }, { name: 'Kate' }], + }, + }) + expect(users.count()).toBe(1) + }) + + it('creates records in an empty collection', async () => { + const users = createUsersCollection() + + const result = await withHandlers( + fromCollection(users, { + format: 'graphql', + name: 'user', + baseUrl: graphqlUrl, + }), + async () => { + await executeOperation({ + query: `mutation CreateUser($data: UserInput!) { createUser(data: $data) { id } }`, + variables: { + data: { id: 'abc-123', name: 'John', age: 32 }, + }, + }) + + // The schema is inferred per request so the newly created + // record is immediately queryable with its actual fields. + return executeOperation({ + query: `query ListUsers { users { id name } }`, + }) + }, + ) + + expect(result).toEqual({ + data: { + users: [{ id: 'abc-123', name: 'John' }], + }, + }) + }) +}) + +describe('endpoint scoping', () => { + it('matches any endpoint without the "baseUrl" option', async () => { + const users = createUsersCollection() + await seedUsers(users) + + const result = await withHandlers( + fromCollection(users, { format: 'graphql', name: 'user' }), + () => + executeOperation( + { query: `query ListUsers { users { id } }` }, + 'https://another.example.com/api', + ), + ) + + expect(result.data.users).toHaveLength(3) + }) +}) + +describe('relations', () => { + it('supports querying records with circular relations', async () => { + const authorSchema = z.object({ + id: z.string(), + get posts() { + return z.array(postSchema).default([]) + }, + }) + const postSchema = z.object({ + title: z.string(), + get author() { + return authorSchema.optional() + }, + }) + + const authors = new Collection({ schema: authorSchema }) + const posts = new Collection({ schema: postSchema }) + + authors.defineRelations(({ many }) => ({ + posts: many(posts), + })) + posts.defineRelations(({ one }) => ({ + author: one(authors), + })) + + const author = await authors.create({ id: 'abc-123' }) + const post = await posts.create({ title: 'First', author }) + await authors.update(author, { + data(draft) { + draft.posts.push(post) + }, + }) + + const result = await withHandlers( + fromCollection(authors, { + format: 'graphql', + name: 'author', + baseUrl: graphqlUrl, + }), + () => + executeOperation({ + query: `query GetAuthor { author(where: { id: { equals: "abc-123" } }) { id posts { title } } }`, + }), + ) + + expect(result).toEqual({ + data: { + author: { + id: 'abc-123', + posts: [{ title: 'First' }], + }, + }, + }) + }) +}) diff --git a/test/data/http.test.ts b/test/data/http.test.ts new file mode 100644 index 0000000..3e6351f --- /dev/null +++ b/test/data/http.test.ts @@ -0,0 +1,494 @@ +import { Collection } from '@msw/data' +import { z } from 'zod' +import { fromCollection } from '../../src/data/from-collection.js' +import { withHandlers } from '../support/with-handlers.js' + +const userSchema = z.object({ + id: z.string(), + name: z.string(), + roles: z.array(z.string()).default([]), + address: z + .object({ + city: z.string(), + }) + .optional(), + nickname: z.string().optional(), +}) + +function createUsersCollection() { + return new Collection({ schema: userSchema }) +} + +const baseUrl = 'https://api.example.com/users' + +it('generates handlers relative to the root by default', () => { + const users = createUsersCollection() + const handlers = fromCollection(users) + + expect( + handlers.map((handler) => `${handler.info.method} ${handler.info.path}`), + ).toEqual([ + 'GET /', + 'GET /:id', + 'POST /', + 'PUT /:id', + 'PATCH /:id', + 'DELETE /:id', + ]) +}) + +it('generates handlers relative to the custom base url', () => { + const users = createUsersCollection() + const handlers = fromCollection(users, { baseUrl }) + + expect( + handlers.map((handler) => `${handler.info.method} ${handler.info.path}`), + ).toEqual([ + `GET ${baseUrl}`, + `GET ${baseUrl}/:id`, + `POST ${baseUrl}`, + `PUT ${baseUrl}/:id`, + `PATCH ${baseUrl}/:id`, + `DELETE ${baseUrl}/:id`, + ]) +}) + +describe('GET (list)', () => { + it('returns all records', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John' }) + await users.create({ id: 'def-456', name: 'Kate' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(baseUrl), + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual([ + { id: 'abc-123', name: 'John', roles: [] }, + { id: 'def-456', name: 'Kate', roles: [] }, + ]) + }) + + it('filters records by a query parameter', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John' }) + await users.create({ id: 'def-456', name: 'Kate' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}?name=Kate`), + ) + + await expect(response.json()).resolves.toEqual([ + { id: 'def-456', name: 'Kate', roles: [] }, + ]) + }) + + it('filters records by a nested property query parameter', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John', address: { city: 'NY' } }) + await users.create({ id: 'def-456', name: 'Kate', address: { city: 'LA' } }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}?address.city=LA`), + ) + + await expect(response.json()).resolves.toEqual([ + { id: 'def-456', name: 'Kate', roles: [], address: { city: 'LA' } }, + ]) + }) + + it('filters records by an array property query parameter', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John', roles: ['admin'] }) + await users.create({ id: 'def-456', name: 'Kate', roles: ['editor'] }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}?roles=admin`), + ) + + await expect(response.json()).resolves.toEqual([ + { id: 'abc-123', name: 'John', roles: ['admin'] }, + ]) + }) + + it('returns an empty array when no records match the filters', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}?name=Unknown`), + ) + + await expect(response.json()).resolves.toEqual([]) + }) + + it('supports offset-based pagination', async () => { + const users = createUsersCollection() + await users.createMany(5, (index) => ({ + id: `user-${index}`, + name: `User ${index}`, + })) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}?skip=1&take=2`), + ) + + await expect(response.json()).resolves.toEqual([ + { id: 'user-1', name: 'User 1', roles: [] }, + { id: 'user-2', name: 'User 2', roles: [] }, + ]) + }) + + it('returns a 400 response for invalid pagination parameters', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}?skip=-5`), + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + message: + 'Failed to query the collection: expected the "skip" query parameter to be a non-negative integer but got "-5".', + }) + }) +}) + +describe('GET (record)', () => { + it('returns a record by its primary key', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}/abc-123`), + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + id: 'abc-123', + name: 'John', + roles: [], + }) + }) + + it('supports non-string primary keys', async () => { + const posts = new Collection({ + schema: z.object({ + id: z.number(), + title: z.string(), + }), + }) + await posts.create({ id: 1, title: 'First' }) + await posts.create({ id: 2, title: 'Second' }) + + const response = await withHandlers( + fromCollection(posts, { baseUrl: 'https://api.example.com/posts' }), + () => fetch('https://api.example.com/posts/2'), + ) + + await expect(response.json()).resolves.toEqual({ id: 2, title: 'Second' }) + }) + + it('supports a custom primary key', async () => { + const users = new Collection({ + schema: z.object({ + email: z.string(), + name: z.string(), + }), + }) + await users.create({ email: 'john@example.com', name: 'John' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl, primaryKey: 'email' }), + () => fetch(`${baseUrl}/john@example.com`), + ) + + await expect(response.json()).resolves.toEqual({ + email: 'john@example.com', + name: 'John', + }) + }) + + it('returns a 404 response for a non-existing record', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => fetch(`${baseUrl}/unknown`), + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + message: + 'Failed to execute "findFirst" on collection: no record found matching the query', + }) + }) +}) + +describe('POST', () => { + it('creates a new record', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'abc-123', name: 'John' }), + }), + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ + id: 'abc-123', + name: 'John', + roles: [], + }) + expect(users.count()).toBe(1) + }) + + it('returns a 400 response for initial values not matching the schema', async () => { + const users = createUsersCollection() + vi.spyOn(console, 'error').mockImplementation(() => void 0) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'abc-123' }), + }), + ) + + expect(response.status).toBe(400) + expect(users.count()).toBe(0) + }) + + it('returns a 400 response for a malformed request body', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: 'not-json', + }), + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + message: + 'Failed to handle the request: expected a valid JSON request body.', + }) + }) +}) + +describe('PUT', () => { + it('replaces the record with the request body', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John', nickname: 'Johnny' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/abc-123`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'John Maverick' }), + }), + ) + + expect(response.status).toBe(200) + // The properties missing in the request body are removed + // while the primary key is always preserved. + await expect(response.json()).resolves.toEqual({ + id: 'abc-123', + name: 'John Maverick', + roles: [], + }) + expect(users.findFirst((query) => query.where({ id: 'abc-123' }))).toEqual({ + id: 'abc-123', + name: 'John Maverick', + roles: [], + }) + }) + + it('returns a 400 response when the replaced record violates the schema', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John' }) + vi.spyOn(console, 'error').mockImplementation(() => void 0) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/abc-123`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 123 }), + }), + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + message: + 'Failed to update a record with "id" equal to "abc-123": the updated record does not match the collection schema.', + }) + }) + + it('returns a 404 response for a non-existing record', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/unknown`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'John' }), + }), + ) + + expect(response.status).toBe(404) + }) +}) + +describe('PATCH', () => { + it('merges the request body into the record', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John', nickname: 'Johnny' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/abc-123`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'John Maverick' }), + }), + ) + + expect(response.status).toBe(200) + // The properties missing in the request body are preserved. + await expect(response.json()).resolves.toEqual({ + id: 'abc-123', + name: 'John Maverick', + nickname: 'Johnny', + roles: [], + }) + }) + + it('returns a 404 response for a non-existing record', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/unknown`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'John' }), + }), + ) + + expect(response.status).toBe(404) + }) +}) + +describe('DELETE', () => { + it('deletes a record by its primary key', async () => { + const users = createUsersCollection() + await users.create({ id: 'abc-123', name: 'John' }) + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/abc-123`, { + method: 'DELETE', + }), + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + id: 'abc-123', + name: 'John', + roles: [], + }) + expect(users.count()).toBe(0) + }) + + it('returns a 404 response for a non-existing record', async () => { + const users = createUsersCollection() + + const response = await withHandlers( + fromCollection(users, { baseUrl }), + () => + fetch(`${baseUrl}/unknown`, { + method: 'DELETE', + }), + ) + + expect(response.status).toBe(404) + }) +}) + +describe('relations', () => { + it('serializes records with circular relations', async () => { + const authorSchema = z.object({ + id: z.string(), + get posts() { + return z.array(postSchema).default([]) + }, + }) + const postSchema = z.object({ + title: z.string(), + get author() { + return authorSchema.optional() + }, + }) + + const authors = new Collection({ schema: authorSchema }) + const posts = new Collection({ schema: postSchema }) + + authors.defineRelations(({ many }) => ({ + posts: many(posts), + })) + posts.defineRelations(({ one }) => ({ + author: one(authors), + })) + + const author = await authors.create({ id: 'abc-123' }) + const post = await posts.create({ title: 'First', author }) + await authors.update(author, { + data(draft) { + draft.posts.push(post) + }, + }) + + const response = await withHandlers( + fromCollection(authors, { baseUrl: 'https://api.example.com/authors' }), + () => fetch('https://api.example.com/authors/abc-123'), + ) + + expect(response.status).toBe(200) + + const json = await response.json() + expect(json.id).toBe('abc-123') + expect(json.posts).toHaveLength(1) + expect(json.posts[0].title).toBe('First') + // The circular "author" reference on the related post is omitted. + expect(json.posts[0].author).toBeUndefined() + }) +})