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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion JsonApiToolkit.TypeGen.Tests/CliIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Article> = {",
generated
);
Assert.Contains(" toOne: [\"author\"],", generated);
Assert.Contains(" toMany: [\"comments\"],", generated);

// --check passes right after generation...
Assert.Equal(
Expand Down
16 changes: 14 additions & 2 deletions JsonApiToolkit.TypeGen.Tests/TypeScriptEmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Widget> = {", 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);
Expand Down
9 changes: 7 additions & 2 deletions JsonApiToolkit.TypeGen/Cli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++)
Expand All @@ -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;
Expand All @@ -38,7 +42,8 @@ public static int Run(string[] args)
if (assemblyPath is null || outPath is null)
{
Console.Error.WriteLine(
"Usage: jsonapi-typegen --assembly <path/to/Api.dll> --out <path/to/api-types.gen.ts> [--check]"
"Usage: jsonapi-typegen --assembly <path/to/Api.dll> --out <path/to/api-types.gen.ts> "
+ "[--client-import <specifier>] [--check]"
);
return 1;
}
Expand All @@ -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)
{
Expand Down
36 changes: 31 additions & 5 deletions JsonApiToolkit.TypeGen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Article> = {
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 <specifier>` to point at a different module (for
example a relative path inside a monorepo).
71 changes: 53 additions & 18 deletions JsonApiToolkit.TypeGen/TypeScriptEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,36 @@ namespace JsonApiToolkit.TypeGen;
/// </summary>
public static class TypeScriptEmitter
{
public static string Generate(IReadOnlyList<(Type Type, string WireType)> resources)
/// <summary>
/// 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.
/// </summary>
/// <param name="clientImport">Module specifier the descriptor type is imported from.</param>
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();
var nullability = new NullabilityInfoContext();

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(
Expand All @@ -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)
Expand Down Expand Up @@ -83,27 +95,50 @@ NullabilityInfoContext nullability
sb.AppendLine("}");
}

private static void EmitResourceMap(
/// <summary>
/// The runtime shape the client needs to make hydration honest: null-stripped
/// attributes come back as null, un-included relationships as null or [].
/// </summary>
private static void EmitDescriptor(
StringBuilder sb,
List<(Type Type, string WireType)> ordered,
Type type,
string wireType,
Dictionary<Type, string> wireTypeByType
)
{
sb.AppendLine("export const ResourceMap = {");
foreach ((Type? type, string? wireType) in ordered)
{
IEnumerable<string> relationships = EntityMapper
var nullability = new NullabilityInfoContext();
IEnumerable<string> attributes = MappedAttributes(type)
.Where(p => MapAttributeType(p, nullability) is not null)
.Select(Name);

List<PropertyInfo> relationships =
[
.. EntityMapper
.GetRelationshipProperties(type)
.Where(p =>
wireTypeByType.ContainsKey(TypeHelpers.GetNavigationTargetType(p.PropertyType))
)
.Select(p => $"\"{EntityMapper.GetAttributeName(p)}\"");
),
];
IEnumerable<string> toOne = relationships
.Where(p => !TypeHelpers.IsCollectionType(p.PropertyType))
.Select(Name);
IEnumerable<string> 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<PropertyInfo> MappedAttributes(Type type)
{
return EntityMapper.GetAttributeProperties(type);
}

/// <summary>
Expand Down
32 changes: 32 additions & 0 deletions clients/typescript/contract/client_contract_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContractArticle>('articles');
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion clients/typescript/contract/document_contract_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<ContractArticle>()
.fields<ContractArticle>('articles', ['title', 'publishedAt'])
.fields(Article, ['title', 'publishedAt'])
.page(1, 2)
.build();
const { doc } = await getDoc<List>(`articles?${qs}`);
Expand Down
40 changes: 35 additions & 5 deletions clients/typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type {
HydratedList,
JsonApiArrayResponse,
JsonApiResourceDescriptor,
JsonApiResourceDescriptorBase,
JsonApiResourceDescriptors,
JsonApiSingleResponse,
} from './types/jsonapi.ts';
import { hydrateResponse } from './hydrate.ts';
Expand All @@ -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). */
Expand All @@ -42,7 +51,20 @@ export interface JsonApiResourceHandle<T> {
}

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<T>(
descriptor: JsonApiResourceDescriptor<T>,
path?: string,
): JsonApiResourceHandle<T>;
/**
* 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<T>(path: string): JsonApiResourceHandle<T>;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -106,16 +130,22 @@ export function createJsonApiClient(
}

function single<T>(doc: unknown): T {
return hydrateResponse<T>(doc as JsonApiSingleResponse).data;
return hydrateResponse<T>(doc as JsonApiSingleResponse, descriptors).data;
}

return {
resource<T>(path: string): JsonApiResourceHandle<T> {
const cleanPath = path.replace(/^\/+|\/+$/g, '');
resource<T>(
source: JsonApiResourceDescriptor<T> | string,
path?: string,
): JsonApiResourceHandle<T> {
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<T>(doc as JsonApiArrayResponse);
return hydrateResponse<T>(doc as JsonApiArrayResponse, descriptors);
},
async get(id, query) {
return single<T>(
Expand Down
Loading