Skip to content
Draft
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
17 changes: 11 additions & 6 deletions .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 All @@ -83,11 +84,11 @@ jobs:
exit 1

publish:
if: github.event_name == 'release'
if: github.event_name == 'release' && !startsWith(github.event.release.tag_name, 'typescript-')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # OIDC token for NuGet trusted publishing
id-token: write

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -100,18 +101,22 @@ jobs:
- name: Restore
run: dotnet restore --locked-mode

- name: Build and Pack
- name: Build and Pack JsonApiToolkit
if: ${{ !startsWith(github.event.release.tag_name, 'typegen-') }}
run: dotnet pack JsonApiToolkit/JsonApiToolkit.csproj -c Release --no-restore

# Exchange OIDC token for a short-lived NuGet API key
- name: Build and Pack JsonApiToolkit.TypeGen
if: startsWith(github.event.release.tag_name, 'typegen-')
run: dotnet pack JsonApiToolkit.TypeGen/JsonApiToolkit.TypeGen.csproj -c Release --no-restore

- name: NuGet login
id: login
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
with:
user: ${{ secrets.NUGET_USER }}

- name: Publish to NuGet.org
run: dotnet nuget push "JsonApiToolkit/bin/Release/*.nupkg"
run: dotnet nuget push "JsonApiToolkit*/bin/Release/*.nupkg"
--source https://api.nuget.org/v3/index.json
--api-key ${{ steps.login.outputs.NUGET_API_KEY }}
--skip-duplicate
120 changes: 120 additions & 0 deletions .github/workflows/next-snapshot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Next Snapshot

on:
push:
branches:
- next

concurrency:
group: next-snapshot
cancel-in-progress: false

jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
toolkit: ${{ steps.filter.outputs.toolkit }}
typegen: ${{ steps.filter.outputs.typegen }}
typescript: ${{ steps.filter.outputs.typescript }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
base: ${{ github.ref_name }}
filters: |
toolkit:
- 'JsonApiToolkit/**'
- 'Directory.Build.props'
typegen:
- 'JsonApiToolkit.TypeGen/**'
- 'Directory.Build.props'
typescript:
- 'clients/typescript/**'

dotnet:
name: Publish .NET Snapshot
needs: changes
if: needs.changes.outputs.toolkit == 'true' || needs.changes.outputs.typegen == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x

- name: Restore
run: dotnet restore --locked-mode

- name: Test
run: dotnet test --configuration Release --verbosity normal --no-restore

- name: Pack JsonApiToolkit
if: needs.changes.outputs.toolkit == 'true'
run: |
BASE=$(sed -n 's:.*<Version>\(.*\)</Version>.*:\1:p' JsonApiToolkit/JsonApiToolkit.csproj)
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
dotnet pack JsonApiToolkit/JsonApiToolkit.csproj -c Release --no-restore \
-p:Version="$MAJOR.$MINOR.$((PATCH + 1))-next.${{ github.run_number }}"

- name: Pack JsonApiToolkit.TypeGen
if: needs.changes.outputs.typegen == 'true'
run: |
BASE=$(sed -n 's:.*<Version>\(.*\)</Version>.*:\1:p' JsonApiToolkit.TypeGen/JsonApiToolkit.TypeGen.csproj)
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
dotnet pack JsonApiToolkit.TypeGen/JsonApiToolkit.TypeGen.csproj -c Release --no-restore \
-p:Version="$MAJOR.$MINOR.$((PATCH + 1))-next.${{ github.run_number }}"

- name: NuGet login
id: login
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
with:
user: ${{ secrets.NUGET_USER }}

- name: Publish to NuGet.org
run: dotnet nuget push "JsonApiToolkit*/bin/Release/*.nupkg"
--source https://api.nuget.org/v3/index.json
--api-key ${{ steps.login.outputs.NUGET_API_KEY }}
--skip-duplicate

typescript:
name: Publish TypeScript Snapshot
needs: changes
if: needs.changes.outputs.typescript == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: clients/typescript
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Deno
uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2
with:
deno-version: v2.x

- name: Test
run: deno task test

- name: Set Snapshot Version
run: |
BASE=$(jq -r .version deno.json)
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
jq --arg v "$MAJOR.$MINOR.$((PATCH + 1))-next.${{ github.run_number }}" \
'.version = $v' deno.json > deno.json.tmp
mv deno.json.tmp deno.json

- name: Publish to JSR
run: deno publish --allow-dirty
35 changes: 35 additions & 0 deletions .github/workflows/typescript-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: TypeScript Release

on:
release:
types: [published]

concurrency:
group: ts-release-${{ github.ref }}
cancel-in-progress: false

defaults:
run:
working-directory: clients/typescript

jobs:
publish:
if: startsWith(github.event.release.tag_name, 'typescript-v')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Deno
uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2
with:
deno-version: v2.x

- name: Test
run: deno task test

- name: Publish to JSR
run: deno publish
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
12 changes: 11 additions & 1 deletion JsonApiToolkit.TypeGen/JsonApiToolkit.TypeGen.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,22 @@
<ToolCommandName>jsonapi-typegen</ToolCommandName>
<PackageId>Intility.JsonApiToolkit.TypeGen</PackageId>
<Title>JsonApiToolkit TypeGen</Title>
<Version>2.1.8</Version>
<Version>0.1.0</Version>
<Authors>Intility</Authors>
<Company>Intility</Company>
<Description>Generates TypeScript resource types from [JsonApiResource]-attributed C# models.</Description>
<PackageTags>jsonapi;json-api;typescript;codegen;dotnet-tool</PackageTags>
<RepositoryUrl>https://github.com/intility/json-api-toolkit</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://github.com/intility/json-api-toolkit</PackageProjectUrl>
<PackageIcon>intility-logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\"/>
<None Include="..\assets\intility-logo.png" Pack="true" Visible="false" PackagePath="\"/>
</ItemGroup>
<ItemGroup>
<!-- Reuses EntityMapper's attribute/relationship classification so the
generated types can never drift from what the toolkit actually
Expand Down
66 changes: 66 additions & 0 deletions JsonApiToolkit.TypeGen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# JsonApiToolkit TypeGen

A .NET tool that generates TypeScript resource types from
`[JsonApiResource]`-attributed C# models. It reuses the exact
attribute/relationship classification that
[JsonApiToolkit](https://www.nuget.org/packages/Intility.JsonApiToolkit)
uses at runtime, so the generated types cannot drift from what your API
serializes on the wire.

## Installation

```bash
dotnet tool install Intility.JsonApiToolkit.TypeGen --prerelease
```

## Usage

Build your API project first, then point the tool at the compiled assembly:

```bash
jsonapi-typegen --assembly bin/Release/net10.0/MyApi.dll --out api-types.gen.ts
```

Pass `--check` to verify the output is up to date without writing it. This
exits non-zero on drift, which makes it useful as a CI gate:

```bash
jsonapi-typegen --assembly bin/Release/net10.0/MyApi.dll --out api-types.gen.ts --check
```

## How it works

Mark your resource models with `[JsonApiResource]` in the API project. The
tool loads the assembly, finds the attributed types, and emits, per
resource, one TypeScript interface and one descriptor constant of the same
name:

```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)
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).
Loading