diff --git a/docs/src/content/docs/packages/typegen.md b/docs/src/content/docs/packages/typegen.md index d8c7cd7..1f97789 100644 --- a/docs/src/content/docs/packages/typegen.md +++ b/docs/src/content/docs/packages/typegen.md @@ -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 @@ -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 diff --git a/docs/src/content/docs/packages/typegen/configuration.md b/docs/src/content/docs/packages/typegen/configuration.md index 5ca61b4..aa0aa9b 100644 --- a/docs/src/content/docs/packages/typegen/configuration.md +++ b/docs/src/content/docs/packages/typegen/configuration.md @@ -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()` per-type fluent overrides 4. Class / property attributes (`[TsName]`, `[OpenApiProperty]`, etc.) @@ -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() diff --git a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md new file mode 100644 index 0000000..eedc8c2 --- /dev/null +++ b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md @@ -0,0 +1,238 @@ +--- +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.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 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"; + }); + } +} +``` + +## 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 { + return apiFetch(`/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 { + return apiFetch(`/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 | +| `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(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. + +## 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. diff --git a/docs/src/content/docs/packages/typegen/endpoint-discovery.md b/docs/src/content/docs/packages/typegen/endpoint-discovery.md index b243f48..28504ed 100644 --- a/docs/src/content/docs/packages/typegen/endpoint-discovery.md +++ b/docs/src/content/docs/packages/typegen/endpoint-discovery.md @@ -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:` @@ -40,6 +42,9 @@ lambda body. 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. @@ -57,9 +62,9 @@ lambda body. extracted yet (uses the raw `Ok` 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()`) 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 +- Response metadata extension methods such as `.Produces()` aren't read yet — + 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:` diff --git a/packages/ZibStack.NET.TypeGen/README.md b/packages/ZibStack.NET.TypeGen/README.md index c028068..d020b6e 100644 --- a/packages/ZibStack.NET.TypeGen/README.md +++ b/packages/ZibStack.NET.TypeGen/README.md @@ -1,9 +1,9 @@ # ZibStack.NET.TypeGen -Roslyn source generator that emits **TypeScript** (`.ts`) and **OpenAPI 3.0** -(`.yaml` / `.json`) from C# DTOs annotated with `[GenerateTypes]`. Optional -**Python** (Pydantic v2 / dataclass) output. Compile-time only, zero reflection, -no running app required. +Roslyn source generator that emits **TypeScript** (`.ts`), **OpenAPI 3.0** +(`.yaml` / `.json`), and **TanStack Query** clients from C# DTOs/endpoints +annotated with `[GenerateTypes]`. Optional **Python** (Pydantic v2 / dataclass) +output. Compile-time only, zero reflection, no running app required. ## What it does @@ -29,6 +29,9 @@ public enum OrderStatus { Pending, Shipped } - `generated/openapi.yaml` — OpenAPI 3.0.3 schema with every class under `components/schemas`, correct `$ref`s, and `nullable` / `required` / validation constraints pulled from `ZibStack.NET.Validation` attributes. +- `generated/api.gen.ts` — optional TanStack Query v5 helpers from discovered + Minimal API, controller, and `[CrudApi]` endpoints: typed fetch functions, + query keys, options factories, hooks, and invalidation/prefetch helpers. Nested types without their own `[GenerateTypes]` are auto-discovered by walking the property graph, so you only annotate root aggregates. @@ -49,6 +52,9 @@ the property graph, so you only annotate root aggregates. (GET list, GET by id, POST, PATCH, DELETE) with the right request/response schema refs, pagination wrappers, and query-string bindings for `ZibStack.NET.Query` when referenced. +- **TanStack Query emitter** — generates React Query v5 clients from the same + endpoint discovery model as OpenAPI. Supports Minimal API `.WithName(...)` / + `.WithTags(...)`, custom API clients, split-by-tag output, and cache helpers. - **Fluent project-wide config** via `ITypeGenConfigurator` — global output dir, file layout (`FilePerClass` / `SingleFile`), naming styles, per-type overrides, property-level overrides. @@ -72,7 +78,7 @@ in the attribute / configurator surface. ## Docs Full reference — type mapping, diagnostic list (`TG0001`-`TG0021`), fluent DSL, -`[CrudApi]` integration, Python emitter, file layout options — lives at +`[CrudApi]` integration, TanStack Query, Python/Zod emitters, file layout options — lives at [mistykuu.github.io/ZibStack.NET/packages/typegen](https://mistykuu.github.io/ZibStack.NET/packages/typegen/). ## License diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Workflow.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Workflow.cs new file mode 100644 index 0000000..94f6c48 --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Workflow.cs @@ -0,0 +1,119 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using ZibStack.NET.TypeGen; + +namespace SampleApi.Models; + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class WorkItemSummary +{ + public Guid Id { get; set; } + public required string Title { get; set; } = ""; + public WorkItemState State { get; set; } + public WorkItemPriority Priority { get; set; } + public string? OwnerDisplayName { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public List Labels { get; set; } = new(); +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class WorkItemDetail : WorkItemSummary +{ + public string DescriptionMarkdown { get; set; } = ""; + public List Timeline { get; set; } = new(); + public Dictionary CustomFields { get; set; } = new(); +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class CreateWorkItemCommand +{ + [MinLength(3)] + [MaxLength(120)] + public required string Title { get; set; } = ""; + + [MinLength(10)] + public string DescriptionMarkdown { get; set; } = ""; + + public WorkItemPriority Priority { get; set; } = WorkItemPriority.Normal; + public Guid? OwnerId { get; set; } + public List Labels { get; set; } = new(); + public DateTimeOffset? DueAt { get; set; } +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class BulkTransitionCommand +{ + public List ItemIds { get; set; } = new(); + public WorkItemState TargetState { get; set; } + public string? Reason { get; set; } +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class AddWorkItemCommentCommand +{ + [MinLength(1)] + public required string Markdown { get; set; } = ""; + + public List MentionedUserIds { get; set; } = new(); +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class TransitionResult +{ + public Guid ItemId { get; set; } + public WorkItemState PreviousState { get; set; } + public WorkItemState State { get; set; } + public bool Applied { get; set; } + public string? Message { get; set; } +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +public class WorkItemEvent +{ + public Guid Id { get; set; } + public DateTimeOffset At { get; set; } + public string ActorDisplayName { get; set; } = ""; + public WorkItemEventKind Kind { get; set; } + public string? Markdown { get; set; } +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum WorkItemState +{ + Backlog, + Ready, + InProgress, + Blocked, + Done, +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum WorkItemPriority +{ + Low, + Normal, + High, + Critical, +} + +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.OpenApi | TypeTarget.Zod | TypeTarget.TanStackQuery, + OutputDir = "generated")] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum WorkItemEventKind +{ + Created, + Commented, + Assigned, + Transitioned, +} diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Program.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Program.cs index dd20b52..ea4f1e2 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Program.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Program.cs @@ -5,12 +5,15 @@ // 3. Hand-written [ApiController] (this file) → /api/widgets/{id} // // After `dotnet build`, check ./generated/openapi.yaml for the emitted `paths:` -// block — all three sources contribute without any per-source wiring. The -// generator runs inside the compiler, no running app needed for emission. +// block and ./generated/api.gen.ts for the TanStack Query client. All three +// sources contribute without any per-source wiring. The generator runs inside +// the compiler, no running app needed for emission. // // Run `dotnet run` to start the server and exercise the endpoints live. using Microsoft.AspNetCore.Mvc; +using SampleApi.Models; +using ZibStack.NET.Dto; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); @@ -30,6 +33,122 @@ var adminGroup = app.MapGroup("/api/admin"); adminGroup.MapGet("/ping", () => "pong"); +var workflowItems = new List +{ + new() + { + Id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + Title = "Reconcile vendor invoices", + State = WorkItemState.InProgress, + Priority = WorkItemPriority.High, + OwnerDisplayName = "Avery Stone", + UpdatedAt = DateTimeOffset.UtcNow.AddHours(-3), + Labels = ["finance", "q2"], + }, + new() + { + Id = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + Title = "Publish onboarding checklist", + State = WorkItemState.Ready, + Priority = WorkItemPriority.Normal, + UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1), + Labels = ["people", "docs"], + }, +}; + +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) => + { + var filtered = workflowItems + .Where(item => search is null || item.Title.Contains(search, StringComparison.OrdinalIgnoreCase)) + .Where(item => state is null || item.State == state) + .Where(item => labels is null || labels.Length == 0 || labels.All(item.Labels.Contains)) + .ToList(); + + return PaginatedResponse.Create(filtered, filtered.Count, page, pageSize); + }) + .WithName("searchWorkItems") + .WithTags("Workflow"); + +workflow.MapGet("/workspaces/{workspaceId:guid}/items/{itemId:guid}", + (Guid workspaceId, Guid itemId, [FromQuery] bool includeTimeline = true) => + new WorkItemDetail + { + Id = itemId, + Title = "Reconcile vendor invoices", + State = WorkItemState.InProgress, + Priority = WorkItemPriority.High, + OwnerDisplayName = "Avery Stone", + UpdatedAt = DateTimeOffset.UtcNow.AddHours(-3), + Labels = ["finance", "q2"], + DescriptionMarkdown = "Match invoice lines against approved purchase orders.", + Timeline = includeTimeline + ? [new WorkItemEvent + { + Id = Guid.NewGuid(), + At = DateTimeOffset.UtcNow.AddHours(-2), + ActorDisplayName = "Avery Stone", + Kind = WorkItemEventKind.Commented, + Markdown = "Waiting on updated tax code from AP.", + }] + : [], + CustomFields = new Dictionary + { + ["department"] = "finance", + ["risk"] = "medium", + }, + }) + .WithName("getWorkItem") + .WithTags("Workflow"); + +workflow.MapPost("/workspaces/{workspaceId:guid}/items", + (Guid workspaceId, [FromBody] CreateWorkItemCommand command) => + new WorkItemDetail + { + Id = Guid.NewGuid(), + Title = command.Title, + State = WorkItemState.Backlog, + Priority = command.Priority, + UpdatedAt = DateTimeOffset.UtcNow, + Labels = command.Labels, + DescriptionMarkdown = command.DescriptionMarkdown, + }) + .WithName("createWorkItem") + .WithTags("Workflow"); + +workflow.MapPost("/workspaces/{workspaceId:guid}/items:transition", + (Guid workspaceId, [FromBody] BulkTransitionCommand command) => + command.ItemIds.Select(id => new TransitionResult + { + ItemId = id, + PreviousState = WorkItemState.InProgress, + State = command.TargetState, + Applied = true, + Message = command.Reason, + }).ToList()) + .WithName("bulkTransitionWorkItems") + .WithTags("Workflow"); + +workflow.MapPost("/workspaces/{workspaceId:guid}/items/{itemId:guid}/comments", + (Guid workspaceId, Guid itemId, [FromBody] AddWorkItemCommentCommand command) => + new WorkItemEvent + { + Id = Guid.NewGuid(), + At = DateTimeOffset.UtcNow, + ActorDisplayName = "Avery Stone", + Kind = WorkItemEventKind.Commented, + Markdown = command.Markdown, + }) + .WithName("addWorkItemComment") + .WithTags("Workflow"); + app.Run(); // DTOs used by the Minimal API endpoints above. [GenerateTypes] pulls them into diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs index 6ae35fe..4db2e58 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs @@ -21,6 +21,15 @@ public void Configure(ITypeGenBuilder b) ts.TypeNameStyle = NameStyle.CamelCase; }); + b.TanStackQuery(q => + { + q.OutputDir = "generated"; + q.SingleFileName = "api.gen.ts"; + // The default is import.meta.env.VITE_API_URL. The sample uses the + // current origin so api.gen.ts type-checks in non-Vite clients too. + q.BaseUrlExpression = "window.location.origin"; + }); + b.OpenApi(oa => { oa.Title = "Sample Order API"; diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs index d2c55f9..ab96238 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs @@ -87,6 +87,9 @@ public interface ITypeGenBuilder /// Apply settings to the global . ITypeGenBuilder Zod(Action configure); + /// Apply settings to the global . + ITypeGenBuilder TanStackQuery(Action configure); + /// /// Begin per-type overrides for . Overrides take /// precedence over the TypeScript/OpenApi global blocks but lose diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/README.md b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/README.md index 340fd9e..e5f2687 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/README.md +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/README.md @@ -2,12 +2,14 @@ Attributes, settings types, and the `ITypeGenConfigurator` interface consumed by [ZibStack.NET.TypeGen](https://www.nuget.org/packages/ZibStack.NET.TypeGen) — the -Roslyn source generator that emits TypeScript and OpenAPI from C# DTOs. +Roslyn source generator that emits TypeScript, OpenAPI, and TanStack Query +clients from C# DTOs/endpoints. ## What's in this package - **`[GenerateTypes]`** — entry-point attribute. Declares which targets (TS / - OpenAPI / Python) and output directory a DTO should emit to. + OpenAPI / Python / Zod / GraphQL / TanStack Query) and output directory a DTO + should emit to. - **Per-class overrides** — `[TsName]`, `[OpenApiSchemaName]`, `[TsIgnore]`, `[OpenApiIgnore]`. - **Per-property overrides** — `[TsType("…")]` / `[TsType]`, @@ -16,7 +18,8 @@ Roslyn source generator that emits TypeScript and OpenAPI from C# DTOs. fluent DSL for project-wide defaults and per-type / per-property configuration without touching model files. - **Settings records** — `TypeScriptSettings`, `OpenApiSettings`, `PythonSettings`, - plus `NameStyle`, `TypeScriptFileLayout`, `PythonStyle` enums. + `ZodSettings`, `GraphQLSettings`, `TanStackQuerySettings`, plus layout/name + enums such as `NameStyle`, `TypeScriptFileLayout`, and `QueryFileLayout`. ## Why separate from the analyzer package diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs index 34951a5..f7800fb 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs @@ -246,6 +246,72 @@ public sealed class ZodSettings public bool EmitGeneratedBanner { get; set; } = true; } +/// +/// Layout strategy for TanStack Query client output. +/// +public enum QueryFileLayout +{ + /// All endpoint helpers in one file. Default file name is api.gen.ts. + SingleFile, + + /// One {tag}.gen.ts file per endpoint tag/resource. + SplitByTag, +} + +/// +/// TanStack Query React emitter settings. Emits TypeScript source files importing +/// @tanstack/react-query. Use with so +/// request/response model imports exist. +/// +public sealed class TanStackQuerySettings +{ + /// Output directory, relative to the project or absolute. + public string? OutputDir { get; set; } + + /// Default . + public QueryFileLayout FileLayout { get; set; } = QueryFileLayout.SingleFile; + + /// File name for mode. Default "api.gen.ts". + public string SingleFileName { get; set; } = "api.gen.ts"; + + /// + /// Expression used as the second argument to new URL(path, ...) in + /// the generated default fetch client. Default is import.meta.env.VITE_API_URL. + /// + public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL"; + + /// + /// Optional module specifier for a user-supplied API client. When set, the + /// emitter imports from this module and does not + /// emit the default apiFetch implementation. + /// + public string? ApiClientImportPath { get; set; } + + /// Function name for the default or imported API client. Default "apiFetch". + public string ApiClientName { get; set; } = "apiFetch"; + + /// + /// Optional import base for model types. When unset, a relative import path is + /// computed from to the TypeScript model output. + /// + public string? ModelsImportPath { get; set; } + + /// Emit queryOptions helpers for GET endpoints. Default true. + public bool EmitQueryOptions { get; set; } = true; + + /// Emit mutationOptions helpers for non-GET endpoints. Default true. + public bool EmitMutationOptions { get; set; } = true; + + /// Emit React useQuery/useMutation hooks. Default true. + public bool EmitHooks { get; set; } = true; + + /// Emit invalidation and prefetch helpers. Default true. + public bool EmitCacheHelpers { get; set; } = true; + + /// Emit the standard // @generated banner at the top of each file. + public bool EmitGeneratedBanner { get; set; } = true; +} + /// /// GraphQL SDL emitter settings. Emits .graphql files with /// type and enum definitions. diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/TypeTarget.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/TypeTarget.cs index 74f98ae..92cc72e 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/TypeTarget.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/TypeTarget.cs @@ -41,4 +41,13 @@ public enum TypeTarget /// each enum a GraphQL enum with UPPER_CASE members. /// GraphQL = 1 << 4, + + /// + /// TanStack Query React client helpers (.ts files). Emits typed fetch + /// functions, query keys, queryOptions/mutationOptions, React + /// hooks, and cache helpers from discovered endpoints. Intended to be used + /// together with so request/response model imports + /// are generated in the same build. + /// + TanStackQuery = 1 << 5, } diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs new file mode 100644 index 0000000..245684b --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs @@ -0,0 +1,1010 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace ZibStack.NET.TypeGen.Generator; + +/// +/// Emits typed TanStack Query helpers from the endpoint model populated by +/// . The output is TypeScript source only: +/// fetch functions, query keys, options factories, React hooks, and cache +/// helpers. DTO/model definitions remain the TypeScript emitter's job. +/// +internal static class TanStackQueryEmitter +{ + public static IReadOnlyList Emit(SchemaModel model, GlobalSettings settings) + { + EndpointDiscovery.SynthesizeFromCrudApi(model); + if (model.Endpoints.Count == 0) return System.Array.Empty(); + + var query = settings.TanStackQuery; + var outputDir = ResolveOutputDir(query.OutputDir, settings.TypeScript.OutputDir, model); + EnsureTypeScriptNames(model, settings.TypeScript); + var nameLookup = BuildTypeNameLookup(model); + + if (query.FileLayout == QueryFileLayout.SplitByTag) + { + var files = new List(); + foreach (var group in model.Endpoints + .GroupBy(e => TagName(e.Tag)) + .OrderBy(g => g.Key, System.StringComparer.Ordinal)) + { + var fileName = ToKebabCase(group.Key) + ".gen.ts"; + var content = EmitFile(group.ToList(), outputDir, settings, model, nameLookup); + files.Add(new EmittedFile(TypeTarget.TanStackQuery, outputDir, fileName, content)); + } + return files; + } + + return new[] + { + new EmittedFile( + TypeTarget.TanStackQuery, + outputDir, + string.IsNullOrWhiteSpace(query.SingleFileName) ? "api.gen.ts" : query.SingleFileName, + EmitFile(model.Endpoints, outputDir, settings, model, nameLookup)) + }; + } + + private static string EmitFile( + IReadOnlyList endpoints, + string queryOutputDir, + GlobalSettings settings, + SchemaModel model, + IReadOnlyDictionary nameLookup) + { + var query = settings.TanStackQuery; + var ops = BuildOperations(endpoints, settings, nameLookup); + var sb = new StringBuilder(); + + if (query.EmitGeneratedBanner) + sb.AppendLine("// @generated by ZibStack.NET.TypeGen - do not edit"); + + EmitImports(sb, ops, queryOutputDir, settings, model); + + if (string.IsNullOrEmpty(query.ApiClientImportPath)) + { + EmitDefaultClient(sb, query); + } + + EmitPaginatedAliases(sb, ops); + + foreach (var group in ops + .GroupBy(o => o.Tag) + .OrderBy(g => g.Key, System.StringComparer.Ordinal)) + { + EmitKeyFactory(sb, group.Key, group.Where(o => o.IsQuery).ToList()); + } + + foreach (var op in ops.OrderBy(o => o.Name, System.StringComparer.Ordinal)) + { + EmitInputType(sb, op); + EmitFetchFunction(sb, op, query); + if (op.IsQuery && query.EmitQueryOptions) + EmitQueryOptions(sb, op); + if (!op.IsQuery && query.EmitMutationOptions) + EmitMutationOptions(sb, op); + if (query.EmitHooks) + EmitHook(sb, op, query); + if (op.IsQuery && query.EmitCacheHelpers && query.EmitQueryOptions) + EmitPrefetchHelper(sb, op); + } + + if (query.EmitCacheHelpers) + { + foreach (var tag in ops.Select(o => o.Tag).Distinct(System.StringComparer.Ordinal).OrderBy(t => t, System.StringComparer.Ordinal)) + EmitInvalidateHelper(sb, tag); + } + + return sb.ToString(); + } + + private static List BuildOperations( + IReadOnlyList endpoints, + GlobalSettings settings, + IReadOnlyDictionary nameLookup) + { + var usedNames = new HashSet(System.StringComparer.Ordinal); + var operations = new List(); + + foreach (var ep in endpoints.OrderBy(e => e.Tag ?? "", System.StringComparer.Ordinal) + .ThenBy(e => e.Pattern, System.StringComparer.Ordinal) + .ThenBy(e => e.Verb, System.StringComparer.Ordinal)) + { + var tag = TagName(ep.Tag); + var operationName = MakeUnique(ToCamelIdentifier(ep.OperationId), usedNames); + var inputTypeName = ToPascalIdentifier(operationName) + "Input"; + var inputMembers = BuildInputMembers(ep, settings, nameLookup); + var response = ResolveResponseType(ep, nameLookup); + + operations.Add(new OperationModel + { + Endpoint = ep, + Tag = tag, + Name = operationName, + InputTypeName = inputTypeName, + InputMembers = inputMembers, + ResponseType = response.TypeExpression, + TypeImports = response.TypeImports, + PaginatedAliases = response.PaginatedAliases, + HasInput = inputMembers.Count > 0, + HasRequiredInput = inputMembers.Any(m => m.Required), + IsQuery = string.Equals(ep.Verb, "get", System.StringComparison.OrdinalIgnoreCase), + }); + } + + return operations; + } + + private static List BuildInputMembers( + EndpointInfo ep, + GlobalSettings settings, + IReadOnlyDictionary nameLookup) + { + var members = new List(); + var seen = new HashSet(System.StringComparer.Ordinal); + + foreach (var p in GetWireParameters(ep, settings)) + { + if (p.Location == ParamLocation.Body) continue; + if (!seen.Add(p.Name)) continue; + var type = ResolveType(p.CSharpType, nameLookup); + members.Add(new InputMember + { + WireName = p.Name, + PropertyName = p.Name, + TypeExpression = type.TypeExpression, + Required = p.Required || p.Location == ParamLocation.Route, + Location = p.Location, + TypeImports = type.TypeImports, + PaginatedAliases = type.PaginatedAliases, + }); + } + + if (ep.RequestBodyCSharpType is not null) + { + var body = ResolveType(ep.RequestBodyCSharpType, nameLookup); + members.Add(new InputMember + { + WireName = "body", + PropertyName = "body", + TypeExpression = body.TypeExpression, + Required = true, + Location = ParamLocation.Body, + TypeImports = body.TypeImports, + PaginatedAliases = body.PaginatedAliases, + }); + } + else if (ep.RequestBodyArrayItemCSharpType is not null) + { + var body = ResolveType(ep.RequestBodyArrayItemCSharpType, nameLookup); + members.Add(new InputMember + { + WireName = "body", + PropertyName = "body", + TypeExpression = body.TypeExpression + "[]", + Required = true, + Location = ParamLocation.Body, + TypeImports = body.TypeImports, + PaginatedAliases = body.PaginatedAliases, + }); + } + + return members; + } + + private static IEnumerable GetWireParameters(EndpointInfo ep, GlobalSettings settings) + { + foreach (var p in ep.Parameters) + yield return p; + + if (!ep.IsListEndpoint) yield break; + + foreach (var p in ListEndpointParameters(settings.HasQueryDsl)) + yield return p; + } + + private static IEnumerable ListEndpointParameters(bool hasQueryDsl) + { + yield return QueryParam("page", "int"); + yield return QueryParam("pageSize", "int"); + if (!hasQueryDsl) yield break; + yield return QueryParam("filter", "string"); + yield return QueryParam("sort", "string"); + yield return QueryParam("select", "string"); + yield return QueryParam("count", "bool"); + } + + private static EndpointParameter QueryParam(string name, string type) => + new() { Name = name, CSharpType = type, Location = ParamLocation.Query, Required = false }; + + private static TypeResolution ResolveResponseType(EndpointInfo ep, IReadOnlyDictionary nameLookup) + { + if (ep.ResponseCSharpType is not null) + return ResolveType(ep.ResponseCSharpType, nameLookup); + if (ep.ResponseArrayItemCSharpType is not null) + { + var item = ResolveType(ep.ResponseArrayItemCSharpType, nameLookup); + item.TypeExpression += "[]"; + return item; + } + return new TypeResolution("void"); + } + + private static TypeResolution ResolveType(string cSharpType, IReadOnlyDictionary nameLookup) + { + var t = cSharpType.Trim().TrimEnd('?'); + var result = new TypeResolution("unknown"); + + var patchInner = ExtractGeneric(t, "PatchField"); + if (patchInner is not null) return ResolveType(patchInner, nameLookup); + + var nullableInner = ExtractGeneric(t, "Nullable", "System.Nullable"); + if (nullableInner is not null) return ResolveType(nullableInner, nameLookup); + + var pagedInner = ExtractGeneric(t, "PaginatedResponse"); + if (pagedInner is not null) + { + var inner = ResolveType(pagedInner, nameLookup); + var aliasName = "PaginatedResponseOf" + ToPascalIdentifier(RemoveTypeSyntax(inner.TypeExpression)); + result.TypeExpression = aliasName; + result.TypeImports.UnionWith(inner.TypeImports); + result.PaginatedAliases[aliasName] = inner.TypeExpression; + return result; + } + + if (nameLookup.TryGetValue(t, out var mapped)) + return TypeResolution.WithImport(mapped, mapped); + + if (t.EndsWith("[]", System.StringComparison.Ordinal)) + { + var inner = ResolveType(t.Substring(0, t.Length - 2), nameLookup); + inner.TypeExpression += "[]"; + return inner; + } + + var listInner = ExtractGeneric(t, + "List", "IList", "ICollection", "IEnumerable", "IReadOnlyList", "IReadOnlyCollection", + "HashSet", "ISet", "IReadOnlySet", + "System.Collections.Generic.List", "System.Collections.Generic.IList", "System.Collections.Generic.ICollection", + "System.Collections.Generic.IEnumerable", "System.Collections.Generic.IReadOnlyList", + "System.Collections.Generic.IReadOnlyCollection", "System.Collections.Generic.HashSet", + "System.Collections.Generic.ISet", "System.Collections.Generic.IReadOnlySet"); + if (listInner is not null) + { + var inner = ResolveType(listInner, nameLookup); + inner.TypeExpression += "[]"; + return inner; + } + + var dict = ExtractTwoGenericArgs(t, + "Dictionary", "IDictionary", "IReadOnlyDictionary", + "System.Collections.Generic.Dictionary", "System.Collections.Generic.IDictionary", + "System.Collections.Generic.IReadOnlyDictionary"); + if (dict is not null) + { + var key = ResolveType(dict.Value.K, nameLookup); + var val = ResolveType(dict.Value.V, nameLookup); + result.TypeExpression = $"Record<{key.TypeExpression}, {val.TypeExpression}>"; + result.TypeImports.UnionWith(key.TypeImports); + result.TypeImports.UnionWith(val.TypeImports); + foreach (var kvp in key.PaginatedAliases) result.PaginatedAliases[kvp.Key] = kvp.Value; + foreach (var kvp in val.PaginatedAliases) result.PaginatedAliases[kvp.Key] = kvp.Value; + return result; + } + + var primitive = t switch + { + "string" or "char" or "System.String" or "System.Char" => "string", + "bool" or "System.Boolean" => "boolean", + "byte" or "sbyte" or "short" or "ushort" or "int" or "uint" or "long" or "ulong" + or "float" or "double" or "System.Byte" or "System.SByte" or "System.Int16" or "System.UInt16" + or "System.Int32" or "System.UInt32" or "System.Int64" or "System.UInt64" + or "System.Single" or "System.Double" => "number", + "decimal" or "System.Decimal" => "string", + "System.Guid" or "Guid" => "string", + "System.DateTime" or "DateTime" or "System.DateTimeOffset" or "DateTimeOffset" => "string", + "System.DateOnly" or "DateOnly" or "System.TimeOnly" or "TimeOnly" or "System.TimeSpan" or "TimeSpan" => "string", + "object" or "System.Object" => "unknown", + "void" or "System.Void" => "void", + _ => null, + }; + if (primitive is not null) return new TypeResolution(primitive); + + if (LooksLikeTypeName(t, out var shortName)) + return TypeResolution.WithImport(shortName, shortName); + + return result; + } + + private static void EmitImports(StringBuilder sb, IReadOnlyList ops, string queryOutputDir, GlobalSettings settings, SchemaModel model) + { + var query = settings.TanStackQuery; + var queryOps = ops.Where(o => o.IsQuery).ToList(); + var mutationOps = ops.Where(o => !o.IsQuery).ToList(); + + var tanstackImports = new List(); + var tanstackTypeImports = new List(); + if (queryOps.Count > 0 && query.EmitQueryOptions) tanstackImports.Add("queryOptions"); + if (mutationOps.Count > 0 && query.EmitMutationOptions) tanstackImports.Add("mutationOptions"); + if (query.EmitHooks) + { + if (queryOps.Count > 0) tanstackImports.Add("useQuery"); + if (mutationOps.Count > 0) tanstackImports.Add("useMutation"); + if (mutationOps.Count > 0 && query.EmitCacheHelpers) tanstackImports.Add("useQueryClient"); + } + if (query.EmitCacheHelpers) tanstackTypeImports.Add("QueryClient"); + + if (tanstackImports.Count > 0 || tanstackTypeImports.Count > 0) + { + var parts = new List(); + parts.AddRange(tanstackImports.Distinct(System.StringComparer.Ordinal).OrderBy(x => x, System.StringComparer.Ordinal)); + if (tanstackTypeImports.Count > 0) + parts.AddRange(tanstackTypeImports.Distinct(System.StringComparer.Ordinal).OrderBy(x => x, System.StringComparer.Ordinal).Select(x => "type " + x)); + sb.AppendLine($"import {{ {string.Join(", ", parts)} }} from '@tanstack/react-query';"); + } + + if (!string.IsNullOrEmpty(query.ApiClientImportPath)) + sb.AppendLine($"import {{ {query.ApiClientName} }} from '{query.ApiClientImportPath}';"); + + var modelImports = CollectModelImports(ops, queryOutputDir, settings, model); + foreach (var kvp in modelImports.OrderBy(k => k.Key, System.StringComparer.Ordinal)) + { + var names = string.Join(", ", kvp.Value.OrderBy(n => n, System.StringComparer.Ordinal)); + sb.AppendLine($"import type {{ {names} }} from '{kvp.Key}';"); + } + + if (tanstackImports.Count > 0 || tanstackTypeImports.Count > 0 || !string.IsNullOrEmpty(query.ApiClientImportPath) || modelImports.Count > 0 || query.EmitGeneratedBanner) + sb.AppendLine(); + } + + private static Dictionary> CollectModelImports( + IReadOnlyList ops, + string queryOutputDir, + GlobalSettings settings, + SchemaModel model) + { + var allTypes = new HashSet(System.StringComparer.Ordinal); + foreach (var op in ops) + { + allTypes.UnionWith(op.TypeImports); + foreach (var member in op.InputMembers) + allTypes.UnionWith(member.TypeImports); + } + + foreach (var alias in ops.SelectMany(o => o.PaginatedAliases.Keys)) + allTypes.Remove(alias); + + var byPath = new Dictionary>(System.StringComparer.Ordinal); + if (allTypes.Count == 0) return byPath; + + var query = settings.TanStackQuery; + if (!string.IsNullOrEmpty(query.ModelsImportPath)) + { + byPath[query.ModelsImportPath!] = allTypes; + return byPath; + } + + var ts = settings.TypeScript; + var modelDir = ResolveModelOutputDir(ts.OutputDir, model); + if (ts.FileLayout == TypeScriptFileLayout.SingleFile) + { + var fileName = StripTsExtension(string.IsNullOrWhiteSpace(ts.SingleFileName) ? "models.ts" : ts.SingleFileName); + var path = SchemaParser.ComputeRelativeImport(queryOutputDir, modelDir, fileName); + byPath[path] = allTypes; + return byPath; + } + + var dirByTypeName = BuildModelDirLookup(model, ts); + foreach (var typeName in allTypes) + { + var targetDir = dirByTypeName.TryGetValue(typeName, out var perTypeDir) ? perTypeDir : modelDir; + var path = SchemaParser.ComputeRelativeImport(queryOutputDir, targetDir, typeName); + if (!byPath.TryGetValue(path, out var names)) + byPath[path] = names = new HashSet(System.StringComparer.Ordinal); + names.Add(typeName); + } + + return byPath; + } + + private static void EmitDefaultClient(StringBuilder sb, TanStackQuerySettings query) + { + sb.AppendLine("export type ApiFetchOptions = {"); + sb.AppendLine(" method?: string;"); + sb.AppendLine(" query?: Record;"); + sb.AppendLine(" headers?: Record;"); + sb.AppendLine(" body?: unknown;"); + sb.AppendLine(" signal?: AbortSignal;"); + sb.AppendLine("};"); + sb.AppendLine(); + sb.AppendLine("export class ApiError extends Error {"); + sb.AppendLine(" constructor(public readonly status: number, public readonly body: unknown, message: string) {"); + sb.AppendLine(" super(message);"); + sb.AppendLine(" this.name = 'ApiError';"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + sb.AppendLine(); + sb.AppendLine($"export async function {query.ApiClientName}(path: string, options: ApiFetchOptions = {{}}): Promise {{"); + sb.AppendLine($" const url = new URL(path, {query.BaseUrlExpression});"); + sb.AppendLine(" if (options.query) {"); + sb.AppendLine(" for (const [key, value] of Object.entries(options.query)) {"); + sb.AppendLine(" if (value === undefined || value === null) continue;"); + sb.AppendLine(" if (Array.isArray(value)) {"); + sb.AppendLine(" for (const item of value) url.searchParams.append(key, String(item));"); + sb.AppendLine(" } else {"); + sb.AppendLine(" url.searchParams.set(key, String(value));"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(" const headers: Record = {};"); + sb.AppendLine(" if (options.body !== undefined) headers['content-type'] = 'application/json';"); + sb.AppendLine(" if (options.headers) {"); + sb.AppendLine(" for (const [key, value] of Object.entries(options.headers)) {"); + sb.AppendLine(" if (value !== undefined) headers[key] = value;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(" const response = await fetch(url.toString(), {"); + sb.AppendLine(" method: options.method ?? 'GET',"); + sb.AppendLine(" headers,"); + sb.AppendLine(" body: options.body === undefined ? undefined : JSON.stringify(options.body),"); + sb.AppendLine(" signal: options.signal,"); + sb.AppendLine(" });"); + sb.AppendLine(" if (!response.ok) {"); + sb.AppendLine(" const errorText = await response.text();"); + sb.AppendLine(" let errorBody: unknown = errorText;"); + sb.AppendLine(" try { errorBody = errorText ? JSON.parse(errorText) : undefined; } catch { }"); + sb.AppendLine(" throw new ApiError(response.status, errorBody, response.statusText || 'Request failed');"); + sb.AppendLine(" }"); + sb.AppendLine(" if (response.status === 204) return undefined as T;"); + sb.AppendLine(" const text = await response.text();"); + sb.AppendLine(" if (!text) return undefined as T;"); + sb.AppendLine(" const contentType = response.headers.get('content-type') ?? '';"); + sb.AppendLine(" return (contentType.includes('application/json') ? JSON.parse(text) : text) as T;"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static void EmitPaginatedAliases(StringBuilder sb, IReadOnlyList ops) + { + var aliases = new Dictionary(System.StringComparer.Ordinal); + foreach (var op in ops) + { + foreach (var kvp in op.PaginatedAliases) + aliases[kvp.Key] = kvp.Value; + foreach (var member in op.InputMembers) + foreach (var kvp in member.PaginatedAliases) + aliases[kvp.Key] = kvp.Value; + } + + foreach (var kvp in aliases.OrderBy(k => k.Key, System.StringComparer.Ordinal)) + { + sb.AppendLine($"export type {kvp.Key} = {{"); + sb.AppendLine($" items: {kvp.Value}[];"); + sb.AppendLine(" totalCount: number;"); + sb.AppendLine(" page: number;"); + sb.AppendLine(" pageSize: number;"); + sb.AppendLine(" totalPages: number;"); + sb.AppendLine(" hasNextPage: boolean;"); + sb.AppendLine(" hasPreviousPage: boolean;"); + sb.AppendLine("};"); + sb.AppendLine(); + } + } + + private static void EmitKeyFactory(StringBuilder sb, string tag, IReadOnlyList queryOps) + { + var keyName = KeyFactoryName(tag); + sb.AppendLine($"export const {keyName} = {{"); + sb.AppendLine($" all: ['{ToCamelIdentifier(tag)}'] as const,"); + foreach (var op in queryOps.OrderBy(o => o.Name, System.StringComparer.Ordinal)) + { + if (!op.HasInput) + sb.AppendLine($" {op.Name}: () => [...{keyName}.all, '{op.Name}'] as const,"); + else if (op.HasRequiredInput) + sb.AppendLine($" {op.Name}: (input: {op.InputTypeName}) => [...{keyName}.all, '{op.Name}', input] as const,"); + else + sb.AppendLine($" {op.Name}: (input: {op.InputTypeName} = {{}}) => [...{keyName}.all, '{op.Name}', input] as const,"); + } + sb.AppendLine("};"); + sb.AppendLine(); + } + + private static void EmitInputType(StringBuilder sb, OperationModel op) + { + if (!op.HasInput) return; + sb.AppendLine($"export type {op.InputTypeName} = {{"); + foreach (var member in op.InputMembers) + { + var optional = member.Required ? "" : "?"; + sb.AppendLine($" {MemberDeclarationName(member.PropertyName)}{optional}: {member.TypeExpression};"); + } + sb.AppendLine("};"); + sb.AppendLine(); + } + + private static void EmitFetchFunction(StringBuilder sb, OperationModel op, TanStackQuerySettings query) + { + sb.Append($"export function {op.Name}("); + if (op.HasInput) + sb.Append(op.HasRequiredInput ? $"input: {op.InputTypeName}" : $"input: {op.InputTypeName} = {{}}"); + if (op.HasInput) + sb.Append(", signal?: AbortSignal"); + else + sb.Append("signal?: AbortSignal"); + sb.AppendLine($"): Promise<{op.ResponseType}> {{"); + + sb.AppendLine($" return {query.ApiClientName}<{op.ResponseType}>({BuildPathExpression(op)}, {{"); + sb.AppendLine($" method: '{op.Endpoint.Verb.ToUpperInvariant()}',"); + EmitRequestOptionObject(sb, "query", op.InputMembers.Where(m => m.Location == ParamLocation.Query).ToList()); + EmitRequestOptionObject(sb, "headers", op.InputMembers.Where(m => m.Location == ParamLocation.Header).ToList()); + var body = op.InputMembers.FirstOrDefault(m => m.Location == ParamLocation.Body); + if (body is not null) + sb.AppendLine($" body: {InputAccess(body)},"); + sb.AppendLine(" signal,"); + sb.AppendLine(" });"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static void EmitRequestOptionObject(StringBuilder sb, string optionName, IReadOnlyList members) + { + if (members.Count == 0) return; + sb.AppendLine($" {optionName}: {{"); + foreach (var member in members) + sb.AppendLine($" {ObjectKey(member.WireName)}: {InputAccess(member)},"); + sb.AppendLine(" },"); + } + + private static void EmitQueryOptions(StringBuilder sb, OperationModel op) + { + sb.Append($"export function {op.Name}Options("); + if (op.HasInput) + sb.Append(op.HasRequiredInput ? $"input: {op.InputTypeName}" : $"input: {op.InputTypeName} = {{}}"); + sb.AppendLine(") {"); + var keyCall = BuildKeyCall(op); + var fetchCall = op.HasInput ? $"{op.Name}(input, signal)" : $"{op.Name}(signal)"; + sb.AppendLine(" return queryOptions({"); + sb.AppendLine($" queryKey: {keyCall},"); + sb.AppendLine($" queryFn: ({{ signal }}) => {fetchCall},"); + sb.AppendLine(" });"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static void EmitMutationOptions(StringBuilder sb, OperationModel op) + { + sb.AppendLine($"export function {op.Name}MutationOptions() {{"); + sb.AppendLine(" return mutationOptions({"); + sb.AppendLine($" mutationFn: {BuildMutationFn(op)},"); + sb.AppendLine(" });"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static void EmitHook(StringBuilder sb, OperationModel op, TanStackQuerySettings query) + { + var hookName = "use" + ToPascalIdentifier(op.Name); + if (op.IsQuery) + { + sb.Append($"export function {hookName}("); + if (op.HasInput) + sb.Append(op.HasRequiredInput ? $"input: {op.InputTypeName}" : $"input: {op.InputTypeName} = {{}}"); + sb.AppendLine(") {"); + if (query.EmitQueryOptions) + sb.AppendLine(op.HasInput + ? $" return useQuery({op.Name}Options(input));" + : $" return useQuery({op.Name}Options());"); + else + { + var keyCall = BuildKeyCall(op); + var fetchCall = op.HasInput ? $"{op.Name}(input, signal)" : $"{op.Name}(signal)"; + sb.AppendLine(" return useQuery({"); + sb.AppendLine($" queryKey: {keyCall},"); + sb.AppendLine($" queryFn: ({{ signal }}) => {fetchCall},"); + sb.AppendLine(" });"); + } + sb.AppendLine("}"); + sb.AppendLine(); + return; + } + + sb.AppendLine($"export function {hookName}() {{"); + if (query.EmitCacheHelpers) + { + sb.AppendLine(" const queryClient = useQueryClient();"); + sb.AppendLine(" return useMutation({"); + if (query.EmitMutationOptions) + sb.AppendLine($" ...{op.Name}MutationOptions(),"); + else + sb.AppendLine($" mutationFn: {BuildMutationFn(op)},"); + sb.AppendLine(" onSuccess: async () => {"); + sb.AppendLine($" await {InvalidateHelperName(op.Tag)}(queryClient);"); + sb.AppendLine(" },"); + sb.AppendLine(" });"); + } + else if (query.EmitMutationOptions) + { + sb.AppendLine($" return useMutation({op.Name}MutationOptions());"); + } + else + { + sb.AppendLine(" return useMutation({"); + sb.AppendLine($" mutationFn: {BuildMutationFn(op)},"); + sb.AppendLine(" });"); + } + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static string BuildMutationFn(OperationModel op) => + op.HasInput + ? $"(input: {op.InputTypeName}) => {op.Name}(input)" + : $"() => {op.Name}()"; + + private static void EmitPrefetchHelper(StringBuilder sb, OperationModel op) + { + var name = "prefetch" + ToPascalIdentifier(op.Name); + sb.Append($"export function {name}(queryClient: QueryClient"); + if (op.HasInput) + sb.Append(op.HasRequiredInput ? $", input: {op.InputTypeName}" : $", input: {op.InputTypeName} = {{}}"); + sb.AppendLine(") {"); + sb.AppendLine(op.HasInput + ? $" return queryClient.prefetchQuery({op.Name}Options(input));" + : $" return queryClient.prefetchQuery({op.Name}Options());"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static void EmitInvalidateHelper(StringBuilder sb, string tag) + { + sb.AppendLine($"export function {InvalidateHelperName(tag)}(queryClient: QueryClient) {{"); + sb.AppendLine($" return queryClient.invalidateQueries({{ queryKey: {KeyFactoryName(tag)}.all }});"); + sb.AppendLine("}"); + sb.AppendLine(); + } + + private static string BuildPathExpression(OperationModel op) + { + var pattern = op.Endpoint.Pattern; + var sb = new StringBuilder(); + sb.Append('`'); + for (int i = 0; i < pattern.Length; i++) + { + var ch = pattern[i]; + if (ch == '`') { sb.Append("\\`"); continue; } + if (ch == '$') { sb.Append("\\$"); continue; } + if (ch != '{') { sb.Append(ch); continue; } + + var close = pattern.IndexOf('}', i + 1); + if (close < 0) { sb.Append(ch); continue; } + var raw = pattern.Substring(i + 1, close - i - 1); + var name = CleanRouteParameterName(raw); + var member = op.InputMembers.FirstOrDefault(m => + m.Location == ParamLocation.Route && + string.Equals(m.WireName, name, System.StringComparison.Ordinal)); + sb.Append("${encodeURIComponent(String("); + sb.Append(member is null ? "undefined" : InputAccess(member)); + sb.Append("))}"); + i = close; + } + sb.Append('`'); + return sb.ToString(); + } + + private static string BuildKeyCall(OperationModel op) + { + var keyName = KeyFactoryName(op.Tag); + return op.HasInput ? $"{keyName}.{op.Name}(input)" : $"{keyName}.{op.Name}()"; + } + + private static string InputAccess(InputMember member) => + IsIdentifier(member.PropertyName) ? "input." + member.PropertyName : $"input[{TsString(member.PropertyName)}]"; + + private static string MemberDeclarationName(string name) => + IsIdentifier(name) ? name : TsString(name); + + private static string ObjectKey(string name) => + IsIdentifier(name) ? name : TsString(name); + + private static void EnsureTypeScriptNames(SchemaModel model, TypeScriptSettings ts) + { + foreach (var cls in model.Classes) + { + if (cls.TsIgnore || (cls.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) == 0) continue; + cls.EmittedName = ResolveTsTypeName(cls.SourceName, cls.TsNameOverride, ts); + } + foreach (var en in model.Enums) + { + if (en.TsIgnore || (en.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) == 0) continue; + en.EmittedName = ResolveTsTypeName(en.SourceName, en.TsNameOverride, ts); + } + } + + private static Dictionary BuildTypeNameLookup(SchemaModel model) + { + var lookup = new Dictionary(System.StringComparer.Ordinal); + foreach (var c in model.Classes) + if (!c.TsIgnore && (c.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) != 0) + lookup[c.CSharpFullName] = c.EmittedName; + foreach (var e in model.Enums) + if (!e.TsIgnore && (e.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) != 0) + lookup[e.CSharpFullName] = e.EmittedName; + return lookup; + } + + private static string ResolveTsTypeName(string source, string? overrideName, TypeScriptSettings ts) + { + if (overrideName != null) return overrideName; + var n = source; + foreach (var suffix in ts.StripSuffixes.OrderByDescending(s => s.Length)) + { + if (n.EndsWith(suffix, System.StringComparison.Ordinal) && n.Length > suffix.Length) + { + n = n.Substring(0, n.Length - suffix.Length); + break; + } + } + return ApplyNameStyle(n, ts.TypeNameStyle); + } + + private static string ApplyNameStyle(string name, NameStyle style) + { + if (string.IsNullOrEmpty(name)) return name; + return style switch + { + NameStyle.AsIs => name, + NameStyle.CamelCase => char.ToLowerInvariant(name[0]) + name.Substring(1), + NameStyle.PascalCase => char.ToUpperInvariant(name[0]) + name.Substring(1), + NameStyle.SnakeCase => ToSeparated(name, '_'), + _ => name, + }; + } + + private static string ResolveOutputDir(string? queryDir, string? typeScriptDir, SchemaModel model) + { + if (!string.IsNullOrEmpty(queryDir)) return queryDir!; + if (!string.IsNullOrEmpty(typeScriptDir)) return typeScriptDir!; + var first = model.Classes.FirstOrDefault(c => (c.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) != 0); + return first?.OutputDir ?? "."; + } + + private static string ResolveModelOutputDir(string? typeScriptDir, SchemaModel model) + { + if (!string.IsNullOrEmpty(typeScriptDir)) return typeScriptDir!; + var firstClass = model.Classes.FirstOrDefault(c => + !c.TsIgnore && (c.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) != 0); + if (firstClass is not null) return firstClass.OutputDir; + var firstEnum = model.Enums.FirstOrDefault(e => + !e.TsIgnore && (e.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) != 0); + return firstEnum?.OutputDir ?? "."; + } + + private static Dictionary BuildModelDirLookup(SchemaModel model, TypeScriptSettings ts) + { + var lookup = new Dictionary(System.StringComparer.Ordinal); + foreach (var c in model.Classes) + { + if (c.TsIgnore || (c.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) == 0) continue; + lookup[c.EmittedName] = !string.IsNullOrEmpty(ts.OutputDir) ? ts.OutputDir! : c.OutputDir; + } + foreach (var e in model.Enums) + { + if (e.TsIgnore || (e.Targets & (TypeTarget.TypeScript | TypeTarget.TanStackQuery)) == 0) continue; + lookup[e.EmittedName] = !string.IsNullOrEmpty(ts.OutputDir) ? ts.OutputDir! : e.OutputDir; + } + return lookup; + } + + private static string StripTsExtension(string fileName) => + fileName.EndsWith(".ts", System.StringComparison.OrdinalIgnoreCase) + ? fileName.Substring(0, fileName.Length - 3) + : fileName; + + private static string? ExtractGeneric(string typeName, params string[] names) + { + foreach (var n in names) + { + var idx = typeName.IndexOf(n + "<", System.StringComparison.Ordinal); + if (idx < 0) continue; + if (idx > 0 && (char.IsLetterOrDigit(typeName[idx - 1]) || typeName[idx - 1] == '_')) continue; + var open = idx + n.Length; + var inner = ExtractBalanced(typeName, open); + if (inner != null) return inner; + } + return null; + } + + private static (string K, string V)? ExtractTwoGenericArgs(string typeName, params string[] names) + { + var inner = ExtractGeneric(typeName, names); + if (inner is null) return null; + int depth = 0; + for (int i = 0; i < inner.Length; i++) + { + if (inner[i] == '<') depth++; + else if (inner[i] == '>') depth--; + else if (inner[i] == ',' && depth == 0) + return (inner.Substring(0, i).Trim(), inner.Substring(i + 1).Trim()); + } + return null; + } + + private static string? ExtractBalanced(string s, int openAngleBracketIndex) + { + if (openAngleBracketIndex >= s.Length || s[openAngleBracketIndex] != '<') return null; + int depth = 1; + for (int i = openAngleBracketIndex + 1; i < s.Length; i++) + { + if (s[i] == '<') depth++; + else if (s[i] == '>') + { + depth--; + if (depth == 0) return s.Substring(openAngleBracketIndex + 1, i - openAngleBracketIndex - 1); + } + } + return null; + } + + private static string CleanRouteParameterName(string raw) + { + var s = raw.Trim(); + while (s.StartsWith("*", System.StringComparison.Ordinal)) s = s.Substring(1); + var colon = s.IndexOf(':'); + if (colon >= 0) s = s.Substring(0, colon); + var equals = s.IndexOf('='); + if (equals >= 0) s = s.Substring(0, equals); + return s.Trim(); + } + + private static bool LooksLikeTypeName(string name, out string shortName) + { + shortName = name; + if (string.IsNullOrEmpty(name)) return false; + if (name.IndexOf('<') >= 0) return false; + var dot = name.LastIndexOf('.'); + if (dot >= 0) shortName = name.Substring(dot + 1); + if (shortName.Length == 0 || !char.IsUpper(shortName[0])) return false; + foreach (var ch in shortName) + if (!char.IsLetterOrDigit(ch) && ch != '_') return false; + return true; + } + + private static string TagName(string? tag) => + string.IsNullOrWhiteSpace(tag) ? "Api" : ToPascalIdentifier(tag!); + + private static string KeyFactoryName(string tag) => ToCamelIdentifier(tag) + "Keys"; + + private static string InvalidateHelperName(string tag) => "invalidate" + ToPascalIdentifier(tag) + "Queries"; + + private static string MakeUnique(string name, HashSet used) + { + var baseName = string.IsNullOrEmpty(name) ? "operation" : name; + var candidate = baseName; + var index = 2; + while (!used.Add(candidate)) + candidate = baseName + index++; + return candidate; + } + + private static string ToCamelIdentifier(string value) + { + var pascal = ToPascalIdentifier(value); + return string.IsNullOrEmpty(pascal) ? "operation" : char.ToLowerInvariant(pascal[0]) + pascal.Substring(1); + } + + private static string ToPascalIdentifier(string value) + { + if (string.IsNullOrWhiteSpace(value)) return "Operation"; + var sb = new StringBuilder(); + var upperNext = true; + foreach (var ch in value) + { + if (char.IsLetterOrDigit(ch) || ch == '_') + { + if (sb.Length == 0 && char.IsDigit(ch)) sb.Append('_'); + sb.Append(upperNext ? char.ToUpperInvariant(ch) : ch); + upperNext = false; + } + else + { + upperNext = true; + } + } + return sb.Length == 0 ? "Operation" : sb.ToString(); + } + + private static string ToKebabCase(string name) + { + var separated = ToSeparated(name, '-'); + return string.IsNullOrEmpty(separated) ? "api" : separated; + } + + private static string ToSeparated(string name, char sep) + { + var sb = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + var ch = name[i]; + if (!char.IsLetterOrDigit(ch)) + { + if (sb.Length > 0 && sb[sb.Length - 1] != sep) sb.Append(sep); + continue; + } + if (i > 0 && char.IsUpper(ch) && sb.Length > 0 && sb[sb.Length - 1] != sep) + sb.Append(sep); + sb.Append(char.ToLowerInvariant(ch)); + } + return sb.ToString().Trim(sep); + } + + private static string RemoveTypeSyntax(string typeExpression) + { + var sb = new StringBuilder(); + foreach (var ch in typeExpression) + { + if (char.IsLetterOrDigit(ch) || ch == '_') sb.Append(ch); + } + return sb.Length == 0 ? "Value" : sb.ToString(); + } + + private static bool IsIdentifier(string value) + { + if (string.IsNullOrEmpty(value)) return false; + if (!(char.IsLetter(value[0]) || value[0] == '_' || value[0] == '$')) return false; + for (int i = 1; i < value.Length; i++) + { + var ch = value[i]; + if (!(char.IsLetterOrDigit(ch) || ch == '_' || ch == '$')) return false; + } + return true; + } + + private static string TsString(string value) => + "'" + value.Replace("\\", "\\\\").Replace("'", "\\'") + "'"; + + private sealed class OperationModel + { + public EndpointInfo Endpoint { get; set; } = new(); + public string Tag { get; set; } = ""; + public string Name { get; set; } = ""; + public string InputTypeName { get; set; } = ""; + public List InputMembers { get; set; } = new(); + public string ResponseType { get; set; } = "void"; + public HashSet TypeImports { get; set; } = new(System.StringComparer.Ordinal); + public Dictionary PaginatedAliases { get; set; } = new(System.StringComparer.Ordinal); + public bool HasInput { get; set; } + public bool HasRequiredInput { get; set; } + public bool IsQuery { get; set; } + } + + private sealed class InputMember + { + public string WireName { get; set; } = ""; + public string PropertyName { get; set; } = ""; + public string TypeExpression { get; set; } = "unknown"; + public bool Required { get; set; } + public ParamLocation Location { get; set; } + public HashSet TypeImports { get; set; } = new(System.StringComparer.Ordinal); + public Dictionary PaginatedAliases { get; set; } = new(System.StringComparer.Ordinal); + } + + private sealed class TypeResolution + { + public TypeResolution(string typeExpression) + { + TypeExpression = typeExpression; + } + + public string TypeExpression { get; set; } + public HashSet TypeImports { get; } = new(System.StringComparer.Ordinal); + public Dictionary PaginatedAliases { get; } = new(System.StringComparer.Ordinal); + + public static TypeResolution WithImport(string typeExpression, string importName) + { + var resolution = new TypeResolution(typeExpression); + resolution.TypeImports.Add(importName); + return resolution; + } + } +} diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs index e6c9a23..1bee619 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs @@ -23,6 +23,7 @@ internal enum TypeTarget Python = 1 << 2, Zod = 1 << 3, GraphQL = 1 << 4, + TanStackQuery = 1 << 5, } /// @@ -72,6 +73,7 @@ internal enum TsEnumStyle { Union, Enum } internal enum PythonFileLayout { FilePerClass, SingleFile } internal enum PythonStyle { Pydantic, Dataclass } internal enum ZodFileLayout { FilePerClass, SingleFile } +internal enum QueryFileLayout { SingleFile, SplitByTag } internal sealed class GraphQLSettings { @@ -93,6 +95,22 @@ internal sealed class ZodSettings public bool EmitGeneratedBanner { get; set; } = true; } +internal sealed class TanStackQuerySettings +{ + public string? OutputDir { get; set; } + public QueryFileLayout FileLayout { get; set; } = QueryFileLayout.SingleFile; + public string SingleFileName { get; set; } = "api.gen.ts"; + public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL"; + public string? ApiClientImportPath { get; set; } + public string ApiClientName { get; set; } = "apiFetch"; + public string? ModelsImportPath { get; set; } + public bool EmitQueryOptions { get; set; } = true; + public bool EmitMutationOptions { get; set; } = true; + public bool EmitHooks { get; set; } = true; + public bool EmitCacheHelpers { get; set; } = true; + public bool EmitGeneratedBanner { get; set; } = true; +} + internal sealed class PythonSettings { public string? OutputDir { get; set; } @@ -574,6 +592,7 @@ internal sealed class GlobalSettings public PythonSettings Python { get; set; } = new(); public ZodSettings Zod { get; set; } = new(); public GraphQLSettings GraphQL { get; set; } = new(); + public TanStackQuerySettings TanStackQuery { get; set; } = new(); /// /// Set by the generator when ZibStack.NET.Query is referenced by the diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs index a0e35ee..b2171c7 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs @@ -187,6 +187,12 @@ private static void ProcessChain( if (calls.Count > 1) ReportUnknown(report, calls[1]); return; + case "TanStackQuery": + ApplyLambdaBlock(first, semantic, parsed.Settings.TanStackQuery, report, + (settings, prop, val) => AssignTanStackQuery(settings, prop, val)); + if (calls.Count > 1) ReportUnknown(report, calls[1]); + return; + case "ForType": var (typeName, typeSym) = ResolveForTypeArgWithSymbol(first, semantic); // If symbol resolution fails (Dto-generated companion types like @@ -400,6 +406,25 @@ private static void AssignZod(ZodSettings s, string prop, object? val) } } + private static void AssignTanStackQuery(TanStackQuerySettings s, string prop, object? val) + { + switch (prop) + { + case "OutputDir": s.OutputDir = val as string; break; + case "FileLayout": if (val is int fl) s.FileLayout = (QueryFileLayout)fl; break; + case "SingleFileName": if (val is string sf) s.SingleFileName = sf; break; + case "BaseUrlExpression": if (val is string bu) s.BaseUrlExpression = bu; break; + case "ApiClientImportPath": s.ApiClientImportPath = val as string; break; + case "ApiClientName": if (val is string ac) s.ApiClientName = ac; break; + case "ModelsImportPath": s.ModelsImportPath = val as string; break; + case "EmitQueryOptions": if (val is bool eqo) s.EmitQueryOptions = eqo; break; + case "EmitMutationOptions": if (val is bool emo) s.EmitMutationOptions = emo; break; + case "EmitHooks": if (val is bool eh) s.EmitHooks = eh; break; + case "EmitCacheHelpers": if (val is bool ech) s.EmitCacheHelpers = ech; break; + case "EmitGeneratedBanner": if (val is bool egb) s.EmitGeneratedBanner = egb; break; + } + } + // ── per-type chain calls (state machine for type ↔ property context) ─── /// diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/EndpointDiscovery.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/EndpointDiscovery.cs index ebf072c..a60bb41 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/EndpointDiscovery.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/EndpointDiscovery.cs @@ -78,7 +78,7 @@ public static void SynthesizeFromCrudApi(SchemaModel model) foreach (var cls in model.Classes) { if (cls.Crud is null) continue; - if ((cls.Targets & TypeTarget.OpenApi) == 0) continue; + if ((cls.Targets & (TypeTarget.OpenApi | TypeTarget.TanStackQuery)) == 0) continue; var route = ResolveRoute(cls); var collectionPath = "/" + route.TrimStart('/'); @@ -518,12 +518,17 @@ private static void ScanMinimalApi(SchemaModel model, Compilation compilation) var prefix = ResolveGroupChain(inv.Expression, semantic); var pattern = NormalizePath(CombineRouteSegments(prefix, patternLit)); + var operationId = ResolveChainedEndpointMetadata(inv, semantic, "WithName") + ?? DeriveMinimalApiOperationId(inv, pattern, verb); + var tag = ResolveChainedEndpointMetadata(inv, semantic, "WithTags") + ?? DeriveMinimalApiTag(pattern); + var endpoint = new EndpointInfo { Verb = verb, Pattern = pattern, - OperationId = DeriveMinimalApiOperationId(inv, pattern, verb), - Tag = DeriveMinimalApiTag(pattern), + OperationId = LowerFirst(operationId), + Tag = tag, Source = EndpointSource.MinimalApi, }; @@ -566,6 +571,26 @@ private static void ScanMinimalApi(SchemaModel model, Compilation compilation) return cv.HasValue ? cv.Value as string : null; } + private static string? ResolveChainedEndpointMetadata( + InvocationExpressionSyntax mapInvocation, + SemanticModel sm, + string methodName) + { + SyntaxNode current = mapInvocation; + while (current.Parent is MemberAccessExpressionSyntax ma + && ReferenceEquals(ma.Expression, current) + && ma.Parent is InvocationExpressionSyntax outer) + { + if (ma.Name.Identifier.Text == methodName && outer.ArgumentList.Arguments.Count > 0) + { + var value = GetStringLiteral(outer.ArgumentList.Arguments[0].Expression, sm); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + current = outer; + } + return null; + } + /// /// Walks left of a .MapGet(...) invocation looking for /// MapGroup("/prefix") calls or variables of type @@ -593,13 +618,19 @@ private static string ResolveReceiver(ExpressionSyntax receiver, SemanticModel s // `x.MapGroup("/api")` — peel off the MapGroup layer, recurse left, // append this layer's literal prefix. if (receiver is InvocationExpressionSyntax nested - && nested.Expression is MemberAccessExpressionSyntax nestedMa - && nestedMa.Name.Identifier.Text == "MapGroup" - && nested.ArgumentList.Arguments.Count >= 1) + && nested.Expression is MemberAccessExpressionSyntax nestedMa) { - var inner = ResolveReceiver(nestedMa.Expression, sm); - var thisPrefix = GetStringLiteral(nested.ArgumentList.Arguments[0].Expression, sm); - return CombineRouteSegments(inner, thisPrefix); + if (nestedMa.Name.Identifier.Text == "MapGroup") + { + var inner = ResolveReceiver(nestedMa.Expression, sm); + var thisPrefix = nested.ArgumentList.Arguments.Count >= 1 + ? GetStringLiteral(nested.ArgumentList.Arguments[0].Expression, sm) + : null; + return CombineRouteSegments(inner, thisPrefix); + } + + if (IsEndpointConventionBuilderMetadata(nestedMa.Name.Identifier.Text)) + return ResolveReceiver(nestedMa.Expression, sm); } // `g.MapGet(...)` where g is a local from `var g = app.MapGroup("/api")` — @@ -618,6 +649,11 @@ private static string ResolveReceiver(ExpressionSyntax receiver, SemanticModel s return ""; } + private static bool IsEndpointConventionBuilderMetadata(string methodName) => + methodName is "WithName" or "WithTags" or "WithDisplayName" or "WithDescription" + or "WithSummary" or "WithGroupName" or "WithOpenApi" or "RequireAuthorization" + or "AllowAnonymous" or "Produces" or "ProducesProblem" or "Accepts"; + /// /// Builds an operationId for a Minimal API endpoint. Minimal API has no /// method name to pull from, so synthesize: {verb}{PascalFromPath} diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs index 360865a..0863b8f 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs @@ -139,7 +139,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // recreate the schemas ourselves. Output targets the SAME emitters the // parent does — TypeScript companions when parent is TS, OpenAPI when // parent is OpenAPI, etc. $refs / cross-file imports resolve cleanly. - if ((cls.Targets & (TypeTarget.OpenApi | TypeTarget.TypeScript | TypeTarget.Python | TypeTarget.Zod)) != 0 + if ((cls.Targets & (TypeTarget.OpenApi | TypeTarget.TypeScript | TypeTarget.Python | TypeTarget.Zod | TypeTarget.TanStackQuery)) != 0 && !cls.OpenApiIgnore) { SynthesizeAuxiliaryForVariant(model, cls, sym, Shared.DtoTarget.Create); @@ -240,7 +240,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // b.ForType().WithGeneratedTypes(TS) // — that goes through the second pass below which detects the naming // pattern and synthesizes that one variant from the parent's properties. - if ((aux.Targets & (TypeTarget.OpenApi | TypeTarget.TypeScript | TypeTarget.Python | TypeTarget.Zod)) != 0 + if ((aux.Targets & (TypeTarget.OpenApi | TypeTarget.TypeScript | TypeTarget.Python | TypeTarget.Zod | TypeTarget.TanStackQuery)) != 0 && !aux.OpenApiIgnore) { SynthesizeAuxiliaryForVariant(model, aux, sym, Shared.DtoTarget.Create); @@ -344,6 +344,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) allFiles.AddRange(ZodEmitter.Emit(model, settings)); if (RequestsTarget(model, TypeTarget.GraphQL)) allFiles.AddRange(GraphQLEmitter.Emit(model, settings)); + if (RequestsTarget(model, TypeTarget.TanStackQuery)) + allFiles.AddRange(TanStackQueryEmitter.Emit(model, settings)); if (allFiles.Count == 0) return; diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs index 2891d0c..b9b525c 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs @@ -75,6 +75,40 @@ public void Configure(ITypeGenBuilder b) { Assert.Equal("2.1.0", parsed.Settings.OpenApi.Version); } + [Fact] + public void TanStackQueryBlock_SetsGlobalSettings() + { + var parsed = Parse(""" + public class Cfg : ITypeGenConfigurator { + public void Configure(ITypeGenBuilder b) { + b.TanStackQuery(q => { + q.OutputDir = "../client/src/api"; + q.FileLayout = QueryFileLayout.SplitByTag; + q.SingleFileName = "query.gen.ts"; + q.BaseUrlExpression = "window.location.origin"; + q.ApiClientImportPath = "./client"; + q.ApiClientName = "request"; + q.ModelsImportPath = "../models"; + q.EmitHooks = false; + q.EmitCacheHelpers = false; + }); + } + } + """, out var diags); + + Assert.Empty(diags); + Assert.NotNull(parsed); + Assert.Equal("../client/src/api", parsed!.Settings.TanStackQuery.OutputDir); + Assert.Equal(QueryFileLayout.SplitByTag, parsed.Settings.TanStackQuery.FileLayout); + Assert.Equal("query.gen.ts", parsed.Settings.TanStackQuery.SingleFileName); + Assert.Equal("window.location.origin", parsed.Settings.TanStackQuery.BaseUrlExpression); + Assert.Equal("./client", parsed.Settings.TanStackQuery.ApiClientImportPath); + Assert.Equal("request", parsed.Settings.TanStackQuery.ApiClientName); + Assert.Equal("../models", parsed.Settings.TanStackQuery.ModelsImportPath); + Assert.False(parsed.Settings.TanStackQuery.EmitHooks); + Assert.False(parsed.Settings.TanStackQuery.EmitCacheHelpers); + } + [Fact] public void ForType_CollectsPerTypeOverrides() { @@ -419,9 +453,10 @@ public class B : ITypeGenConfigurator { public void Configure(ITypeGenBuilder b) using System; namespace ZibStack.NET.TypeGen { [System.Flags] - public enum TypeTarget { None = 0, TypeScript = 1, OpenApi = 2, Python = 4 } + public enum TypeTarget { None = 0, TypeScript = 1, OpenApi = 2, Python = 4, Zod = 8, GraphQL = 16, TanStackQuery = 32 } public enum NameStyle { AsIs, CamelCase, SnakeCase, PascalCase } public enum TypeScriptFileLayout { FilePerClass, SingleFile } + public enum QueryFileLayout { SingleFile, SplitByTag } public sealed class TypeScriptSettings { public string? OutputDir { get; set; } public string SingleFileName { get; set; } = "models.ts"; @@ -438,9 +473,24 @@ public sealed class OpenApiSettings { public string? Description { get; set; } public string OpenApiVersion { get; set; } = "3.0.3"; } + public sealed class TanStackQuerySettings { + public string? OutputDir { get; set; } + public QueryFileLayout FileLayout { get; set; } + public string SingleFileName { get; set; } = "api.gen.ts"; + public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL"; + public string? ApiClientImportPath { get; set; } + public string ApiClientName { get; set; } = "apiFetch"; + public string? ModelsImportPath { get; set; } + public bool EmitQueryOptions { get; set; } = true; + public bool EmitMutationOptions { get; set; } = true; + public bool EmitHooks { get; set; } = true; + public bool EmitCacheHelpers { get; set; } = true; + public bool EmitGeneratedBanner { get; set; } = true; + } public interface ITypeGenBuilder { ITypeGenBuilder TypeScript(Action c); ITypeGenBuilder OpenApi(Action c); + ITypeGenBuilder TanStackQuery(Action c); ITypeBuilder ForType(); ITypeBuilder ForType(System.Type t); } diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/MinimalApiScanTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/MinimalApiScanTests.cs index 509d2c5..bba48c2 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/MinimalApiScanTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/MinimalApiScanTests.cs @@ -119,6 +119,32 @@ public void MapGroup_ChainPrefixesPattern() Assert.Equal("/api/v1/orders", ep.Pattern); } + [Fact] + public void WithNameAndWithTags_OverrideDerivedOperationMetadata() + { + var src = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + app.MapGet("/api/workflow/workspaces/{workspaceId:guid}/items", (System.Guid workspaceId) => "ok") + .WithName("SearchWorkItems") + .WithTags("Workflow"); + + app.Run(); + """; + var comp = Compile(src); + var model = new SchemaModel(); + EndpointDiscovery.Populate(model, comp); + + var ep = Assert.Single(model.Endpoints); + Assert.Equal("searchWorkItems", ep.OperationId); + Assert.Equal("Workflow", ep.Tag); + } + [Fact] public void MapGroup_ViaLocalVariable_PrefixResolved() { @@ -143,6 +169,34 @@ public void MapGroup_ViaLocalVariable_PrefixResolved() Assert.Equal("/api/widgets/{id}", ep.Pattern); } + [Fact] + public void MapGroup_ViaLocalVariable_WithMetadata_PrefixResolved() + { + var src = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + var group = app.MapGroup("/api/workflow").WithTags("Workflow"); + group.MapGet("/workspaces/{workspaceId:guid}/items", (System.Guid workspaceId) => "ok") + .WithName("SearchWorkItems") + .WithTags("Workflow"); + + app.Run(); + """; + var comp = Compile(src); + var model = new SchemaModel(); + EndpointDiscovery.Populate(model, comp); + + var ep = Assert.Single(model.Endpoints); + Assert.Equal("/api/workflow/workspaces/{workspaceId:guid}/items", ep.Pattern); + Assert.Equal("searchWorkItems", ep.OperationId); + Assert.Equal("Workflow", ep.Tag); + } + [Fact] public void FromBodyLambdaParam_BoundAsRequestBody() { diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/SampleApiBuildTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/SampleApiBuildTests.cs index 29f0033..b3b4446 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/SampleApiBuildTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/SampleApiBuildTests.cs @@ -42,6 +42,7 @@ public void SampleApi_Build_WritesExpectedFilesToGeneratedDir() Assert.True(File.Exists(Path.Combine(GeneratedDir, "hoho.ts"))); Assert.True(File.Exists(Path.Combine(GeneratedDir, "OrderStatus.ts"))); Assert.True(File.Exists(Path.Combine(GeneratedDir, "openapi.yaml"))); + Assert.True(File.Exists(Path.Combine(GeneratedDir, "api.gen.ts"))); // The root-level openapi.yaml bug (pre-OutputDir-routing fix) would leave // a copy next to SampleApi.csproj. Guard against regression. @@ -97,9 +98,23 @@ public void SampleApi_Build_WritesExpectedFilesToGeneratedDir() Assert.Contains(" /api/health:", yaml); Assert.Contains(" /api/echo:", yaml); Assert.Contains(" /api/admin/ping:", yaml); // via MapGroup prefix chain + Assert.Contains(" /api/workflow/workspaces/{workspaceId:guid}/items:", yaml); + Assert.Contains("operationId: searchWorkItems", yaml); + Assert.Contains("operationId: createWorkItem", yaml); // Hand-written [ApiController]: Assert.Contains(" /api/widgets/{id}:", yaml); Assert.Contains("tags: [Widgets]", yaml); + + var queryClient = File.ReadAllText(Path.Combine(GeneratedDir, "api.gen.ts")); + Assert.Contains("export const workflowKeys = {", queryClient); + Assert.Contains("export type SearchWorkItemsInput = {", queryClient); + Assert.Contains("state?: workItemState;", queryClient); + Assert.Contains("labels?: string[];", queryClient); + Assert.Contains("export type PaginatedResponseOfWorkItemSummary = {", queryClient); + Assert.Contains("export function searchWorkItems(input: SearchWorkItemsInput", queryClient); + Assert.Contains("`/api/workflow/workspaces/${encodeURIComponent(String(input.workspaceId))}/items`", queryClient); + Assert.Contains("export function useCreateWorkItem()", queryClient); + Assert.Contains("export const echoKeys = {", queryClient); } private static (int Exit, string Output) Run(string file, string args) diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs new file mode 100644 index 0000000..8163f5b --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs @@ -0,0 +1,173 @@ +using System.Linq; +using Xunit; +using ZibStack.NET.TypeGen.Generator; + +namespace TypeGenTests; + +public class TanStackQueryEmitterTests +{ + private static SchemaClass Cls( + string name, + TypeTarget targets = TypeTarget.TypeScript | TypeTarget.TanStackQuery, + params (string Name, string CSharpType, bool Nullable)[] props) + { + var cls = new SchemaClass + { + CSharpFullName = name, + SourceName = name, + EmittedName = name, + OutputDir = "models", + Targets = targets, + }; + foreach (var (propName, type, nullable) in props) + cls.Properties.Add(new SchemaProperty { SourceName = propName, CSharpTypeFullName = type, IsNullable = nullable }); + return cls; + } + + private static SchemaModel ModelWith(params SchemaClass[] classes) + { + var model = new SchemaModel(); + model.Classes.AddRange(classes); + return model; + } + + [Fact] + public void ComplexEndpoint_EmitsTypedQueryAndMutationSurface() + { + var model = ModelWith( + Cls("JobSummary", props: new[] { ("Id", "System.Guid", false), ("Name", "string", false) }), + Cls("StartJobRequest", props: new[] { ("TemplateId", "System.Guid", false), ("Priority", "int", true) })); + model.Endpoints.Add(new EndpointInfo + { + Verb = "get", + Pattern = "/api/workspaces/{workspaceId:guid}/reports/{reportId:int}/jobs", + OperationId = "getWorkspaceReportJobs", + Tag = "Reports", + IsListEndpoint = true, + ResponseCSharpType = "PaginatedResponse", + Parameters = + { + new EndpointParameter { Name = "workspaceId", Location = ParamLocation.Route, CSharpType = "System.Guid", Required = true }, + new EndpointParameter { Name = "reportId", Location = ParamLocation.Route, CSharpType = "int", Required = true }, + new EndpointParameter { Name = "includeHistory", Location = ParamLocation.Query, CSharpType = "bool", Required = false }, + }, + }); + model.Endpoints.Add(new EndpointInfo + { + Verb = "post", + Pattern = "/api/workspaces/{workspaceId:guid}/jobs", + OperationId = "startWorkspaceJob", + Tag = "Jobs", + RequestBodyCSharpType = "StartJobRequest", + ResponseCSharpType = "JobSummary", + Parameters = + { + new EndpointParameter { Name = "workspaceId", Location = ParamLocation.Route, CSharpType = "System.Guid", Required = true }, + }, + }); + + var settings = new GlobalSettings { HasQueryDsl = true }; + settings.TanStackQuery.OutputDir = "client/api"; + settings.TanStackQuery.ModelsImportPath = "../models"; + settings.TanStackQuery.BaseUrlExpression = "window.location.origin"; + + var file = Assert.Single(TanStackQueryEmitter.Emit(model, settings)); + var ts = file.Content; + + Assert.Equal(TypeTarget.TanStackQuery, file.Target); + Assert.Equal("api.gen.ts", file.FileName); + Assert.Contains("from '@tanstack/react-query';", ts); + Assert.Contains("import type { JobSummary, StartJobRequest } from '../models';", ts); + Assert.Contains("new URL(path, window.location.origin)", ts); + + Assert.Contains("export type PaginatedResponseOfJobSummary = {", ts); + Assert.Contains("items: JobSummary[];", ts); + Assert.Contains("export const reportsKeys = {", ts); + Assert.Contains("getWorkspaceReportJobs: (input: GetWorkspaceReportJobsInput)", ts); + + Assert.Contains("export type GetWorkspaceReportJobsInput = {", ts); + Assert.Contains("workspaceId: string;", ts); + Assert.Contains("reportId: number;", ts); + Assert.Contains("includeHistory?: boolean;", ts); + Assert.Contains("page?: number;", ts); + Assert.Contains("pageSize?: number;", ts); + Assert.Contains("filter?: string;", ts); + Assert.Contains("count?: boolean;", ts); + + Assert.Contains("/api/workspaces/${encodeURIComponent(String(input.workspaceId))}/reports/${encodeURIComponent(String(input.reportId))}/jobs", ts); + Assert.Contains("includeHistory: input.includeHistory", ts); + Assert.Contains("pageSize: input.pageSize", ts); + Assert.Contains("export function getWorkspaceReportJobsOptions(input: GetWorkspaceReportJobsInput)", ts); + Assert.Contains("return useQuery(getWorkspaceReportJobsOptions(input));", ts); + Assert.Contains("export function prefetchGetWorkspaceReportJobs(queryClient: QueryClient, input: GetWorkspaceReportJobsInput)", ts); + Assert.Contains("export function invalidateReportsQueries(queryClient: QueryClient)", ts); + + Assert.Contains("export type StartWorkspaceJobInput = {", ts); + Assert.Contains("export const jobsKeys = {", ts); + Assert.Contains("body: StartJobRequest;", ts); + Assert.Contains("body: input.body", ts); + Assert.Contains("export function startWorkspaceJobMutationOptions()", ts); + Assert.Contains("return useMutation({", ts); + Assert.Contains("await invalidateJobsQueries(queryClient);", ts); + } + + [Fact] + public void SplitByTag_WithCustomClient_EmitsOneFilePerTagWithoutDefaultClient() + { + var model = ModelWith(Cls("JobSummary")); + model.Endpoints.Add(new EndpointInfo + { + Verb = "get", + Pattern = "/reports", + OperationId = "listReports", + Tag = "Reports", + ResponseArrayItemCSharpType = "JobSummary", + }); + model.Endpoints.Add(new EndpointInfo + { + Verb = "post", + Pattern = "/jobs/retry", + OperationId = "retryJob", + Tag = "Jobs", + ResponseCSharpType = "JobSummary", + }); + + var settings = new GlobalSettings(); + settings.TanStackQuery.FileLayout = QueryFileLayout.SplitByTag; + settings.TanStackQuery.ApiClientImportPath = "./http-client"; + settings.TanStackQuery.ApiClientName = "request"; + settings.TanStackQuery.EmitHooks = false; + settings.TanStackQuery.EmitCacheHelpers = false; + + var files = TanStackQueryEmitter.Emit(model, settings); + + Assert.Equal(2, files.Count); + var reports = files.Single(f => f.FileName == "reports.gen.ts").Content; + var jobs = files.Single(f => f.FileName == "jobs.gen.ts").Content; + + Assert.Contains("import { request } from './http-client';", reports); + Assert.DoesNotContain("export type ApiFetchOptions", reports); + Assert.Contains("export const reportsKeys", reports); + Assert.DoesNotContain("useQuery", reports); + Assert.Contains("import { request } from './http-client';", jobs); + Assert.Contains("mutationOptions", jobs); + Assert.DoesNotContain("useMutation", jobs); + } + + [Fact] + public void CrudApiClass_TargetingTanStackQuery_SynthesizesEndpointClient() + { + var order = Cls("Order", TypeTarget.TanStackQuery | TypeTarget.TypeScript, + ("Id", "int", false), + ("Name", "string", false)); + order.Crud = new CrudApiInfo { Operations = CrudOperations.GetList | CrudOperations.Create }; + + var ts = TanStackQueryEmitter.Emit(ModelWith(order), new GlobalSettings()).Single().Content; + + Assert.Contains("export const orderKeys = {", ts); + Assert.Contains("export function listOrder(input: ListOrderInput = {})", ts); + Assert.Contains("export type PaginatedResponseOfOrder", ts); + Assert.Contains("export function createOrder(input: CreateOrderInput", ts); + Assert.Contains("body: CreateOrderRequest;", ts); + } +}