Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions clients/typescript/contract/client_contract_test.ts
Original file line number Diff line number Diff line change
@@ -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<ContractArticle>('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']);
},
);
});
144 changes: 144 additions & 0 deletions clients/typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (builder: JsonApiQueryBuilder<T>) => unknown;

export interface JsonApiListResult<T> {
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<T> {
list(query?: QueryFn<T> | RawQueryParams): Promise<JsonApiListResult<T>>;
get(id: string | number, query?: QueryFn<T>): Promise<T>;
create(body: unknown): Promise<T>;
update(id: string | number, body: unknown): Promise<T>;
/** 204 No Content on success. */
remove(id: string | number): Promise<void>;
}

export interface JsonApiClient {
/** @param path Collection path relative to `baseUrl`, e.g. "articles". */
resource<T>(path: string): JsonApiResourceHandle<T>;
}

function queryString<T>(query?: QueryFn<T> | RawQueryParams): string {
if (!query) return '';
if (typeof query === 'function') {
const builder = new JsonApiQueryBuilder<T>();
query(builder);
return builder.build();
}
return query.params;
}

async function readBody(res: Response): Promise<unknown> {
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<T>(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<unknown> {
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<T>(doc: unknown): T {
return hydrateResponse<T>(doc as JsonApiSingleResponse<T>).data;
}

return {
resource<T>(path: string): JsonApiResourceHandle<T> {
const cleanPath = path.replace(/^\/+|\/+$/g, '');
return {
async list(query) {
const doc = await send('GET', cleanPath, { qs: queryString(query) });
const hydrated = hydrateResponse<T>(doc as JsonApiArrayResponse<T>);
return { data: hydrated.data, pagination: hydrated.meta?.pagination };
},
async get(id, query) {
return single<T>(
await send('GET', `${cleanPath}/${id}`, { qs: queryString(query) }),
);
},
async create(body) {
return single<T>(await send('POST', cleanPath, { body }));
},
async update(id, body) {
return single<T>(await send('PATCH', `${cleanPath}/${id}`, { body }));
},
async remove(id) {
await send('DELETE', `${cleanPath}/${id}`);
},
};
},
};
}
145 changes: 145 additions & 0 deletions clients/typescript/src/client_test.ts
Original file line number Diff line number Diff line change
@@ -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<Todo>('/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);
});
});
Loading