From 2e115cf952ac187660417f1434c88cec4e93a305 Mon Sep 17 00:00:00 2001 From: RejectKid Date: Thu, 10 Sep 2026 12:34:32 -0400 Subject: [PATCH] [TypeGen]: add Zod 4.6 compilation and validation --- docs/design/zod-4.6-integration.md | 407 ++++++++++++++++++ .../typegen/emitters/tanstack-query.md | 19 + .../docs/packages/typegen/emitters/zod.md | 39 +- docs/src/content/docs/packages/validation.md | 1 + .../docs/packages/validation/attributes.md | 10 + .../docs/packages/validation/fluent.md | 5 +- packages/ZibStack.NET.TypeGen/README.md | 61 ++- .../sample/SampleApi/Models/Order.cs | 33 ++ .../sample/SampleApi/TypeGenConfig.cs | 16 + .../ITypeGenConfigurator.cs | 6 + .../Settings.cs | 65 +++ .../ZodOverrides.cs | 18 + .../Emitters/TanStackQueryEmitter.cs | 201 ++++++++- .../Emitters/ZodEmitter.cs | 223 ++++++++-- .../ZibStack.NET.TypeGen/Model/SchemaModel.cs | 15 + .../Parser/ConfiguratorParser.cs | 37 ++ .../Parser/SchemaParser.cs | 24 ++ .../ZibStack.NET.TypeGen/TypeGenGenerator.cs | 2 + .../ConfiguratorParserTests.cs | 52 +++ .../TanStackQueryEmitterTests.cs | 31 ++ .../ZodCompilationTests.cs | 112 ++++- .../ZodEmitterTests.cs | 107 +++++ .../sample/SampleApi/Models.cs | 17 + .../AttributeSources.cs | 16 + .../ValidationEmitter.cs | 33 +- .../ValidationGenerator.cs | 1 + .../ValidationModels.cs | 1 + .../ValidationParser.cs | 4 + .../ZibStack.NET.Validation.Tests/Models.cs | 3 + .../ValidationTests.cs | 23 + 30 files changed, 1532 insertions(+), 50 deletions(-) create mode 100644 docs/design/zod-4.6-integration.md create mode 100644 packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs diff --git a/docs/design/zod-4.6-integration.md b/docs/design/zod-4.6-integration.md new file mode 100644 index 0000000..dda1623 --- /dev/null +++ b/docs/design/zod-4.6-integration.md @@ -0,0 +1,407 @@ +# Zod 4.6 integration design + +Branch: `zod-4-6-support` +Baseline: ZibStack.NET v3.2.7 / Zod 4.4.3 +Target: Zod 4.6.1 +Status: core integration implemented on `zod-4-6-support`; Zod Mini and +build-time `withParser` remain deferred pending bundle/performance evidence. + +## Context + +Zod 4.6.1 was published on September 9, 2026. The `4.6.1` release itself is a +small patch for discriminated-union defaults and recursive inference, plus a +locale addition. The larger opportunity is the combined feature set released +in Zod 4.5 and 4.6 since ZibStack's current `zod@4.4.3` integration-test pin. + +Primary references: + +- [Zod 4.5 release](https://github.com/colinhacks/zod/releases/tag/v4.5.0) +- [Zod 4.6 release](https://github.com/colinhacks/zod/releases/tag/v4.6.0) +- [Zod 4.6.1 release](https://github.com/colinhacks/zod/releases/tag/v4.6.1) +- [Changes from 4.4.3 to 4.6.1](https://github.com/colinhacks/zod/compare/v4.4.3...v4.6.1) + +The current ZibStack emitter already uses Zod 4 factories such as `z.email()`, +`z.uuid()`, and `z.iso.datetime()`. It emits runtime schemas and inferred types, +but does not compile schemas, emit boolean validation helpers, connect schemas +to the TanStack client, model exact optional properties, or correctly break +recursive schema initialization cycles. + +## Goals + +1. Keep generated schemas correct and type-safe before adding performance modes. +2. Make validation of API payloads cheap enough to enable at client boundaries. +3. Preserve C# DTO wire semantics, especially patch fields and nullable values. +4. Keep the default output CSP-safe and backwards compatible. +5. Expose Zod-specific behavior without leaking it into the TypeScript and + OpenAPI emitters. + +## Non-goals + +- Reimplement all of Zod in C#. +- Generate schemas for JavaScript class instances or symbol-keyed objects. +- Consume JSON Schema through `z.fromJSONSchema()`; ZibStack already owns a + direct schema model and emitter. +- Automatically choose application locale. +- Generate arbitrary transforms or refinements from opaque C# methods. + +## Proposed roadmap + +### Phase 0 — compatibility baseline + +Update `ZodCompilationTests` from `zod@4.4.3` to `zod@4.6.1` and keep the real +`tsc` compilation test in CI. Add fixtures for: + +- discriminated unions; +- nullable and optional properties; +- dictionaries and arrays; +- recursive/self-referencing contracts; +- generated TypeScript and Zod output enabled together. + +No generated syntax needs to change merely to consume 4.6.1. Users receive +Zod's lower schema memory usage, faster failure paths, safer formats, and bug +fixes by upgrading their npm dependency. + +### Phase 1 — type conformance and correctness + +#### 1. Assert schemas against emitted TypeScript + +Zod 4.5 adds `z.toZod()`. When TypeScript and Zod targets are both enabled, +offer an opt-in mode that makes `tsc` prove the runtime schema agrees with the +generated interface. + +Proposed configuration: + +```csharp +b.Zod(z => +{ + z.ConformToTypeScriptTypes = true; +}); +``` + +Proposed output: + +```ts +import { z } from "zod"; +import type { Order } from "./Order"; + +export const OrderSchema = z.toZod()( + z.object({ id: z.number().int(), note: z.string().nullish() }) +); +``` + +Keep today's `z.infer` behavior as the default for Zod-only generation. Report a +TypeGen diagnostic if conformance mode is requested without TypeScript output. + +#### 2. Correct `PatchField` optionality + +Zod 4.5 adds exact optionals and `.exactPartial()`. A generated update DTO needs +to distinguish these wire states: + +- property absent: do not modify the field; +- property present with `null`: explicitly clear a nullable field; +- property present with a value: update the field. + +Emit a `PatchField` property as exact-optional, with nullability applied to +the value schema: + +```ts +displayName: z.exactOptional(z.string().nullable()) +``` + +Do not globally replace `.optional()` with exact optionality. Read-only response +properties and ordinary nullable request properties have different semantics. +Carry an explicit `PropertyPresence` value in `SchemaProperty` instead of +inferring all presence behavior from `IsNullable` and `IsReadOnly`. + +Suggested model: + +```csharp +internal enum PropertyPresence +{ + Required, + Optional, + ExactOptional, +} +``` + +Use `z.deepPartial()` only for contracts explicitly declared as deep partials. +It should not be applied automatically to every update DTO because DTO ignore, +write-once, and nested-DTO rules can make its shape differ from the entity. + +#### 3. Fix recursive schemas + +The current emitter's comment says cycles are broken with `z.lazy()`, but the +emitter does not currently emit `z.lazy()`. Topological sorting alone cannot +order a cycle and can leave a JavaScript temporal-dead-zone reference. + +Build a schema dependency graph, find strongly connected components, and wrap +only cyclic edges: + +```ts +export const CategorySchema: z.ZodType = z.object({ + name: z.string(), + children: z.array(z.lazy(() => CategorySchema)), +}); +``` + +Cover self-cycles, two-type cycles, collections, nullable edges, file-per-class +ES-module cycles, and cyclic input data. Zod 4.5 can preserve cycles in input; +ZibStack first has to emit an initialization-safe schema graph. + +### Phase 2 — performance features + +#### 4. Optional schema compilation + +Zod 4.5 adds `z.compile()`, which generates a fast parser using `new Function`. +Expose compilation as an explicit setting because strict Content Security +Policies can prohibit dynamic code generation. + +```csharp +public enum ZodCompilationMode +{ + None, + Compile, +} + +b.Zod(z => z.Compilation = ZodCompilationMode.Compile); +``` + +Proposed output: + +```ts +const OrderSchemaDefinition = z.object({ /* ... */ }); +export const OrderSchema = z.compile(OrderSchemaDefinition); +``` + +Default to `None`. Do not emit the global `import "zod/compile"` side effect: +explicit compilation is deterministic, works with file-per-class output, and +does not depend on import order. + +#### 5. Boolean validation guards + +Zod 4.6 adds `.validate()`/`.validateAsync()`, which short-circuit without +building a parse result or issue array. Offer generated guards: + +```csharp +b.Zod(z => z.EmitValidationGuards = true); +``` + +```ts +export const isOrder = (input: unknown): input is z.input => + OrderSchema.validate(input); +``` + +These become especially useful with compiled schemas. Keep `.parse()` available +for boundaries that need normalized output or detailed validation errors. + +### Phase 3 — TanStack Query integration + +Today the generated TanStack client trusts `response.json()` and only uses C# +types at compile time. Add optional runtime response parsing with generated Zod +schemas: + +```csharp +public enum QueryPayloadValidation +{ + None, + Responses, + RequestsAndResponses, +} + +b.TanStackQuery(q => +{ + q.PayloadValidation = QueryPayloadValidation.Responses; +}); +``` + +Proposed response path: + +```ts +const payload: unknown = await apiFetch(path, options); +return OrderSchema.parse(payload); +``` + +Design requirements: + +- `None` remains the default and adds no Zod dependency to TanStack-only users. +- Enabling validation automatically requires or diagnoses missing Zod targets. +- Response types derive from `z.output`. +- Request types derive from `z.input`. +- Errors retain endpoint/operation context before surfacing through TanStack. +- Compiled schemas are reused; never compile per request. + +This is the highest-value cross-package feature: server DTO metadata becomes a +runtime contract at the actual network boundary, not merely a TypeScript hint. + +### Phase 4 — richer checks and formats + +The new exported schema factories since Zod 4.4.3 are `z.creditCard()`, +`z.iban()`, and `z.properties()`. + +ZibStack.NET.Validation already has `[ZCreditCard]`, its fluent `.CreditCard()` +equivalent, and generated Luhn validation. TypeGen does not read that attribute +today. Make this the first format bridge: + +```csharp +public sealed class PaymentRequest +{ + [ZCreditCard] + [Sensitive] + public string CardNumber { get; init; } = ""; + + [ZIban] + public string Iban { get; init; } = ""; +} +``` + +Mappings: + +```ts +cardNumber: z.creditCard() +iban: z.iban() +``` + +For credit cards, teach TypeGen to recognize both +`ZibStack.NET.Validation.ZCreditCardAttribute` and +`System.ComponentModel.DataAnnotations.CreditCardAttribute`, then emit +`z.creditCard()` and suitable OpenAPI metadata. Reconcile one existing semantic +difference first: Zod accepts 12–19 digits while ZibStack's generated validator +previously accepted 13–19. The generated C# rule is now aligned to Zod's 12–19 +range so the same value does not pass on one side of the API and fail on the other. + +For IBAN, add `[ZIban]` plus a fluent `.Iban()` rule to +`ZibStack.NET.Validation`, implement electronic-format ISO 7064 MOD 97-10 +validation, and teach TypeGen to emit `z.iban()`. + +Add parity fixtures that feed the same valid and invalid corpus to generated C# +validation and Zod. This prevents a request from passing on one side of the API +boundary and failing on the other. + +`z.properties()` validates properties in place while preserving a JavaScript +instance's prototype. JSON API DTOs are plain objects, so it does not warrant a +general ZibStack attribute. Keep it as an escape-hatch/consumer concern unless +TypeGen later supports browser/runtime types such as `Response`, `URL`, or +third-party class instances. + +Related check improvements worth exposing where the C# model carries equivalent +metadata: + +- custom NanoID length (`z.nanoid({ length })`); +- exact optional/partial semantics for patch DTOs; +- JSON Schema collection/object constraints (`uniqueItems`, `contains`, + `minContains`, `maxContains`, `minProperties`, `maxProperties`). + +The last group is newly enforced by `z.fromJSONSchema()` rather than being new +direct schema factories. ZibStack emits Zod directly, so support should come from +neutral schema-model constraints, not by routing generated output through JSON +Schema. + +Also add a Zod-specific escape hatch for formats that do not have a server-side +rule yet: + +```csharp +b.ForType() + .Property(x => x.ExternalId) + .ZodFormat(ZodStringFormat.Ulid); +``` + +Do not keep routing Zod behavior through `OpenApiFormat`; introduce a neutral +wire-format field for shared semantics and a Zod-only override for intentional +target-specific behavior. + +Candidate format enum values: + +- `Email`, `Url`, `Uuid`, `Date`, `DateTime`; +- `Hostname`, `Ulid`, `NanoId`; +- `Base64`, `Base64Url`; +- `CreditCard`, `Iban`. + +### Phase 5 — optional Zod Mini backend + +Zod 4.5 publishes `@zod/mini` as a standalone package. Add a separate emitter +flavor only after measuring generated bundle size: + +```csharp +public enum ZodFlavor +{ + Classic, + Mini, +} +``` + +Mini is not an import-path substitution. Current fluent chains such as +`.min()`, `.max()`, and `.regex()` must be emitted as Mini checks, so isolate +schema construction behind a small internal expression model before supporting +both backends. + +### Future — CSP-safe generated parsers + +Zod 4.6's `z.withParser()` can install a parser generated elsewhere without +`new Function`. ZibStack is itself a build-time generator, so it could emit a +specialized TypeScript parser and attach it to the Zod schema: + +```ts +export const OrderSchema = z.withParser(OrderSchemaDefinition, parseOrder); +``` + +This could offer compiled-schema performance under strict CSP, but it is a +large project. The generated parser must exactly preserve Zod output semantics: +unknown-key stripping, nested output rebuilding, defaults, catches, codecs, +unions, and error fallback. Start only with a restricted set of pure schemas, +return `z.INVALID` for unsupported or failing paths, and differential-test every +generated parser against normal Zod parsing. + +## Features that need no ZibStack API + +These Zod improvements are automatically inherited after the npm upgrade: + +- lower per-schema memory use; +- faster CommonJS exports and failure paths; +- fixed email, IPv6, ULID, emoji, base64, and Unicode-length behavior; +- recursive-input memory retention fixes; +- corrected numeric enum options; +- corrected JSON Schema constraint folding; +- locale additions; +- `z.getDiscriminatedOption()` for consumer code. + +`z.properties()`, JavaScript symbol keys, and `z.fromJSONSchema()` do not map +naturally to JSON DTO generation and should remain consumer-level Zod features. + +## Compatibility strategy + +Do not silently emit 4.5/4.6-only APIs for every existing project. Add a target +capability setting and diagnostics: + +```csharp +b.Zod(z => +{ + z.TargetVersion = ZodTargetVersion.V4_6; +}); +``` + +- Existing configurations preserve current output. +- Enabling compilation, validation guards, exact optionals, IBAN, or + `z.toZod()` implies a 4.5/4.6 capability and emits a clear diagnostic when + the configured target is too old. +- Documentation should show `npm install zod@^4.6.1` for the new profile. +- Generated files should include the target in the banner for troubleshooting: + `// @generated by ZibStack.NET.TypeGen (Zod >= 4.6.1)`. + +ZibStack cannot reliably inspect a frontend's installed npm version from a +Roslyn generator, so this is a declared compatibility target, not automatic +package discovery. + +## Suggested delivery order + +1. Compatibility pin and recursive-schema correctness. +2. `z.toZod()` conformance mode and exact `PatchField` semantics. +3. Compilation and validation guards. +4. TanStack response validation. +5. IBAN/credit-card validation parity. +6. Zod Mini after bundle measurements. +7. CSP-safe external parsers only after differential-test infrastructure exists. + +The first three items are cohesive enough for one minor TypeGen release. Runtime +TanStack validation and new server-side validation attributes should be separate +features because they cross package boundaries and deserve independent opt-in +and release notes. diff --git a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md index 2dcca88..25d8888 100644 --- a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md +++ b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md @@ -203,6 +203,8 @@ export function invalidateWorkflowQueries(queryClient: QueryClient) { | `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 | +| `SchemasImportPath` | computed | Force generated Zod schema imports from one module | +| `PayloadValidation` | `None` | `None`, `Responses`, or `RequestsAndResponses` runtime parsing | | `EmitQueryOptions` | `true` | Emit `queryOptions(...)` helpers | | `EmitMutationOptions` | `true` | Emit `mutationOptions(...)` helpers | | `EmitHooks` | `true` | Emit `useQuery` / `useMutation` wrappers | @@ -238,6 +240,23 @@ arrays as repeated query-string keys and JSON-serializes request bodies. Route and query parameter types use the same primitive mapping as generated models; notably `decimal` maps to `string` to preserve precision. +## Runtime payload validation + +Add `TypeTarget.Zod` to the request/response DTOs, install `zod@^4.6.1`, and opt in: + +```csharp +b.TanStackQuery(q => +{ + q.PayloadValidation = QueryPayloadValidation.RequestsAndResponses; + // Optional when TypeGen can compute the relative schema paths: + q.SchemasImportPath = "../validation/schemas"; +}); +``` + +`Responses` parses successful API payloads before returning them to TanStack +Query. `RequestsAndResponses` additionally parses JSON request bodies before +they are sent. `None` preserves the existing zero-Zod-dependency client. + ## Naming For Minimal APIs, prefer `.WithName("searchWorkItems")` and `.WithTags("Workflow")`. diff --git a/docs/src/content/docs/packages/typegen/emitters/zod.md b/docs/src/content/docs/packages/typegen/emitters/zod.md index e9e4c6c..0e8894f 100644 --- a/docs/src/content/docs/packages/typegen/emitters/zod.md +++ b/docs/src/content/docs/packages/typegen/emitters/zod.md @@ -44,11 +44,12 @@ export const OrderSchema = z.object({ export type Order = z.infer; ``` -**Independent from TypeScript emitter.** Both files are generated from the same -`SchemaModel`, so drift is structurally impossible — change the C# class and -both regen identically on the next build. The TS interface stays as the +**Independent from TypeScript emitter by default.** Both files are generated from the same +`SchemaModel` and regenerate together on the next build. The TS interface stays as the ergonomic type-only view (cheap to import, no runtime dep); the Zod schema carries the runtime validator and its own `z.infer` alias for Zod-only consumers. +Enable `ConformToTypeScriptTypes` when you also want `z.toZod()` to make the +TypeScript compiler prove that both generated shapes match. ## Validation constraint mapping @@ -62,6 +63,8 @@ Same attributes that drive OpenAPI constraints map to Zod chained calls: | `[RegularExpression("pat")]`, `[ZMatch("pat")]` | `.regex(/pat/)` | | `[EmailAddress]`, `[ZEmail]` | `z.email()` | | `[Url]`, `[ZUrl]` | `z.url()` | +| `[CreditCard]`, `[ZCreditCard]` | `z.creditCard()` | +| `[ZIban]` | `z.iban()` | | `System.Guid` | `z.uuid()` | | `System.DateTime` | `z.iso.datetime()` | | `System.DateOnly` | `z.iso.date()` | @@ -70,6 +73,25 @@ The emitter targets **Zod 4** — it emits the top-level format factories (`z.uuid()`, `z.email()`, `z.iso.datetime()`, …) rather than the chained `z.string().uuid()` forms that Zod 4 deprecated. Install Zod 4: `npm install zod@^4`. +Zod-only formats can be selected without changing the OpenAPI contract: + +```csharp +[ZodFormat(ZodStringFormat.Ulid)] +public string PublicId { get; set; } = ""; + +b.ForType() + .Property(x => x.CardToken) + .ZodFormat(ZodStringFormat.Base64Url); + +// Zod 4.6 custom NanoID length: +b.ForType() + .Property(x => x.PublicToken) + .ZodNanoId(16); +``` + +Available formats are email, URL, UUID, date, date-time, hostname, ULID, +NanoID, Base64, Base64URL, credit card, and IBAN. + ## Type mapping | C# | Zod | @@ -80,6 +102,7 @@ The emitter targets **Zod 4** — it emits the top-level format factories | `string` | `z.string()` | | `bool` | `z.boolean()` | | `T?` (nullable) | `.nullish()` *(null ∪ undefined ∪ absent)* | +| `PatchField` | `T.optional()` *(omission is distinct from an explicit value)* | | `List`, `T[]` | `z.array(T)` | | `Dictionary` | `z.record(z.string(), V)` | | user DTO | direct ref `{Name}Schema` (cross-file import) | @@ -128,9 +151,17 @@ b.Zod(z => z.SchemaConstSuffix = "Schema"; // default; "XxxSchema" z.EmitInferredTypes = true; // default; adds `export type X = z.infer<…>` z.FileSuffix = ".schema"; // `Order.schema.ts` avoids collision with TS's `Order.ts` + z.ConformToTypeScriptTypes = true; // opt-in `z.toZod()` compile-time check + z.Compilation = ZodCompilationMode.Compile; // opt-in optimized parser + z.EmitValidationGuards = true; // emits `isOrder(value)` using `.validate()` }); ``` +Recursive DTO references are emitted with `z.lazy(...)`, including mutually +recursive types split across files. Compilation is opt-in because Zod uses +generated JavaScript internally; unsupported schemas safely fall back to the +normal parser. + **Consumer install:** the emitted code imports `zod` — add it to the frontend -project: `npm install zod@^4`. The emitter targets Zod 4's top-level format +project: `npm install zod@^4.6.1`. The emitter targets Zod 4's top-level format factories. TypeGen doesn't bundle or generate the dep. diff --git a/docs/src/content/docs/packages/validation.md b/docs/src/content/docs/packages/validation.md index 7dfd118..e4b1cef 100644 --- a/docs/src/content/docs/packages/validation.md +++ b/docs/src/content/docs/packages/validation.md @@ -74,6 +74,7 @@ All attributes live in the `ZibStack.NET.Validation` namespace and are processed | `[ZIn("a","b","c")]` | property | Value must be one of the allowed values | | `[ZNotIn("x","y")]` | property | Value must NOT be any of the specified values | | `[ZCreditCard]` | property (string) | Must pass the Luhn algorithm check | +| `[ZIban]` | property (string) | Must pass the ISO 13616 mod-97 check | | `[ZPhone]` | property (string) | Must match phone number format regex | | `[ZCascade]` | property | Stop after first rule failure for this property | diff --git a/docs/src/content/docs/packages/validation/attributes.md b/docs/src/content/docs/packages/validation/attributes.md index 6953d59..59a4984 100644 --- a/docs/src/content/docs/packages/validation/attributes.md +++ b/docs/src/content/docs/packages/validation/attributes.md @@ -19,6 +19,7 @@ description: Complete reference for all validation attributes — [ZRequired], [ | `[ZIn("a","b","c")]` | property | Value must be one of the allowed values | | `[ZNotIn("x","y")]` | property | Value must NOT be any of the specified values | | `[ZCreditCard]` | property (string) | Must pass the Luhn algorithm check | +| `[ZIban]` | property (string) | Must pass the ISO 13616 mod-97 check | | `[ZPhone]` | property (string) | Must match phone number format | | `[ZCascade]` | property | Stop after first rule failure for this property | @@ -84,6 +85,15 @@ public partial class PaymentForm // Invalid: "1234567890123456" ``` +## `[ZIban]` — ISO 13616 + +```csharp +[ZIban] +public string? BankAccount { get; set; } + +// Valid: "GB82 WEST 1234 5698 7654 32" +``` + ## `[ZPhone]` ```csharp diff --git a/docs/src/content/docs/packages/validation/fluent.md b/docs/src/content/docs/packages/validation/fluent.md index a927297..2f88d43 100644 --- a/docs/src/content/docs/packages/validation/fluent.md +++ b/docs/src/content/docs/packages/validation/fluent.md @@ -72,9 +72,12 @@ b.Property(x => x.Role) b.Property(x => x.CardNumber) .CreditCard(); + +b.Property(x => x.BankAccount) + .Iban(); ``` -Available fluent methods: `.Required()`, `.Email()`, `.Url()`, `.NotEmpty()`, `.MinLength(n)`, `.MaxLength(n)`, `.Range(min, max)`, `.Match(pattern)`, `.In(values)`, `.NotIn(values)`, `.CreditCard()`, `.Phone()`. +Available fluent methods: `.Required()`, `.Email()`, `.Url()`, `.NotEmpty()`, `.MinLength(n)`, `.MaxLength(n)`, `.Range(min, max)`, `.Match(pattern)`, `.In(values)`, `.NotIn(values)`, `.CreditCard()`, `.Iban()`, `.Phone()`. ## Cross-Field Comparisons diff --git a/packages/ZibStack.NET.TypeGen/README.md b/packages/ZibStack.NET.TypeGen/README.md index d020b6e..7f168cb 100644 --- a/packages/ZibStack.NET.TypeGen/README.md +++ b/packages/ZibStack.NET.TypeGen/README.md @@ -3,7 +3,7 @@ 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. +and **Zod 4.6** output. Compile-time only, zero reflection, no running app required. ## What it does @@ -75,6 +75,65 @@ That's it — `ZibStack.NET.TypeGen.Abstractions` (attributes + settings types) is pulled in transitively. The analyzer self-registers; everything else is in the attribute / configurator surface. +## Zod 4.6 and validated TanStack clients + +Zod generation is opt-in per model. The configurator can compile schemas, +check them against the emitted TypeScript type, add `isX` guards, and make the +TanStack client parse API payloads at the network boundary: + +```csharp +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod | TypeTarget.TanStackQuery)] +public sealed class Payment +{ + [ZodFormat(ZodStringFormat.CreditCard)] + public string CardNumber { get; set; } = ""; + + [ZodFormat(ZodStringFormat.Iban)] + public string Iban { get; set; } = ""; + + public string PublicToken { get; set; } = ""; + + public Payment? Parent { get; set; } // emitted through z.lazy(...) +} + +public sealed class TypeGenConfig : ITypeGenConfigurator +{ + public void Configure(ITypeGenBuilder b) + { + b.Zod(z => + { + z.Compilation = ZodCompilationMode.Compile; + z.ConformToTypeScriptTypes = true; + z.EmitValidationGuards = true; + }); + + b.TanStackQuery(q => + q.PayloadValidation = QueryPayloadValidation.RequestsAndResponses); + + b.ForType() + .Property(x => x.PublicToken) + .ZodNanoId(16); // custom-length NanoID is also available fluently + } +} +``` + +The generated module uses Zod's native factories and compiler: + +```ts +export const PaymentSchema = z.compile(z.toZod()(z.object({ + cardNumber: z.creditCard(), + iban: z.iban(), + publicToken: z.nanoid({ length: 16 }), + parent: z.lazy(() => PaymentSchema).optional(), +}))); + +export const isPayment = (value: unknown): value is z.output => + PaymentSchema.validate(value); +``` + +See the sample project's `ZodFeatureExample` for credit cards, IBANs, ULIDs, +hostnames, Base64/Base64URL, custom NanoIDs, recursion, and response validation. + ## Docs Full reference — type mapping, diagnostic list (`TG0001`-`TG0021`), fluent DSL, diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs index 2bd7519..7a828f6 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs @@ -67,3 +67,36 @@ public class Customer public string Name { get; set; } = ""; public string Email { get; set; } = ""; } + +/// +/// Zod 4.6 format showcase. These are TypeGen-only wire checks; use the +/// matching ZibStack.Validation attributes when the server must enforce the +/// same rule as well. Parent demonstrates recursive schema emission via z.lazy. +/// +[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod, + OutputDir = "generated")] +public class ZodFeatureExample +{ + [ZodFormat(ZodStringFormat.CreditCard)] + public string CardNumber { get; set; } = ""; + + [ZodFormat(ZodStringFormat.Iban)] + public string BankAccount { get; set; } = ""; + + [ZodFormat(ZodStringFormat.Ulid)] + public string SortableId { get; set; } = ""; + + [ZodFormat(ZodStringFormat.Hostname)] + public string Host { get; set; } = ""; + + [ZodFormat(ZodStringFormat.Base64)] + public string EncodedPayload { get; set; } = ""; + + [ZodFormat(ZodStringFormat.Base64Url)] + public string UrlSafePayload { get; set; } = ""; + + // Configured with .ZodNanoId(16) in TypeGenConfig.cs. + public string PublicToken { get; set; } = ""; + + public ZodFeatureExample? Parent { get; set; } +} diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs index 4db2e58..c9351c0 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs @@ -25,6 +25,10 @@ public void Configure(ITypeGenBuilder b) { q.OutputDir = "generated"; q.SingleFileName = "api.gen.ts"; + // Opt-in runtime boundary validation. Responses are fetched as + // unknown and parsed through their generated Zod schema before + // TanStack Query exposes them to components. + q.PayloadValidation = QueryPayloadValidation.Responses; // 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"; @@ -45,7 +49,19 @@ public void Configure(ITypeGenBuilder b) { z.OutputDir = "generated"; z.EmitInferredTypes = true; + // Zod 4.6 additions: compile optimized parsers, prove the schemas + // match the generated TS interfaces, and emit cheap `isX(value)` + // guards backed by the short-circuiting validate API. + z.Compilation = ZodCompilationMode.Compile; + z.ConformToTypeScriptTypes = true; + z.EmitValidationGuards = true; }); + + // Zod 4.6 supports a custom NanoID length. The fluent form is useful + // when the model lives in another project and cannot be annotated. + b.ForType() + .Property(x => x.PublicToken) + .ZodNanoId(16); b.ForType() .WithGeneratedTypes(TypeTarget.TypeScript) 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 ab96238..ff2f7b9 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 @@ -221,6 +221,12 @@ public interface IPropertyBuilder /// Equivalent to [OpenApiProperty(Format = format)]. IPropertyBuilder OpenApiFormat(string format); + /// Use a built-in Zod string-format validator for this property. + IPropertyBuilder ZodFormat(ZodStringFormat format); + + /// Validate a NanoID with an exact custom length. + IPropertyBuilder ZodNanoId(int length); + /// Equivalent to [OpenApiProperty(Description = description)]. IPropertyBuilder OpenApiDescription(string description); 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 a6d4b33..1694f8e 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 @@ -199,6 +199,33 @@ public enum ZodFileLayout SingleFile, } +/// Controls whether generated schemas use Zod's explicit AOT compiler. +public enum ZodCompilationMode +{ + /// Emit ordinary schemas. This preserves the pre-4.6 behaviour. + None, + + /// Wrap schemas in z.compile(...) for Zod's optimized parser. + Compile, +} + +/// Built-in Zod string checks available to per-property configuration. +public enum ZodStringFormat +{ + Email, + Url, + Uuid, + Date, + DateTime, + Hostname, + Ulid, + NanoId, + Base64, + Base64Url, + CreditCard, + Iban, +} + /// /// Zod emitter settings. Emits TypeScript source files importing zod — /// the consuming project must have a zod dependency installed. Independent @@ -239,6 +266,22 @@ public sealed class ZodSettings /// public bool EmitInferredTypes { get; set; } = true; + /// + /// When enabled, imports the generated TypeScript model and wraps each schema + /// with z.toZod<T>()(...). Zod then reports schema/model drift during + /// TypeScript compilation. The inferred alias is omitted to avoid a duplicate name. + /// + public bool ConformToTypeScriptTypes { get; set; } = false; + + /// Opt in to Zod 4.5+'s explicit schema compiler. Default is . + public ZodCompilationMode Compilation { get; set; } = ZodCompilationMode.None; + + /// + /// Emit an is{Name}(value) type guard backed by Zod 4.5+'s short-circuiting + /// validate API. Default false. + /// + public bool EmitValidationGuards { get; set; } = false; + /// Default — JS/TS convention. public NameStyle PropertyNameStyle { get; set; } = NameStyle.CamelCase; @@ -258,6 +301,19 @@ public enum QueryFileLayout SplitByTag, } +/// Controls runtime Zod validation in generated TanStack Query clients. +public enum QueryPayloadValidation +{ + /// Trust request and response payloads, preserving existing behaviour. + None, + + /// Parse successful API responses through their generated Zod schemas. + Responses, + + /// Parse request bodies and successful responses through generated Zod schemas. + RequestsAndResponses, +} + /// /// TanStack Query React emitter settings. Emits TypeScript source files importing /// @tanstack/react-query. Use with so @@ -298,6 +354,15 @@ public sealed class TanStackQuerySettings /// public string? ModelsImportPath { get; set; } + /// + /// Optional import base for Zod schemas. When unset, relative imports are + /// computed from to the configured Zod output. + /// + public string? SchemasImportPath { get; set; } + + /// Opt-in runtime request/response parsing using generated Zod schemas. + public QueryPayloadValidation PayloadValidation { get; set; } = QueryPayloadValidation.None; + /// Emit queryOptions helpers for GET endpoints. Default true. public bool EmitQueryOptions { get; set; } = true; diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs new file mode 100644 index 0000000..59130e3 --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs @@ -0,0 +1,18 @@ +using System; + +namespace ZibStack.NET.TypeGen; + +/// +/// Overrides the Zod string validator emitted for a property. This is useful for +/// JavaScript-specific wire formats that do not have a DataAnnotations equivalent. +/// +[AttributeUsage(AttributeTargets.Property, Inherited = true)] +public sealed class ZodFormatAttribute : Attribute +{ + public ZodStringFormat Format { get; } + + /// Expected NanoID length. Used only with . + public int Length { get; set; } + + public ZodFormatAttribute(ZodStringFormat format) => Format = format; +} 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 index 4ef472f..4a824c4 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs @@ -58,7 +58,7 @@ private static string EmitFile( IReadOnlyDictionary nameLookup) { var query = settings.TanStackQuery; - var ops = BuildOperations(endpoints, settings, nameLookup); + var ops = BuildOperations(endpoints, settings, model, nameLookup); var sb = new StringBuilder(); if (query.EmitGeneratedBanner) @@ -106,10 +106,12 @@ private static string EmitFile( private static List BuildOperations( IReadOnlyList endpoints, GlobalSettings settings, + SchemaModel model, IReadOnlyDictionary nameLookup) { var usedNames = new HashSet(System.StringComparer.Ordinal); var operations = new List(); + var zodNameLookup = BuildZodNameLookup(model); foreach (var ep in endpoints.OrderBy(e => e.Tag ?? "", System.StringComparer.Ordinal) .ThenBy(e => e.Pattern, System.StringComparer.Ordinal) @@ -118,8 +120,9 @@ private static List BuildOperations( var tag = TagName(ep.Tag); var operationName = MakeUnique(ToCamelIdentifier(ep.OperationId), usedNames); var inputTypeName = ToPascalIdentifier(operationName) + "Input"; - var inputMembers = BuildInputMembers(ep, settings, nameLookup); + var inputMembers = BuildInputMembers(ep, settings, nameLookup, zodNameLookup); var response = ResolveResponseType(ep, nameLookup); + var responseSchema = ResolveResponseSchema(ep, zodNameLookup, settings.Zod.SchemaConstSuffix); operations.Add(new OperationModel { @@ -131,6 +134,8 @@ private static List BuildOperations( ResponseType = response.TypeExpression, TypeImports = response.TypeImports, PaginatedAliases = response.PaginatedAliases, + ResponseSchemaExpression = responseSchema.Expression, + SchemaImports = responseSchema.Imports, HasInput = inputMembers.Count > 0, HasRequiredInput = inputMembers.Any(m => m.Required), IsQuery = string.Equals(ep.Verb, "get", System.StringComparison.OrdinalIgnoreCase), @@ -143,7 +148,8 @@ private static List BuildOperations( private static List BuildInputMembers( EndpointInfo ep, GlobalSettings settings, - IReadOnlyDictionary nameLookup) + IReadOnlyDictionary nameLookup, + IReadOnlyDictionary zodNameLookup) { var members = new List(); var seen = new HashSet(System.StringComparer.Ordinal); @@ -153,6 +159,7 @@ private static List BuildInputMembers( if (p.Location == ParamLocation.Body) continue; if (!seen.Add(p.Name)) continue; var type = ResolveType(p.CSharpType, nameLookup); + var schema = ResolveZodSchema(p.CSharpType, zodNameLookup, settings.Zod.SchemaConstSuffix); members.Add(new InputMember { WireName = p.Name, @@ -162,6 +169,8 @@ private static List BuildInputMembers( Location = p.Location, TypeImports = type.TypeImports, PaginatedAliases = type.PaginatedAliases, + SchemaExpression = schema.Expression, + SchemaImports = schema.Imports, }); } @@ -170,6 +179,7 @@ private static List BuildInputMembers( if (ep.RequestBodyCSharpType is not null) { var body = ResolveType(ep.RequestBodyCSharpType, nameLookup); + var schema = ResolveZodSchema(ep.RequestBodyCSharpType, zodNameLookup, settings.Zod.SchemaConstSuffix); members.Add(new InputMember { WireName = "body", @@ -179,11 +189,14 @@ private static List BuildInputMembers( Location = ParamLocation.Body, TypeImports = body.TypeImports, PaginatedAliases = body.PaginatedAliases, + SchemaExpression = schema.Expression, + SchemaImports = schema.Imports, }); } else if (ep.RequestBodyArrayItemCSharpType is not null) { var body = ResolveType(ep.RequestBodyArrayItemCSharpType, nameLookup); + var schema = ResolveZodSchema(ep.RequestBodyArrayItemCSharpType, zodNameLookup, settings.Zod.SchemaConstSuffix); members.Add(new InputMember { WireName = "body", @@ -193,6 +206,8 @@ private static List BuildInputMembers( Location = ParamLocation.Body, TypeImports = body.TypeImports, PaginatedAliases = body.PaginatedAliases, + SchemaExpression = schema.Expression is null ? null : $"z.array({schema.Expression})", + SchemaImports = schema.Imports, }); } @@ -257,6 +272,98 @@ private static TypeResolution ResolveResponseType(EndpointInfo ep, IReadOnlyDict return new TypeResolution("void"); } + private static SchemaResolution ResolveResponseSchema( + EndpointInfo ep, + IReadOnlyDictionary nameLookup, + string suffix) + { + if (ep.ResponseCSharpType is not null) + return ResolveZodSchema(ep.ResponseCSharpType, nameLookup, suffix); + if (ep.ResponseArrayItemCSharpType is not null) + { + var item = ResolveZodSchema(ep.ResponseArrayItemCSharpType, nameLookup, suffix); + if (item.Expression is not null) item.Expression = $"z.array({item.Expression})"; + return item; + } + return new SchemaResolution(); + } + + private static SchemaResolution ResolveZodSchema( + string cSharpType, + IReadOnlyDictionary nameLookup, + string suffix) + { + var nullable = cSharpType.Trim().EndsWith("?", System.StringComparison.Ordinal); + var t = cSharpType.Trim().TrimEnd('?'); + var result = new SchemaResolution(); + + var patch = ExtractGeneric(t, "PatchField"); + if (patch is not null) return ResolveZodSchema(patch, nameLookup, suffix); + var nullableInner = ExtractGeneric(t, "Nullable", "System.Nullable"); + if (nullableInner is not null) + { + var inner = ResolveZodSchema(nullableInner, nameLookup, suffix); + if (inner.Expression is not null) inner.Expression += ".nullable()"; + return inner; + } + var paged = ExtractGeneric(t, "PaginatedResponse"); + if (paged is not null) + { + var inner = ResolveZodSchema(paged, nameLookup, suffix); + if (inner.Expression is not null) + inner.Expression = $"z.object({{ items: z.array({inner.Expression}), totalCount: z.number().int(), page: z.number().int(), pageSize: z.number().int(), totalPages: z.number().int(), hasNextPage: z.boolean(), hasPreviousPage: z.boolean() }})"; + return inner; + } + if (nameLookup.TryGetValue(t, out var mapped)) + { + result.Expression = mapped + suffix; + result.Imports.Add(mapped); + } + else if (t.EndsWith("[]", System.StringComparison.Ordinal)) + { + result = ResolveZodSchema(t.Substring(0, t.Length - 2), nameLookup, suffix); + if (result.Expression is not null) result.Expression = $"z.array({result.Expression})"; + } + else + { + var list = ExtractGeneric(t, "List", "IList", "ICollection", "IEnumerable", "IReadOnlyList", "IReadOnlyCollection", "HashSet", "ISet", "IReadOnlySet"); + if (list is not null) + { + result = ResolveZodSchema(list, nameLookup, suffix); + if (result.Expression is not null) result.Expression = $"z.array({result.Expression})"; + } + else + { + result.Expression = t switch + { + "string" => "z.string()", + "bool" or "System.Boolean" => "z.boolean()", + "byte" or "sbyte" or "short" or "ushort" or "int" or "uint" or "long" or "ulong" or "System.Int32" or "System.Int64" => "z.number().int()", + "float" or "double" or "System.Single" or "System.Double" => "z.number()", + "decimal" or "System.Decimal" => "z.string()", + "System.Guid" or "Guid" => "z.uuid()", + "System.DateTime" or "DateTime" or "System.DateTimeOffset" or "DateTimeOffset" => "z.iso.datetime()", + _ => null, + }; + } + } + + if (nullable && result.Expression is not null) result.Expression += ".nullable()"; + return result; + } + + private static Dictionary BuildZodNameLookup(SchemaModel model) + { + var lookup = new Dictionary(System.StringComparer.Ordinal); + foreach (var c in model.Classes) + if (!c.TsIgnore) + lookup[c.CSharpFullName] = c.EmittedName; + foreach (var e in model.Enums) + if (!e.TsIgnore) + lookup[e.CSharpFullName] = e.EmittedName; + return lookup; + } + private static TypeResolution ResolveType(string cSharpType, IReadOnlyDictionary nameLookup) { var t = cSharpType.Trim().TrimEnd('?'); @@ -357,6 +464,17 @@ private static void EmitImports(StringBuilder sb, IReadOnlyList if (!string.IsNullOrEmpty(query.ApiClientImportPath)) sb.AppendLine($"import {{ {query.ApiClientName} }} from '{query.ApiClientImportPath}';"); + if (query.PayloadValidation != QueryPayloadValidation.None) + { + sb.AppendLine("import { z } from 'zod';"); + foreach (var kvp in CollectSchemaImports(ops, queryOutputDir, settings, model).OrderBy(k => k.Key, System.StringComparer.Ordinal)) + { + var names = string.Join(", ", kvp.Value.OrderBy(n => n, System.StringComparer.Ordinal) + .Select(n => n + settings.Zod.SchemaConstSuffix)); + sb.AppendLine($"import {{ {names} }} from '{kvp.Key}';"); + } + } + var modelImports = CollectModelImports(ops, queryOutputDir, settings, model); foreach (var kvp in modelImports.OrderBy(k => k.Key, System.StringComparer.Ordinal)) { @@ -364,10 +482,58 @@ private static void EmitImports(StringBuilder sb, IReadOnlyList sb.AppendLine($"import type {{ {names} }} from '{kvp.Key}';"); } - if (tanstackImports.Count > 0 || tanstackTypeImports.Count > 0 || !string.IsNullOrEmpty(query.ApiClientImportPath) || modelImports.Count > 0 || query.EmitGeneratedBanner) + if (tanstackImports.Count > 0 || tanstackTypeImports.Count > 0 || !string.IsNullOrEmpty(query.ApiClientImportPath) || modelImports.Count > 0 || query.PayloadValidation != QueryPayloadValidation.None || query.EmitGeneratedBanner) sb.AppendLine(); } + private static Dictionary> CollectSchemaImports( + IReadOnlyList ops, + string queryOutputDir, + GlobalSettings settings, + SchemaModel model) + { + var names = new HashSet(System.StringComparer.Ordinal); + foreach (var op in ops) + { + names.UnionWith(op.SchemaImports); + if (settings.TanStackQuery.PayloadValidation == QueryPayloadValidation.RequestsAndResponses) + foreach (var member in op.InputMembers.Where(m => m.Location == ParamLocation.Body)) + names.UnionWith(member.SchemaImports); + } + var result = new Dictionary>(System.StringComparer.Ordinal); + if (names.Count == 0) return result; + if (!string.IsNullOrEmpty(settings.TanStackQuery.SchemasImportPath)) + { + result[settings.TanStackQuery.SchemasImportPath!] = names; + return result; + } + var zs = settings.Zod; + var schemaDir = !string.IsNullOrEmpty(zs.OutputDir) + ? zs.OutputDir! + : model.Classes.FirstOrDefault()?.OutputDir ?? "."; + if (zs.FileLayout == ZodFileLayout.SingleFile) + { + var file = StripTsExtension(string.IsNullOrWhiteSpace(zs.SingleFileName) ? "schemas.ts" : zs.SingleFileName); + result[SchemaParser.ComputeRelativeImport(queryOutputDir, schemaDir, file)] = names; + return result; + } + foreach (var name in names) + { + var matchingClass = model.Classes.FirstOrDefault(c => c.EmittedName == name); + var matchingEnum = model.Enums.FirstOrDefault(e => e.EmittedName == name); + var perTypeDir = matchingClass is not null + ? matchingClass.HasExplicitOutputDir ? matchingClass.OutputDir : !string.IsNullOrEmpty(zs.OutputDir) ? zs.OutputDir! : matchingClass.OutputDir + : matchingEnum is not null + ? matchingEnum.HasExplicitOutputDir ? matchingEnum.OutputDir : !string.IsNullOrEmpty(zs.OutputDir) ? zs.OutputDir! : matchingEnum.OutputDir + : schemaDir; + var path = SchemaParser.ComputeRelativeImport(queryOutputDir, perTypeDir ?? ".", name + zs.FileSuffix); + if (!result.TryGetValue(path, out var imported)) + result[path] = imported = new HashSet(System.StringComparer.Ordinal); + imported.Add(name); + } + return result; + } + private static Dictionary> CollectModelImports( IReadOnlyList ops, string queryOutputDir, @@ -548,15 +714,26 @@ private static void EmitFetchFunction(StringBuilder sb, OperationModel op, TanSt sb.Append("signal?: AbortSignal"); sb.AppendLine($"): Promise<{op.ResponseType}> {{"); - sb.AppendLine($" return {query.ApiClientName}<{op.ResponseType}>({BuildPathExpression(op)}, {{"); + var validateResponse = query.PayloadValidation != QueryPayloadValidation.None + && op.ResponseSchemaExpression is not null; + sb.AppendLine($" return {query.ApiClientName}<{(validateResponse ? "unknown" : 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)},"); + { + var bodyExpr = query.PayloadValidation == QueryPayloadValidation.RequestsAndResponses + && body.SchemaExpression is not null + ? $"{body.SchemaExpression}.parse({InputAccess(body)})" + : InputAccess(body); + sb.AppendLine($" body: {bodyExpr},"); + } sb.AppendLine(" signal,"); - sb.AppendLine(" });"); + sb.Append(" })"); + if (validateResponse) + sb.Append($".then(value => {op.ResponseSchemaExpression}.parse(value))"); + sb.AppendLine(";"); sb.AppendLine("}"); sb.AppendLine(); } @@ -1014,6 +1191,8 @@ private sealed class OperationModel 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 string? ResponseSchemaExpression { get; set; } + public HashSet SchemaImports { get; set; } = new(System.StringComparer.Ordinal); public bool HasInput { get; set; } public bool HasRequiredInput { get; set; } public bool IsQuery { get; set; } @@ -1028,6 +1207,14 @@ private sealed class InputMember public ParamLocation Location { get; set; } public HashSet TypeImports { get; set; } = new(System.StringComparer.Ordinal); public Dictionary PaginatedAliases { get; set; } = new(System.StringComparer.Ordinal); + public string? SchemaExpression { get; set; } + public HashSet SchemaImports { get; set; } = new(System.StringComparer.Ordinal); + } + + private sealed class SchemaResolution + { + public string? Expression { get; set; } + public HashSet Imports { get; set; } = new(System.StringComparer.Ordinal); } private sealed class TypeResolution diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs index cdbb5d9..9a7247b 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs @@ -53,6 +53,9 @@ public static IReadOnlyList Emit(SchemaModel model, GlobalSettings var sb = new StringBuilder(); EmitBanner(sb, zs); sb.AppendLine("import { z } from 'zod';"); + EmitConformanceImports(sb, model.Classes.Where(c => !SkipClass(c) && (c.Targets & TypeTarget.TypeScript) != 0).Select(c => c.EmittedName) + .Concat(model.Enums.Where(e => !SkipEnum(e) && (e.Targets & TypeTarget.TypeScript) != 0).Select(e => e.EmittedName)), + ResolveOutputDir(zs.OutputDir, model), settings.TypeScript, zs, model); sb.AppendLine(); // In SingleFile mode order matters — a schema has to be declared @@ -79,12 +82,14 @@ public static IReadOnlyList Emit(SchemaModel model, GlobalSettings var sb = new StringBuilder(); EmitBanner(sb, zs); sb.AppendLine("import { z } from 'zod';"); + var outputDir = (cls.HasExplicitOutputDir ? cls.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : cls.OutputDir) ?? "."; + EmitConformanceImports(sb, (cls.Targets & TypeTarget.TypeScript) != 0 ? new[] { cls.EmittedName } : System.Array.Empty(), outputDir, settings.TypeScript, zs, model); EmitImports(sb, CollectClassReferences(cls, nameByCSharp), cls.EmittedName, zs); sb.AppendLine(); EmitClass(sb, cls, zs, nameByCSharp, model); files.Add(new EmittedFile( Target: TypeTarget.Zod, - OutputDir: cls.HasExplicitOutputDir ? cls.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : cls.OutputDir, + OutputDir: outputDir, FileName: cls.EmittedName + zs.FileSuffix + ".ts", Content: sb.ToString())); } @@ -94,11 +99,13 @@ public static IReadOnlyList Emit(SchemaModel model, GlobalSettings var sb = new StringBuilder(); EmitBanner(sb, zs); sb.AppendLine("import { z } from 'zod';"); + var outputDir = (en.HasExplicitOutputDir ? en.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : en.OutputDir) ?? "."; + EmitConformanceImports(sb, (en.Targets & TypeTarget.TypeScript) != 0 ? new[] { en.EmittedName } : System.Array.Empty(), outputDir, settings.TypeScript, zs, model); sb.AppendLine(); EmitEnum(sb, en, zs); files.Add(new EmittedFile( Target: TypeTarget.Zod, - OutputDir: en.HasExplicitOutputDir ? en.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : en.OutputDir, + OutputDir: outputDir, FileName: en.EmittedName + zs.FileSuffix + ".ts", Content: sb.ToString())); } @@ -130,6 +137,44 @@ private static void EmitImports(StringBuilder sb, IEnumerable refs, stri sb.AppendLine($"import {{ {r}{zs.SchemaConstSuffix} }} from './{r}{zs.FileSuffix}';"); } + private static void EmitConformanceImports( + StringBuilder sb, + IEnumerable typeNames, + string zodOutputDir, + TypeScriptSettings ts, + ZodSettings zs, + SchemaModel model) + { + if (!model.Classes.Any() && !model.Enums.Any()) return; + if (!zs.ConformToTypeScriptTypes) return; + + var names = typeNames.Distinct().OrderBy(n => n, System.StringComparer.Ordinal).ToList(); + if (names.Count == 0) return; + var tsOutputDir = !string.IsNullOrEmpty(ts.OutputDir) + ? ts.OutputDir! + : model.Classes.FirstOrDefault()?.OutputDir ?? model.Enums.FirstOrDefault()?.OutputDir ?? "."; + if (ts.FileLayout == TypeScriptFileLayout.SingleFile) + { + var file = StripTsExtension(string.IsNullOrWhiteSpace(ts.SingleFileName) ? "models.ts" : ts.SingleFileName); + var path = SchemaParser.ComputeRelativeImport(zodOutputDir, tsOutputDir, file); + sb.AppendLine($"import type {{ {string.Join(", ", names)} }} from '{path}';"); + return; + } + + foreach (var name in names) + { + var matchingClass = model.Classes.FirstOrDefault(c => c.EmittedName == name || c.TypeScriptEmittedName == name); + var matchingEnum = model.Enums.FirstOrDefault(e => e.EmittedName == name || e.TypeScriptEmittedName == name); + var perTypeDir = matchingClass is not null + ? matchingClass.HasExplicitOutputDir ? matchingClass.OutputDir : !string.IsNullOrEmpty(ts.OutputDir) ? ts.OutputDir! : matchingClass.OutputDir + : matchingEnum is not null + ? matchingEnum.HasExplicitOutputDir ? matchingEnum.OutputDir : !string.IsNullOrEmpty(ts.OutputDir) ? ts.OutputDir! : matchingEnum.OutputDir + : tsOutputDir; + var path = SchemaParser.ComputeRelativeImport(zodOutputDir, perTypeDir ?? ".", name); + sb.AppendLine($"import type {{ {name} }} from '{path}';"); + } + } + private static void EmitClass( StringBuilder sb, SchemaClass cls, @@ -140,6 +185,16 @@ private static void EmitClass( if (SkipClass(cls)) return; var schemaConst = cls.EmittedName + zs.SchemaConstSuffix; + var conform = zs.ConformToTypeScriptTypes && (cls.Targets & TypeTarget.TypeScript) != 0; + var lazySchemaNames = CollectLazySchemaNames(cls, model, nameByCSharp); + var typeAnnotation = lazySchemaNames.Count > 0 + ? conform ? $": z.ZodType<{cls.EmittedName}>" : ": z.ZodType" + : ""; + var wrappers = (zs.Compilation == ZodCompilationMode.Compile ? 1 : 0) + + (conform ? 1 : 0); + var initializerPrefix = (zs.Compilation == ZodCompilationMode.Compile ? "z.compile(" : "") + + (conform ? $"z.toZod<{cls.EmittedName}>()(" : ""); + var initializerSuffix = new string(')', wrappers); // Polymorphic base → z.discriminatedUnion("kind", [VariantASchema, …]). // Zod's discriminatedUnion gives exhaustive narrowing from the literal @@ -153,14 +208,14 @@ private static void EmitClass( .ToList(); if (variantSchemas.Count > 0) { - sb.AppendLine($"export const {schemaConst} = z.discriminatedUnion('{cls.PolymorphicDiscriminator}', ["); + sb.AppendLine($"export const {schemaConst}{typeAnnotation} = {initializerPrefix}z.discriminatedUnion('{cls.PolymorphicDiscriminator}', ["); for (int i = 0; i < variantSchemas.Count; i++) { var comma = i < variantSchemas.Count - 1 ? "," : ""; sb.AppendLine($" {variantSchemas[i]}{comma}"); } - sb.AppendLine("]);"); - if (zs.EmitInferredTypes) + sb.AppendLine($"]){initializerSuffix};"); + if (zs.EmitInferredTypes && !conform) { sb.AppendLine($"export type {cls.EmittedName} = z.infer;"); } @@ -197,7 +252,7 @@ private static void EmitClass( ifaceSchemas.Add(ifaceName + zs.SchemaConstSuffix); } - sb.Append($"export const {schemaConst} = "); + sb.Append($"export const {schemaConst}{typeAnnotation} = {initializerPrefix}"); if (baseSchema is not null) { // Base first, then interface composition, then own shape via .extend. @@ -248,7 +303,7 @@ private static void EmitClass( // Explicit TsName override bypasses the style transform — user said what they // wanted verbatim. Otherwise run the source name through the configured style. var name = prop.TsNameOverride ?? ApplyNameStyle(prop.SourceName, zs.PropertyNameStyle); - var expr = BuildPropertyZodExpr(prop, nameByCSharp, cls.TypeParameters, zs.SchemaConstSuffix); + var expr = BuildPropertyZodExpr(prop, nameByCSharp, cls.TypeParameters, zs.SchemaConstSuffix, lazySchemaNames, conform); sb.AppendLine($" {name}: {expr},"); } @@ -263,12 +318,14 @@ private static void EmitClass( : "z.unknown()"; catchall = $".catchall({catchallInner})"; } - sb.AppendLine($"}}){catchall};"); + sb.AppendLine($"}}){catchall}{initializerSuffix};"); - if (zs.EmitInferredTypes) + if (zs.EmitInferredTypes && !conform) { sb.AppendLine($"export type {cls.EmittedName} = z.infer;"); } + if (zs.EmitValidationGuards) + sb.AppendLine($"export const {ValidationGuardName(cls.EmittedName)} = (value: unknown): value is z.output => {schemaConst}.validate(value);"); sb.AppendLine(); } @@ -276,12 +333,17 @@ private static void EmitEnum(StringBuilder sb, SchemaEnum en, ZodSettings zs) { if (SkipEnum(en)) return; var schemaConst = en.EmittedName + zs.SchemaConstSuffix; + var conform = zs.ConformToTypeScriptTypes && (en.Targets & TypeTarget.TypeScript) != 0; + var prefix = (zs.Compilation == ZodCompilationMode.Compile ? "z.compile(" : "") + + (conform ? $"z.toZod<{en.EmittedName}>()(" : ""); + var suffix = new string(')', (zs.Compilation == ZodCompilationMode.Compile ? 1 : 0) + + (conform ? 1 : 0)); if (en.IsStringSerialized) { // z.enum(['A','B','C']) — exhaustive string literal union. var members = string.Join(", ", en.Members.Select(m => $"'{m.Name}'")); - sb.AppendLine($"export const {schemaConst} = z.enum([{members}]);"); + sb.AppendLine($"export const {schemaConst} = {prefix}z.enum([{members}]){suffix};"); } else { @@ -290,13 +352,15 @@ private static void EmitEnum(StringBuilder sb, SchemaEnum en, ZodSettings zs) // take Zod target alone). Literal union works without a native enum // import and Zod narrows exhaustively. var literals = string.Join(", ", en.Members.Select(m => $"z.literal({m.Value})")); - sb.AppendLine($"export const {schemaConst} = z.union([{literals}]);"); + sb.AppendLine($"export const {schemaConst} = {prefix}z.union([{literals}]){suffix};"); } - if (zs.EmitInferredTypes) + if (zs.EmitInferredTypes && !conform) { sb.AppendLine($"export type {en.EmittedName} = z.infer;"); } + if (zs.EmitValidationGuards) + sb.AppendLine($"export const {ValidationGuardName(en.EmittedName)} = (value: unknown): value is z.output => {schemaConst}.validate(value);"); sb.AppendLine(); } @@ -306,25 +370,28 @@ private static string BuildPropertyZodExpr( SchemaProperty prop, IReadOnlyDictionary nameByCSharp, IReadOnlyList typeParameters, - string schemaConstSuffix) + string schemaConstSuffix, + HashSet? lazySchemaNames = null, + bool conformToTypeScript = false) { var targetFqn = prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName; - var core = MapCSharpToZod(targetFqn, prop.IsNullable, nameByCSharp, schemaConstSuffix, typeParameters); + var core = MapCSharpToZod(targetFqn, prop.IsNullable, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames); // Apply string-shaped constraints (length, regex, email/url/uuid formats). // Numeric constraints use gte/lte. core = ApplyStringConstraints(core, prop); core = ApplyNumericConstraints(core, prop); - // Nullable + optional → .nullish() is the Zod shortcut for "null or - // undefined or absent". Read-only (computed) props stay optional only — + // Nullable + optional → .nullish() by default. In TypeScript conformance + // mode, mirror the TS emitter's optional-only contract so z.toZod() + // can prove the schemas match. Read-only (computed) props stay optional — // server always produces a value, client doesn't supply one. Explicit // `[Required]` / `[ZRequired]` / C# `required` override NRT: field must // be provided, so no nullish/optional even if the C# type is `string?`. var effectivelyNullable = prop.IsNullable && !prop.IsExplicitlyRequired; if (effectivelyNullable) - core += ".nullish()"; - else if (prop.IsReadOnly) + core += conformToTypeScript ? ".optional()" : ".nullish()"; + else if (prop.IsReadOnly || prop.IsPatchField || ExtractGeneric(targetFqn.TrimEnd('?'), "PatchField") is not null) core += ".optional()"; return core; @@ -342,7 +409,7 @@ private static string ApplyStringConstraints(string expr, SchemaProperty prop) // SchemaParser normalises [EmailAddress]/[ZEmail] → "email" etc. Zod 4 // moved these to top-level factories (z.email(), z.uuid(), z.iso.datetime()) // and deprecated the chained z.string().email() forms. - expr = ApplyStringFormat(expr, prop.OpenApiFormat); + expr = ApplyStringFormat(expr, prop.ZodFormat, prop.ZodFormatLength, prop.OpenApiFormat); if (prop.MinLength is int min) expr += $".min({min})"; if (prop.MaxLength is int max) expr += $".max({max})"; @@ -362,16 +429,32 @@ private static string ApplyStringConstraints(string expr, SchemaProperty prop) /// The factory replaces the leading z.string() so any subsequent /// .min()/.max()/.regex() chain onto it. /// - private static string ApplyStringFormat(string expr, string? format) + private static string ApplyStringFormat(string expr, ZodStringFormat? zodFormat, int? formatLength, string? format) { - var factory = format switch - { - "email" => "z.email()", - "uri" or "url" => "z.url()", - "uuid" => "z.uuid()", - "date-time" => "z.iso.datetime()", - "date" => "z.iso.date()", - _ => null, + var factory = zodFormat switch + { + ZodStringFormat.Email => "z.email()", + ZodStringFormat.Url => "z.url()", + ZodStringFormat.Uuid => "z.uuid()", + ZodStringFormat.Date => "z.iso.date()", + ZodStringFormat.DateTime => "z.iso.datetime()", + ZodStringFormat.Hostname => "z.hostname()", + ZodStringFormat.Ulid => "z.ulid()", + ZodStringFormat.NanoId when formatLength is > 0 => $"z.nanoid({{ length: {formatLength.Value} }})", + ZodStringFormat.NanoId => "z.nanoid()", + ZodStringFormat.Base64 => "z.base64()", + ZodStringFormat.Base64Url => "z.base64url()", + ZodStringFormat.CreditCard => "z.creditCard()", + ZodStringFormat.Iban => "z.iban()", + _ => format switch + { + "email" => "z.email()", + "uri" or "url" => "z.url()", + "uuid" => "z.uuid()", + "date-time" => "z.iso.datetime()", + "date" => "z.iso.date()", + _ => null, + }, }; if (factory is null) return expr; // no recognised format @@ -415,7 +498,8 @@ private static string MapCSharpToZod( bool isNullable, IReadOnlyDictionary nameByCSharp, string schemaConstSuffix, - IReadOnlyList? typeParameters = null) + IReadOnlyList? typeParameters = null, + HashSet? lazySchemaNames = null) { var t = cSharpType.TrimEnd('?'); @@ -428,24 +512,26 @@ private static string MapCSharpToZod( // Unwrap Dto's PatchField tri-state — Zod consumers validate plain T. var patchInner = ExtractGeneric(t, "PatchField"); - if (patchInner != null) return MapCSharpToZod(patchInner, isNullable, nameByCSharp, schemaConstSuffix, typeParameters); + if (patchInner != null) return MapCSharpToZod(patchInner, isNullable, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames); // User DTO reference → direct reference to the sibling schema const. // FilePerClass mode: import resolution runs before the z.object body // is evaluated, so direct refs are safe. SingleFile mode: the topo sort // guarantees declaration order. if (nameByCSharp.TryGetValue(t, out var mapped)) - return mapped + schemaConstSuffix; + return lazySchemaNames is not null && lazySchemaNames.Contains(mapped) + ? $"z.lazy(() => {mapped}{schemaConstSuffix})" + : mapped + schemaConstSuffix; if (t.EndsWith("[]")) - return $"z.array({MapCSharpToZod(t.Substring(0, t.Length - 2), false, nameByCSharp, schemaConstSuffix, typeParameters)})"; + return $"z.array({MapCSharpToZod(t.Substring(0, t.Length - 2), false, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames)})"; var listMatch = ExtractGeneric(t, "List", "IList", "ICollection", "IEnumerable", "IReadOnlyList", "IReadOnlyCollection"); if (listMatch != null) - return $"z.array({MapCSharpToZod(listMatch, false, nameByCSharp, schemaConstSuffix, typeParameters)})"; + return $"z.array({MapCSharpToZod(listMatch, false, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames)})"; var dictMatch = ExtractTwoGenericArgs(t, "Dictionary", "IDictionary", "IReadOnlyDictionary"); if (dictMatch != null) - return $"z.record({MapCSharpToZod(dictMatch.Value.K, false, nameByCSharp, schemaConstSuffix, typeParameters)}, {MapCSharpToZod(dictMatch.Value.V, false, nameByCSharp, schemaConstSuffix, typeParameters)})"; + return $"z.record({MapCSharpToZod(dictMatch.Value.K, false, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames)}, {MapCSharpToZod(dictMatch.Value.V, false, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames)})"; // Zod 4 top-level format factories — the chained z.string().uuid() forms // are deprecated in Zod 4. See also ApplyStringFormat for attr-driven formats. @@ -507,6 +593,65 @@ private static void CollectRefs(string cSharpType, IReadOnlyDictionary CollectLazySchemaNames( + SchemaClass owner, + SchemaModel model, + IReadOnlyDictionary nameByCSharp) + { + var classes = model.Classes.ToDictionary(c => c.CSharpFullName, c => c); + var result = new HashSet(System.StringComparer.Ordinal); + foreach (var prop in owner.Properties) + { + foreach (var referenced in EnumerateReferencedTypes(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, classes)) + { + if (CanReach(referenced, owner.CSharpFullName, classes, new HashSet(System.StringComparer.Ordinal)) + && nameByCSharp.TryGetValue(referenced, out var emittedName)) + result.Add(emittedName); + } + } + return result; + } + + private static bool CanReach( + string current, + string target, + IReadOnlyDictionary classes, + HashSet visited) + { + if (current == target) return true; + if (!visited.Add(current) || !classes.TryGetValue(current, out var cls)) return false; + foreach (var prop in cls.Properties) + foreach (var next in EnumerateReferencedTypes(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, classes)) + if (CanReach(next, target, classes, visited)) return true; + return false; + } + + private static IEnumerable EnumerateReferencedTypes( + string cSharpType, + IReadOnlyDictionary classes) + { + var t = cSharpType.TrimEnd('?'); + if (classes.ContainsKey(t)) { yield return t; yield break; } + if (t.EndsWith("[]", System.StringComparison.Ordinal)) + { + foreach (var item in EnumerateReferencedTypes(t.Substring(0, t.Length - 2), classes)) yield return item; + yield break; + } + var single = ExtractGeneric(t, "PatchField", "Nullable", "List", "IList", "ICollection", "IEnumerable", + "IReadOnlyList", "IReadOnlyCollection", "HashSet", "ISet", "IReadOnlySet"); + if (single is not null) + { + foreach (var item in EnumerateReferencedTypes(single, classes)) yield return item; + yield break; + } + var pair = ExtractTwoGenericArgs(t, "Dictionary", "IDictionary", "IReadOnlyDictionary"); + if (pair is not null) + { + foreach (var item in EnumerateReferencedTypes(pair.Value.K, classes)) yield return item; + foreach (var item in EnumerateReferencedTypes(pair.Value.V, classes)) yield return item; + } + } + /// /// Returns classes ordered so every schema is declared before any that /// references it. Single-file mode can't forward-reference const bindings. @@ -571,6 +716,11 @@ void Visit(SchemaClass c) return null; } + private static string StripTsExtension(string fileName) => + fileName.EndsWith(".ts", System.StringComparison.OrdinalIgnoreCase) + ? fileName.Substring(0, fileName.Length - 3) + : fileName; + private static (string K, string V)? ExtractTwoGenericArgs(string typeName, params string[] names) { var inner = ExtractGeneric(typeName, names); @@ -611,6 +761,11 @@ private static string ApplyNameStyle(string name, NameStyle style) }; } + private static string ValidationGuardName(string emittedName) => + string.IsNullOrEmpty(emittedName) + ? "isValue" + : "is" + char.ToUpperInvariant(emittedName[0]) + emittedName.Substring(1); + private static string ToSeparated(string name, char sep) { var sb = new StringBuilder(); 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 fc0ca4d..46e51e8 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 @@ -73,7 +73,10 @@ internal enum TsEnumStyle { Union, Enum } internal enum PythonFileLayout { FilePerClass, SingleFile } internal enum PythonStyle { Pydantic, Dataclass } internal enum ZodFileLayout { FilePerClass, SingleFile } +internal enum ZodCompilationMode { None, Compile } +internal enum ZodStringFormat { Email, Url, Uuid, Date, DateTime, Hostname, Ulid, NanoId, Base64, Base64Url, CreditCard, Iban } internal enum QueryFileLayout { SingleFile, SplitByTag } +internal enum QueryPayloadValidation { None, Responses, RequestsAndResponses } internal sealed class GraphQLSettings { @@ -91,6 +94,9 @@ internal sealed class ZodSettings public string FileSuffix { get; set; } = ".schema"; public string SchemaConstSuffix { get; set; } = "Schema"; public bool EmitInferredTypes { get; set; } = true; + public bool ConformToTypeScriptTypes { get; set; } + public ZodCompilationMode Compilation { get; set; } + public bool EmitValidationGuards { get; set; } public NameStyle PropertyNameStyle { get; set; } = NameStyle.CamelCase; public bool EmitGeneratedBanner { get; set; } = true; } @@ -104,6 +110,8 @@ internal sealed class TanStackQuerySettings public string? ApiClientImportPath { get; set; } public string ApiClientName { get; set; } = "apiFetch"; public string? ModelsImportPath { get; set; } + public string? SchemasImportPath { get; set; } + public QueryPayloadValidation PayloadValidation { get; set; } public bool EmitQueryOptions { get; set; } = true; public bool EmitMutationOptions { get; set; } = true; public bool EmitHooks { get; set; } = true; @@ -510,6 +518,13 @@ internal sealed class SchemaProperty /// Fluent-only — emit as $ref to a named external schema instead of the inferred shape. public string? OpenApiRefOverride { get; set; } + /// Optional Zod-specific string format factory. + public ZodStringFormat? ZodFormat { get; set; } + public int? ZodFormatLength { get; set; } + + /// True for ZibStack.NET.Dto's tri-state PatchField<T>. + public bool IsPatchField { get; set; } + // ── constraints read from DataAnnotations / ZibStack.Validation attributes ── /// Minimum string length / array item count ([MinLength], [StringLength(_, MinimumLength=_)], [ZMinLength], [ZNotEmpty]). 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 b2171c7..8cb3146 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 @@ -58,6 +58,8 @@ public sealed class PerPropertyOverrides public string? OpenApiRef { get; set; } public string? OpenApiFormat { get; set; } public string? OpenApiDescription { get; set; } + public ZodStringFormat? ZodFormat { get; set; } + public int? ZodFormatLength { get; set; } public bool? OpenApiNullable { get; set; } public bool Ignore { get; set; } public bool TsIgnore { get; set; } @@ -401,6 +403,9 @@ private static void AssignZod(ZodSettings s, string prop, object? val) case "FileSuffix": if (val is string fs) s.FileSuffix = fs; break; case "SchemaConstSuffix": if (val is string scs) s.SchemaConstSuffix = scs; break; case "EmitInferredTypes": if (val is bool eit) s.EmitInferredTypes = eit; break; + case "ConformToTypeScriptTypes": if (val is bool ctt) s.ConformToTypeScriptTypes = ctt; break; + case "Compilation": if (val is int c) s.Compilation = (ZodCompilationMode)c; break; + case "EmitValidationGuards": if (val is bool evg) s.EmitValidationGuards = evg; break; case "PropertyNameStyle": if (val is int pn) s.PropertyNameStyle = (NameStyle)pn; break; case "EmitGeneratedBanner": if (val is bool egb) s.EmitGeneratedBanner = egb; break; } @@ -417,6 +422,8 @@ private static void AssignTanStackQuery(TanStackQuerySettings s, string prop, ob 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 "SchemasImportPath": s.SchemasImportPath = val as string; break; + case "PayloadValidation": if (val is int pv) s.PayloadValidation = (QueryPayloadValidation)pv; 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; @@ -519,6 +526,36 @@ private static void ApplyPropertyLevelCall( return; } + if (name == "ZodFormat") + { + if (inv.ArgumentList.Arguments.Count > 0) + { + var value = ReadLiteralValue(inv.ArgumentList.Arguments[0].Expression, sm); + if (value is int format) o.ZodFormat = (ZodStringFormat)format; + else if (value is NonLiteralMarker) report(Diagnostic.Create( + TypeGenDiagnostics.NonLiteralArgument, + inv.ArgumentList.Arguments[0].GetLocation(), name)); + } + return; + } + + if (name == "ZodNanoId") + { + if (inv.ArgumentList.Arguments.Count > 0) + { + var value = ReadLiteralValue(inv.ArgumentList.Arguments[0].Expression, sm); + if (value is int length && length > 0) + { + o.ZodFormat = ZodStringFormat.NanoId; + o.ZodFormatLength = length; + } + else if (value is NonLiteralMarker) report(Diagnostic.Create( + TypeGenDiagnostics.NonLiteralArgument, + inv.ArgumentList.Arguments[0].GetLocation(), name)); + } + return; + } + string? arg = ReadStringArg(inv, name, sm, report); switch (name) { diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs index 4566d70..44b6c21 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs @@ -22,6 +22,7 @@ internal static class SchemaParser private const string TsIgnoreAttr = "ZibStack.NET.TypeGen.TsIgnoreAttribute"; private const string OpenApiSchemaNameAttr = "ZibStack.NET.TypeGen.OpenApiSchemaNameAttribute"; private const string OpenApiPropertyAttr = "ZibStack.NET.TypeGen.OpenApiPropertyAttribute"; + private const string ZodFormatAttr = "ZibStack.NET.TypeGen.ZodFormatAttribute"; private const string OpenApiIgnoreAttr = "ZibStack.NET.TypeGen.OpenApiIgnoreAttribute"; // String-only — no reference to ZibStack.NET.Dto. The attribute is generated // by Dto's source generator into the user's compilation, so we read it via @@ -901,8 +902,22 @@ private static SchemaProperty ParseProperty(IPropertySymbol prop) // directly. Wire-level semantics: client MUST send this field, even // when the type is NRT-nullable. IsExplicitlyRequired = prop.IsRequired, + IsPatchField = prop.Type is INamedTypeSymbol patchType + && patchType.Name == "PatchField" + && patchType.Arity == 1, }; + var zodFormatAttr = prop.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == ZodFormatAttr); + if (zodFormatAttr is not null + && zodFormatAttr.ConstructorArguments.Length > 0 + && zodFormatAttr.ConstructorArguments[0].Value is int zodFormat) + sp.ZodFormat = (ZodStringFormat)zodFormat; + if (zodFormatAttr is not null) + foreach (var named in zodFormatAttr.NamedArguments) + if (named.Key == "Length" && named.Value.Value is int length && length > 0) + sp.ZodFormatLength = length; + // `[UseType]` cross-target generic override — captures T's FQN now; // actual per-target rendering (TS import, OpenAPI $ref, Python import) // is resolved late via ResolveGenericTypeReferences after the model @@ -989,6 +1004,9 @@ private static void ReadValidationAttributes(IPropertySymbol prop, SchemaPropert case "System.ComponentModel.DataAnnotations.UrlAttribute": sp.OpenApiFormat ??= "uri"; break; + case "System.ComponentModel.DataAnnotations.CreditCardAttribute": + sp.ZodFormat ??= ZodStringFormat.CreditCard; + break; case "System.ComponentModel.DataAnnotations.RequiredAttribute": sp.IsExplicitlyRequired = true; break; @@ -1015,6 +1033,12 @@ private static void ReadValidationAttributes(IPropertySymbol prop, SchemaPropert case "ZibStack.NET.Validation.ZUrlAttribute": sp.OpenApiFormat ??= "uri"; break; + case "ZibStack.NET.Validation.ZCreditCardAttribute": + sp.ZodFormat ??= ZodStringFormat.CreditCard; + break; + case "ZibStack.NET.Validation.ZIbanAttribute": + sp.ZodFormat ??= ZodStringFormat.Iban; + break; case "ZibStack.NET.Validation.ZNotEmptyAttribute": // Approximation — for strings "non-empty" includes whitespace rules // OpenAPI can't express, but minLength: 1 rules out empty strings / arrays. 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 0863b8f..1519a54 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs @@ -540,6 +540,8 @@ private static void ApplyFluentToClass(SchemaClass cls, ConfiguratorParser.Parse prop.OpenApiFormat ??= po.OpenApiFormat; prop.OpenApiDescription ??= po.OpenApiDescription; prop.OpenApiNullableOverride ??= po.OpenApiNullable; + prop.ZodFormat ??= po.ZodFormat; + prop.ZodFormatLength ??= po.ZodFormatLength; if (po.Ignore) { prop.TsIgnore = true; prop.OpenApiIgnore = true; } prop.TsIgnore |= po.TsIgnore; prop.OpenApiIgnore |= po.OpenApiIgnore; 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 b9b525c..2e8f8fa 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 @@ -89,6 +89,8 @@ public void Configure(ITypeGenBuilder b) { q.ApiClientImportPath = "./client"; q.ApiClientName = "request"; q.ModelsImportPath = "../models"; + q.SchemasImportPath = "../schemas"; + q.PayloadValidation = QueryPayloadValidation.RequestsAndResponses; q.EmitHooks = false; q.EmitCacheHelpers = false; }); @@ -105,10 +107,38 @@ public void Configure(ITypeGenBuilder b) { Assert.Equal("./client", parsed.Settings.TanStackQuery.ApiClientImportPath); Assert.Equal("request", parsed.Settings.TanStackQuery.ApiClientName); Assert.Equal("../models", parsed.Settings.TanStackQuery.ModelsImportPath); + Assert.Equal("../schemas", parsed.Settings.TanStackQuery.SchemasImportPath); + Assert.Equal(QueryPayloadValidation.RequestsAndResponses, parsed.Settings.TanStackQuery.PayloadValidation); Assert.False(parsed.Settings.TanStackQuery.EmitHooks); Assert.False(parsed.Settings.TanStackQuery.EmitCacheHelpers); } + [Fact] + public void ZodBlockAndPropertyFormat_SetNewSettings() + { + var parsed = Parse(""" + public class Payment { public string Card { get; set; } = ""; } + public class Cfg : ITypeGenConfigurator { + public void Configure(ITypeGenBuilder b) { + b.Zod(z => { + z.Compilation = ZodCompilationMode.Compile; + z.ConformToTypeScriptTypes = true; + z.EmitValidationGuards = true; + }); + b.ForType().Property(x => x.Card).ZodFormat(ZodStringFormat.CreditCard); + b.ForType().Property(x => x.Card).ZodNanoId(16); + } + } + """, out var diags); + + Assert.Empty(diags); + Assert.Equal(ZodCompilationMode.Compile, parsed!.Settings.Zod.Compilation); + Assert.True(parsed.Settings.Zod.ConformToTypeScriptTypes); + Assert.True(parsed.Settings.Zod.EmitValidationGuards); + Assert.Equal(ZodStringFormat.NanoId, parsed.PerType["Payment"].Properties["Card"].ZodFormat); + Assert.Equal(16, parsed.PerType["Payment"].Properties["Card"].ZodFormatLength); + } + [Fact] public void ForType_CollectsPerTypeOverrides() { @@ -457,6 +487,10 @@ public enum TypeTarget { None = 0, TypeScript = 1, OpenApi = 2, Python = 4, Zod public enum NameStyle { AsIs, CamelCase, SnakeCase, PascalCase } public enum TypeScriptFileLayout { FilePerClass, SingleFile } public enum QueryFileLayout { SingleFile, SplitByTag } + public enum QueryPayloadValidation { None, Responses, RequestsAndResponses } + public enum ZodFileLayout { FilePerClass, SingleFile } + public enum ZodCompilationMode { None, Compile } + public enum ZodStringFormat { Email, Url, Uuid, Date, DateTime, Hostname, Ulid, NanoId, Base64, Base64Url, CreditCard, Iban } public sealed class TypeScriptSettings { public string? OutputDir { get; set; } public string SingleFileName { get; set; } = "models.ts"; @@ -481,16 +515,32 @@ public sealed class TanStackQuerySettings { public string? ApiClientImportPath { get; set; } public string ApiClientName { get; set; } = "apiFetch"; public string? ModelsImportPath { get; set; } + public string? SchemasImportPath { get; set; } + public QueryPayloadValidation PayloadValidation { 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 sealed class ZodSettings { + public string? OutputDir { get; set; } + public ZodFileLayout FileLayout { get; set; } + public string SingleFileName { get; set; } = "schemas.ts"; + public string FileSuffix { get; set; } = ".schema"; + public string SchemaConstSuffix { get; set; } = "Schema"; + public bool EmitInferredTypes { get; set; } = true; + public bool ConformToTypeScriptTypes { get; set; } + public ZodCompilationMode Compilation { get; set; } + public bool EmitValidationGuards { get; set; } + public NameStyle PropertyNameStyle { get; set; } + public bool EmitGeneratedBanner { get; set; } = true; + } public interface ITypeGenBuilder { ITypeGenBuilder TypeScript(Action c); ITypeGenBuilder OpenApi(Action c); ITypeGenBuilder TanStackQuery(Action c); + ITypeGenBuilder Zod(Action c); ITypeBuilder ForType(); ITypeBuilder ForType(System.Type t); } @@ -512,6 +562,8 @@ public interface IPropertyBuilder { IPropertyBuilder OpenApiType(string t); IPropertyBuilder OpenApiRef(string s); IPropertyBuilder OpenApiFormat(string f); + IPropertyBuilder ZodFormat(ZodStringFormat f); + IPropertyBuilder ZodNanoId(int length); IPropertyBuilder OpenApiDescription(string d); IPropertyBuilder OpenApiNullable(bool n); IPropertyBuilder Ignore(); 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 index 634727f..c0279cf 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs @@ -227,4 +227,35 @@ public void MissingRouteParameters_AreAddedAsRequiredInputMembers() Assert.Contains("${encodeURIComponent(String(input.minimumBudget))}", ts); Assert.DoesNotContain("String(undefined)", ts); } + + [Fact] + public void PayloadValidation_ParsesRequestBodiesAndResponsesWithGeneratedSchemas() + { + var targets = TypeTarget.TypeScript | TypeTarget.Zod | TypeTarget.TanStackQuery; + var model = ModelWith(Cls("Order", targets), Cls("UpdateOrder", targets)); + model.Endpoints.Add(new EndpointInfo + { + Verb = "put", + Pattern = "/orders/{id:int}", + OperationId = "updateOrder", + Tag = "Orders", + RequestBodyCSharpType = "UpdateOrder", + ResponseCSharpType = "Order", + Parameters = + { + new EndpointParameter { Name = "id", Location = ParamLocation.Route, CSharpType = "int", Required = true }, + }, + }); + var settings = new GlobalSettings(); + settings.TanStackQuery.PayloadValidation = QueryPayloadValidation.RequestsAndResponses; + settings.TanStackQuery.SchemasImportPath = "../schemas"; + + var ts = TanStackQueryEmitter.Emit(model, settings).Single().Content; + + Assert.Contains("import { z } from 'zod';", ts); + Assert.Contains("import { OrderSchema, UpdateOrderSchema } from '../schemas';", ts); + Assert.Contains("body: UpdateOrderSchema.parse(input.body)", ts); + Assert.Contains("apiFetch", ts); + Assert.Contains(".then(value => OrderSchema.parse(value));", ts); + } } diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs index 9ed226c..0720449 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs @@ -22,7 +22,7 @@ public sealed class ZodCompilationTests : IDisposable { // Pin both packages for determinism across machines / CI. private const string TscPackageSpec = "typescript@5.7.3"; - private const string ZodPackageSpec = "zod@4.4.3"; + private const string ZodPackageSpec = "zod@4.6.1"; private readonly string _tempDir; private readonly bool _skip; @@ -154,9 +154,117 @@ public async Task ZodV4FormatTypes_CompileAgainstRealZod() { SourceName = "Website", CSharpTypeFullName = "string", OpenApiFormat = "url", }); + cls.Properties.Add(new SchemaProperty + { + SourceName = "CardNumber", CSharpTypeFullName = "string", ZodFormat = ZodStringFormat.CreditCard, + }); + cls.Properties.Add(new SchemaProperty + { + SourceName = "Iban", CSharpTypeFullName = "string", ZodFormat = ZodStringFormat.Iban, + }); + cls.Properties.Add(new SchemaProperty + { + SourceName = "PublicToken", CSharpTypeFullName = "string", + ZodFormat = ZodStringFormat.NanoId, ZodFormatLength = 16, + }); model.Classes.Add(cls); + model.Classes.Add(ClsModel("TreeNode", new[] { ("Children", "List", false) })); - var files = ZodEmitter.Emit(model, new GlobalSettings()); + var settings = new GlobalSettings + { + Zod = new ZodSettings + { + Compilation = ZodCompilationMode.Compile, + EmitValidationGuards = true, + }, + }; + var files = ZodEmitter.Emit(model, settings); + await PrepareWorkspaceAsync(); + foreach (var f in files) + File.WriteAllText(Path.Combine(_tempDir, f.FileName), f.Content); + + var (exitCode, stdout, stderr) = await RunAsync( + "npx", + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + + string.Join(" ", files.Select(f => f.FileName)), + workingDir: _tempDir); + + Assert.True(exitCode == 0, + $"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); + } + + [Fact] + public async Task ToZodConformanceAndCompile_TypeCheckWithGeneratedTypeScriptModel() + { + if (_skip) return; + + var model = new SchemaModel(); + var account = ClsModel("Account", new[] + { + ("Id", "int", false), + ("Name", "string", false), + ("Parent", "Account", true), + }); + account.Targets = TypeTarget.TypeScript | TypeTarget.Zod; + model.Classes.Add(account); + var settings = new GlobalSettings(); + settings.Zod.ConformToTypeScriptTypes = true; + settings.Zod.Compilation = ZodCompilationMode.Compile; + settings.Zod.EmitValidationGuards = true; + + var files = TypeScriptEmitter.Emit(model, settings).Concat(ZodEmitter.Emit(model, settings)).ToList(); + await PrepareWorkspaceAsync(); + foreach (var f in files) + File.WriteAllText(Path.Combine(_tempDir, f.FileName), f.Content); + + var (exitCode, stdout, stderr) = await RunAsync( + "npx", + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + + string.Join(" ", files.Select(f => f.FileName)), + workingDir: _tempDir); + + Assert.True(exitCode == 0, + $"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); + } + + [Fact] + public async Task TanStackPayloadValidation_CompilesWithGeneratedSchemas() + { + if (_skip) return; + + var targets = TypeTarget.TypeScript | TypeTarget.Zod | TypeTarget.TanStackQuery; + var model = new SchemaModel(); + var request = ClsModel("UpdateOrder", new[] { ("Name", "string", false) }); + var response = ClsModel("Order", new[] { ("Id", "int", false), ("Name", "string", false) }); + request.Targets = targets; + response.Targets = targets; + model.Classes.Add(request); + model.Classes.Add(response); + model.Endpoints.Add(new EndpointInfo + { + Verb = "put", + Pattern = "/orders/{id:int}", + OperationId = "updateOrder", + Tag = "Orders", + RequestBodyCSharpType = "UpdateOrder", + ResponseCSharpType = "Order", + Parameters = + { + new EndpointParameter { Name = "id", CSharpType = "int", Location = ParamLocation.Route, Required = true }, + }, + }); + var settings = new GlobalSettings(); + settings.TanStackQuery.PayloadValidation = QueryPayloadValidation.RequestsAndResponses; + settings.TanStackQuery.BaseUrlExpression = "undefined"; + settings.TanStackQuery.EmitQueryOptions = false; + settings.TanStackQuery.EmitMutationOptions = false; + settings.TanStackQuery.EmitHooks = false; + settings.TanStackQuery.EmitCacheHelpers = false; + + var files = TypeScriptEmitter.Emit(model, settings) + .Concat(ZodEmitter.Emit(model, settings)) + .Concat(TanStackQueryEmitter.Emit(model, settings)) + .ToList(); await PrepareWorkspaceAsync(); foreach (var f in files) File.WriteAllText(Path.Combine(_tempDir, f.FileName), f.Content); diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs index df06881..0c77523 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs @@ -95,6 +95,19 @@ public void Nullable_BecomesNullishModifier() Assert.Contains("note: z.string().nullish()", content); } + [Fact] + public void Nullable_InTypeScriptConformanceMode_MatchesOptionalProperty() + { + var cls = Cls("Order", props: new[] { ("Note", "string", true) }); + cls.Targets = TypeTarget.TypeScript | TypeTarget.Zod; + var settings = new GlobalSettings { Zod = { ConformToTypeScriptTypes = true } }; + + var content = ZodEmitter.Emit(ModelWith(cls), settings).Single().Content; + + Assert.Contains("note: z.string().optional()", content); + Assert.DoesNotContain("note: z.string().nullish()", content); + } + [Fact] public void ReadOnly_BecomesOptional() { @@ -111,6 +124,21 @@ public void ReadOnly_BecomesOptional() Assert.Contains("computed: z.number().int().optional()", content); } + [Fact] + public void PatchField_BecomesOptional() + { + var cls = Cls("UpdateOrder"); + cls.Properties.Add(new SchemaProperty + { + SourceName = "Name", + CSharpTypeFullName = "ZibStack.NET.Dto.PatchField", + IsPatchField = true, + }); + + var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; + Assert.Contains("name: z.string().optional()", content); + } + // ── collections ───────────────────────────────────────────────────────── [Fact] @@ -187,6 +215,44 @@ public void EmailFormat_BecomesTopLevelEmail() Assert.Contains("email: z.email()", content); } + [Theory] + [InlineData((int)ZodStringFormat.CreditCard, "z.creditCard()")] + [InlineData((int)ZodStringFormat.Iban, "z.iban()")] + [InlineData((int)ZodStringFormat.Hostname, "z.hostname()")] + [InlineData((int)ZodStringFormat.Ulid, "z.ulid()")] + [InlineData((int)ZodStringFormat.NanoId, "z.nanoid()")] + [InlineData((int)ZodStringFormat.Base64, "z.base64()")] + [InlineData((int)ZodStringFormat.Base64Url, "z.base64url()")] + public void ZodStringFormat_UsesTopLevelFactory(int format, string expected) + { + var cls = Cls("Payment"); + cls.Properties.Add(new SchemaProperty + { + SourceName = "Value", + CSharpTypeFullName = "string", + ZodFormat = (ZodStringFormat)format, + }); + + var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; + Assert.Contains($"value: {expected}", content); + } + + [Fact] + public void NanoId_CustomLength_UsesZod461LengthParameter() + { + var cls = Cls("Token"); + cls.Properties.Add(new SchemaProperty + { + SourceName = "Value", + CSharpTypeFullName = "string", + ZodFormat = ZodStringFormat.NanoId, + ZodFormatLength = 16, + }); + + var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; + Assert.Contains("value: z.nanoid({ length: 16 })", content); + } + [Fact] public void Pattern_BecomesRegexLiteral() { @@ -349,6 +415,47 @@ public void CustomSchemaConstSuffix_Honored() Assert.Contains("z.infer", content); } + [Fact] + public void CompileAndValidationGuard_AreOptIn() + { + var cls = Cls("Order", props: new[] { ("Id", "int", false) }); + var settings = new GlobalSettings + { + Zod = new ZodSettings + { + Compilation = ZodCompilationMode.Compile, + EmitValidationGuards = true, + }, + }; + + var content = ZodEmitter.Emit(ModelWith(cls), settings).Single().Content; + Assert.Contains("export const OrderSchema = z.compile(z.object({", content); + Assert.Contains("export const isOrder = (value: unknown): value is z.output => OrderSchema.validate(value);", content); + } + + [Fact] + public void ValidationGuard_UsesPascalCaseAfterIsPrefix() + { + var cls = Cls("Order", props: new[] { ("Id", "int", false) }); + cls.EmittedName = "order"; + var settings = new GlobalSettings { Zod = { EmitValidationGuards = true } }; + + var content = ZodEmitter.Emit(ModelWith(cls), settings).Single().Content; + + Assert.Contains("export const isOrder =", content); + Assert.DoesNotContain("export const isorder =", content); + } + + [Fact] + public void RecursiveProperty_UsesLazyReference() + { + var node = Cls("Node", props: new[] { ("Children", "List", false) }); + var content = ZodEmitter.Emit(ModelWith(node), new GlobalSettings()).Single().Content; + + Assert.Contains("export const NodeSchema: z.ZodType", content); + Assert.Contains("children: z.array(z.lazy(() => NodeSchema))", content); + } + [Fact] public void ZodIgnore_Respected() { diff --git a/packages/ZibStack.NET.Validation/sample/SampleApi/Models.cs b/packages/ZibStack.NET.Validation/sample/SampleApi/Models.cs index ac21b1f..71a79ed 100644 --- a/packages/ZibStack.NET.Validation/sample/SampleApi/Models.cs +++ b/packages/ZibStack.NET.Validation/sample/SampleApi/Models.cs @@ -39,6 +39,11 @@ public partial class PaymentInfo [ZCreditCard] public string CardNumber { get; set; } = ""; + // New in the Zod 4.6 integration: the same server-side rule maps to + // z.iban() when this DTO is also emitted by TypeGen. + [ZIban] + public string? BankAccountIban { get; set; } + [ZRequired] [ZMatch(@"^\d{2}/\d{2}$", Message = "Expiry must be MM/YY format")] public string Expiry { get; set; } = ""; @@ -47,6 +52,18 @@ public partial class PaymentInfo public int Cvv { get; set; } } +// Fluent equivalent for models that keep validation rules in one Configure block. +[ZValidate] +public partial class BankTransferInfo : IValidationConfigurator +{ + public string Iban { get; set; } = ""; + + public void Configure(IValidationBuilder b) + { + b.Property(x => x.Iban).Required().Iban(); + } +} + // ── Line item (in collection) ─────────────────────────────────────────────── [ZValidate] diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/AttributeSources.cs b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/AttributeSources.cs index 98aad5c..74749ee 100644 --- a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/AttributeSources.cs +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/AttributeSources.cs @@ -335,6 +335,19 @@ internal sealed class ZCreditCardAttribute : System.Attribute public string? Message { get; set; } } } +"; + + private const string ZIbanAttributeSource = @"// +#nullable enable +namespace ZibStack.NET.Validation +{ + /// String must be a valid IBAN (ISO 13616 mod-97 check). + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class ZIbanAttribute : System.Attribute + { + public string? Message { get; set; } + } +} "; private const string ZPhoneAttributeSource = @"// @@ -471,6 +484,9 @@ public interface IPropertyValidationBuilder /// Must pass Luhn algorithm (credit card number). Equivalent to [ZCreditCard]. IPropertyValidationBuilder CreditCard(string? message = null); + /// Must be a valid IBAN. Equivalent to [ZIban]. + IPropertyValidationBuilder Iban(string? message = null); + /// Must be a valid phone number format. Equivalent to [ZPhone]. IPropertyValidationBuilder Phone(string? message = null); diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationEmitter.cs b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationEmitter.cs index 6c3aa95..ef0aaae 100644 --- a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationEmitter.cs +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationEmitter.cs @@ -296,7 +296,7 @@ private static void EmitRule(StringBuilder sb, PropertyValidationInfo prop, Vali sb.AppendLine($"{indent}if ({name} is not null)"); sb.AppendLine($"{indent}{{"); sb.AppendLine($"{indent} var __cc = {name}.Replace(\"-\", \"\").Replace(\" \", \"\");"); - sb.AppendLine($"{indent} var __ccValid = __cc.Length >= 13 && __cc.Length <= 19 && __cc.All(char.IsDigit);"); + sb.AppendLine($"{indent} var __ccValid = __cc.Length >= 12 && __cc.Length <= 19 && __cc.All(char.IsDigit);"); sb.AppendLine($"{indent} if (__ccValid)"); sb.AppendLine($"{indent} {{"); sb.AppendLine($"{indent} var __sum = 0;"); @@ -319,6 +319,37 @@ private static void EmitRule(StringBuilder sb, PropertyValidationInfo prop, Vali break; } + case ValidationRuleKind.Iban: + { + var msg = rule.CustomMessage ?? $"{displayName} is not a valid IBAN."; + var err = ErrWithPlaceholders(name, msg, name); + sb.AppendLine($"{indent}if ({name} is not null)"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} var __iban = {name}.Replace(\" \", \"\").ToUpperInvariant();"); + sb.AppendLine($"{indent} var __ibanValid = __iban.Length >= 15 && __iban.Length <= 34"); + sb.AppendLine($"{indent} && __iban[0] >= 'A' && __iban[0] <= 'Z' && __iban[1] >= 'A' && __iban[1] <= 'Z'"); + sb.AppendLine($"{indent} && __iban[2] >= '0' && __iban[2] <= '9' && __iban[3] >= '0' && __iban[3] <= '9'"); + sb.AppendLine($"{indent} && __iban.All(__c => (__c >= 'A' && __c <= 'Z') || (__c >= '0' && __c <= '9'));"); + sb.AppendLine($"{indent} if (__ibanValid)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} var __remainder = 0;"); + sb.AppendLine($"{indent} var __rearranged = __iban.Substring(4) + __iban.Substring(0, 4);"); + sb.AppendLine($"{indent} foreach (var __ch in __rearranged)"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} if (char.IsDigit(__ch)) __remainder = (__remainder * 10 + (__ch - '0')) % 97;"); + sb.AppendLine($"{indent} else"); + sb.AppendLine($"{indent} {{"); + sb.AppendLine($"{indent} var __value = __ch - 'A' + 10;"); + sb.AppendLine($"{indent} __remainder = (__remainder * 100 + __value) % 97;"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent} __ibanValid = __remainder == 1;"); + sb.AppendLine($"{indent} }}"); + sb.AppendLine($"{indent} if (!__ibanValid) {{ errors.Add({err});{brk} }}"); + sb.AppendLine($"{indent}}}"); + break; + } + case ValidationRuleKind.Phone: { var msg = rule.CustomMessage ?? $"{displayName} is not a valid phone number."; diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs index 2fe2196..5ac9ece 100644 --- a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs @@ -27,6 +27,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("ZInAttribute.g.cs", ZInAttributeSource); ctx.AddSource("ZNotInAttribute.g.cs", ZNotInAttributeSource); ctx.AddSource("ZCreditCardAttribute.g.cs", ZCreditCardAttributeSource); + ctx.AddSource("ZIbanAttribute.g.cs", ZIbanAttributeSource); ctx.AddSource("ZPhoneAttribute.g.cs", ZPhoneAttributeSource); ctx.AddSource("ZCascadeAttribute.g.cs", ZCascadeAttributeSource); ctx.AddSource("IValidationConfigurator.g.cs", CrossFieldInterfacesSource); diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationModels.cs b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationModels.cs index 2bd068e..60945b2 100644 --- a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationModels.cs +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationModels.cs @@ -17,6 +17,7 @@ internal enum ValidationRuleKind In, NotIn, CreditCard, + Iban, Phone, } diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationParser.cs b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationParser.cs index c0a78fd..98df75a 100644 --- a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationParser.cs +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationParser.cs @@ -111,6 +111,9 @@ public sealed partial class ValidationGenerator case "ZibStack.NET.Validation.ZCreditCardAttribute": rules.Add(new ValidationRule(ValidationRuleKind.CreditCard, customMessage)); break; + case "ZibStack.NET.Validation.ZIbanAttribute": + rules.Add(new ValidationRule(ValidationRuleKind.Iban, customMessage)); + break; case "ZibStack.NET.Validation.ZPhoneAttribute": rules.Add(new ValidationRule(ValidationRuleKind.Phone, customMessage)); @@ -392,6 +395,7 @@ private static void ParsePropertyChain(ExpressionSyntax expr, SemanticModel sm, "Match" when args.Count >= 1 && sm.GetConstantValue(args[0].Expression) is { HasValue: true, Value: string pattern } => new ValidationRule(ValidationRuleKind.Match, GetOptionalMessage(args, 1, sm), pattern: pattern), "CreditCard" => new ValidationRule(ValidationRuleKind.CreditCard, GetOptionalMessage(args, 0, sm)), + "Iban" => new ValidationRule(ValidationRuleKind.Iban, GetOptionalMessage(args, 0, sm)), "Phone" => new ValidationRule(ValidationRuleKind.Phone, GetOptionalMessage(args, 0, sm)), "In" when args.Count >= 1 => new ValidationRule(ValidationRuleKind.In, null, allowedValues: ExtractStringArgs(args, sm)), "NotIn" when args.Count >= 1 => new ValidationRule(ValidationRuleKind.NotIn, null, allowedValues: ExtractStringArgs(args, sm)), diff --git a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs index dea9675..c9217e9 100644 --- a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs +++ b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs @@ -200,6 +200,9 @@ [ZRequired] [ZCreditCard] [ZPhone] public string? Phone { get; set; } + + [ZIban] + public string? Iban { get; set; } } // ── Conditional validation test ────────────────────────────────────────────── diff --git a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs index a48ce5d..0c91cb1 100644 --- a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs +++ b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs @@ -549,6 +549,29 @@ public void ZCreditCard_InvalidNumber_ReturnsError() Assert.Contains(result.Errors, e => e.Contains("CardNumber")); } + [Fact] + public void ZIban_ValidNumber_NoError() + { + var req = new PaymentRequest { CardNumber = "4111111111111111", Iban = "GB82 WEST 1234 5698 7654 32" }; + Assert.True(req.Validate().IsValid); + } + + [Fact] + public void ZCreditCard_TwelveDigitLuhnNumber_NoError() + { + var req = new PaymentRequest { CardNumber = "123456789015" }; + Assert.True(req.Validate().IsValid); + } + + [Fact] + public void ZIban_InvalidNumber_ReturnsError() + { + var req = new PaymentRequest { CardNumber = "4111111111111111", Iban = "GB82 TEST 1234" }; + var result = req.Validate(); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Iban")); + } + // ── ZPhone ─────────────────────────────────────────────────────── [Fact]