From 0dca2baa5ea592fd016f299f74b53b85b52166b3 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Wed, 2 Sep 2026 15:53:37 +0200 Subject: [PATCH] feat(typegen): emit resource descriptors and use them for honest hydration --- .github/workflows/ci-cd.yml | 3 +- .../CliIntegrationTests.cs | 4 +- .../TypeScriptEmitterTests.cs | 16 ++++- JsonApiToolkit.TypeGen/Cli.cs | 9 ++- JsonApiToolkit.TypeGen/README.md | 36 ++++++++-- JsonApiToolkit.TypeGen/TypeScriptEmitter.cs | 71 ++++++++++++++----- .../contract/client_contract_test.ts | 32 +++++++++ .../contract/document_contract_test.ts | 3 +- clients/typescript/src/client.ts | 40 +++++++++-- clients/typescript/src/client_test.ts | 25 +++++++ clients/typescript/src/hydrate.ts | 27 ++++++- clients/typescript/src/hydrate_test.ts | 35 ++++++++- .../src/query-builder/JsonApiQueryBuilder.ts | 30 +++++--- clients/typescript/src/types/jsonapi.ts | 29 ++++++++ clients/typescript/src/types/query-builder.ts | 33 +++++---- .../src/types/query-builder_test.ts | 39 ++++++++++ justfile | 3 +- samples/ContractApi/Program.cs | 9 ++- samples/ContractApi/api-types.gen.ts | 26 +++++-- 19 files changed, 396 insertions(+), 74 deletions(-) create mode 100644 clients/typescript/src/types/query-builder_test.ts diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index e7f963c..0b38dc8 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -68,7 +68,8 @@ jobs: dotnet build samples/ContractApi --configuration Release dotnet run --project JsonApiToolkit.TypeGen --configuration Release --no-build -- \ --assembly samples/ContractApi/bin/Release/net10.0/ContractApi.dll \ - --out samples/ContractApi/api-types.gen.ts --check + --out samples/ContractApi/api-types.gen.ts \ + --client-import ../../clients/typescript/src/index.ts --check status: name: Build and Test diff --git a/JsonApiToolkit.TypeGen.Tests/CliIntegrationTests.cs b/JsonApiToolkit.TypeGen.Tests/CliIntegrationTests.cs index ff78c48..9fe43cb 100644 --- a/JsonApiToolkit.TypeGen.Tests/CliIntegrationTests.cs +++ b/JsonApiToolkit.TypeGen.Tests/CliIntegrationTests.cs @@ -26,9 +26,11 @@ public void Run_resolves_JsonApiResource_types_from_a_separately_built_assembly( Assert.Contains("export interface Author", generated); Assert.Contains("export interface Comment", generated); Assert.Contains( - "Article: { type: \"articles\", relationships: [\"author\", \"comments\"] }", + "export const Article: JsonApiResourceDescriptor
= {", generated ); + Assert.Contains(" toOne: [\"author\"],", generated); + Assert.Contains(" toMany: [\"comments\"],", generated); // --check passes right after generation... Assert.Equal( diff --git a/JsonApiToolkit.TypeGen.Tests/TypeScriptEmitterTests.cs b/JsonApiToolkit.TypeGen.Tests/TypeScriptEmitterTests.cs index ddf15de..3f09fa1 100644 --- a/JsonApiToolkit.TypeGen.Tests/TypeScriptEmitterTests.cs +++ b/JsonApiToolkit.TypeGen.Tests/TypeScriptEmitterTests.cs @@ -51,15 +51,27 @@ public void Generate_maps_attributes_and_relationships_and_skips_unmapped_types( (typeof(Part), "parts"), }; - var ts = TypeScriptEmitter.Generate(resources); + var ts = TypeScriptEmitter.Generate(resources, "@intility/json-api-client"); + Assert.Contains( + "import type { JsonApiResourceDescriptor } from \"@intility/json-api-client\";", + ts + ); Assert.Contains("description: string | null;", ts); Assert.Contains("retiredAt: string | null;", ts); Assert.Contains("tags: string[];", ts); Assert.Contains("ownerId: number;", ts); Assert.Contains("owner: Owner | null;", ts); Assert.Contains("parts: Part[];", ts); - Assert.Contains("Widget: { type: \"widgets\", relationships: [\"owner\", \"parts\"] }", ts); + + Assert.Contains("export const Widget: JsonApiResourceDescriptor = {", ts); + Assert.Contains(" type: \"widgets\",", ts); + Assert.Contains( + " attributes: [\"name\", \"description\", \"retiredAt\", \"tags\", \"ownerId\"],", + ts + ); + Assert.Contains(" toOne: [\"owner\"],", ts); + Assert.Contains(" toMany: [\"parts\"],", ts); // UnmappedThing has no [JsonApiResource]: the property is dropped, not guessed at. Assert.DoesNotContain("extra", ts); diff --git a/JsonApiToolkit.TypeGen/Cli.cs b/JsonApiToolkit.TypeGen/Cli.cs index b128c8d..f615e59 100644 --- a/JsonApiToolkit.TypeGen/Cli.cs +++ b/JsonApiToolkit.TypeGen/Cli.cs @@ -14,6 +14,7 @@ public static int Run(string[] args) { string? assemblyPath = null; string? outPath = null; + string clientImport = "@intility/json-api-client"; bool check = false; for (int i = 0; i < args.Length; i++) @@ -26,6 +27,9 @@ public static int Run(string[] args) case "--out": outPath = args[++i]; break; + case "--client-import": + clientImport = args[++i]; + break; case "--check": check = true; break; @@ -38,7 +42,8 @@ public static int Run(string[] args) if (assemblyPath is null || outPath is null) { Console.Error.WriteLine( - "Usage: jsonapi-typegen --assembly --out [--check]" + "Usage: jsonapi-typegen --assembly --out " + + "[--client-import ] [--check]" ); return 1; } @@ -61,7 +66,7 @@ public static int Run(string[] args) return 1; } - string generated = TypeScriptEmitter.Generate(resources); + string generated = TypeScriptEmitter.Generate(resources, clientImport); if (check) { diff --git a/JsonApiToolkit.TypeGen/README.md b/JsonApiToolkit.TypeGen/README.md index 345c8f3..4d6f88a 100644 --- a/JsonApiToolkit.TypeGen/README.md +++ b/JsonApiToolkit.TypeGen/README.md @@ -31,10 +31,36 @@ jsonapi-typegen --assembly bin/Release/net10.0/MyApi.dll --out api-types.gen.ts ## How it works Mark your resource models with `[JsonApiResource]` in the API project. The -tool loads the assembly, finds the attributed types, and emits one -TypeScript interface per resource, with attributes and relationships -classified the same way JsonApiToolkit maps them. +tool loads the assembly, finds the attributed types, and emits, per +resource, one TypeScript interface and one descriptor constant of the same +name: -The generated types pair with +```ts +export interface Article { + id: string; + title: string; + publishedAt: string | null; + author: Author | null; + comments: Comment[]; +} + +export const Article: JsonApiResourceDescriptor
= { + type: "articles", + attributes: ["title", "publishedAt"], + toOne: ["author"], + toMany: ["comments"], +}; +``` + +Attributes and relationships are classified the same way JsonApiToolkit +maps them. The descriptor is what [`@intility/json-api-client`](https://jsr.io/@intility/json-api-client) -for type-safe queries and hydration on the frontend. +needs to keep hydration honest: `client.resource(Article)` infers the type, +uses the wire type as the path, and fills in what the wire omits +(null-stripped attributes as `null`, un-included relationships as `null` or +`[]`). Set `UseResourceAttributeTypeNames` in the API's `JsonApiOptions` so +included resources carry the same type names and match their descriptors. + +The descriptor type is imported from `@intility/json-api-client` by default. +Pass `--client-import ` to point at a different module (for +example a relative path inside a monorepo). diff --git a/JsonApiToolkit.TypeGen/TypeScriptEmitter.cs b/JsonApiToolkit.TypeGen/TypeScriptEmitter.cs index 3f5f106..7447ccf 100644 --- a/JsonApiToolkit.TypeGen/TypeScriptEmitter.cs +++ b/JsonApiToolkit.TypeGen/TypeScriptEmitter.cs @@ -12,7 +12,17 @@ namespace JsonApiToolkit.TypeGen; /// public static class TypeScriptEmitter { - public static string Generate(IReadOnlyList<(Type Type, string WireType)> resources) + /// + /// Emits one interface and one descriptor constant per resource. The + /// descriptor shares the interface's name (TypeScript keeps types and + /// values in separate namespaces) so `client.resource(Article)` infers + /// the `Article` type. + /// + /// Module specifier the descriptor type is imported from. + public static string Generate( + IReadOnlyList<(Type Type, string WireType)> resources, + string clientImport + ) { var wireTypeByType = resources.ToDictionary(r => r.Type, r => r.WireType); var ordered = resources.OrderBy(r => r.Type.Name, StringComparer.Ordinal).ToList(); @@ -20,16 +30,18 @@ public static string Generate(IReadOnlyList<(Type Type, string WireType)> resour var sb = new StringBuilder(); sb.AppendLine("// AUTO-GENERATED by `dotnet jsonapi-typegen`. Do not edit by hand."); + sb.AppendLine($"import type {{ JsonApiResourceDescriptor }} from \"{clientImport}\";"); sb.AppendLine(); - foreach ((Type? type, string _) in ordered) + foreach ((Type? type, string? wireType) in ordered) { EmitInterface(sb, type, wireTypeByType, nullability); sb.AppendLine(); + EmitDescriptor(sb, type, wireType, wireTypeByType); + sb.AppendLine(); } - EmitResourceMap(sb, ordered, wireTypeByType); - return sb.ToString(); + return sb.ToString().TrimEnd() + Environment.NewLine; } private static void EmitInterface( @@ -42,7 +54,7 @@ NullabilityInfoContext nullability sb.AppendLine($"export interface {type.Name} {{"); sb.AppendLine(" id: string;"); // JSON:API ids are always strings on the wire - foreach (PropertyInfo prop in EntityMapper.GetAttributeProperties(type)) + foreach (PropertyInfo prop in MappedAttributes(type)) { (string TsType, bool Nullable)? mapped = MapAttributeType(prop, nullability); if (mapped is null) @@ -83,27 +95,50 @@ NullabilityInfoContext nullability sb.AppendLine("}"); } - private static void EmitResourceMap( + /// + /// The runtime shape the client needs to make hydration honest: null-stripped + /// attributes come back as null, un-included relationships as null or []. + /// + private static void EmitDescriptor( StringBuilder sb, - List<(Type Type, string WireType)> ordered, + Type type, + string wireType, Dictionary wireTypeByType ) { - sb.AppendLine("export const ResourceMap = {"); - foreach ((Type? type, string? wireType) in ordered) - { - IEnumerable relationships = EntityMapper + var nullability = new NullabilityInfoContext(); + IEnumerable attributes = MappedAttributes(type) + .Where(p => MapAttributeType(p, nullability) is not null) + .Select(Name); + + List relationships = + [ + .. EntityMapper .GetRelationshipProperties(type) .Where(p => wireTypeByType.ContainsKey(TypeHelpers.GetNavigationTargetType(p.PropertyType)) - ) - .Select(p => $"\"{EntityMapper.GetAttributeName(p)}\""); + ), + ]; + IEnumerable toOne = relationships + .Where(p => !TypeHelpers.IsCollectionType(p.PropertyType)) + .Select(Name); + IEnumerable toMany = relationships + .Where(p => TypeHelpers.IsCollectionType(p.PropertyType)) + .Select(Name); + + sb.AppendLine($"export const {type.Name}: JsonApiResourceDescriptor<{type.Name}> = {{"); + sb.AppendLine($" type: \"{wireType}\","); + sb.AppendLine($" attributes: [{string.Join(", ", attributes)}],"); + sb.AppendLine($" toOne: [{string.Join(", ", toOne)}],"); + sb.AppendLine($" toMany: [{string.Join(", ", toMany)}],"); + sb.AppendLine("};"); + + static string Name(PropertyInfo p) => $"\"{EntityMapper.GetAttributeName(p)}\""; + } - sb.AppendLine( - $" {type.Name}: {{ type: \"{wireType}\", relationships: [{string.Join(", ", relationships)}] }}," - ); - } - sb.AppendLine("} as const;"); + private static IEnumerable MappedAttributes(Type type) + { + return EntityMapper.GetAttributeProperties(type); } /// diff --git a/clients/typescript/contract/client_contract_test.ts b/clients/typescript/contract/client_contract_test.ts index 2eaf126..5ce4d2d 100644 --- a/clients/typescript/contract/client_contract_test.ts +++ b/clients/typescript/contract/client_contract_test.ts @@ -9,8 +9,10 @@ import { BASE_URL, type ContractArticle, PUBLISHED_ARTICLES, + STRICT_BASE_URL, TOTAL_ARTICLES, } from './helpers.ts'; +import { Article, Author } from '../../../samples/ContractApi/api-types.gen.ts'; const articles = createJsonApiClient({ baseUrl: BASE_URL }) .resource('articles'); @@ -56,6 +58,36 @@ Deno.test('get', async (t) => { }); }); +Deno.test('generated descriptors (strict instance)', async (t) => { + // UseResourceAttributeTypeNames is on here, so included resources carry + // the [JsonApiResource] type name and match their descriptor. + const generated = createJsonApiClient({ + baseUrl: STRICT_BASE_URL, + resources: [Author], + }).resource(Article); + + await t.step('null-stripped attributes come back as null', async () => { + const article = await generated.get(2); // even id: publishedAt is null + assertEquals(article.publishedAt, null); + assertEquals(article.body, 'Body of article 2'); + }); + + await t.step('un-included relationships are null / []', async () => { + const article = await generated.get(2); + assertEquals(article.author, null); + assertEquals(article.comments, []); + }); + + await t.step( + 'included resources are filled from their descriptor', + async () => { + const article = await generated.get(2, (q) => q.include('author')); + assertEquals(article.author?.name, 'Bjarne Moen'); + assertEquals(article.author?.email, null); // stripped on the wire + }, + ); +}); + Deno.test('writes', async (t) => { await t.step('create, update, remove round trip', async () => { const created = await articles.create({ diff --git a/clients/typescript/contract/document_contract_test.ts b/clients/typescript/contract/document_contract_test.ts index 3d68803..3d64c1b 100644 --- a/clients/typescript/contract/document_contract_test.ts +++ b/clients/typescript/contract/document_contract_test.ts @@ -10,6 +10,7 @@ import { type List, type Single, } from './helpers.ts'; +import { Article } from '../../../samples/ContractApi/api-types.gen.ts'; Deno.test('document shape', async (t) => { await t.step('primary resources use the controller type string', async () => { @@ -103,7 +104,7 @@ Deno.test('sparse fieldsets', async (t) => { 'fields[type] works for the primary resource via the builder', async () => { const qs = new JsonApiQueryBuilder() - .fields('articles', ['title', 'publishedAt']) + .fields(Article, ['title', 'publishedAt']) .page(1, 2) .build(); const { doc } = await getDoc(`articles?${qs}`); diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 86ac1c0..ae9086a 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,6 +1,9 @@ import type { HydratedList, JsonApiArrayResponse, + JsonApiResourceDescriptor, + JsonApiResourceDescriptorBase, + JsonApiResourceDescriptors, JsonApiSingleResponse, } from './types/jsonapi.ts'; import { hydrateResponse } from './hydrate.ts'; @@ -18,6 +21,12 @@ export interface JsonApiClientOptions { * handling, and parsing. Defaults to the global `fetch`. */ fetch?: typeof fetch; + /** + * Generated descriptors for resources that show up as `included` but + * never get their own handle. Descriptors passed to `resource()` are + * registered automatically. + */ + resources?: readonly JsonApiResourceDescriptorBase[]; } /** Pre-built query string, for interop with externally-built params (e.g. gjallarbru). */ @@ -42,7 +51,20 @@ export interface JsonApiResourceHandle { } export interface JsonApiClient { - /** @param path Collection path relative to `baseUrl`, e.g. "articles". */ + /** + * Handle for a generated resource. `T` is inferred from the descriptor; + * hydration fills what the wire omits (see {@link JsonApiResourceDescriptor}). + * @param path Collection path relative to `baseUrl`; defaults to the wire type. + */ + resource( + descriptor: JsonApiResourceDescriptor, + path?: string, + ): JsonApiResourceHandle; + /** + * Handle for a hand-typed resource. No descriptor, so absent attributes + * and un-included relationships stay `undefined`. + * @param path Collection path relative to `baseUrl`, e.g. "articles". + */ resource(path: string): JsonApiResourceHandle; } @@ -79,6 +101,8 @@ export function createJsonApiClient( ): JsonApiClient { const fetchImpl = options.fetch ?? fetch; const baseUrl = options.baseUrl.replace(/\/+$/, ''); + const descriptors: JsonApiResourceDescriptors = {}; + for (const d of options.resources ?? []) descriptors[d.type] = d; async function send( method: string, @@ -106,16 +130,22 @@ export function createJsonApiClient( } function single(doc: unknown): T { - return hydrateResponse(doc as JsonApiSingleResponse).data; + return hydrateResponse(doc as JsonApiSingleResponse, descriptors).data; } return { - resource(path: string): JsonApiResourceHandle { - const cleanPath = path.replace(/^\/+|\/+$/g, ''); + resource( + source: JsonApiResourceDescriptor | string, + path?: string, + ): JsonApiResourceHandle { + if (typeof source !== 'string') descriptors[source.type] = source; + const cleanPath = + (path ?? (typeof source === 'string' ? source : source.type)) + .replace(/^\/+|\/+$/g, ''); return { async list(query) { const doc = await send('GET', cleanPath, { qs: queryString(query) }); - return hydrateResponse(doc as JsonApiArrayResponse); + return hydrateResponse(doc as JsonApiArrayResponse, descriptors); }, async get(id, query) { return single( diff --git a/clients/typescript/src/client_test.ts b/clients/typescript/src/client_test.ts index ba26a0e..0ae7df5 100644 --- a/clients/typescript/src/client_test.ts +++ b/clients/typescript/src/client_test.ts @@ -1,6 +1,7 @@ import { assertEquals, assertRejects } from '@std/assert'; import { createJsonApiClient } from './client.ts'; import { JsonApiRequestError } from './errors.ts'; +import type { JsonApiResourceDescriptor } from './types/jsonapi.ts'; type Todo = { id: string; title: string; completed: boolean }; @@ -90,6 +91,30 @@ Deno.test('list', async (t) => { }); }); +Deno.test('resource(descriptor)', async (t) => { + await t.step('paths from the wire type and fills omissions', async () => { + type Article = { id: string; title: string; author: Todo | null }; + const Article: JsonApiResourceDescriptor
= { + type: 'articles', + attributes: ['title'], + toOne: ['author'], + toMany: [], + }; + const { fetch: f, lastRequest } = fakeFetch(() => + jsonResponse(200, { + data: { id: '1', type: 'articles', attributes: { title: 'A' } }, + }) + ); + const client = createJsonApiClient({ + baseUrl: 'https://api.test', + fetch: f, + }); + const article = await client.resource(Article).get(1); + assertEquals(lastRequest().url, 'https://api.test/articles/1'); + assertEquals(article, { id: '1', title: 'A', author: null }); + }); +}); + Deno.test('get', async (t) => { await t.step('appends the id and returns the resource', async () => { const { todos, lastRequest } = setup(() => diff --git a/clients/typescript/src/hydrate.ts b/clients/typescript/src/hydrate.ts index faba474..e18004e 100644 --- a/clients/typescript/src/hydrate.ts +++ b/clients/typescript/src/hydrate.ts @@ -3,6 +3,7 @@ import type { HydratedSingle, JsonApiArrayResponse, JsonApiResource, + JsonApiResourceDescriptors, JsonApiSingleResponse, } from './types/jsonapi.ts'; @@ -21,10 +22,15 @@ function buildIncludedMap(included: JsonApiResource[] = []): IncludedMap { * 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`. + * + * With a descriptor for the resource's wire type, what the wire omits is + * filled in: null-stripped attributes as `null`, un-included relationships + * as `null` (to-one) or `[]` (to-many). */ function hydrateOne( resource: JsonApiResource, map: IncludedMap, + descriptors: JsonApiResourceDescriptors, path: Set, ): T { const key = `${resource.type}:${resource.id}`; @@ -37,7 +43,7 @@ function hydrateOne( 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); + return hydrateOne(related, map, descriptors, nextPath); }; for (const [name, rel] of Object.entries(resource.relationships ?? {})) { @@ -47,6 +53,16 @@ function hydrateOne( ? resolve(rel.data) : null; } + + const descriptor = descriptors[resource.type]; + if (descriptor) { + for (const name of [...descriptor.attributes, ...descriptor.toOne]) { + out[name] ??= null; + } + for (const name of descriptor.toMany) { + out[name] ??= []; + } + } return out as T; } @@ -58,19 +74,24 @@ function hydrateOne( */ export function hydrateResponse( response: JsonApiSingleResponse, + descriptors?: JsonApiResourceDescriptors, ): HydratedSingle; export function hydrateResponse( response: JsonApiArrayResponse, + descriptors?: JsonApiResourceDescriptors, ): HydratedList; export function hydrateResponse( response: JsonApiSingleResponse | JsonApiArrayResponse, + descriptors: JsonApiResourceDescriptors = {}, ): HydratedSingle | HydratedList { const map = buildIncludedMap(response.included); if (Array.isArray(response.data)) { return { - data: response.data.map((res) => hydrateOne(res, map, new Set())), + data: response.data.map((res) => + hydrateOne(res, map, descriptors, new Set()) + ), pagination: response.meta?.pagination, }; } - return { data: hydrateOne(response.data, map, new Set()) }; + return { data: hydrateOne(response.data, map, descriptors, new Set()) }; } diff --git a/clients/typescript/src/hydrate_test.ts b/clients/typescript/src/hydrate_test.ts index 395701f..2eb8d35 100644 --- a/clients/typescript/src/hydrate_test.ts +++ b/clients/typescript/src/hydrate_test.ts @@ -1,6 +1,9 @@ import { assertEquals } from '@std/assert'; import { hydrateResponse } from './hydrate.ts'; -import type { JsonApiResource } from './types/jsonapi.ts'; +import type { + JsonApiResource, + JsonApiResourceDescriptor, +} from './types/jsonapi.ts'; type User = { id: string; name: string; friend?: User | null }; type Todo = { @@ -99,6 +102,36 @@ Deno.test('hydrateResponse', async (t) => { assertEquals(data.owner, null); }); + await t.step('descriptor fills what the wire omits', () => { + type Article = { + id: string; + title: string; + body: string | null; + author: User | null; + comments: { id: string }[]; + }; + const descriptors = { + articles: { + type: 'articles', + attributes: ['title', 'body'], + toOne: ['author'], + toMany: ['comments'], + } satisfies JsonApiResourceDescriptor
, + }; + const { data } = hydrateResponse
( + // body is null-stripped, author and comments are not included + { data: { id: '1', type: 'articles', attributes: { title: 'A' } } }, + descriptors, + ); + assertEquals(data, { + id: '1', + title: 'A', + body: null, + author: null, + comments: [], + }); + }); + await t.step('resolves nested includes, cycles stop at null', () => { const { data } = hydrateResponse({ data: { diff --git a/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts b/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts index 9a119b7..de843c8 100644 --- a/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts +++ b/clients/typescript/src/query-builder/JsonApiQueryBuilder.ts @@ -7,6 +7,10 @@ import type { } from '../types/query-builder.ts'; import { FilterGroupBuilder } from './FilterGroupBuilder.ts'; import type { FilterGroup } from '../types/filters.ts'; +import type { + JsonApiResourceDescriptor, + JsonApiResourceDescriptorBase, +} from '../types/jsonapi.ts'; /** * Recursively serializes a filter group into JSON:API query parameter key-value pairs. @@ -129,17 +133,25 @@ export class JsonApiQueryBuilder { } /** - * Set sparse fieldsets for a resource type. - * Produces `fields[type]=field1,field2` in the query string. - * Pass a type parameter for type-safe field suggestions. - * @param type - The JSON:API resource type name - * @param fields - The attribute names to include + * Set a sparse fieldset for a resource: `fields[type]=field1,field2`. + * Pass a generated descriptor for the wire type and typed field names. */ - fields( - type: string, - fields: (unknown extends R ? string : DirectAttributeKeys)[], + fields( + descriptor: JsonApiResourceDescriptor, + fields: DirectAttributeKeys[], + ): this; + /** Sparse fieldset by wire type name, untyped field names. */ + fields(type: string, fields: string[]): this; + /** Sparse fieldset by wire type name, field names typed against `R`. */ + fields(type: string, fields: DirectAttributeKeys[]): this; + fields( + source: string | JsonApiResourceDescriptorBase, + fields: string[], ): this { - this.fieldsets.set(type, fields as string[]); + this.fieldsets.set( + typeof source === 'string' ? source : source.type, + fields, + ); return this; } diff --git a/clients/typescript/src/types/jsonapi.ts b/clients/typescript/src/types/jsonapi.ts index a0f4399..cbf5c5d 100644 --- a/clients/typescript/src/types/jsonapi.ts +++ b/clients/typescript/src/types/jsonapi.ts @@ -51,6 +51,35 @@ export interface JsonApiLinks { next?: string; } +/** + * Runtime shape of one resource, emitted by `dotnet jsonapi-typegen` next to + * the interface it describes. Lets hydration be honest about what the wire + * omits: null-stripped attributes become `null`, un-included relationships + * become `null` (to-one) or `[]` (to-many). Names are checked against `T`, + * so a stale generated file fails to compile. + */ +export interface JsonApiResourceDescriptor + extends JsonApiResourceDescriptorBase { + readonly attributes: readonly (keyof T & string)[]; + readonly toOne: readonly (keyof T & string)[]; + readonly toMany: readonly (keyof T & string)[]; +} + +/** Untyped form of {@link JsonApiResourceDescriptor}, for registries. */ +export interface JsonApiResourceDescriptorBase { + /** Wire `type`, also the default collection path. */ + readonly type: string; + readonly attributes: readonly string[]; + readonly toOne: readonly string[]; + readonly toMany: readonly string[]; +} + +/** Descriptors keyed by wire type, for resolving included resources. */ +export type JsonApiResourceDescriptors = Record< + string, + JsonApiResourceDescriptorBase +>; + export interface HydratedSingle { data: T; } diff --git a/clients/typescript/src/types/query-builder.ts b/clients/typescript/src/types/query-builder.ts index 6092569..b7ee360 100644 --- a/clients/typescript/src/types/query-builder.ts +++ b/clients/typescript/src/types/query-builder.ts @@ -18,36 +18,39 @@ export type Primitive = type StringKeys = Extract; /** - * Extracts keys from T whose values are primitives (attributes), - * but excludes "id" and "type". + * Attribute values: primitives and primitive arrays (JSON columns). + * Nullability is stripped first, so `string | null` classifies as `string`. + */ +type IsAttribute = NonNullable extends Primitive | Primitive[] ? true + : false; + +/** + * Extracts keys from T whose values are attributes, excluding "id" and "type". */ export type DirectAttributeKeys = Exclude< { - [K in StringKeys]: T[K] extends Primitive ? K : never; + [K in StringKeys]: IsAttribute extends true ? K : never; }[StringKeys], 'id' | 'type' >; /** - * Extract keys from T whose values are objects or arrays (relationships). + * Extracts keys from T whose values are objects or object arrays + * (relationships). `User | null` and optional properties count. */ export type RelationshipKeys = { - [K in StringKeys]: T[K] extends Array | object - ? (T[K] extends Primitive ? never : K) + [K in StringKeys]: IsAttribute extends true ? never + : NonNullable extends object ? K : never; }[StringKeys]; /** - * Extracts primitive attributes from a relationship type (excluding arrays). + * Extracts attributes from a to-one relationship's type (to-many yields none). */ -type RelationshipAttributeKeys = T[R] extends - Array ? never - : T[R] extends object ? Exclude< - { - [K in StringKeys]: T[R][K] extends Primitive ? K : never; - }[StringKeys], - 'id' | 'type' - > +type RelationshipAttributeKeys = NonNullable< + T[R] +> extends Array ? never + : NonNullable extends object ? DirectAttributeKeys> : never; /** diff --git a/clients/typescript/src/types/query-builder_test.ts b/clients/typescript/src/types/query-builder_test.ts new file mode 100644 index 0000000..54d2085 --- /dev/null +++ b/clients/typescript/src/types/query-builder_test.ts @@ -0,0 +1,39 @@ +/** + * Type-level probes for the key helpers. There is nothing to run; a wrong + * classification fails `deno check`. Each probe pairs a positive assignment + * with a `@ts-expect-error` negative so both directions are pinned. + */ +import type { AttributeKeys, RelationshipKeys } from './query-builder.ts'; + +type Author = { id: string; name: string; email: string | null }; +type Article = { + id: string; + title: string; + publishedAt: string | null; + tags: string[]; + author: Author | null; + editor?: Author; + comments: { id: string; text: string }[]; +}; + +// Nullable and optional relationships are relationships. +const author: RelationshipKeys
= 'author'; +const editor: RelationshipKeys
= 'editor'; +const comments: RelationshipKeys
= 'comments'; +// @ts-expect-error a primitive array is an attribute, not a relationship +const tags: RelationshipKeys
= 'tags'; + +// Nullable attributes and primitive arrays are attributes. +const publishedAt: AttributeKeys
= 'publishedAt'; +const tagsAttr: AttributeKeys
= 'tags'; +// Nested attributes reach through nullable to-one relationships. +const authorEmail: AttributeKeys
= 'author.email'; +// @ts-expect-error id is never a filterable attribute +const id: AttributeKeys
= 'id'; +// @ts-expect-error to-many relationships do not expose nested attributes +const commentText: AttributeKeys
= 'comments.text'; + +Deno.test('type probes compile', () => { + void [author, editor, comments, tags, publishedAt, tagsAttr, authorEmail]; + void [id, commentText]; +}); diff --git a/justfile b/justfile index c0071a8..8ed60ec 100644 --- a/justfile +++ b/justfile @@ -126,7 +126,8 @@ typegen *args: dotnet build {{sample}} --configuration Release dotnet run --project JsonApiToolkit.TypeGen --configuration Release --no-build -- \ --assembly {{sample}}/bin/Release/net10.0/ContractApi.dll \ - --out {{sample}}/api-types.gen.ts {{args}} + --out {{sample}}/api-types.gen.ts \ + --client-import ../../clients/typescript/src/index.ts {{args}} # Everything CI runs: format check, unit tests, the contract suite, and typegen drift [group('quality')] diff --git a/samples/ContractApi/Program.cs b/samples/ContractApi/Program.cs index 515e25d..01682ed 100644 --- a/samples/ContractApi/Program.cs +++ b/samples/ContractApi/Program.cs @@ -2,24 +2,23 @@ using JsonApiToolkit.Extensions; using Microsoft.EntityFrameworkCore; -var builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(o => o.UseInMemoryDatabase("contract-api")); builder.Services.AddControllers(); builder.Services.AddJsonApiToolkit(o => { - // Contract tests run one default instance and one instance with every - // opt-in behavior enabled. bool strict = builder.Configuration.GetValue("JSONAPI_STRICT"); o.StrictPagination = strict; o.StrictQueryValidation = strict; o.PreserveQueryInPaginationLinks = strict; + o.UseResourceAttributeTypeNames = strict; }); -var app = builder.Build(); +WebApplication app = builder.Build(); app.MapControllers(); -using (var scope = app.Services.CreateScope()) +using (IServiceScope scope = app.Services.CreateScope()) { Seed.Run(scope.ServiceProvider.GetRequiredService()); } diff --git a/samples/ContractApi/api-types.gen.ts b/samples/ContractApi/api-types.gen.ts index 6f576d8..f9f59ca 100644 --- a/samples/ContractApi/api-types.gen.ts +++ b/samples/ContractApi/api-types.gen.ts @@ -1,4 +1,5 @@ // AUTO-GENERATED by `dotnet jsonapi-typegen`. Do not edit by hand. +import type { JsonApiResourceDescriptor } from "../../clients/typescript/src/index.ts"; export interface Article { id: string; @@ -13,12 +14,26 @@ export interface Article { comments: Comment[]; } +export const Article: JsonApiResourceDescriptor
= { + type: "articles", + attributes: ["title", "body", "published", "publishedAt", "viewCount", "tags", "authorId"], + toOne: ["author"], + toMany: ["comments"], +}; + export interface Author { id: string; name: string; email: string | null; } +export const Author: JsonApiResourceDescriptor = { + type: "authors", + attributes: ["name", "email"], + toOne: [], + toMany: [], +}; + export interface Comment { id: string; text: string; @@ -29,8 +44,9 @@ export interface Comment { author: Author | null; } -export const ResourceMap = { - Article: { type: "articles", relationships: ["author", "comments"] }, - Author: { type: "authors", relationships: [] }, - Comment: { type: "comments", relationships: ["article", "author"] }, -} as const; +export const Comment: JsonApiResourceDescriptor = { + type: "comments", + attributes: ["text", "createdAt", "articleId", "authorId"], + toOne: ["article", "author"], + toMany: [], +};