Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
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
7 changes: 6 additions & 1 deletion __tests__/unit/acceptance/real-world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ describe('acceptance: giphy.com (apis.guru)', () => {

it('discovers /gifs as the only resource', () => {
expect(discoverResources(document.paths)).toEqual([
{ collectionPath: '/gifs', itemPath: '/gifs/{gifId}', itemParam: 'gifId' },
{
collectionPath: '/gifs',
itemPath: '/gifs/{gifId}',
itemParam: 'gifId',
updateMethod: 'PUT',
},
]);
});

Expand Down
183 changes: 183 additions & 0 deletions __tests__/unit/client/github-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest';
import { createApiClient, type ApiClient } from '../../../src/client/client.js';
import type { OpenApiDocument } from '../../../src/openapi/types.js';

/**
* Exercises the three things a GitHub-shaped API needs that a generic
* `id`-keyed, PUT-updating, enveloped-list client does not provide:
*
* 1. list responses that are a **top-level array** (not an envelope) are
* collected across `Link`-header pages (issue #6);
* 2. an update is sent as **PATCH**, because the item path offers no PUT
* (issue #5);
* 3. the resource identity is a field other than `id` — here `number` —
* used for storage keys and create reconciliation (issue #4).
*
* The `{owner}/{repo}` templating a real GitHub client also needs is handled
* one layer up (in the caller's `fetch`), so the paths here are concrete.
*/
const document: OpenApiDocument = {
openapi: '3.0.0',
info: { title: 'GitHub Issues (test)', version: '1.0.0' },
paths: {
'/issues': {
get: {
'x-pagination': [{ scheme: 'nextLink' }],
responses: {
'200': {
description: 'issues',
content: {
'application/json': {
schema: { type: 'array', items: { type: 'object' } },
},
},
},
},
},
post: { responses: { '201': { description: 'created' } } },
},
'/issues/{issue_number}': {
get: { responses: { '200': { description: 'issue' } } },
patch: { responses: { '200': { description: 'updated' } } },
},
},
components: {
paginationSchemes: {
nextLink: {
type: 'nextLink',
request: { queryParameters: { per_page: { role: 'pageSize' } } },
response: { headers: { Link: { role: 'nextLink' } } },
},
},
},
} as unknown as OpenApiDocument;

interface Call {
method: string;
url: string;
}

function githubFetch(calls: Call[]): typeof fetch {
let nextNumber = 100;
const impl = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const url = typeof input === 'string' ? input : input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
calls.push({ method, url });

if (method === 'GET' && url.includes('/issues')) {
if (url.includes('page=2')) {
return new Response(
JSON.stringify([{ number: 3, title: 'c', state: 'open' }]),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
// First page: a bare array plus a Link header pointing at page 2.
return new Response(
JSON.stringify([
{ number: 1, title: 'a', state: 'open' },
{ number: 2, title: 'b', state: 'open' },
]),
{
status: 200,
headers: {
'Content-Type': 'application/json',
Link: '<https://api.github.test/issues?page=2>; rel="next"',
},
},
);
}

if (method === 'POST' && url.endsWith('/issues')) {
const body = JSON.parse(String(init?.body ?? '{}')) as Record<
string,
unknown
>;
nextNumber += 1;
return new Response(
JSON.stringify({ ...body, number: nextNumber, state: 'open' }),
{ status: 201, headers: { 'Content-Type': 'application/json' } },
);
}

if (method === 'PATCH' && /\/issues\/\d+$/.test(url)) {
const body = JSON.parse(String(init?.body ?? '{}')) as Record<
string,
unknown
>;
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}

return new Response('not found', { status: 404 });
};
return impl as typeof fetch;
}

async function settled(client: {
pendingWrites: () => unknown[];
}): Promise<void> {
const start = Date.now();
while (client.pendingWrites().length > 0) {
if (Date.now() - start > 2000) throw new Error('writes did not settle');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}

describe('GitHub-shaped client', () => {
const build = (calls: Call[]): ApiClient =>
createApiClient(document, {
baseUrl: 'https://api.github.test',
fetch: githubFetch(calls),
identityField: 'number',
retry: { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 5 },
});

it('collects a top-level-array list across Link-header pages, keyed by the identity field', async () => {
const calls: Call[] = [];
const client = build(calls);

await client.sync();

const issues = await client.list('/issues');
expect(issues.map((i) => i['number']).sort()).toEqual([1, 2, 3]);
// Stored under `number`, not `id`.
expect(await client.get('/issues', '2')).toMatchObject({ title: 'b' });

const pages = calls.filter((c) => c.method === 'GET');
expect(pages.length).toBe(2); // walked both pages
});

it('reconciles a create onto the server-assigned number', async () => {
const calls: Call[] = [];
const client = build(calls);

const created = await client.create('/issues', { title: 'new one' });
// Local id is a temporary uuid until the POST settles.
await settled(client);

// The server assigned number 101; the record now lives under it.
const stored = await client.get('/issues', '101');
expect(stored).toMatchObject({ number: 101, title: 'new one' });
// The temporary local key (the uuid create returned) is gone.
const tempKey = String(created['number']);
expect(tempKey).not.toBe('101');
expect(await client.get('/issues', tempKey)).toBeUndefined();
});

it('sends an update as PATCH (never PUT)', async () => {
const calls: Call[] = [];
const client = build(calls);

await client.update('/issues', '101', { state: 'closed' });
await settled(client);

const patch = calls.find((c) => c.url.endsWith('/issues/101'));
expect(patch?.method).toBe('PATCH');
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
});
});
25 changes: 24 additions & 1 deletion __tests__/unit/resources/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,33 @@ describe('discoverResources', () => {
});

expect(resources).toEqual([
{ collectionPath: '/pets', itemPath: '/pets/{petId}', itemParam: 'petId' },
{
collectionPath: '/pets',
itemPath: '/pets/{petId}',
itemParam: 'petId',
updateMethod: 'PUT',
},
]);
});

it('uses PATCH as the update method when the item path only offers patch', () => {
const resources = discoverResources({
'/repos/{owner}/{repo}/issues': { post: {}, get: {} },
'/repos/{owner}/{repo}/issues/{issue_number}': { get: {}, patch: {} },
});

expect(resources[0]?.updateMethod).toBe('PATCH');
});

it('prefers PUT when the item path declares both put and patch', () => {
const resources = discoverResources({
'/calendars': {},
'/calendars/{calendarId}': { put: {}, patch: {} },
});

expect(resources[0]?.updateMethod).toBe('PUT');
});

it('omits paths that have no matching item path', () => {
const resources = discoverResources({
'/health': {},
Expand Down
43 changes: 31 additions & 12 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export interface ApiClientOptions {
storage?: StorageAdapter;
fetch?: typeof fetch;
retry?: RetryOptions;
/**
* Record property that holds a resource's identity — the value used as the
* local storage key, read back from a create response to reconcile the
* server-assigned id, and substituted into the item URL's path variable.
* Defaults to `id`. Set this when the API addresses a resource by a
* different field (e.g. GitHub issues are keyed by `number`, not the
* global `id` the payload also carries).
*/
identityField?: string;
}

export interface PaginateOptions {
Expand Down Expand Up @@ -173,15 +182,16 @@ function itemFingerprint(item: Record<string, unknown>): string {
function hasChanges(
previous: Record<string, unknown>[] | undefined,
next: Record<string, unknown>[],
idField: string,
): boolean {
if (!previous || previous.length !== next.length) {
return true;
}
const previousById = new Map(
previous.map((item) => [String(item['id']), item]),
previous.map((item) => [String(item[idField]), item]),
);
return next.some((item) => {
const match = previousById.get(String(item['id']));
const match = previousById.get(String(item[idField]));
return !match || itemFingerprint(match) !== itemFingerprint(item);
});
}
Expand Down Expand Up @@ -233,6 +243,7 @@ export function createApiClient(
): ApiClient {
const storage = options.storage ?? new InMemoryStorageAdapter();
const fetchImpl = options.fetch ?? fetch;
const idField = options.identityField ?? 'id';
const routes = discoverResources(document.paths);
const byResource = new Map(
routes.map((route) => [route.collectionPath, route]),
Expand Down Expand Up @@ -291,7 +302,7 @@ export function createApiClient(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(write.data),
})) as Record<string, unknown>;
const resolvedId = String(created['id']);
const resolvedId = String(created[idField]);
if (resolvedId !== write.id) {
await storage.delete(write.resource, write.id);
}
Expand All @@ -302,7 +313,7 @@ export function createApiClient(
if (write.type === 'update') {
const route = resolveRoute(write.resource);
const updated = (await requestJson(itemUrl(route, write.id), {
method: 'PUT',
method: route.updateMethod,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(write.data),
})) as Record<string, unknown>;
Expand Down Expand Up @@ -568,7 +579,13 @@ export function createApiClient(
}

const { body, headers } = await requestJsonWithHeaders(url);
const pageItems = itemsField ? body[itemsField] : undefined;
// A top-level array body is itself the page of items (mirrors the
// non-paginated path); otherwise the items live under `itemsField`.
const pageItems = Array.isArray(body)
? (body as Record<string, unknown>[])
: itemsField
? body[itemsField]
: undefined;
if (Array.isArray(pageItems)) {
items.push(...(pageItems as Record<string, unknown>[]));
}
Expand Down Expand Up @@ -634,15 +651,15 @@ export function createApiClient(
}

const previous = lastSyncedItems.get(route.collectionPath);
if (hasChanges(previous, items)) {
if (hasChanges(previous, items, idField)) {
changed.push(route.collectionPath);
for (const item of items) {
await storage.put(route.collectionPath, String(item['id']), item);
await storage.put(route.collectionPath, String(item[idField]), item);
}
if (previous) {
const nextIds = new Set(items.map((item) => String(item['id'])));
const nextIds = new Set(items.map((item) => String(item[idField])));
for (const staleItem of previous) {
const staleId = String(staleItem['id']);
const staleId = String(staleItem[idField]);
if (!nextIds.has(staleId)) {
await storage.delete(route.collectionPath, staleId);
}
Expand Down Expand Up @@ -701,8 +718,10 @@ export function createApiClient(
async create(resource, data): Promise<Record<string, unknown>> {
const route = resolveRoute(resource);
const id =
typeof data['id'] === 'string' ? data['id'] : crypto.randomUUID();
const record = { ...data, id };
typeof data[idField] === 'string'
? (data[idField] as string)
: crypto.randomUUID();
const record = { ...data, [idField]: id };
await storage.put(route.collectionPath, id, record);
enqueueWrite({
resource: route.collectionPath,
Expand All @@ -716,7 +735,7 @@ export function createApiClient(
async update(resource, id, data): Promise<Record<string, unknown>> {
const route = resolveRoute(resource);
const existing = await storage.get(route.collectionPath, id);
const record = { ...existing, ...data, id };
const record = { ...existing, ...data, [idField]: id };
await storage.put(route.collectionPath, id, record);
enqueueWrite({
resource: route.collectionPath,
Expand Down
29 changes: 29 additions & 0 deletions src/resources/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@ export interface ResourceRoute {
collectionPath: string;
itemPath: string;
itemParam: string;
/**
* HTTP method a background `update` sends. Derived from the item path's
* declared operations: `PUT` when the item path has a `put` operation
* (a full replace), otherwise `PATCH` when it only offers a partial
* update. Some APIs (e.g. GitHub) expose no `PUT` and require `PATCH`.
*/
updateMethod: 'PUT' | 'PATCH';
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Chooses the update method for an item path from the operations it
* declares: prefer `PUT` (replace) when present, fall back to `PATCH`
* when the path only offers a partial update, and default to `PUT` when
* neither is declared (preserving prior behaviour).
*/
function updateMethodFor(
paths: Record<string, unknown>,
itemPath: string,
): 'PUT' | 'PATCH' {
const item = paths[itemPath];
if (isRecord(item) && !('put' in item) && 'patch' in item) {
return 'PATCH';
}
return 'PUT';
}

/**
Expand All @@ -26,6 +54,7 @@ export function discoverResources(
collectionPath,
itemPath,
itemParam: extractParamName(itemPath),
updateMethod: updateMethodFor(paths, itemPath),
});
}
}
Expand Down
Loading