From f32b91a1ba1354d894a4c6b1fd724f85116a9df3 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Wed, 2 Sep 2026 13:27:52 +0200 Subject: [PATCH] feat(typescript): add createJsonApiClient with typed resource handles --- .../contract/client_contract_test.ts | 89 +++++++++++ clients/typescript/src/client.ts | 144 +++++++++++++++++ clients/typescript/src/client_test.ts | 145 ++++++++++++++++++ clients/typescript/src/errors.ts | 43 +++++- clients/typescript/src/errors_test.ts | 49 +++++- clients/typescript/src/index.ts | 39 ++--- 6 files changed, 489 insertions(+), 20 deletions(-) create mode 100644 clients/typescript/contract/client_contract_test.ts create mode 100644 clients/typescript/src/client.ts create mode 100644 clients/typescript/src/client_test.ts diff --git a/clients/typescript/contract/client_contract_test.ts b/clients/typescript/contract/client_contract_test.ts new file mode 100644 index 0000000..2eaf126 --- /dev/null +++ b/clients/typescript/contract/client_contract_test.ts @@ -0,0 +1,89 @@ +/** + * Core client contract: a resource handle's reads and writes against a real + * toolkit-backed API, end to end (query building -> fetch -> hydration -> + * error parsing). + */ +import { assert, assertEquals, assertRejects } from '@std/assert'; +import { createJsonApiClient, JsonApiRequestError } from '../src/index.ts'; +import { + BASE_URL, + type ContractArticle, + PUBLISHED_ARTICLES, + TOTAL_ARTICLES, +} from './helpers.ts'; + +const articles = createJsonApiClient({ baseUrl: BASE_URL }) + .resource('articles'); + +Deno.test('list', async (t) => { + await t.step('hydrates data and returns pagination', async () => { + const { data, pagination } = await articles.list( + (q) => q.filter('published', true).page(1, 5), + ); + assertEquals(data.length, 5); + assertEquals(pagination?.totalResources, PUBLISHED_ARTICLES); + assertEquals(data[0].title, 'Article 01'); + }); + + await t.step('accepts a pre-built query string for interop', async () => { + const { data, pagination } = await articles.list({ + params: 'page%5Bsize%5D=1', + }); + assertEquals(data.length, 1); + assertEquals(pagination?.totalResources, TOTAL_ARTICLES); + }); + + await t.step('unpaginated request has no pagination', async () => { + const { data, pagination } = await articles.list(); + assertEquals(data.length, TOTAL_ARTICLES); + assertEquals(pagination, undefined); + }); +}); + +Deno.test('get', async (t) => { + await t.step('hydrates an included relationship', async () => { + const article = await articles.get(1, (q) => q.include('author')); + assertEquals(article.id, '1'); + assertEquals(article.author.name, 'Astrid Berg'); + }); + + await t.step('throws JsonApiRequestError with the status', async () => { + const error = await assertRejects( + () => articles.get(999999), + JsonApiRequestError, + ); + assertEquals(error.status, 404); + }); +}); + +Deno.test('writes', async (t) => { + await t.step('create, update, remove round trip', async () => { + const created = await articles.create({ + title: 'Client contract test article', + published: false, + authorId: 2, + tags: ['contract-client'], + }); + assertEquals(created.title, 'Client contract test article'); + assert(created.id); + + const updated = await articles.update(created.id, { viewCount: 7 }); + assertEquals(updated.viewCount, 7); + assertEquals(updated.title, 'Client contract test article'); + + await articles.remove(created.id); + await assertRejects(() => articles.get(created.id), JsonApiRequestError); + }); + + await t.step( + 'validation failure surfaces via hasCode and fieldErrors', + async () => { + const error = await assertRejects( + () => articles.create({ body: 'no title' }), + JsonApiRequestError, + ); + assertEquals(error.hasCode('REQUIRED_FIELD_MISSING'), true); + assertEquals(Object.keys(error.fieldErrors()), ['title']); + }, + ); +}); diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts new file mode 100644 index 0000000..0442674 --- /dev/null +++ b/clients/typescript/src/client.ts @@ -0,0 +1,144 @@ +import type { + JsonApiArrayResponse, + JsonApiPaginationMeta, + JsonApiSingleResponse, +} from './types/jsonapi.ts'; +import { hydrateResponse } from './hydrate.ts'; +import { isJsonApiErrorResponse, JsonApiRequestError } from './errors.ts'; +import { JsonApiQueryBuilder } from './query-builder/JsonApiQueryBuilder.ts'; + +const JSON_API_CONTENT_TYPE = 'application/vnd.api+json'; + +export interface JsonApiClientOptions { + /** Origin + path prefix, no trailing slash required (e.g. "https://api.example.com"). */ + baseUrl: string; + /** + * Standard fetch signature. Attach auth (tokens, dynamic headers) in a + * wrapper and pass it here; the client owns content-type headers, status + * handling, and parsing. Defaults to the global `fetch`. + */ + fetch?: typeof fetch; +} + +/** Pre-built query string, for interop with externally-built params (e.g. gjallarbru). */ +export interface RawQueryParams { + params: string; +} + +export type QueryFn = (builder: JsonApiQueryBuilder) => unknown; + +export interface JsonApiListResult { + data: T[]; + /** Present only when the request was paginated (any `page[...]` param). */ + pagination?: JsonApiPaginationMeta; +} + +/** + * Typed handle for one resource path. Bodies for `create`/`update` are + * plain camelCase DTOs (the backend has no JSON:API request deserializer); + * responses are JSON:API documents, hydrated into plain objects. + */ +export interface JsonApiResourceHandle { + list(query?: QueryFn | RawQueryParams): Promise>; + get(id: string | number, query?: QueryFn): Promise; + create(body: unknown): Promise; + update(id: string | number, body: unknown): Promise; + /** 204 No Content on success. */ + remove(id: string | number): Promise; +} + +export interface JsonApiClient { + /** @param path Collection path relative to `baseUrl`, e.g. "articles". */ + resource(path: string): JsonApiResourceHandle; +} + +function queryString(query?: QueryFn | RawQueryParams): string { + if (!query) return ''; + if (typeof query === 'function') { + const builder = new JsonApiQueryBuilder(); + query(builder); + return builder.build(); + } + return query.params; +} + +async function readBody(res: Response): Promise { + if (res.status === 204) return null; + const text = await res.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Builds a JSON:API client. `client.resource(path)` returns a typed + * handle covering query building, fetch, hydration, and error handling for + * reads and writes in one call path. + * + * Any non-2xx response throws {@link JsonApiRequestError}. + */ +export function createJsonApiClient( + options: JsonApiClientOptions, +): JsonApiClient { + const fetchImpl = options.fetch ?? fetch; + const baseUrl = options.baseUrl.replace(/\/+$/, ''); + + async function send( + method: string, + path: string, + opts: { qs?: string; body?: unknown } = {}, + ): Promise { + const url = opts.qs + ? `${baseUrl}/${path}?${opts.qs}` + : `${baseUrl}/${path}`; + const res = await fetchImpl(url, { + method, + headers: opts.body !== undefined + ? { 'Content-Type': JSON_API_CONTENT_TYPE } + : undefined, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + const doc = await readBody(res); + if (!res.ok) { + throw new JsonApiRequestError( + res.status, + isJsonApiErrorResponse(doc) ? doc.errors : [], + ); + } + return doc; + } + + function single(doc: unknown): T { + return hydrateResponse(doc as JsonApiSingleResponse).data; + } + + return { + resource(path: string): JsonApiResourceHandle { + const cleanPath = path.replace(/^\/+|\/+$/g, ''); + return { + async list(query) { + const doc = await send('GET', cleanPath, { qs: queryString(query) }); + const hydrated = hydrateResponse(doc as JsonApiArrayResponse); + return { data: hydrated.data, pagination: hydrated.meta?.pagination }; + }, + async get(id, query) { + return single( + await send('GET', `${cleanPath}/${id}`, { qs: queryString(query) }), + ); + }, + async create(body) { + return single(await send('POST', cleanPath, { body })); + }, + async update(id, body) { + return single(await send('PATCH', `${cleanPath}/${id}`, { body })); + }, + async remove(id) { + await send('DELETE', `${cleanPath}/${id}`); + }, + }; + }, + }; +} diff --git a/clients/typescript/src/client_test.ts b/clients/typescript/src/client_test.ts new file mode 100644 index 0000000..a45cb5a --- /dev/null +++ b/clients/typescript/src/client_test.ts @@ -0,0 +1,145 @@ +import { assertEquals, assertRejects } from '@std/assert'; +import { createJsonApiClient } from './client.ts'; +import { JsonApiRequestError } from './errors.ts'; + +// hydrateResponse always stamps `type` onto the flattened object today +// (pre-existing behavior; the hydration honesty fixes are a separate PR). +type Todo = { id: string; type: string; title: string; completed: boolean }; + +const todoDoc = { + id: '1', + type: 'todos', + attributes: { title: 'a', completed: false }, +}; +const hydratedTodo: Todo = { + id: '1', + type: 'todos', + title: 'a', + completed: false, +}; + +/** Records the request it received and returns a canned Response. */ +function fakeFetch( + response: () => Response, +): { fetch: typeof fetch; lastRequest: () => Request } { + let last: Request; + return { + fetch: ((input: string | URL | Request, init?: RequestInit) => { + last = new Request(input, init); + return Promise.resolve(response()); + }) as typeof fetch, + lastRequest: () => last, + }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/vnd.api+json' }, + }); +} + +function setup(response: () => Response) { + const { fetch: f, lastRequest } = fakeFetch(response); + const client = createJsonApiClient({ + baseUrl: 'https://api.test/', + fetch: f, + }); + return { todos: client.resource('/todos'), lastRequest }; +} + +Deno.test('list', async (t) => { + await t.step('builds the URL from baseUrl, path, and query', async () => { + const { todos, lastRequest } = setup(() => jsonResponse(200, { data: [] })); + await todos.list((q) => q.filter('completed', true)); + assertEquals( + lastRequest().url, + 'https://api.test/todos?filter%5Bcompleted%5D=true', + ); + assertEquals(lastRequest().method, 'GET'); + }); + + await t.step('accepts pre-built params for interop', async () => { + const { todos, lastRequest } = setup(() => jsonResponse(200, { data: [] })); + await todos.list({ params: 'page[number]=2' }); + assertEquals(lastRequest().url, 'https://api.test/todos?page[number]=2'); + }); + + await t.step('returns hydrated data and pagination', async () => { + const pagination = { + totalResources: 1, + totalPages: 1, + currentPage: 1, + pageSize: 10, + }; + const { todos } = setup(() => + jsonResponse(200, { data: [todoDoc], meta: { pagination } }) + ); + assertEquals(await todos.list(), { data: [hydratedTodo], pagination }); + }); + + await t.step('throws JsonApiRequestError on non-2xx', async () => { + const { todos } = setup(() => + jsonResponse(404, { + errors: [{ status: '404', code: 'RESOURCE_NOT_FOUND' }], + }) + ); + const error = await assertRejects(() => todos.list(), JsonApiRequestError); + assertEquals(error.status, 404); + assertEquals(error.hasCode('RESOURCE_NOT_FOUND'), true); + }); + + await t.step('non-JSON:API error body yields empty errors', async () => { + const { todos } = setup(() => new Response(null, { status: 415 })); + const error = await assertRejects(() => todos.list(), JsonApiRequestError); + assertEquals(error.status, 415); + assertEquals(error.errors, []); + }); +}); + +Deno.test('get', async (t) => { + await t.step('appends the id and returns the resource', async () => { + const { todos, lastRequest } = setup(() => + jsonResponse(200, { data: todoDoc }) + ); + const todo = await todos.get(1, (q) => q.include('owner' as never)); + assertEquals(lastRequest().url, 'https://api.test/todos/1?include=owner'); + assertEquals(todo, hydratedTodo); + }); +}); + +Deno.test('writes', async (t) => { + await t.step('create posts a plain DTO with the JSON:API type', async () => { + const { todos, lastRequest } = setup(() => + jsonResponse(201, { data: todoDoc }) + ); + const created = await todos.create({ title: 'a' }); + const req = lastRequest(); + assertEquals(req.method, 'POST'); + assertEquals(req.url, 'https://api.test/todos'); + assertEquals(req.headers.get('content-type'), 'application/vnd.api+json'); + assertEquals(await req.json(), { title: 'a' }); + assertEquals(created, hydratedTodo); + }); + + await t.step('update patches the id path with a partial DTO', async () => { + const { todos, lastRequest } = setup(() => + jsonResponse(200, { data: todoDoc }) + ); + await todos.update('1', { completed: true }); + const req = lastRequest(); + assertEquals(req.method, 'PATCH'); + assertEquals(req.url, 'https://api.test/todos/1'); + assertEquals(await req.json(), { completed: true }); + }); + + await t.step('remove sends no body and resolves void on 204', async () => { + const { todos, lastRequest } = setup(() => + new Response(null, { status: 204 }) + ); + assertEquals(await todos.remove(1), undefined); + assertEquals(lastRequest().method, 'DELETE'); + assertEquals(lastRequest().url, 'https://api.test/todos/1'); + assertEquals(lastRequest().headers.get('content-type'), null); + }); +}); diff --git a/clients/typescript/src/errors.ts b/clients/typescript/src/errors.ts index 51a45c4..0853844 100644 --- a/clients/typescript/src/errors.ts +++ b/clients/typescript/src/errors.ts @@ -1,4 +1,8 @@ -import type { JsonApiErrorResponse } from './types/errors.ts'; +import type { + JsonApiError, + JsonApiErrorCode, + JsonApiErrorResponse, +} from './types/errors.ts'; /** * Type guard that checks if a value is a JSON:API error response. @@ -24,3 +28,40 @@ export function isJsonApiErrorResponse( Array.isArray((value as JsonApiErrorResponse).errors) ); } + +/** + * Thrown by {@link createJsonApiClient} for any non-2xx response. + * `errors` is empty when the body was not a JSON:API error document + * (e.g. a 415 with no body). + */ +export class JsonApiRequestError extends Error { + readonly status: number; + readonly errors: JsonApiError[]; + + constructor(status: number, errors: JsonApiError[]) { + super(errors[0]?.title ?? `Request failed with status ${status}`); + this.name = 'JsonApiRequestError'; + this.status = status; + this.errors = errors; + } + + /** Whether any error in the response carries the given code. */ + hasCode(code: JsonApiErrorCode): boolean { + return this.errors.some((error) => error.code === code); + } + + /** + * Groups errors by the field named in `source.pointer` + * (e.g. "/data/attributes/email" -> "email"). Errors without a + * pointer are omitted. + */ + fieldErrors(): Record { + const out: Record = {}; + for (const error of this.errors) { + const field = error.source?.pointer?.split('/').pop(); + if (!field) continue; + (out[field] ??= []).push(error); + } + return out; + } +} diff --git a/clients/typescript/src/errors_test.ts b/clients/typescript/src/errors_test.ts index 8dd640f..03880c1 100644 --- a/clients/typescript/src/errors_test.ts +++ b/clients/typescript/src/errors_test.ts @@ -1,5 +1,5 @@ import { assertEquals } from '@std/assert'; -import { isJsonApiErrorResponse } from './errors.ts'; +import { isJsonApiErrorResponse, JsonApiRequestError } from './errors.ts'; import { JsonApiErrorCodes } from './types/errors.ts'; Deno.test('isJsonApiErrorResponse', async (t) => { @@ -109,3 +109,50 @@ Deno.test('JsonApiErrorCodes', async (t) => { assertEquals(Object.keys(JsonApiErrorCodes).length, 19); }); }); + +Deno.test('JsonApiRequestError', async (t) => { + await t.step('message falls back to the status when no errors', () => { + const error = new JsonApiRequestError(500, []); + assertEquals(error.message, 'Request failed with status 500'); + assertEquals(error.status, 500); + assertEquals(error.errors, []); + }); + + await t.step('message uses the first error title', () => { + const error = new JsonApiRequestError(404, [ + { status: '404', title: 'Not Found' }, + ]); + assertEquals(error.message, 'Not Found'); + }); + + await t.step('hasCode matches any error with that code', () => { + const error = new JsonApiRequestError(400, [ + { code: JsonApiErrorCodes.VALIDATION_FAILED }, + { code: JsonApiErrorCodes.REQUIRED_FIELD_MISSING }, + ]); + assertEquals(error.hasCode(JsonApiErrorCodes.VALIDATION_FAILED), true); + assertEquals(error.hasCode(JsonApiErrorCodes.RESOURCE_NOT_FOUND), false); + }); + + await t.step("fieldErrors groups by the pointer's last segment", () => { + const error = new JsonApiRequestError(400, [ + { + code: JsonApiErrorCodes.VALIDATION_FAILED, + source: { pointer: '/data/attributes/email' }, + }, + { + code: JsonApiErrorCodes.REQUIRED_FIELD_MISSING, + source: { pointer: '/data/attributes/email' }, + }, + { + code: JsonApiErrorCodes.REQUIRED_FIELD_MISSING, + source: { pointer: '/data/attributes/title' }, + }, + { code: JsonApiErrorCodes.AUTHENTICATION_REQUIRED }, // no pointer + ]); + const fields = error.fieldErrors(); + assertEquals(Object.keys(fields).sort(), ['email', 'title']); + assertEquals(fields.email.length, 2); + assertEquals(fields.title.length, 1); + }); +}); diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index f18d3bf..53cc2d8 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -2,39 +2,42 @@ * TypeScript tools for JSON:API responses from * [JsonApiToolkit](https://github.com/intility/json-api-toolkit) backends. * - * The library has three parts: + * The library has four parts: * + * - **Client**: `createJsonApiClient` builds a fetch-based client. Its + * `resource(path)` handles cover query building, hydration, and error + * handling for reads and writes in one call path. * - **Hydration**: `hydrateResponse` resolves relationships from the * `included` array and returns plain objects as `{ data, meta, links }`. + * Used internally by the client; exported for interop. * - **Query builder**: `JsonApiQueryBuilder` builds type-safe JSON:API * query strings with filters, sorts, includes, sparse fieldsets, and * pagination. - * - **Error types**: JSON:API error types and the - * `isJsonApiErrorResponse` type guard, matching the C# toolkit's error - * model. + * - **Error types**: JSON:API error types, the `isJsonApiErrorResponse` + * type guard, and `JsonApiRequestError` (thrown by the client on + * non-2xx responses), matching the C# toolkit's error model. * * ## Usage * * ```ts - * import { - * hydrateResponse, - * JsonApiQueryBuilder, - * } from '@intility/json-api-client'; - * - * const query = new JsonApiQueryBuilder() - * .filter('author.name', 'John') - * .include('author') - * .sort('-publishedAt') - * .page(1, 25) - * .build(); - * - * const response = await fetch(`/api/books?${query}`); - * const { data, meta } = hydrateResponse(await response.json()); + * import { createJsonApiClient } from '@intility/json-api-client'; + * + * const client = createJsonApiClient({ baseUrl: '/api', fetch }); + * const books = client.resource('books'); + * + * const { data, pagination } = await books.list((q) => + * q.filter('author.name', 'John').include('author').page(1, 25)); + * + * const book = await books.get(id, (q) => q.include('author')); + * const created = await books.create({ title: '...' }); + * await books.update(created.id, { title: 'renamed' }); + * await books.remove(created.id); * ``` * * @module */ +export * from './client.ts'; export * from './hydrate.ts'; export * from './errors.ts'; export * from './types/jsonapi.ts';