diff --git a/clients/typescript/contract/document_contract_test.ts b/clients/typescript/contract/document_contract_test.ts index bc03565..3d68803 100644 --- a/clients/typescript/contract/document_contract_test.ts +++ b/clients/typescript/contract/document_contract_test.ts @@ -3,27 +3,27 @@ * sparse fieldsets, null-stripping. */ import { assert, assertEquals, assertFalse } from '@std/assert'; +import { hydrateResponse, JsonApiQueryBuilder } from '../src/index.ts'; import { - hydrateResponse, - type JsonApiArrayResponse, - JsonApiQueryBuilder, - type JsonApiSingleResponse, -} from '../src/index.ts'; -import { type ContractArticle, getDoc } from './helpers.ts'; + type ContractArticle, + getDoc, + type List, + type Single, +} from './helpers.ts'; Deno.test('document shape', async (t) => { await t.step('primary resources use the controller type string', async () => { - const { status, doc } = await getDoc('articles/3'); + const { status, doc } = await getDoc('articles/3'); assertEquals(status, 200); assertEquals(doc.data.type, 'articles'); assertEquals(doc.data.id, '3'); // ids are strings on the wire - assertEquals(doc.data.links.self.endsWith('/articles/3'), true); + assertEquals(doc.data.links?.self?.endsWith('/articles/3'), true); }); await t.step( 'attributes: camelCase, ISO dates, FK ids leak as attributes', async () => { - const { doc } = await getDoc('articles/3'); + const { doc } = await getDoc('articles/3'); assertEquals(doc.data.attributes.title, 'Article 03'); assertEquals(doc.data.attributes.publishedAt, '2025-01-03T12:00:00Z'); assertEquals(doc.data.attributes.viewCount, 30); @@ -35,39 +35,39 @@ Deno.test('document shape', async (t) => { await t.step( 'primitive collections are attributes (JSON column detection)', async () => { - const { doc } = await getDoc('articles/3'); + const { doc } = await getDoc('articles/3'); assertEquals(doc.data.attributes.tags, ['tech', 'news']); // empty collections serialize as [], not stripped - const { doc: doc23 } = await getDoc('articles/23'); + const { doc: doc23 } = await getDoc('articles/23'); assertEquals(doc23.data.attributes.tags, []); }, ); await t.step('null attributes are stripped from responses', async () => { // article 25: body is null; publishedAt is set (odd id) - const { doc } = await getDoc('articles/25'); + const { doc } = await getDoc('articles/25'); assertFalse('body' in doc.data.attributes); // article 2: publishedAt is null (even id) - const { doc: doc2 } = await getDoc('articles/2'); + const { doc: doc2 } = await getDoc('articles/2'); assertFalse('publishedAt' in doc2.data.attributes); assertEquals(doc2.data.attributes.published, false); // authors/2 has null email - const { doc: author } = await getDoc('authors/2'); + const { doc: author } = await getDoc('authors/2'); assertEquals(author.data.attributes, { name: 'Bjarne Moen' }); }); await t.step( 'included resources use the camelCased CLR class name, not the controller type', async () => { - const { doc } = await getDoc('articles/3?include=author'); + const { doc } = await getDoc('articles/3?include=author'); assertEquals(doc.data.type, 'articles'); - assertEquals(doc.data.relationships.author.data, { + assertEquals(doc.data.relationships?.author.data, { id: '3', type: 'author', }); - assertEquals(doc.included.length, 1); - assertEquals(doc.included[0].type, 'author'); // singular CLR name - assertEquals(doc.included[0].attributes.name, 'Carmen Diaz'); + assertEquals(doc.included?.length, 1); + assertEquals(doc.included?.[0].type, 'author'); // singular CLR name + assertEquals(doc.included?.[0].attributes.name, 'Carmen Diaz'); }, ); @@ -76,18 +76,21 @@ Deno.test('document shape', async (t) => { async () => { // include=comments.author: included is populated, but data has no // relationships object at all, so the linkage is unrecoverable. - const { doc } = await getDoc('articles/3?include=comments.author'); + const { doc } = await getDoc( + 'articles/3?include=comments.author', + ); assertFalse('relationships' in doc.data); - const includedTypes = doc.included.map((r: { type: string }) => r.type) + const includedTypes = doc.included?.map((r) => r.type) .sort(); assertEquals(includedTypes, ['author', 'author', 'comment', 'comment']); // included comments DO carry their own relationships - const comment = doc.included.find((r: { type: string }) => - r.type === 'comment' + const comment = doc.included?.find((r) => r.type === 'comment'); + const authorRef = comment?.relationships?.author.data; + assert( + authorRef && !Array.isArray(authorRef) && authorRef.type === 'author', ); - assertEquals(comment.relationships.author.data.type, 'author'); // same on collections - const { doc: list } = await getDoc( + const { doc: list } = await getDoc( 'articles?include=comments.author&page%5Bsize%5D=2', ); assertFalse('relationships' in list.data[0]); @@ -103,7 +106,7 @@ Deno.test('sparse fieldsets', async (t) => { .fields('articles', ['title', 'publishedAt']) .page(1, 2) .build(); - const { doc } = await getDoc(`articles?${qs}`); + const { doc } = await getDoc(`articles?${qs}`); assertEquals(Object.keys(doc.data[0].attributes), [ 'title', 'publishedAt', @@ -115,22 +118,22 @@ Deno.test('sparse fieldsets', async (t) => { 'included resources need the CLR-derived type name, not the wire type', async () => { // fields[author] (CLR name) trims the included author... - const { doc } = await getDoc( + const { doc } = await getDoc( 'articles/3?include=author&fields%5Bauthor%5D=name', ); - assertEquals(Object.keys(doc.included[0].attributes), ['name']); + assertEquals(Object.keys(doc.included?.[0].attributes ?? {}), ['name']); // ...fields[authors] (what a JSON:API client would guess) does nothing - const { doc: doc2 } = await getDoc( + const { doc: doc2 } = await getDoc( 'articles/3?include=author&fields%5Bauthors%5D=name', ); - assert(Object.keys(doc2.included[0].attributes).length > 1); + assert(Object.keys(doc2.included?.[0].attributes ?? {}).length > 1); }, ); await t.step( 'id and type are always present regardless of fieldset', async () => { - const { doc } = await getDoc( + const { doc } = await getDoc( 'articles?fields%5Barticles%5D=title&page%5Bsize%5D=1', ); assertEquals(doc.data[0].id, '1'); @@ -143,13 +146,14 @@ Deno.test('hydration', async (t) => { await t.step( 'single-level include hydrates to a flat nested object', async () => { - const { doc } = await getDoc('articles/3?include=author'); - const { data } = hydrateResponse( - doc as JsonApiSingleResponse, - ); + const { doc } = await getDoc('articles/3?include=author'); + const { data } = hydrateResponse(doc); assertEquals(data.title, 'Article 03'); assertEquals(data.author.name, 'Carmen Diaz'); - // un-included to-many relationship is simply absent, not [] or null + assertFalse('type' in data); + // WART: un-included relationships are absent from the wire entirely, + // so the hydrator cannot emit [] or null for them without a resource + // descriptor (arity is not on the wire) assertEquals(data.comments, undefined); }, ); @@ -157,10 +161,10 @@ Deno.test('hydration', async (t) => { await t.step( 'nested include hydrates to NOTHING because linkage is missing', async () => { - const { doc } = await getDoc('articles/3?include=comments.author'); - const { data } = hydrateResponse( - doc as JsonApiSingleResponse, + const { doc } = await getDoc( + 'articles/3?include=comments.author', ); + const { data } = hydrateResponse(doc); // included has 4 resources, but without data.relationships the // hydrator cannot attach any of them assertEquals(data.comments, undefined); @@ -168,14 +172,13 @@ Deno.test('hydration', async (t) => { }, ); - await t.step('collection hydration preserves meta and links', async () => { - const { doc } = await getDoc('articles?include=author&page%5Bsize%5D=2'); - const { data, meta, links } = hydrateResponse( - doc as JsonApiArrayResponse, + await t.step('collection hydration returns data and pagination', async () => { + const { doc } = await getDoc( + 'articles?include=author&page%5Bsize%5D=2', ); + const { data, pagination } = hydrateResponse(doc); assertEquals(data.length, 2); assertEquals(data[0].author.name, 'Astrid Berg'); - assertEquals(meta?.pagination?.totalResources, 25); - assert(links?.self); + assertEquals(pagination?.totalResources, 25); }); }); diff --git a/clients/typescript/contract/errors_contract_test.ts b/clients/typescript/contract/errors_contract_test.ts index 91cdf3a..be6008b 100644 --- a/clients/typescript/contract/errors_contract_test.ts +++ b/clients/typescript/contract/errors_contract_test.ts @@ -4,11 +4,11 @@ */ import { assert, assertEquals, assertFalse } from '@std/assert'; import { isJsonApiErrorResponse } from '../src/index.ts'; -import { getDoc, request } from './helpers.ts'; +import { type Errors, getDoc, type List, request } from './helpers.ts'; Deno.test('not found', async (t) => { await t.step('WART: plain GET 404 has no error code', async () => { - const { doc, status } = await getDoc('articles/999'); + const { doc, status } = await getDoc('articles/999'); assertEquals(status, 404); assertEquals(doc.errors, [ { status: '404', title: 'Not Found', detail: 'Resource not found' }, @@ -19,7 +19,7 @@ Deno.test('not found', async (t) => { await t.step( 'explicit JsonApiErrors.NotFound carries RESOURCE_NOT_FOUND and meta', async () => { - const { doc, status } = await request('DELETE', 'articles/999'); + const { doc, status } = await request('DELETE', 'articles/999'); assertEquals(status, 404); assertEquals(doc.errors[0].code, 'RESOURCE_NOT_FOUND'); assertEquals(doc.errors[0].meta, { resourceType: 'articles', id: 999 }); @@ -31,7 +31,7 @@ Deno.test('include allowlisting', async (t) => { await t.step( 'unlisted include is 403 INCLUDE_NOT_ALLOWED with meta', async () => { - const { doc, status } = await getDoc('articles/3?include=bogus'); + const { doc, status } = await getDoc('articles/3?include=bogus'); assertEquals(status, 403); assertEquals(doc.errors[0].code, 'INCLUDE_NOT_ALLOWED'); assertEquals(doc.errors[0].meta, { @@ -43,10 +43,10 @@ Deno.test('include allowlisting', async (t) => { ); await t.step('empty [AllowedIncludes] forbids every include', async () => { - const { doc, status } = await getDoc('authors?include=articles'); + const { doc, status } = await getDoc('authors?include=articles'); assertEquals(status, 403); assertEquals(doc.errors[0].code, 'INCLUDE_NOT_ALLOWED'); - assertEquals(doc.errors[0].meta.allowedIncludes, []); + assertEquals(doc.errors[0].meta?.allowedIncludes, []); }); await t.step( @@ -54,19 +54,19 @@ Deno.test('include allowlisting', async (t) => { async () => { // filter[or][field][op] parses as an include-filter on a relationship // named "or", which a non-empty allowlist then rejects - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles/3?filter%5Bor%5D%5BviewCount%5D%5Bgt%5D=230', ); assertEquals(status, 403); assertEquals(doc.errors[0].code, 'FILTER_NOT_ALLOWED'); - assertEquals(doc.errors[0].meta.forbiddenFilterPaths, ['or']); + assertEquals(doc.errors[0].meta?.forbiddenFilterPaths, ['or']); }, ); await t.step( 'indexed (builder-emitted) groups pass the allowlist and apply', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'authors?filter%5Bor%5D%5B0%5D%5Bname%5D=x', ); assertEquals(status, 200); @@ -108,7 +108,7 @@ Deno.test('server errors', async (t) => { await t.step( 'WART: unconvertible filter values are 500, not 400', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles?filter%5BpublishedAt%5D=isnull', ); assertEquals(status, 500); diff --git a/clients/typescript/contract/helpers.ts b/clients/typescript/contract/helpers.ts index 3d3007c..25f1f7f 100644 --- a/clients/typescript/contract/helpers.ts +++ b/clients/typescript/contract/helpers.ts @@ -1,4 +1,3 @@ -// deno-lint-ignore-file no-explicit-any /** * Shared plumbing for the contract test suite. * @@ -6,6 +5,11 @@ * wire behavior, warts included. It assumes a freshly seeded server (see * samples/ContractApi/Data.cs); restart the sample between local runs. */ +import type { + JsonApiArrayResponse, + JsonApiErrorResponse, + JsonApiSingleResponse, +} from '../src/index.ts'; export const BASE_URL = Deno.env.get('CONTRACT_API_URL') ?? 'http://localhost:5198'; @@ -17,6 +21,11 @@ export const TOTAL_ARTICLES = 25; export const PUBLISHED_ARTICLES = 13; // odd ids 1..25 export const UNPUBLISHED_ARTICLES = 12; // even ids, publishedAt is null +/** Wire document shapes, named short because every test step names one. */ +export type Single = JsonApiSingleResponse; +export type List = JsonApiArrayResponse; +export type Errors = JsonApiErrorResponse; + /** * Resource types as today's consumers write them: non-nullable everywhere, * because the current AttributeKeys/RelationshipKeys helpers drop nullable @@ -50,17 +59,22 @@ export type ContractArticle = { comments: ContractComment[]; }; -export interface WireResult { +export interface WireResult { status: number; - doc: any; + /** Parsed JSON body as `T`; `null` when the body is empty or not JSON. */ + doc: T; headers: Headers; } -export async function request( +/** + * Raw request. `T` is the caller's claim about the body shape; the suite + * asserts on the wire, so a wrong claim fails loudly at the assertion. + */ +export async function request( method: string, path: string, opts: { body?: unknown; contentType?: string; base?: string } = {}, -): Promise { +): Promise> { const res = await fetch(`${opts.base ?? BASE_URL}/${path}`, { method, headers: opts.body !== undefined @@ -69,7 +83,7 @@ export async function request( body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); const text = await res.text(); - let doc: any = null; + let doc: unknown = null; if (text) { try { doc = JSON.parse(text); @@ -77,14 +91,17 @@ export async function request( doc = text; } } - return { status: res.status, doc, headers: res.headers }; + return { status: res.status, doc: doc as T, headers: res.headers }; } -export function getDoc(path: string, base?: string): Promise { - return request('GET', path, { base }); +export function getDoc( + path: string, + base?: string, +): Promise> { + return request('GET', path, { base }); } /** Convenience: totalResources from a collection response. */ -export function total(doc: any): number { - return doc?.meta?.pagination?.totalResources; +export function total(doc: List): number | undefined { + return doc.meta?.pagination?.totalResources; } diff --git a/clients/typescript/contract/pagination_contract_test.ts b/clients/typescript/contract/pagination_contract_test.ts index 61f256c..fc188ce 100644 --- a/clients/typescript/contract/pagination_contract_test.ts +++ b/clients/typescript/contract/pagination_contract_test.ts @@ -5,7 +5,9 @@ import { assert, assertEquals, assertFalse } from '@std/assert'; import { BASE_URL, + type Errors, getDoc, + type List, PUBLISHED_ARTICLES, STRICT_BASE_URL, total, @@ -16,19 +18,19 @@ Deno.test('default pagination', async (t) => { await t.step( 'WART: no page params returns the entire collection, unpaginated', async () => { - const { doc } = await getDoc('articles'); + const { doc } = await getDoc('articles'); assertEquals(doc.data.length, TOTAL_ARTICLES); assertEquals(doc.meta, undefined); // no pagination meta at all - assertEquals(Object.keys(doc.links), ['self']); // no first/last/next + assertEquals(Object.keys(doc.links ?? {}), ['self']); // no first/last/next }, ); await t.step( 'any page param triggers pagination with DefaultPageSize 10', async () => { - const { doc } = await getDoc('articles?page%5Bnumber%5D=1'); + const { doc } = await getDoc('articles?page%5Bnumber%5D=1'); assertEquals(doc.data.length, 10); - assertEquals(doc.meta.pagination, { + assertEquals(doc.meta?.pagination, { totalResources: TOTAL_ARTICLES, totalPages: 3, currentPage: 1, @@ -38,18 +40,20 @@ Deno.test('default pagination', async (t) => { ); await t.step('WART: page 0 and negative pages clamp to page 1', async () => { - const { doc } = await getDoc('articles?page%5Bnumber%5D=0'); - assertEquals(doc.meta.pagination.currentPage, 1); - const { doc: neg } = await getDoc('articles?page%5Bnumber%5D=-5'); - assertEquals(neg.meta.pagination.currentPage, 1); + const { doc } = await getDoc('articles?page%5Bnumber%5D=0'); + assertEquals(doc.meta?.pagination?.currentPage, 1); + const { doc: neg } = await getDoc('articles?page%5Bnumber%5D=-5'); + assertEquals(neg.meta?.pagination?.currentPage, 1); }); await t.step( 'WART: overflowing page numbers clamp to the last page', async () => { - const { doc, status } = await getDoc('articles?page%5Bnumber%5D=999'); + const { doc, status } = await getDoc( + 'articles?page%5Bnumber%5D=999', + ); assertEquals(status, 200); - assertEquals(doc.meta.pagination.currentPage, 3); + assertEquals(doc.meta?.pagination?.currentPage, 3); assertEquals(doc.data[0].id, '21'); }, ); @@ -57,8 +61,8 @@ Deno.test('default pagination', async (t) => { await t.step( 'WART: oversized page size clamps to MaxPageSize (100)', async () => { - const { doc } = await getDoc('articles?page%5Bsize%5D=1000'); - assertEquals(doc.meta.pagination.pageSize, 100); + const { doc } = await getDoc('articles?page%5Bsize%5D=1000'); + assertEquals(doc.meta?.pagination?.pageSize, 100); }, ); }); @@ -66,30 +70,30 @@ Deno.test('default pagination', async (t) => { Deno.test('pagination links', async (t) => { await t.step('self link preserves the full query string', async () => { const path = 'articles?filter%5Bpublished%5D=true&page%5Bsize%5D=2'; - const { doc } = await getDoc(path); - assertEquals(doc.links.self, `${BASE_URL}/${path}`); + const { doc } = await getDoc(path); + assertEquals(doc.links?.self, `${BASE_URL}/${path}`); }); await t.step( 'WART: first/last/prev/next drop filter, sort, include and fields', async () => { - const { doc } = await getDoc( + const { doc } = await getDoc( 'articles?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bsize%5D=2', ); // links are rebuilt from the bare path with unencoded brackets assertEquals( - doc.links.next, + doc.links?.next, `${BASE_URL}/articles?page[number]=2&page[size]=2`, ); assertEquals( - doc.links.last, + doc.links?.last, `${BASE_URL}/articles?page[number]=7&page[size]=2`, ); - assertFalse(doc.links.next.includes('filter')); + assertFalse(doc.links?.next?.includes('filter')); // following next therefore returns UNFILTERED, UNSORTED data - const next = new URL(doc.links.next); - const { doc: page2 } = await getDoc(`articles${next.search}`); + const next = new URL(doc.links?.next ?? ''); + const { doc: page2 } = await getDoc(`articles${next.search}`); assertEquals(total(page2), TOTAL_ARTICLES); }, ); @@ -97,18 +101,18 @@ Deno.test('pagination links', async (t) => { Deno.test('strict pagination (StrictPagination = true)', async (t) => { await t.step('valid pages behave like default mode', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles?page%5Bnumber%5D=2', STRICT_BASE_URL, ); assertEquals(status, 200); - assertEquals(doc.meta.pagination.currentPage, 2); + assertEquals(doc.meta?.pagination?.currentPage, 2); }); await t.step( 'overflowing page number is 404 INVALID_PAGE_NUMBER', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles?page%5Bnumber%5D=999', STRICT_BASE_URL, ); @@ -124,7 +128,7 @@ Deno.test('strict pagination (StrictPagination = true)', async (t) => { ); await t.step('page 0 is 400 INVALID_PAGE_NUMBER (not clamped)', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles?page%5Bnumber%5D=0', STRICT_BASE_URL, ); @@ -135,13 +139,13 @@ Deno.test('strict pagination (StrictPagination = true)', async (t) => { await t.step( 'oversized page size is 400 PAGE_SIZE_EXCEEDED (not clamped)', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles?page%5Bsize%5D=1000', STRICT_BASE_URL, ); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'PAGE_SIZE_EXCEEDED'); - assert(doc.errors[0].meta.max === 100); + assert(doc.errors[0].meta?.max === 100); }, ); }); @@ -150,20 +154,20 @@ Deno.test( 'pagination links with PreserveQueryInPaginationLinks (opt-in, strict instance)', async (t) => { await t.step('links keep the query, only page params change', async () => { - const { doc } = await getDoc( + const { doc } = await getDoc( 'articles?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bsize%5D=2', STRICT_BASE_URL, ); // keys are re-encoded by the link builder (%5B/%5D) assertEquals( - doc.links.next, + doc.links?.next, `${STRICT_BASE_URL}/articles` + '?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bnumber%5D=2&page%5Bsize%5D=2', ); // following next keeps the filtered, sorted result set - const next = new URL(doc.links.next); - const { doc: page2 } = await getDoc( + const next = new URL(doc.links?.next ?? ''); + const { doc: page2 } = await getDoc( `articles${next.search}`, STRICT_BASE_URL, ); diff --git a/clients/typescript/contract/query_contract_test.ts b/clients/typescript/contract/query_contract_test.ts index 9e322dc..6065af1 100644 --- a/clients/typescript/contract/query_contract_test.ts +++ b/clients/typescript/contract/query_contract_test.ts @@ -8,6 +8,7 @@ import { JsonApiQueryBuilder } from '../src/index.ts'; import { type ContractArticle, getDoc, + type List, PUBLISHED_ARTICLES, total, TOTAL_ARTICLES, @@ -15,7 +16,7 @@ import { } from './helpers.ts'; function list(qb: JsonApiQueryBuilder) { - return getDoc( + return getDoc( `articles?${qb.page(1, 1).fields('articles', ['title']).build()}`, ); } @@ -105,7 +106,7 @@ Deno.test('simple filters', async (t) => { ); await t.step('WART: unknown filter fields are silently ignored', async () => { - const { doc, status } = await getDoc( + const { doc, status } = await getDoc( 'articles?filter%5Bbogus%5D=x&page%5Bsize%5D=1', ); assertEquals(status, 200); @@ -171,9 +172,9 @@ Deno.test('dot-path filters', async (t) => { .include('author') .page(1, 1) .build(); - const { doc } = await getDoc(`articles?${qs}`); + const { doc } = await getDoc(`articles?${qs}`); assertEquals(total(doc), 9); // seed: authors round-robin, Astrid owns 9 - assertEquals(doc.included[0].attributes.name, 'Astrid Berg'); + assertEquals(doc.included?.[0].attributes.name, 'Astrid Berg'); }, ); diff --git a/clients/typescript/contract/strict_contract_test.ts b/clients/typescript/contract/strict_contract_test.ts index f4f2a4b..f40c036 100644 --- a/clients/typescript/contract/strict_contract_test.ts +++ b/clients/typescript/contract/strict_contract_test.ts @@ -7,13 +7,15 @@ import { assertEquals } from '@std/assert'; import { JsonApiQueryBuilder } from '../src/index.ts'; import { type ContractArticle, + type Errors, getDoc, + type List, STRICT_BASE_URL, total, } from './helpers.ts'; -function strictGet(qs: string) { - return getDoc(`articles?${qs}`, STRICT_BASE_URL); +function strictGet(qs: string) { + return getDoc(`articles?${qs}`, STRICT_BASE_URL); } Deno.test('strict query validation: rejected shapes', async (t) => { @@ -21,7 +23,7 @@ Deno.test('strict query validation: rejected shapes', async (t) => { const qs = new JsonApiQueryBuilder() .and((b) => b.filter('published', 'eq', false)) .build(); - const { doc, status } = await strictGet(qs); + const { doc, status } = await strictGet(qs); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'UNSUPPORTED_FILTER_GROUP'); }); @@ -34,13 +36,13 @@ Deno.test('strict query validation: rejected shapes', async (t) => { ) ) .build(); - const { doc, status } = await strictGet(qs); + const { doc, status } = await strictGet(qs); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'UNSUPPORTED_FILTER_GROUP'); }); await t.step('unknown filter field is 400 INVALID_FILTER_FIELD', async () => { - const { doc, status } = await strictGet('filter%5Bbogus%5D=x'); + const { doc, status } = await strictGet('filter%5Bbogus%5D=x'); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'INVALID_FILTER_FIELD'); }); @@ -51,7 +53,7 @@ Deno.test('strict query validation: rejected shapes', async (t) => { const qs = new JsonApiQueryBuilder() .filter('publishedAt', 'isnull') .build(); - const { doc, status } = await strictGet(qs); + const { doc, status } = await strictGet(qs); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'INVALID_FILTER_VALUE'); }, @@ -63,14 +65,14 @@ Deno.test('strict query validation: rejected shapes', async (t) => { const qs = new JsonApiQueryBuilder() .filter('publishedAt', 'gt', new Date('2025-01-20T00:00:00Z')) .build(); - const { doc, status } = await strictGet(qs); + const { doc, status } = await strictGet(qs); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'INVALID_FILTER_VALUE'); }, ); await t.step('unknown operator is 400 INVALID_FILTER_OPERATOR', async () => { - const { doc, status } = await strictGet( + const { doc, status } = await strictGet( 'filter%5Btitle%5D%5Bcontains%5D=x', ); assertEquals(status, 400); @@ -78,7 +80,7 @@ Deno.test('strict query validation: rejected shapes', async (t) => { }); await t.step('unknown sort field is 400 INVALID_SORT_FIELD', async () => { - const { doc, status } = await strictGet('sort=bogus'); + const { doc, status } = await strictGet('sort=bogus'); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'INVALID_SORT_FIELD'); }); @@ -87,7 +89,7 @@ Deno.test('strict query validation: rejected shapes', async (t) => { const qs = new JsonApiQueryBuilder() .sort('author.name') .build(); - const { doc, status } = await strictGet(qs); + const { doc, status } = await strictGet(qs); assertEquals(status, 400); assertEquals(doc.errors[0].code, 'INVALID_SORT_FIELD'); }); @@ -95,7 +97,7 @@ Deno.test('strict query validation: rejected shapes', async (t) => { await t.step( 'bracket include-filter without include is 400 FILTER_NOT_ALLOWED', async () => { - const { doc, status } = await strictGet( + const { doc, status } = await strictGet( 'filter%5Bauthor%5D%5Bname%5D%5Blike%5D=Astrid', ); assertEquals(status, 400); @@ -114,7 +116,7 @@ Deno.test('strict query validation: valid queries unaffected', async (t) => { .include('author') .page(1, 10) .build(); - const { doc, status } = await strictGet(qs); + const { doc, status } = await strictGet(qs); assertEquals(status, 200); assertEquals(total(doc), 3); assertEquals(doc.data[0].id, '25'); @@ -123,7 +125,7 @@ Deno.test('strict query validation: valid queries unaffected', async (t) => { .filter('publishedAt', 'isnull', true) .page(1, 1) .build(); - const { doc: nulls } = await strictGet(isnull); + const { doc: nulls } = await strictGet(isnull); assertEquals(total(nulls), 12); }); }); diff --git a/clients/typescript/contract/writes_contract_test.ts b/clients/typescript/contract/writes_contract_test.ts index 360fc49..1399e9f 100644 --- a/clients/typescript/contract/writes_contract_test.ts +++ b/clients/typescript/contract/writes_contract_test.ts @@ -7,7 +7,7 @@ * contract keeps seeing the pristine seed. */ import { assert, assertEquals } from '@std/assert'; -import { getDoc, request } from './helpers.ts'; +import { type Errors, getDoc, request, type Single } from './helpers.ts'; Deno.test('write path', async (t) => { let createdId = ''; @@ -15,16 +15,20 @@ Deno.test('write path', async (t) => { await t.step( 'POST plain DTO returns 201 + Location + JSON:API document', async () => { - const { doc, status, headers } = await request('POST', 'articles', { - body: { - title: 'Contract test article', - body: 'Written by the contract suite', - published: true, - publishedAt: '2025-03-01T09:00:00Z', - authorId: 2, - tags: ['contract'], + const { doc, status, headers } = await request( + 'POST', + 'articles', + { + body: { + title: 'Contract test article', + body: 'Written by the contract suite', + published: true, + publishedAt: '2025-03-01T09:00:00Z', + authorId: 2, + tags: ['contract'], + }, }, - }); + ); assertEquals(status, 201); createdId = doc.data.id; assert(headers.get('location')?.endsWith(`/articles/${createdId}`)); @@ -39,7 +43,7 @@ Deno.test('write path', async (t) => { await t.step( 'POST without required field is 400 REQUIRED_FIELD_MISSING', async () => { - const { doc, status } = await request('POST', 'articles', { + const { doc, status } = await request('POST', 'articles', { body: { body: 'no title here' }, }); assertEquals(status, 400); @@ -52,9 +56,13 @@ Deno.test('write path', async (t) => { await t.step( 'PATCH applies partial updates, other fields untouched', async () => { - const { doc, status } = await request('PATCH', `articles/${createdId}`, { - body: { viewCount: 5 }, - }); + const { doc, status } = await request( + 'PATCH', + `articles/${createdId}`, + { + body: { viewCount: 5 }, + }, + ); assertEquals(status, 200); assertEquals(doc.data.attributes.viewCount, 5); assertEquals(doc.data.attributes.title, 'Contract test article'); @@ -65,9 +73,13 @@ Deno.test('write path', async (t) => { await t.step( 'PATCH on a missing resource is 404 RESOURCE_NOT_FOUND', async () => { - const { doc, status } = await request('PATCH', 'articles/999999', { - body: { title: 'nope' }, - }); + const { doc, status } = await request( + 'PATCH', + 'articles/999999', + { + body: { title: 'nope' }, + }, + ); assertEquals(status, 404); assertEquals(doc.errors[0].code, 'RESOURCE_NOT_FOUND'); }, diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 0442674..86ac1c0 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,6 +1,6 @@ import type { + HydratedList, JsonApiArrayResponse, - JsonApiPaginationMeta, JsonApiSingleResponse, } from './types/jsonapi.ts'; import { hydrateResponse } from './hydrate.ts'; @@ -27,19 +27,13 @@ export interface RawQueryParams { 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>; + list(query?: QueryFn | RawQueryParams): Promise>; get(id: string | number, query?: QueryFn): Promise; create(body: unknown): Promise; update(id: string | number, body: unknown): Promise; @@ -112,7 +106,7 @@ export function createJsonApiClient( } function single(doc: unknown): T { - return hydrateResponse(doc as JsonApiSingleResponse).data; + return hydrateResponse(doc as JsonApiSingleResponse).data; } return { @@ -121,8 +115,7 @@ export function createJsonApiClient( 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 }; + return hydrateResponse(doc as JsonApiArrayResponse); }, async get(id, query) { return single( diff --git a/clients/typescript/src/client_test.ts b/clients/typescript/src/client_test.ts index a45cb5a..ba26a0e 100644 --- a/clients/typescript/src/client_test.ts +++ b/clients/typescript/src/client_test.ts @@ -2,21 +2,14 @@ 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 }; +type Todo = { id: 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, -}; +const hydratedTodo: Todo = { id: '1', title: 'a', completed: false }; /** Records the request it received and returns a canned Response. */ function fakeFetch( diff --git a/clients/typescript/src/hydrate.ts b/clients/typescript/src/hydrate.ts index f644988..faba474 100644 --- a/clients/typescript/src/hydrate.ts +++ b/clients/typescript/src/hydrate.ts @@ -1,23 +1,15 @@ -// utils/hydrate.ts - import type { - HydratedArrayResult, - HydratedSingleResult, + HydratedList, + HydratedSingle, JsonApiArrayResponse, JsonApiResource, - JsonApiResponse, JsonApiSingleResponse, } from './types/jsonapi.ts'; -type ResourceMap = Record>; +type IncludedMap = Record>; -/** - * Builds a lookup map for included resources. - */ -function buildResourceMap( - included: JsonApiResource[] = [], -): ResourceMap { - const map: ResourceMap = {}; +function buildIncludedMap(included: JsonApiResource[] = []): IncludedMap { + const map: IncludedMap = {}; for (const res of included) { (map[res.type] ??= {})[res.id] = res; } @@ -25,101 +17,60 @@ function buildResourceMap( } /** - * Hydrates a single resource, resolving relationships. + * Flattens one resource: `{ id, ...attributes, ...relationships }`. + * Relationships resolve from `included`; a linked resource that is not + * included resolves to `null` (to-one) or is dropped (to-many). A cycle + * back to a resource already on the current path resolves to `null`. */ -function hydrateOne( +function hydrateOne( resource: JsonApiResource, - map: ResourceMap, - deep: boolean, - visited: Set, + map: IncludedMap, + path: Set, ): T { const key = `${resource.type}:${resource.id}`; - if (visited.has(key)) { - // Prevent infinite recursion - return { id: resource.id, type: resource.type, circular: true } as T; - } - visited.add(key); - const out: Record = { id: resource.id, - type: resource.type, ...resource.attributes, }; + const nextPath = new Set(path).add(key); - if (resource.relationships) { - for (const [relName, rel] of Object.entries(resource.relationships)) { - const relData = rel.data; - if (Array.isArray(relData)) { - out[relName] = relData - .map((ref) => { - const related = map[ref.type]?.[ref.id]; - if (!related) return null; - return deep - ? hydrateOne(related, map, true, new Set(visited)) - : { id: related.id, type: related.type, ...related.attributes }; - }) - .filter(Boolean); - } else if (relData) { - const related = map[relData.type]?.[relData.id]; - out[relName] = related - ? (deep - ? hydrateOne(related, map, true, new Set(visited)) - : { id: related.id, type: related.type, ...related.attributes }) - : null; - } else { - out[relName] = null; - } - } + const resolve = (ref: { id: string; type: string }): unknown => { + const related = map[ref.type]?.[ref.id]; + if (!related || nextPath.has(`${ref.type}:${ref.id}`)) return null; + return hydrateOne(related, map, nextPath); + }; + + for (const [name, rel] of Object.entries(resource.relationships ?? {})) { + out[name] = Array.isArray(rel.data) + ? rel.data.map(resolve).filter((r) => r !== null) + : rel.data + ? resolve(rel.data) + : null; } return out as T; } /** - * Hydrates a JSON:API response, returning { data, meta, links }. - * Preserves single vs array structure from input. + * Hydrates a JSON:API document into plain objects. Lists return + * `{ data, pagination }`; single resources return `{ data }`. + * + * Runtime validation is out of scope: the caller's `T` is trusted. */ -export function hydrateResponse( +export function hydrateResponse( response: JsonApiSingleResponse, -): HydratedSingleResult; -export function hydrateResponse( +): HydratedSingle; +export function hydrateResponse( response: JsonApiArrayResponse, -): HydratedArrayResult; -export function hydrateResponse( - response: JsonApiResponse | null | undefined, -): HydratedSingleResult | HydratedArrayResult | null; -export function hydrateResponse( - response: JsonApiResponse | null | undefined, -): HydratedSingleResult | HydratedArrayResult | null { - // Handle null/undefined input (e.g., from 204 No Content) - if (response === null || response === undefined) { - return null; - } - - const { data, included, meta, links } = response; - - // Preserve null for single resource responses (e.g., 204 No Content) - if (data === null) { - return { data: null, meta, links } as HydratedSingleResult; - } - - // Handle undefined/missing data as empty array - if (data === undefined) { - return { data: [] as T[], meta, links }; - } - - const map = buildResourceMap(included); - - if (Array.isArray(data)) { +): HydratedList; +export function hydrateResponse( + response: JsonApiSingleResponse | JsonApiArrayResponse, +): HydratedSingle | HydratedList { + const map = buildIncludedMap(response.included); + if (Array.isArray(response.data)) { return { - data: data.map((res) => hydrateOne(res, map, true, new Set())), - meta, - links, + data: response.data.map((res) => hydrateOne(res, map, new Set())), + pagination: response.meta?.pagination, }; } - - return { - data: hydrateOne(data, map, true, new Set()), - meta, - links, - }; + return { data: hydrateOne(response.data, map, new Set()) }; } diff --git a/clients/typescript/src/hydrate_test.ts b/clients/typescript/src/hydrate_test.ts index 95597d4..395701f 100644 --- a/clients/typescript/src/hydrate_test.ts +++ b/clients/typescript/src/hydrate_test.ts @@ -1,361 +1,120 @@ -// deno-lint-ignore-file no-explicit-any import { assertEquals } from '@std/assert'; import { hydrateResponse } from './hydrate.ts'; -import type { - JsonApiArrayResponse, - JsonApiSingleResponse, -} from './types/jsonapi.ts'; +import type { JsonApiResource } from './types/jsonapi.ts'; + +type User = { id: string; name: string; friend?: User | null }; +type Todo = { + id: string; + title: string; + owner?: User | null; + tags?: { id: string; label: string }[]; +}; + +const alice: JsonApiResource = { + id: '10', + type: 'users', + attributes: { name: 'Alice' }, +}; Deno.test('hydrateResponse', async (t) => { - await t.step('hydrates array response with flattened attributes', () => { - const response: JsonApiArrayResponse = { - data: [ - { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk', completed: false }, - }, - { - id: '2', - type: 'todos', - attributes: { title: 'Walk dog', completed: true }, - }, - ], - }; - - const result = hydrateResponse(response); - - assertEquals(result.data.length, 2); - assertEquals(result.data[0], { - id: '1', - type: 'todos', - title: 'Buy milk', - completed: false, - }); - assertEquals(result.data[1], { - id: '2', - type: 'todos', - title: 'Walk dog', - completed: true, + await t.step('flattens attributes onto id, without type', () => { + const { data } = hydrateResponse({ + data: { id: '1', type: 'todos', attributes: { title: 'Buy milk' } }, }); + assertEquals(data, { id: '1', title: 'Buy milk' }); }); - await t.step('hydrates single resource response', () => { - const response: JsonApiSingleResponse = { - data: { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk', completed: false }, - }, + await t.step('list returns data and pagination', () => { + const pagination = { + totalResources: 1, + totalPages: 1, + currentPage: 1, + pageSize: 10, }; - - const result = hydrateResponse(response); - - assertEquals(result.data, { - id: '1', - type: 'todos', - title: 'Buy milk', - completed: false, + const result = hydrateResponse({ + data: [{ id: '1', type: 'todos', attributes: { title: 'Buy milk' } }], + meta: { pagination }, + links: { self: '/todos' }, }); - }); - - await t.step('resolves to-one relationship from included', () => { - const response: JsonApiSingleResponse = { - data: { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk' }, - relationships: { - owner: { data: { id: '10', type: 'users' } }, - }, - }, - included: [ - { - id: '10', - type: 'users', - attributes: { name: 'Alice', email: 'alice@example.com' }, - }, - ], - }; - - const result = hydrateResponse(response); - - assertEquals(result.data, { - id: '1', - type: 'todos', - title: 'Buy milk', - owner: { - id: '10', - type: 'users', - name: 'Alice', - email: 'alice@example.com', - }, + assertEquals(result, { + data: [{ id: '1', title: 'Buy milk' }], + pagination, }); }); - await t.step('resolves to-many relationship from included', () => { - const response: JsonApiSingleResponse = { + await t.step('resolves to-one and to-many from included', () => { + const { data } = hydrateResponse({ data: { id: '1', type: 'todos', attributes: { title: 'Buy milk' }, relationships: { + owner: { data: { id: '10', type: 'users' } }, tags: { - data: [ - { id: '20', type: 'tags' }, - { id: '21', type: 'tags' }, - ], + data: [{ id: '20', type: 'tags' }, { id: '21', type: 'tags' }], }, }, }, included: [ + alice, { id: '20', type: 'tags', attributes: { label: 'urgent' } }, { id: '21', type: 'tags', attributes: { label: 'shopping' } }, ], - }; - - const result = hydrateResponse(response); - - assertEquals(result.data, { - id: '1', - type: 'todos', - title: 'Buy milk', - tags: [ - { id: '20', type: 'tags', label: 'urgent' }, - { id: '21', type: 'tags', label: 'shopping' }, - ], }); + assertEquals(data.owner, { id: '10', name: 'Alice' }); + assertEquals(data.tags, [ + { id: '20', label: 'urgent' }, + { id: '21', label: 'shopping' }, + ]); }); - await t.step('handles circular references without infinite loop', () => { - const response: JsonApiSingleResponse = { + await t.step('linked but not included: to-one null, to-many dropped', () => { + const { data } = hydrateResponse({ data: { id: '1', - type: 'users', - attributes: { name: 'Alice' }, + type: 'todos', + attributes: { title: 'Buy milk' }, relationships: { - friend: { data: { id: '2', type: 'users' } }, - }, - }, - included: [ - { - id: '2', - type: 'users', - attributes: { name: 'Bob' }, - relationships: { - friend: { data: { id: '1', type: 'users' } }, - }, - }, - { - id: '1', - type: 'users', - attributes: { name: 'Alice' }, - relationships: { - friend: { data: { id: '2', type: 'users' } }, + owner: { data: { id: '99', type: 'users' } }, + tags: { + data: [{ id: '20', type: 'tags' }, { id: '99', type: 'tags' }], }, }, - ], - }; - - const result = hydrateResponse(response); - - // Alice -> Bob resolves, Bob -> Alice hits circular guard - const data = result.data as any; - assertEquals(data.id, '1'); - assertEquals(data.name, 'Alice'); - assertEquals(data.friend.id, '2'); - assertEquals(data.friend.name, 'Bob'); - assertEquals(data.friend.friend, { - id: '1', - type: 'users', - circular: true, + }, + included: [{ id: '20', type: 'tags', attributes: { label: 'urgent' } }], }); + assertEquals(data.owner, null); + assertEquals(data.tags, [{ id: '20', label: 'urgent' }]); }); - await t.step('returns null for null input', () => { - const result = hydrateResponse(null); - assertEquals(result, null); - }); - - await t.step('returns null for undefined input', () => { - const result = hydrateResponse(undefined); - assertEquals(result, null); - }); - - await t.step('preserves null data in single resource response', () => { - const response = { data: null } as any; - const result = hydrateResponse(response); - - assertEquals(result.data, null); - }); - - await t.step( - 'resolves missing to-one relationship to null', - () => { - const response: JsonApiSingleResponse = { - data: { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk' }, - relationships: { - owner: { data: { id: '99', type: 'users' } }, - }, - }, - included: [], - }; - - const result = hydrateResponse(response); - - assertEquals((result.data as any).owner, null); - }, - ); - - await t.step( - 'filters out missing to-many relationship entries', - () => { - const response: JsonApiSingleResponse = { - data: { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk' }, - relationships: { - tags: { - data: [ - { id: '20', type: 'tags' }, - { id: '99', type: 'tags' }, - ], - }, - }, - }, - included: [ - { id: '20', type: 'tags', attributes: { label: 'urgent' } }, - ], - }; - - const result = hydrateResponse(response); - - assertEquals((result.data as any).tags.length, 1); - assertEquals((result.data as any).tags[0].label, 'urgent'); - }, - ); - - await t.step('resolves deep nested relationships', () => { - const response: JsonApiSingleResponse = { + await t.step('empty to-one relationship is null', () => { + const { data } = hydrateResponse({ data: { id: '1', type: 'todos', attributes: { title: 'Buy milk' }, - relationships: { - owner: { data: { id: '10', type: 'users' } }, - }, + relationships: { owner: { data: null } }, }, - included: [ - { - id: '10', - type: 'users', - attributes: { name: 'Alice' }, - relationships: { - department: { data: { id: '100', type: 'departments' } }, - }, - }, - { - id: '100', - type: 'departments', - attributes: { name: 'Engineering' }, - }, - ], - }; - - const result = hydrateResponse(response); - - assertEquals((result.data as any).owner.name, 'Alice'); - assertEquals((result.data as any).owner.department, { - id: '100', - type: 'departments', - name: 'Engineering', }); + assertEquals(data.owner, null); }); - await t.step('handles empty included array', () => { - const response: JsonApiSingleResponse = { + await t.step('resolves nested includes, cycles stop at null', () => { + const { data } = hydrateResponse({ data: { id: '1', - type: 'todos', - attributes: { title: 'Buy milk' }, - relationships: { - owner: { data: { id: '10', type: 'users' } }, - }, + type: 'users', + attributes: { name: 'Zed' }, + relationships: { friend: { data: { id: '10', type: 'users' } } }, }, - included: [], - }; - - const result = hydrateResponse(response); - - assertEquals((result.data as any).owner, null); - }); - - await t.step('propagates meta and links', () => { - const response: JsonApiArrayResponse = { - data: [ + included: [ { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk' }, + ...alice, + relationships: { friend: { data: { id: '1', type: 'users' } } }, }, ], - meta: { - pagination: { - totalResources: 100, - totalPages: 10, - currentPage: 1, - pageSize: 10, - }, - }, - links: { - self: '/api/todos?page[number]=1', - next: '/api/todos?page[number]=2', - last: '/api/todos?page[number]=10', - }, - }; - - const result = hydrateResponse(response); - - assertEquals(result.meta, { - pagination: { - totalResources: 100, - totalPages: 10, - currentPage: 1, - pageSize: 10, - }, - }); - assertEquals(result.links, { - self: '/api/todos?page[number]=1', - next: '/api/todos?page[number]=2', - last: '/api/todos?page[number]=10', }); - }); - - await t.step('handles undefined data as empty array', () => { - const response = {} as any; - const result = hydrateResponse(response); - - assertEquals(result.data, []); - }); - - await t.step('resolves null to-one relationship data', () => { - const response: JsonApiSingleResponse = { - data: { - id: '1', - type: 'todos', - attributes: { title: 'Buy milk' }, - relationships: { - owner: { data: null }, - }, - }, - }; - - const result = hydrateResponse(response); - - assertEquals((result.data as any).owner, null); + assertEquals(data.friend?.name, 'Alice'); + assertEquals(data.friend?.friend, null); }); }); diff --git a/clients/typescript/src/query-builder/FilterGroupBuilder.ts b/clients/typescript/src/query-builder/FilterGroupBuilder.ts index 8854aed..1a48b5e 100644 --- a/clients/typescript/src/query-builder/FilterGroupBuilder.ts +++ b/clients/typescript/src/query-builder/FilterGroupBuilder.ts @@ -1,4 +1,3 @@ -// deno-lint-ignore-file no-explicit-any import type { AttributeKeys, FilterOp } from '../types/query-builder.ts'; import type { FilterGroup } from '../types/filters.ts'; @@ -12,7 +11,11 @@ export class FilterGroupBuilder { /** * Add a simple filter to this group. */ - filter>(field: K, op: FilterOp, value: any): this { + filter>( + field: K, + op: FilterOp, + value: unknown, + ): this { this.groups.push({ type: 'simple', filter: { field, op, value }, diff --git a/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts b/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts index a4a23ba..9a119b7 100644 --- a/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts +++ b/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts @@ -1,4 +1,3 @@ -// deno-lint-ignore-file no-explicit-any import type { AttributeKeys, DirectAttributeKeys, @@ -48,20 +47,24 @@ export class JsonApiQueryBuilder { * @param field - The attribute to filter on * @param value - The value to filter by (uses "eq" operator) */ - filter>(field: K, value: any): this; + filter>(field: K, value: unknown): this; /** * Add a simple filter with explicit operator. * @param field - The attribute to filter on * @param op - The filter operator * @param value - The value to filter by */ - filter>(field: K, op: FilterOp, value: any): this; filter>( field: K, - opOrValue: FilterOp | any, - value?: any, - ) { - const op: FilterOp = value === undefined ? 'eq' : opOrValue; + op: FilterOp, + value: unknown, + ): this; + filter>( + field: K, + opOrValue: unknown, + value?: unknown, + ): this { + const op = value === undefined ? 'eq' : (opOrValue as FilterOp); const val = value === undefined ? opOrValue : value; this.filterGroups.push({ type: 'simple', diff --git a/clients/typescript/src/types/filters.ts b/clients/typescript/src/types/filters.ts index b634727..5bb3efa 100644 --- a/clients/typescript/src/types/filters.ts +++ b/clients/typescript/src/types/filters.ts @@ -1,4 +1,3 @@ -// deno-lint-ignore-file no-explicit-any import type { AttributeKeys, FilterOp } from './query-builder.ts'; /** @@ -7,7 +6,7 @@ import type { AttributeKeys, FilterOp } from './query-builder.ts'; export type SimpleFilter = { field: AttributeKeys; op: FilterOp; - value: any; + value: unknown; }; /** diff --git a/clients/typescript/src/types/jsonapi.ts b/clients/typescript/src/types/jsonapi.ts index c15e88b..a0f4399 100644 --- a/clients/typescript/src/types/jsonapi.ts +++ b/clients/typescript/src/types/jsonapi.ts @@ -1,5 +1,6 @@ -// deno-lint-ignore-file no-explicit-any -export interface JsonApiResource { +export type JsonApiAttributes = Record; + +export interface JsonApiResource { id: string; type: string; attributes: T; @@ -12,21 +13,25 @@ export type JsonApiRelationship = | { data: Array<{ id: string; type: string }> } | { data: null }; -export interface JsonApiSingleResponse { +export type JsonApiMeta = Record & { + pagination?: JsonApiPaginationMeta; +}; + +export interface JsonApiSingleResponse { data: JsonApiResource; included?: JsonApiResource[]; - meta?: { pagination?: JsonApiPaginationMeta }; + meta?: JsonApiMeta; links?: JsonApiLinks; } -export interface JsonApiArrayResponse { +export interface JsonApiArrayResponse { data: JsonApiResource[]; included?: JsonApiResource[]; - meta?: { pagination?: JsonApiPaginationMeta }; + meta?: JsonApiMeta; links?: JsonApiLinks; } -export type JsonApiResponse = +export type JsonApiResponse = | JsonApiSingleResponse | JsonApiArrayResponse; @@ -46,27 +51,12 @@ export interface JsonApiLinks { next?: string; } -/** - * Type for hydrated single resource result. - */ -export interface HydratedSingleResult { +export interface HydratedSingle { data: T; - meta?: { pagination?: JsonApiPaginationMeta }; - links?: JsonApiLinks; } -/** - * Type for hydrated array resource result. - */ -export interface HydratedArrayResult { +export interface HydratedList { data: T[]; - meta?: { pagination?: JsonApiPaginationMeta }; - links?: JsonApiLinks; + /** Present only when the request was paginated (any `page[...]` param). */ + pagination?: JsonApiPaginationMeta; } - -/** - * Union type for hydrated query result. - */ -export type HydratedQueryResult = - | HydratedSingleResult - | HydratedArrayResult;