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
10 changes: 6 additions & 4 deletions docs/src/content/docs/packages/typegen.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
---
title: TypeGen — TypeScript & OpenAPI from C#
description: Compile-time code generator that emits TypeScript interfaces and OpenAPI 3.0 schemas from C# DTOs. Roslyn-native, zero reflection, no running app required. One [GenerateTypes] attribute on a class, and `dotnet build` writes the .ts and .yaml files into your configured output directory.
title: TypeGen — TypeScript, OpenAPI & TanStack Query from C#
description: Compile-time code generator that emits TypeScript interfaces, OpenAPI 3.0 schemas, and TanStack Query clients from C# DTOs/endpoints. Roslyn-native, zero reflection, no running app required. One [GenerateTypes] attribute on a class, and `dotnet build` writes generated files into your configured output directory.
---

[![NuGet](https://img.shields.io/nuget/v/ZibStack.NET.TypeGen.svg)](https://www.nuget.org/packages/ZibStack.NET.TypeGen) [![Source](https://img.shields.io/badge/source-GitHub-blue)](https://github.com/MistyKuu/ZibStack.NET/tree/master/packages/ZibStack.NET.TypeGen)

Roslyn source generator that turns C# DTOs into **TypeScript interfaces** and an
**OpenAPI 3.0 schema document** at compile time. One attribute on a class,
Roslyn source generator that turns C# DTOs and ASP.NET endpoints into
**TypeScript interfaces**, an **OpenAPI 3.0 schema document**, and optional
**TanStack Query** client helpers at compile time. One attribute on a class,
`dotnet build`, and the files land in your configured output directory.

> **Why not NSwag / Reinforced.Typings?** Both rely on reflection over the
Expand All @@ -29,6 +30,7 @@ Roslyn source generator that turns C# DTOs into **TypeScript interfaces** and an
- [Diagnostic reference](/ZibStack.NET/packages/typegen/diagnostics/) — every `TG00xx` ID
- [Validation → OpenAPI](/ZibStack.NET/packages/typegen/validation-mapping/) — DataAnnotations / `[Z…]` → schema constraints
- [Endpoint discovery](/ZibStack.NET/packages/typegen/endpoint-discovery/) — Minimal API scan, native controllers, `[CrudApi]` synthesis
- [TanStack Query emitter](/ZibStack.NET/packages/typegen/emitters/tanstack-query/) — typed fetch functions, query keys, options, hooks, and cache helpers
- [Polymorphism & interfaces](/ZibStack.NET/packages/typegen/polymorphism-and-interfaces/) — `[JsonPolymorphic]` discriminated unions, opt-in `EmitInterfaces`
- [Advanced type features](/ZibStack.NET/packages/typegen/advanced-types/) — `[JsonExtensionData]`, computed/immutable props, string-enum converters, transitive nested-type discovery, inheritance rules
- [Python emitter](/ZibStack.NET/packages/typegen/emitters/python/) — Pydantic v2 / dataclasses
Expand Down
16 changes: 15 additions & 1 deletion docs/src/content/docs/packages/typegen/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: "Project-wide ITypeGenConfigurator fluent DSL, open-generic targeti
## Layers (lowest → highest precedence)

1. Defaults
2. Global `TypeScript` / `OpenApi` / `Python` / `Zod` blocks in `ITypeGenConfigurator`
2. Global `TypeScript` / `OpenApi` / `Python` / `Zod` / `TanStackQuery` blocks in `ITypeGenConfigurator`
3. `ForType<T>()` per-type fluent overrides
4. Class / property attributes (`[TsName]`, `[OpenApiProperty]`, etc.)

Expand Down Expand Up @@ -39,6 +39,16 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
oa.Description = "Public API for the order service.";
});

b.TanStackQuery(q =>
{
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
});

// Per-type overrides for DTOs you can't (or don't want to) annotate —
// e.g. types from a referenced library.
b.ForType<Order>()
Expand All @@ -59,6 +69,10 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
}
```

For the emitted default TanStack Query fetch client, `BaseUrlExpression` is
evaluated safely. If the expression is unset, empty, or throws, the client falls
back to `window.location.origin` in browsers.

> **Discovery vs override.** Without `.WithGeneratedTypes(...)`, the fluent block
> is a no-op for types that don't carry `[GenerateTypes]` — the chain just sits
> there registering overrides for a class TypeGen never sees. Adding the marker
Expand Down
246 changes: 246 additions & 0 deletions docs/src/content/docs/packages/typegen/emitters/tanstack-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
---
title: TypeGen - TanStack Query emitter
description: "TypeTarget.TanStackQuery emits typed TanStack Query clients from discovered ASP.NET endpoints: fetch functions, query keys, options factories, hooks, and cache helpers."
---

`TypeTarget.TanStackQuery` emits TypeScript client code for
`@tanstack/react-query` v5 from the same endpoint model used by TypeGen's
OpenAPI paths. It scans:

- hand-written Minimal APIs (`MapGet`, `MapPost`, `MapGroup`, `.WithName(...)`, `.WithTags(...)`)
- hand-written `[ApiController]` actions
- `[CrudApi]` synthesis from `ZibStack.NET.Dto`

Use it with `TypeTarget.TypeScript` so request and response model files are
generated in the same build.

## Install in the frontend

```bash
npm install @tanstack/react-query
```

## Minimal API setup

```csharp
using Microsoft.AspNetCore.Mvc;
using ZibStack.NET.Dto;
using ZibStack.NET.TypeGen;

var workflow = app.MapGroup("/api/workflow").WithTags("Workflow");

workflow.MapGet("/workspaces/{workspaceId:guid}/items",
(Guid workspaceId,
[FromQuery] string? search,
[FromQuery] WorkItemState? state,
[FromQuery] string[]? labels,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
PaginatedResponse<WorkItemSummary>.Create(items, items.Count, page, pageSize))
.WithName("searchWorkItems")
.WithTags("Workflow");

workflow.MapPost("/workspaces/{workspaceId:guid}/items",
(Guid workspaceId, [FromBody] CreateWorkItemCommand body) => CreateItem(body))
.WithName("createWorkItem")
.WithTags("Workflow");
```

```csharp
[GenerateTypes(Targets = TypeTarget.TypeScript
| TypeTarget.OpenApi
| TypeTarget.TanStackQuery,
OutputDir = "../client/src/api")]
public class WorkItemSummary
{
public Guid Id { get; set; }
public required string Title { get; set; } = "";
public WorkItemState State { get; set; }
public List<string> Labels { get; set; } = new();
}

[GenerateTypes(Targets = TypeTarget.TypeScript
| TypeTarget.OpenApi
| TypeTarget.TanStackQuery,
OutputDir = "../client/src/api")]
public class CreateWorkItemCommand
{
public required string Title { get; set; } = "";
public WorkItemState InitialState { get; set; }
}
```

## Configuration

```csharp
public sealed class TypeGenConfig : ITypeGenConfigurator
{
public void Configure(ITypeGenBuilder b)
{
b.TypeScript(ts =>
{
ts.OutputDir = "../client/src/api";
ts.PropertyNameStyle = NameStyle.CamelCase;
});

b.TanStackQuery(q =>
{
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
});
}
}
```

The default fetch client evaluates `BaseUrlExpression` defensively. If the
configured expression is missing, empty, or throws, it falls back to
`window.location.origin` in browser environments, then to `http://localhost` for
non-browser execution. You can still set `BaseUrlExpression` to a literal origin
or import a custom client when you want stricter environment handling.

## Generated shape

```typescript
import { mutationOptions, queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import type { CreateWorkItemCommand } from './CreateWorkItemCommand';
import type { WorkItemState } from './WorkItemState';
import type { WorkItemSummary } from './WorkItemSummary';

export type PaginatedResponseOfWorkItemSummary = {
items: WorkItemSummary[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};

export const workflowKeys = {
all: ['workflow'] as const,
searchWorkItems: (input: SearchWorkItemsInput) => [...workflowKeys.all, 'searchWorkItems', input] as const,
};

export type SearchWorkItemsInput = {
workspaceId: string;
search?: string;
state?: WorkItemState;
labels?: string[];
page?: number;
pageSize?: number;
};

export function searchWorkItems(input: SearchWorkItemsInput, signal?: AbortSignal): Promise<PaginatedResponseOfWorkItemSummary> {
return apiFetch<PaginatedResponseOfWorkItemSummary>(`/api/workflow/workspaces/${encodeURIComponent(String(input.workspaceId))}/items`, {
method: 'GET',
query: {
search: input.search,
state: input.state,
labels: input.labels,
page: input.page,
pageSize: input.pageSize,
},
signal,
});
}

export function searchWorkItemsOptions(input: SearchWorkItemsInput) {
return queryOptions({
queryKey: workflowKeys.searchWorkItems(input),
queryFn: ({ signal }) => searchWorkItems(input, signal),
});
}

export function useSearchWorkItems(input: SearchWorkItemsInput) {
return useQuery(searchWorkItemsOptions(input));
}
```

Mutations get a fetch function, `mutationOptions`, a React hook, and tag-wide
invalidation:

```typescript
export type CreateWorkItemInput = {
workspaceId: string;
body: CreateWorkItemCommand;
};

export function createWorkItem(input: CreateWorkItemInput, signal?: AbortSignal): Promise<WorkItemSummary> {
return apiFetch<WorkItemSummary>(`/api/workflow/workspaces/${encodeURIComponent(String(input.workspaceId))}/items`, {
method: 'POST',
body: input.body,
signal,
});
}

export function useCreateWorkItem() {
const queryClient = useQueryClient();
return useMutation({
...createWorkItemMutationOptions(),
onSuccess: async () => {
await invalidateWorkflowQueries(queryClient);
},
});
}

export function invalidateWorkflowQueries(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: workflowKeys.all });
}
```

## Output settings

| Setting | Default | Purpose |
|---|---|---|
| `OutputDir` | TypeScript output dir, then first model output dir | Where query files are written |
| `FileLayout` | `QueryFileLayout.SingleFile` | `SingleFile` or `SplitByTag` |
| `SingleFileName` | `api.gen.ts` | File name for single-file mode |
| `BaseUrlExpression` | `import.meta.env.VITE_API_URL` | Base URL expression used by the default fetch client; falls back to `window.location.origin` when unset |
| `ApiClientImportPath` | `null` | Import a custom client instead of emitting `apiFetch` |
| `ApiClientName` | `apiFetch` | Default or imported client function name |
| `ModelsImportPath` | computed | Force model type imports from one module |
| `EmitQueryOptions` | `true` | Emit `queryOptions(...)` helpers |
| `EmitMutationOptions` | `true` | Emit `mutationOptions(...)` helpers |
| `EmitHooks` | `true` | Emit `useQuery` / `useMutation` wrappers |
| `EmitCacheHelpers` | `true` | Emit invalidation and prefetch helpers |

## Custom fetch client

Set `ApiClientImportPath` when your app already has auth, retry, tenant, or
observability behavior in one HTTP client:

```csharp
b.TanStackQuery(q =>
{
q.ApiClientImportPath = "@/lib/api-client";
q.ApiClientName = "request";
});
```

The imported function is called like this:

```typescript
request<T>(path, {
method,
query,
headers,
body,
signal,
});
```

`query` values can be scalar or arrays. The generated default client appends
arrays as repeated query-string keys and JSON-serializes request bodies.
Route and query parameter types use the same primitive mapping as generated
models; notably `decimal` maps to `string` to preserve precision.

## Naming

For Minimal APIs, prefer `.WithName("searchWorkItems")` and `.WithTags("Workflow")`.
The operation name becomes the function/options/hook base name, and the tag
becomes the query-key group. Without `.WithName(...)`, TypeGen derives a stable
name from verb plus route segments.
32 changes: 21 additions & 11 deletions docs/src/content/docs/packages/typegen/endpoint-discovery.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
---
title: TypeGen — Endpoint discovery (OpenAPI `paths:`)
description: "Three ways TypeGen populates the OpenAPI paths block — hand-written Minimal API scan, native [ApiController] scan, and [CrudApi] synthesis. All unified, with collision rules."
title: TypeGen — Endpoint discovery
description: "Three ways TypeGen discovers endpoints for OpenAPI paths and TanStack Query clients — hand-written Minimal API scan, native [ApiController] scan, and [CrudApi] synthesis. All unified, with collision rules."
---

TypeGen populates the OpenAPI `paths:` block from three sources, merged into
one unified output. Hand-written code is always ground truth — when sources
collide on the same (verb, path), the native handler wins over synthesis.
TypeGen populates its endpoint model from three sources, merged into one unified
output. OpenAPI uses it for `paths:`; the TanStack Query emitter uses it for
client functions, keys, hooks, and cache helpers. Hand-written code is always
ground truth — when sources collide on the same (verb, path), the native handler
wins over synthesis.

## Hand-written Minimal API → OpenAPI `paths:`

Expand Down Expand Up @@ -34,32 +36,40 @@ lambda body.
constants). Interpolated strings, `string.Concat`, and field reads stay
unresolvable at compile time and the endpoint is silently skipped.
- **Inline lambdas** (`(x, y) => body`, parenthesized or simple). Method
references (`app.MapGet("/x", HandlerMethod)`) aren't resolved in MVP.
references (`app.MapGet("/x", HandlerMethod)`) are resolved when the endpoint
has an explicit `.WithName(...)` so the generated operation name stays stable.
- **`MapGroup` prefix chains**, including via local variables:
```csharp
var g = app.MapGroup("/api/widgets");
g.MapGet("/{id}", (int id) => ...); // emits /api/widgets/{id}
```
- **Endpoint names and tags** from `.WithName("operationId")` and
`.WithTags("Tag")`. These feed OpenAPI `operationId`/`tags` and TanStack
Query function/key names.
- **Parameter binding**: explicit `[FromRoute]` / `[FromBody]` / `[FromQuery]` /
`[FromHeader]` first; fallback to ASP.NET convention. `CancellationToken` /
`HttpContext` / `[FromServices]` params are filtered out.
`HttpContext` / `[FromServices]` params are filtered out. Route placeholders
without a matching handler parameter are still emitted as required path
parameters, using common route constraints like `:guid`, `:int`, and
`:decimal` to infer their type when possible.
- **Return type** unwrapped from `Task<T>` / `ValueTask<T>`. `IResult` yields
no response schema (untyped success — ASP.NET Core doesn't expose T in that
path).
- **`.Produces<T>()` response metadata**. When present, TypeGen treats the
produced type as the explicit response contract. It runs after handler return
type inference, so `.Produces<T>()` intentionally wins if the two disagree.

**Collisions** follow the same rule as controllers: if Minimal API and
`[CrudApi]` synthesis both claim the same (verb, pattern), the hand-written
`MapX` wins.

**MVP limitations** (track these before relying heavily on the scan):
- Handler delegates passed as field / method references aren't resolved
- Handler delegates passed through fields or other dynamic registrations aren't
resolved
- `TypedResults.Ok<T>(...)` pattern: response type from the generic arg isn't
extracted yet (uses the raw `Ok<T>` return type which reads as `IResult`)
- Endpoint filters chained via `.AddEndpointFilter(...)` are ignored (they
don't change the contract, only runtime behaviour)
- Per-endpoint metadata extension methods (`.WithName("X").Produces<T>()`) aren't
read — use the handler's actual return type or add `[CrudApi]` on the DTO
if you need fine control over the emitted shape

## Hand-written controllers → OpenAPI `paths:`

Expand Down
Loading