diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 00000000..0619225f --- /dev/null +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -0,0 +1,183 @@ +--- +name: purview-architecture-testing +description: >- + Use when adding, changing or debugging architecture tests in a .NET solution - dependency-direction + rules, forbidden framework references, project naming checks, cycle detection - or when a boundary + keeps being broken in review and should fail the build instead. Adopts Purview.Architecture.Testing + and a single policy file rather than hand-maintained lists. +license: MIT +metadata: + author: Purview-Dev + version: "2.0" +--- + +# Purview architecture testing + +A boundary that only review enforces is a boundary that erodes. The job of this skill is to turn a +rule a reviewer keeps repeating into a test that fails the build, **without** producing a wall of +brittle assertions that everyone disables six months later. + +Prerequisite: know what the projects are *for*. See `purview-architecture`. + +## 1. Only test a rule that is real + +Before writing anything, answer: **has this rule been broken, or is it certain to be?** + +A test for a dependency nobody would plausibly add is cost with no benefit. Good candidates: + +- A direction that has already been violated once. +- A boundary that is load-bearing - Core staying free of EF Core, Composition being referenced by + nothing. +- A rule a newcomer cannot infer from the code. + +Bad candidates: anything the compiler already enforces, and style preferences dressed up as +architecture. + +## 2. Adopt the package; define the policy once + +The failure mode of architecture tests is a dependency matrix written twice - once in a wiki table and +once in assertions - which then disagree. The prose is the copy that drifts, because nothing fails +when it is wrong. + +So do not write the rule engine. `Purview.Architecture.Testing` ships it, so the matrix, naming, +sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are +defined once and shared. Adoption is two files. + +**A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor +validates it as you type: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": ["Shared"], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore"] + } + ], + "waivers": [] +} +``` + +**One derived test class:** + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +That is the whole of the adoption. Each rule is a separate test, so a failure names the rule rather +than collapsing the set into one assertion, and the package validates the policy against its schema +first - a policy that does not match the schema may be silently missing a rule. + +The build-sdk ships a skill for the package itself, `purview-buildsdk-architecture-tests`, which is +authoritative on its API and rule ids. Read it rather than this page when you are extending or +debugging the package. + +Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable +too. Full worked example: +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). + +## 3. Read project files, and assemblies + +Two sources, because they catch different things: + +| Source | Catches | Misses | +| -- | -- | -- | +| `.csproj` `ProjectReference` | The reference a reviewer actually added | A dependency arriving transitively | +| Compiled assembly references | A dependency that arrived indirectly | Intent — it cannot tell direct from transitive | + +The package's rules read the **project files**, because a declared `ProjectReference` is what a +reviewer adds and what the policy governs. Add a small hand-written test that reads the **compiled +assemblies** for the leak a project file cannot show - a namespace from the wrong layer reaching a +public type. + +Classify a project by the **first segment** of its name, so `Persistence.SqlServer` and `Persistence` +are both Persistence - qualification is a naming choice, never a different rule. The package does +this for you; a hand-written assembly test must do it too. + +## 4. The rules worth having + +| Rule | What it catches | +| -- | -- | +| Dependency matrix | Core referencing Persistence; Application referencing a concrete provider | +| Nothing references Composition | A circular implementation choice | +| Sibling independence | `Providers.GitHub` reaching into `Providers.AzureDevOps` | +| Forbidden package prefixes | EF Core in Core; ASP.NET hosting in a domain project | +| Cycle detection | A graph that MSBuild rejects with an unreadable message | +| Catch-all names | `Infrastructure`, `Services`, `Helpers` appearing as project names | +| No production → test reference | A test helper leaking into shipped code | + +The package provides all of these. Do not re-implement one; change the policy instead. + +## 5. Write failures someone can act on + +This is what decides whether the suite survives. A failure must name: + +1. The offending project. +2. The rule violated. +3. The specific reference that broke it. +4. The direction the dependency *should* run. +5. The likely fix. + +```text +'Application' (Application) references 'Persistence.SqlServer' (Persistence), but Application +must not depend on Persistence. Invert it: Persistence may depend on Application, or introduce +an abstraction that Application owns and Persistence implements. +``` + +Not `Expected collection to be empty`. Collect every violation and report them as a numbered list - +one test run should show all of them, not the first. The package already reports this way; keep the +same standard in any rule you add. + +## 6. Waivers, not suppressions + +A repository that predates the policy will violate it. Deleting the test or adding a blanket +`#pragma` makes the policy a lie. Record the departure in the policy instead: + +```json +{ + "project": "Infrastructure", + "mapsTo": "Adapters", + "rules": ["naming"], + "reason": "Holds one adapter, predates the catalogue; renaming churns the wiki and docs for no gain.", + "reviewedOn": "2026-10-15" +} +``` + +The package tests the waivers themselves: each must name a project that still exists, a rule, and a +substantive reason. A waiver for a deleted project is dead weight that hides the next one. + +Critically, a waiver excuses **the narrowest thing possible** - usually the name. `mapsTo` keeps the +project classified so every dependency rule still applies to it. Prefer a waiver to +`SkipRule`, which removes a rule for the whole repository with no record. + +## 7. Conventions + +- TUnit, matching the repository's other tests, adopted through `PurviewArchitectureTests` with + `[InheritsTests]`. +- Arrange/Act/Assert, one behaviour per test. +- Let the package find the repository root - it probes upward for the policy and stops at the `.git` + boundary. Override `RepositoryRoot` only when the policy does not sit above the test binary. +- Keep the policy file free of anything a rule cannot read; a field no rule consults is a rule that + looks enforced and is not. + +## Checks before you answer + +- Is every rule you added one that has been, or will be, broken? +- Is the matrix defined once, in the policy, with the documentation checked against it? +- Does each failure name the fix, not just the fact? +- Did you record a waiver rather than weaken a rule for existing code? + +## See also + +- `purview-architecture` — what the projects are for, and how they are named +- `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md new file mode 100644 index 00000000..c04f3d61 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-architecture +description: >- + Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview architecture + +The job of this skill is to stop two failures that cost far more than they look: + +- **Architecture bought in advance.** Four projects, a repository interface and a mapping layer for an + application that has one table and one caller. +- **A recommendation made without context.** Advice about database-side GUID generation given to a + stateless CLI, or a Clean Architecture split proposed for a 300-line utility. + +So: establish context, then choose proportionally. Both steps are mandatory, and the first one is the +one that gets skipped. + +## 1. Establish context before deciding + +Before recommending a structure, adding a project, or generating substantial code, you must know four +things. **Read them out of the repository first** — asking a user a question the code already answers +is the most common way this skill is applied badly. + +| Dimension | Read it from | Ask only if | +| -- | -- | -- | +| Complexity (small / medium / large) | Number of functional areas, integrations and external consumers — not file count | The requirements are genuinely not written down anywhere | +| Project type | SDK attribute, `OutputType`, `Microsoft.NET.Sdk.Web`, an AppHost project, a `Main` | The repository is empty | +| Persistence | `PackageReference` to an EF Core provider, `DbContext`, a connection string, a `Purview.Containers` resource | Persistence is being introduced now | +| Telemetry | `Purview.Telemetry.SourceGenerator`, `ILogger` injection, `ActivitySource`, an OTLP exporter | Nothing is configured and you are about to add it | + +Identifier generation is a **conditional** dimension: resolve it only when persistence exists *and* +GUID keys are in play. See `purview-persistence`. + +Judge complexity on requirements, not size. A 2,000-line CLI with one output format is small. A +900-line service that reconciles three upstream systems and owns money movement is not. + +**Do not re-ask.** A dimension answered by repository configuration, existing source, a project file, +an earlier turn in the conversation, or a recorded architectural decision is answered. Ask concise, +targeted questions only for the unknowns that change your recommendation — and if nothing changes, +ask nothing. + +Full decision procedure: `references/project-context-discovery.md`. + +## 2. Choose proportionally + +| Scale | Shape | What you still do not add | +| -- | -- | -- | +| Small | One project, or two when a test project needs a non-trivial subject. Direct implementations. | Repository over `DbContext`, CQRS, a mapping layer, speculative interfaces | +| Medium | Explicit boundaries where complexity already hurts — usually a domain project and a host | Event sourcing, a generic infrastructure framework, a second infrastructure implementation nobody asked for | +| Large | Modularity, independent infrastructure implementations, enforceable boundaries (see the playbook's Architecture-As-Tests page) | Patterns adopted for symmetry rather than for a named problem | + +Architecture is allowed to arrive late. A boundary added when the second caller appears is cheap; a +boundary added in anticipation of a second caller that never appears is permanent. + +**Never add a project for symmetry.** "Domain, Application, Infrastructure, API" is a response to a +problem, not a starting position. If you cannot name the failure a layer prevents in this codebase, +do not add the layer. + +Full guidance and the per-pattern justification tests: `references/proportional-architecture.md`. + +## 3. Name what you do create + +A project name states a **responsibility**, optionally qualified by an implementation: + +```text +[.] +``` + +The responsibility decides the rules; the qualifier never changes them. Three rules people usually +get wrong: + +- **Do not qualify a single implementation.** `Persistence` is correct even when it uses SQL Server. + Rename when a second provider arrives, not in anticipation. +- **Do not create an empty parent.** `Persistence.SqlServer` + `Persistence.PostgreSql` with nothing + shared need no `Persistence` project. +- **Multiple implementations do not require multiple assemblies.** Two small adapters with the same + dependencies belong in one project. + +Never use a *category* as a project name — `Infrastructure`, `Services`, `Managers`, `Models`, +`Helpers`, `Common`. Nothing can be excluded from a category, so it grows without limit. And never +rename an established project just to match canonical terminology; `Domain`, `UseCases` and +`Integrations` are recognised aliases. + +Responsibility catalogue, dependency matrix and the naming decision tree: +`references/project-responsibilities.md`. + +## 4. Check the direction + +`A → B` means **A depends on B at compile time** — not a runtime call, a DI registration, an HTTP +request or an Aspire orchestration edge. Confusing those is the most common source of a wrong answer +about whether a dependency is allowed. + +The rules that matter most: + +- Core depends on nothing but `Shared`. +- Application never references a concrete `Persistence`, `Providers` or `Adapters` project. +- Composition references implementations; **nothing references Composition** except a host. +- Sibling implementations (`Providers.GitHub`, `Providers.AzureDevOps`) stay independent. +- Persistence owns migration *definitions*; `Migrator` owns their *execution*. Production API startup + does not apply schema migrations. + +When a boundary is worth having, make it fail the build rather than the review — see +`purview-architecture-testing`. + +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays + +Reuse the Purview libraries when they remove a real source of truth, not for coverage: + +| Concern | Library | Use it when | +| -- | -- | -- | +| Operation outcomes without exceptions for control flow | `Purview.Results` | Failures are expected and the caller must handle them | +| Primitive obsession, validated identity types | `Purview.ValueObjects` | A value has rules that would otherwise be re-checked at each boundary | +| Schema shared with a TypeScript client | `Purview.ZodSharp` | A contract is duplicated between C# and TypeScript today | +| Local dependencies for dev and integration tests | `Purview.Containers` | Tests need a real database, queue or emulator | +| Logging, tracing and metrics from one declaration | `Purview.Telemetry.SourceGenerator` | Always — see `purview-telemetry` | + +Duplication that matters: validation repeated per entry point, a domain rule restated in the UI, +configuration bound in two places, two types that both claim to be the truth. Duplication that does +not: two similar-looking methods that answer to different owners. + +## 7. Telemetry is not an advanced topic + +Recommend instrumentation from the first commit, at every project size. A small application gets +small telemetry — it does not get none. See `purview-telemetry`. + +## Checks before you answer + +- Did you read the four context dimensions rather than ask for them? +- Can you name, for each project and each abstraction you are proposing, the failure it prevents here? +- Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? +- Did you include telemetry? + +## See also + +- `purview-telemetry` — instrumentation at every scale +- `purview-persistence` — storage and GUID-key guidance, only when persistence is real +- [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 00000000..e281a410 --- /dev/null +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,236 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-context-discovery.md b/.agents/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 00000000..d40569a3 --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-context-discovery.md @@ -0,0 +1,115 @@ +# Project context discovery + +The procedure for establishing enough context to make an architectural recommendation, and — just as +importantly — for knowing when to stop asking. + +## The principle + +Every question you ask a user is a question they have usually already answered in the repository. +Reading is free and silent; asking is slow and, when the answer was already in a project file, it +reads as not having looked. So the order is fixed: + +1. Read the repository. +2. Read the conversation so far. +3. Ask only about what remains *and* changes your answer. + +A dimension whose answer would not change your recommendation is not worth a question. If a project +is a stateless CLI, the database GUID strategy is not an unknown — it is irrelevant, and raising it +is noise. + +## Dimension 1 — complexity + +Assess from requirements, never from line or project count. + +| Signal | Suggests | +| -- | -- | +| One functional area, one consumer, no integrations | Small | +| A handful of features, one or two integrations, a single team | Medium | +| Several bounded areas, multiple services or teams, operational obligations (SLOs, auditability, money) | Large | + +Where to look: `README.md`, the wiki, issue or ticket references, the solution file's project list, +`PackageReference` entries naming external systems, the number of distinct hosts. + +Traps: + +- **A big repository is not a complex one.** A monorepo of ten tiny tools is ten small projects. +- **A small repository can be complex.** Few files, hard invariants. +- **Generated code inflates everything.** Discount it. + +## Dimension 2 — project type + +Read it; do not ask it. + +| Evidence | Type | +| -- | -- | +| `Microsoft.NET.Sdk.Web` | Web application or HTTP API — distinguish by whether views/pages or endpoints dominate | +| `OutputType` of `Exe` plus an argument parser | CLI | +| `IsPackable`, no entry point | Reusable library | +| `IHostedService` / `BackgroundService`, `Worker` SDK | Background worker | +| An AppHost project, `Aspire.Hosting` | Distributed application | +| WPF/WinForms/Avalonia/MAUI SDK or `UseWPF` | Desktop application | + +A reusable library carries the strongest constraint on the rest of your advice: it must not configure +global telemetry providers, must not read configuration from the ambient environment, and must not +decide a persistence technology for its consumer. + +## Dimension 3 — persistence + +| Question | Read it from | +| -- | -- | +| Is persistence required at all? | Any data-access `PackageReference`, a `DbContext`, a connection string, a repository type, an Aspire resource | +| Which technology? | The EF Core provider package (`Microsoft.EntityFrameworkCore.SqlServer`, `Npgsql.EntityFrameworkCore.PostgreSQL`), the client package, or the `Purview.Containers` resource in the AppHost | +| Is more than one implementation required? | Two providers referenced, a provider abstraction already present, or an explicitly stated requirement | +| EF Core or something else? | Presence of `Microsoft.EntityFrameworkCore` versus Dapper, a raw client, or a document store SDK | + +"No persistence" is a complete and common answer. When it is the answer, every storage-shaped +recommendation — repositories, migrations, GUID strategies, connection resilience — is out of scope. + +## Dimension 4 — telemetry + +| Question | Read it from | +| -- | -- | +| Is telemetry configured? | `ILogger` injection, `ActivitySource`/`Meter` construction, `AddOpenTelemetry`, a `ServiceDefaults` project | +| Is Purview Telemetry available? | A `Purview.Telemetry.SourceGenerator` `PackageReference`, or interfaces attributed `[Logger]`, `[ActivitySource]`, `[Meter]` | +| Which signals are relevant? | Logs always; traces once a call crosses a process or a dependency; metrics once someone would alert on a rate or a duration | +| Is there an exporter? | An OTLP endpoint in configuration, a console exporter, an Aspire dashboard, an APM package | + +Existing instrumentation is a constraint, not a starting point to replace. Preserve it, extend it, +and never register the same source twice. + +## Conditional dimension — identifier generation + +Resolve **only** when persistence exists and GUIDs are, or are about to be, primary keys. + +- Are GUIDs primary keys, or just surface identifiers? +- Is the value generated in the application or by the database? +- Which provider and version? (This changes the answer — see `purview-persistence`.) +- Does index locality matter at the expected write volume? +- Must the identifier exist before the entity is persisted (outbox, correlation, client-side graph)? + +The last question usually decides it: if the application needs the value before `SaveChanges`, the +database cannot be the generator. + +## Asking well + +When you must ask, ask few and ask concretely. Batch the unknowns into one short set, offer the +likely answers, and state what you will do if the user does not care. + +Good: + +> Two things I can't tell from the repository: is this service expected to own its own database, or +> read from the existing one? And should failures be returned to the caller or thrown? I'll assume +> its own database and `Purview.Results` for expected failures unless you say otherwise. + +Bad: + +> What is the project complexity? What is the project type? Is persistence required? Which database? +> Is telemetry configured? Do you use GUID primary keys? + +The second asks six questions, five of which the repository answers, and one of which is irrelevant. + +## Recording the answer + +When a dimension is resolved by conversation rather than by the repository, that is a decision the +next agent will not be able to read. Write it down — in `AGENTS.md`, a wiki page, or an ADR — so the +question is asked once and never again. diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md new file mode 100644 index 00000000..d74d2cf4 --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,119 @@ +# Project responsibilities, naming and dependency direction + +The catalogue in brief. The authoritative definition is +[`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), +and the full page — with diagrams — is +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +Do not restate either here or in another skill; link to them. + +## The catalogue + +`A → B` means A depends on B **at compile time**. + +| Responsibility | Also known as | Owns | May depend on | +| -- | -- | -- | -- | +| `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | +| `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | +| `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | +| `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | +| `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | +| `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | +| `Composition` | — | DI registration and implementation selection | Everything above | +| `API` | — | HTTP host | Composition, Contracts, ServiceDefaults, … | +| `Worker` | — | Background-processing host | Composition, ServiceDefaults, … | +| `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | +| `AppHost` | — | Aspire orchestration | The hosts it orchestrates | +| `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Shared` | — | Small domain-independent primitives | *nothing* | + +**None of these is mandatory.** A small application with `Core` and one host has a complete and +correct architecture. The catalogue names responsibilities so you can tell when splitting one out +buys something — it is not a list of projects to create. + +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + +## When to split + +| Responsibility | Split it out when | Leave it combined when | +| -- | -- | -- | +| `Core` | A domain model exists | The app is a thin utility | +| `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | +| `Application` | Workflows are reused by more than one host | It would only forward CRUD | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Providers` | Two platforms with different SDKs or lifecycles | One platform | +| `Adapters` | One capability, several technologies | One capability, one technology | +| `Composition` | Two hosts compose the same implementations | One host — compose in the host | +| `Migrator` | Schema changes are a deliberate deployment step | Development-only startup migration | +| `Shared` | A primitive is used by two or more responsibilities | Almost always | + +## Naming + +```text +[.] +``` + +| Situation | Name | +| -- | -- | +| One implementation | `Persistence` — do **not** qualify it just because it is SQL Server | +| Several, same dependencies and lifecycle | `Persistence` — one project, several classes | +| Several, genuinely different | `Persistence.SqlServer`, `Persistence.PostgreSql` | +| Several, with shared code | Add `Persistence` alongside the qualified projects | +| Several, nothing shared | Qualified projects only — no empty parent | + +Discouraged as project names, because each names a category rather than a responsibility: +`Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. + +`Infrastructure` is the common case. It is a useful *category* — covering Persistence, Providers and +Adapters — and a poor *project*, because every technical class qualifies for it. + +**Do not rename an established project** merely to match canonical terminology. A rename costs more +than the consistency is worth, and `Domain`/`UseCases`/`Integrations` are recognised aliases. + +## Providers versus Adapters + +Not a vendor distinction: + +- **Provider** — the requirement names the platform: "sync issues from GitHub". Swapping it changes + the product. +- **Adapter** — the requirement names the capability: "send an email". Swapping the technology + changes nothing the user sees. + +Azure Blob behind `IFileStore` is an Adapter. The Azure DevOps work-item API in a product that +integrates Azure DevOps is a Provider. + +## Dependency rules that catch people out + +1. **Composition is referenced by nothing** except a host. It selects implementations, so it depends + on them; the reverse would make the choice circular. +2. **Application never references a concrete infrastructure project.** It depends on an abstraction + and receives an implementation it cannot name. +3. **Sibling implementations stay independent.** If `Providers.GitHub` needs something from + `Providers.AzureDevOps`, that shared thing belongs in the unqualified project or an abstraction. +4. **Persistence owns migration definitions; Migrator executes them.** Production API startup does + not mutate a schema. +5. **Runtime is not compile time.** DI registration, an HTTP call and Aspire orchestration are not + assembly references. A Worker and an API can share Application without referencing each other. + +## Recommending this to a repository that predates it + +Do not propose a mass rename. Suggest, in order: + +1. Fix dependency *directions* that are actually wrong — those are bugs. +2. Split a project only where a named problem justifies it. +3. Record a waiver for an established name rather than renaming it, so the rule stays enforceable + without churn. + +A waiver excuses the name only; the project still classifies, so every dependency rule still applies. diff --git a/.agents/skills/purview-architecture/references/proportional-architecture.md b/.agents/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 00000000..3ed7f553 --- /dev/null +++ b/.agents/skills/purview-architecture/references/proportional-architecture.md @@ -0,0 +1,92 @@ +# Proportional architecture + +Structure is a cost paid now against a failure expected later. Proportional architecture is the +practice of only paying when the failure is named, present, and more expensive than the structure. + +## The justification test + +Before adding a project, an interface, a layer or a pattern, answer in one sentence: + +> **What failure does this prevent in *this* codebase, and has it happened or is it certain to?** + +If the sentence needs the words "in case", "later", "when we scale", or "it's good practice", the +structure is not yet justified. Write the simpler thing. The simpler thing is also the thing that is +cheapest to change when the failure finally does arrive. + +This is not an argument against structure. It is an argument against *unfalsifiable* structure. + +## By scale + +### Small — a utility, CLI, prototype, simple API, limited-scope application + +- One project. A second only when there is a test project, or when a genuinely reusable library + falls out. +- Call the database, the API or the file system directly from the code that needs it. +- Validate at the edge, once. +- Logging from the first commit; traces and metrics when there is something to correlate. + +Do not add: a repository interface over `DbContext`, a service layer that forwards, a mapping layer +between two near-identical shapes, an interface with one implementation and no test double need +(`Purview.Containers` and TUnit.Mocks remove most of that need), a `Domain`/`Application`/ +`Infrastructure` split. + +### Medium — several functional areas, some integrations, moderate domain complexity + +- Introduce a boundary where you can name what it separates. Usually that is domain rules from the + host, so a domain project and an application host. +- An abstraction over an external dependency earns its place when the dependency is genuinely + swappable, or when testing against it is otherwise painful. "Swappable in principle" is not + swappable. +- Dependency instrumentation, trace propagation across the integrations, metrics on the operations + someone would be paged about. + +Do not add: CQRS unless reads and writes have actually diverged in shape, lifetime or scaling; +event sourcing unless the history *is* the product; a second infrastructure implementation that has +no second consumer. + +### Large — complex domain, multiple services or teams, substantial operational requirements + +- Modularity along ownership lines, because the real constraint is people, not code. +- Independent infrastructure implementations where they are genuinely independent. +- Boundaries made enforceable rather than documented — a dependency-direction rule that a wrong + `ProjectReference` fails the build. See the playbook's Architecture-As-Tests page. +- End-to-end tracing, standardised resource attributes, SLIs, retention and sampling policy. + +Even here, the justification test holds per decision. "Large" licenses the *option* to add +structure, not the obligation to add all of it on day one. + +## Patterns and the conditions that earn them + +| Pattern | Earned when | Not earned by | +| -- | -- | -- | +| Repository over EF Core | You must swap the store, or the `DbContext` API genuinely leaks into domain decisions | Testability — EF Core's in-memory and container-backed testing already covers it | +| CQRS | Read and write models have diverged in shape or scaling needs | A desire for symmetry with a reference architecture | +| Event sourcing | The sequence of changes is the product, or audit is a hard requirement | "We might want history" | +| Mediator / in-process bus | Many handlers, genuinely decoupled registration | Three call sites that could be method calls | +| Mapping layer | Two shapes that must diverge and are owned separately | Two shapes that are identical today | +| Extra DI indirection | A real second implementation, or a composition root that must vary | Inversion for its own sake | +| Generic infrastructure framework | Three or more concrete uses exist now | One use and an expectation of more | + +## Evolving incrementally + +The honest sequence is: write the direct implementation; let the second caller arrive; extract the +boundary the second caller revealed. Each step is small, reversible and informed. + +The dishonest sequence is: predict the second caller; build the boundary; never get the second +caller; maintain the boundary forever. The cost is invisible because it is spread across every +future change. + +When you do extract, extract along the seam the code showed you — not the seam the diagram +predicted. + +## What this does not license + +Proportional is not an excuse for: + +- Skipping tests, or skipping telemetry. +- Leaving validation duplicated across entry points. +- Letting domain rules live in the UI or in the database. +- Two sources of truth for the same fact. + +Those are not structure bought in advance; they are defects. Simplicity means fewer moving parts, +not fewer guarantees. diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 00000000..b1f222c5 --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: purview-buildsdk-adoption +description: Adopt Purview.BuildSdk in a repository, or troubleshoot an adoption that is not behaving. Use when wiring the SDK into Directory.Build.props for the first time, when a property seems to have no effect, when the resolved version or namespace is wrong, or when a consuming repository builds but the SDK's automatic behaviour is not appearing. Covers the import chain, pre-import property ordering, and version detection from package.json. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Adopting Purview.BuildSdk + +The goal is a working adoption that does not break existing build behaviour. Prefer minimal, +targeted property changes over rewriting a repository's `Directory.Build.props`. + +For the full property list use `purview-buildsdk-configuration`; for auditing an existing +repository's shape, `purview-buildsdk-repository-review`. + +## 1. The import chain + +```xml + + + + + Acme.Sales + + + + +``` + +```xml + + + + +``` + +Both halves are required. A repository with only `Sdk.props` imported gets the evaluation-time +defaults — naming, identity, framework selection — but none of the targets, so validation, +packaging behaviour and the generated files never run. That combination usually presents as "the +properties look right but nothing happens". + +The SDK version comes from `global.json`, which the SDK itself bootstraps. + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +Strict +``` + +`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping +it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +The SDK does not install agent skills, and nothing it does during a build writes to an agent +directory. It reads which skills each referenced package ships and recommends the command that +installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 00000000..d82768b3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,115 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Purview.BuildSdk configuration + +Use this skill when the question is "what property controls this, and where do I set it?" + +For *why* the SDK classified a project a particular way, use `purview-buildsdk-project-detection` +instead. For where projects and tests should live, use `purview-buildsdk-project-layout`. + +## First rule: know where a property must be set + +A property that changes `Sdk.props` evaluation must be set **before** the SDK import — in +`Directory.Build.props`, above the import. Setting it in a `.csproj` is too late and silently has no +effect. + +Pre-import properties include: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` +- repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +in `Directory.Build.props` unless the setting is deliberately project-specific. + +## The properties you will reach for most + +| Property | Default | What it does | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity. Required unless `DisableNamespacePrefixCheck=true` | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | +| `TestDataFramework` | `Bogus` | `Bogus` or `None` | +| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `IsPackable` | `false` | Opts a project into the packable defaults | +| `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | +| `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | +| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | + +The full set, grouped by area and with the behaviour each one changes, is in +[references/properties.md](references/properties.md). Read it rather than guessing a property name — +several of these have near-miss names that do nothing. + +## Derived properties to inspect, not re-derive + +When explaining what the SDK did, read the value it computed rather than reasoning from the folder +name: + +`PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, +`RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. + +Ask MSBuild for one directly: + +```bash +dotnet msbuild -getProperty:RootNamespace +dotnet msbuild -getProperty:TestingType -getProperty:TargetProjectName +``` + +## Compiler-visible properties + +The SDK exports its properties to analyzers and generators as `build_property.`. When +authoring an analyzer, read the exported property instead of re-deriving SDK behaviour — the export +is the SDK's answer, and it stays correct when the rules change. The exported set is listed in +[references/properties.md](references/properties.md). + +## Version detection + +1. If `RootPackageJson` is set, that file is used. +2. Otherwise the repo root is discovered from CI variables, `.git`, or a nearby `package.json`. +3. On success, both `Version` and `PackageVersion` come from the `version` field. +4. `UsePackageJsonVersion=Strict` turns a failed discovery into a build failure instead of a silent + fallback. + +## Repository bootstrap + +The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` + +Every project in a solution writes those same destinations, so the write is race-tolerant by +design: an existing file is never overwritten unless asked, each write is staged into a temporary +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a + property documented in one place and absent from another is the failure mode this repository + keeps hitting. +3. A new property that affects import-time behaviour must say so in its documentation. +4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 00000000..1fb1f81b --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,168 @@ +# Purview.BuildSdk property reference + +Every user-facing property, grouped by what it changes. Defaults are what the SDK applies when the +consuming project supplies nothing. + +## Identity and naming + +| Property | Default | Behaviour | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity source. Required unless the check is disabled. Set before the SDK import | +| `DisableNamespacePrefixCheck` | `false` | Stops the build failing when `NamespacePrefix` is unset | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | +| `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | +| `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | +| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | +| `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | + +## Target framework and language + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | +| `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | +| `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | + +## Style policy + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisablePurviewStylePolicyValidation` | `false` | Stops the build failing (`PRSGD0006`–`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy — `dotnet_style_require_accessibility_modifiers` other than `omit_if_default`, hiding `IDE0040`/`IDE1006`, disabling the Style category in bulk, weakening the `_camelCase` private-instance-field rule, or adding the accessibility rules to `NoWarn`. Entries the SDK injects itself are ignored | +| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | The production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`) but are context aware: public test classes, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Set before the SDK import | +| `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | +| `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | + +## Testing + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit`, `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute`, `None` | +| `TestDataFramework` | `Bogus` | `Bogus`, `None` | +| `CollectCoverage` | `true` for detected test projects | | +| `EnableStaticNativeInstrumentation` | `false` for test projects | | +| `EnableDynamicNativeInstrumentation` | `false` for test projects | | +| `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | + +## Packaging + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsPackable` | `false` | | +| `PurviewAutoSdkPack` | `true` | Packs `Sdk/**/*` into the package at the right root-level paths | +| `PurviewSkillsSourceFolder` | `/skills` | Where authored skills live | + +Packable-project defaults, applied only when the project supplied nothing: +`GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, +`PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs ship +in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages +instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. + +When the repository root is discoverable, the root `README.md` is packed automatically and +registered as `PackageReadmeFile`, unless one is configured already. + +Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a +solution-wide pack skips them silently. + +`ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never +forces SourceLink's CI-mode dirty-repository checks. + +## Telemetry and SourceLink + +| Property | Default | Behaviour | +| -- | -- | -- | +| `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | +| `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | +| `DisableSourceLink` | `false` | | + +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + +## Version detection + +| Property | Default | Behaviour | +| -- | -- | -- | +| `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `EnableVersionDetectionCache` | `true` | | +| `VersionDetectionCacheFile` | *(derived)* | | +| `VersionDetectionLogEnabled` | `false` | Logs the detected version | + +## Repository bootstrap + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisableAutoCopySdkFiles` | `false` | Master switch for SDK-managed repository file writes | +| `PurviewRepoBootstrapMode` | `IfMissing` | Also `Always`, `WarnOnDrift`, `Never` | +| `BootstrapEditorConfigToRepoRoot` | `true` | | +| `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | +| `BootstrapGlobalJsonToRepoRoot` | `true` | | +| `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. + +## Compiler-visible properties + +Exported to analyzers and generators as `build_property.`: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 00000000..8573ca40 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,157 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Why Purview.BuildSdk did that + +Use this skill when the question is **why** the SDK applied something automatically. + +For where a project should live, use `purview-buildsdk-project-layout`. For the property that turns +a behaviour off, use `purview-buildsdk-configuration`. + +## Diagnose from computed properties, never from the folder name + +Read what the SDK actually computed: + +```bash +dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:RootNamespace \ + -getProperty:TestingType -getProperty:TargetProjectName -getProperty:IsTestProject +``` + +The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, +`PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, +`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. + +## Detection rules + +### Test projects + +Name matches `^.*\.([\w|_]\w*)Tests?$` — a target name, a dot, then any word before `Test`/`Tests`. +The captured word becomes `TestingType`. + +This is a pattern, **not** a lookup. `MyProject.GoldenTests` is detected exactly as readily as +`MyProject.UnitTests`, with `TestingType=Golden` and `Category=Golden`, even though `Golden` is not a +name the SDK ships. + +**The dot is required.** A project named plainly `UnitTests` does not match, and silently gets no +runner, no coverage, no `InternalsVisibleTo` and no category. This is the single most common +surprise. + +Derived: `IsTestProject=true`, `TestingType=`, `PurviewTestType=Tests`, +`TargetProjectName=`. + +The SDK's explicit `TestType` item group governs **`InternalsVisibleTo` generation**, not detection: +a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name in the group. A +suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. +The 53 names are in [references/test-types.md](references/test-types.md). + +### Shared and shared-testing projects + +Recognised by **exact name**, never by substring. The two lists are in +[references/detection.md](references/detection.md). Derived: `IsSharedProject`, +`IsSharedTestingProject`. + +### SDK and content based + +| Signal | Result | +| -- | -- | +| the project's `Sdk="..."` declaration | `IsSdkProject`, `SdkProjectName` | +| `Microsoft.NET.Sdk.Web` | `IsWebSdkProject=true` | +| `Microsoft.NET.Sdk.Worker` | `IsWorkerSdkProject=true` | +| SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | +| `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | +| name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | +| `Microsoft.NET.Sdk.BlazorWebAssembly` | `IsBlazorWebAssemblyProject=true` | +| `Microsoft.NET.Sdk.Razor` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive, like `CLI`), or that sets `IsBffProject=true` | `IsBffProject=true` | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBffProject=false` opts a `*Bff` project out. `IsBlazorHostProject` is **never** inferred into +`IsBffProject`: a host that keeps no tokens is not a BFF, and BFF rules do not describe it. + +`IsBlazorHostProject` is known at evaluation for the ``/`.Client` pairing. A host +referencing a client under any other name is resolved during the build, so ask for it with the +target that resolves it: + +```bash +dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject +``` + +What each Blazor and BFF classification applies - and the browser-safe library check - is in +[references/blazor.md](references/blazor.md). + +## Namespace and identity + +1. `PurviewLogicalProjectName` is `NamespacePrefix` plus the project name, de-duplicated when the + project name already starts with the prefix's tail. +2. `RootNamespace` defaults to `PurviewLogicalProjectName`. +3. Known suffixes are stripped from `RootNamespace` — shared and shared-testing names, and segments + such as `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`. +4. Test suffixes are stripped too, so `Acme.Api.UnitTests` maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — except where + stripping removed a segment, where they use the full `PurviewLogicalProjectName` so the artifact + stays distinct from its parent. Test and shared-testing projects keep their detected suffix. + Explicit `AssemblyName`/`PackageId`/`RootNamespace` always win. +6. The naming defaults are applied during `Sdk.props`, before the Microsoft SDK computes + `TargetName`, so the compiled output name always matches `AssemblyName`. + +## Automatic packages and references + +- **Non-test C# projects**: SourceLink (unless `DisableSourceLink`), the Purview telemetry packages + (unless `ExcludePurviewTelemetry`), `GenerateDocumentationFile=true`, and `InternalsVisibleTo` + generation (unless `DisableAutoInternalsVisibleTo`). +- **Test and shared-testing projects**: test-friendly `NoWarn` defaults, not packable or + publishable, and the packages selected by `TestingFramework`, `SubstituteFramework` and + `TestDataFramework`. TUnit test projects get Microsoft.Testing.Platform wiring automatically. + Shared-testing projects skip the runnable test package. +- **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, + Aspire host): see [references/detection.md](references/detection.md). +- **Blazor WebAssembly clients, Blazor hosts, BFFs and browser-safe libraries**: see + [references/blazor.md](references/blazor.md). + +Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. + +## The shipped analyzers + +The SDK adds `Purview.BuildSdk.Analyzers` as an `` item to every C# project, so its rules +surface in command-line builds and in Visual Studio. The code-fix assembly ships beside it. + +| Rule | What it enforces | +| -- | -- | +| `PDS0002` | Extensions classes live under `Extensions//` | +| `PDS0003` | Explicit types with target-typed `new()` | +| `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | + +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, +`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, +`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, +`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +implementations and base-class overrides — are never renamed. + +Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), +`.acronym_map` (segment renames, e.g. `Sql:SQL`, or `Db:DB` to re-enable it) and +`.allowed_identifiers` (brand names, exact or word-boundary prefix, first match wins — `CosmosDb` +covers `CosmosDbServer`). All three merge with and override the shipped defaults. + +The shipped `.editorconfig` marks generated content (`Migrations/`, `*.g.cs`, `Generated/`, +`*.Designer.cs`, `obj/`, `bin/`) as `generated_code = true`, and suppresses namespace-conflict +diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. + +Two Visual Studio refactorings ship with them, both described in +[references/detection.md](references/detection.md). + +## When changing a detection rule + +1. Keep the `.csproj` filename equal to its directory name. +2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in + prose is this repository's recurring failure. +3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 00000000..c65d1532 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 00000000..85fde09c --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,83 @@ +# Detection detail + +## Shared project names (exact) + +`Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + +## Shared testing project names (exact) + +`SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers` + +These are matched whole, not as substrings. `SharedModels` is not a shared project. + +A shared-testing project is forced to `PurviewSharedTestingOutputType` (default `Library`), with +`IsTestProject` and `IsTestingPlatformApplication` cleared, because the test packages otherwise flip +it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to +keep that package-driven shape. + +## Packable projects + +Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, +`IncludeSource`, `DebugType=portable`. + +- Portable PDBs ship in the `.snupkg`. The `.nupkg` receives none unless the project opts in. +- The repository-root `README.md` is packed and registered as `PackageReadmeFile` when it exists and + nothing else is already being packed as one. +- Non-packable projects, web apps included, default `WarnOnPackingNonPackableProject=false`, so a + solution-wide `dotnet pack` skips them silently. +- `ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never + forces SourceLink's CI-mode dirty-repository checks. + +## Roslyn components + +A project that explicitly declares `IsRoslynComponent=true` gets: + +- a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, + `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules +- SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry excluded +- compiler-generated output under the framework-specific intermediate directory +- `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers`, so they + never leak into the packed nuspec + +`IsRoslynComponentOnly` defaults to `true`: `IncludeBuildOutput=false`, `IncludeSymbols=false`, no +`.symbols.nupkg` or `.snupkg`, and the analyzer PDB shipped inside the main `.nupkg` under +`analyzers/dotnet/cs/` beside the assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a +dual-role component that wants normal library symbol packaging. + +`ValidateRoslynComponentCompilerSettings` fails the pack of a packable Roslyn component missing +`LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules`. Opt out with +`DisableRoslynCompilerDefaultsValidation=true`. + +`PackProjectReferencedSourceGenerators` (default `true`) makes a packable project include analyzer +`ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set it +`false` globally, or `Pack="false"` on one analyzer reference. + +## Special project types + +| Type | Applied | +| -- | -- | +| CLI | `OutputType=Exe`, `appsettings*.json` included as content | +| Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | +| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | +| Aspire host | `OutputType=Exe` | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | + +## The Visual Studio refactorings + +Two ship with the code-fix assembly, both serving the `PDS0002` convention: + +- **Split extensions class into one class per receiver type** — offered on a static extensions class + targeting several receiver types. It produces one `Extensions` class per receiver + (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes + `HostApplicationBuilderExtensions`) and places each under `Extensions//`. +- **Move extensions class to conventional location** — offered on a misplaced single-receiver + extensions class. It re-paths the file to `Extensions//`, fixes the namespace + to the receiver's, and updates `using` directives in other referencing documents so the move + compiles. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/test-types.md b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 00000000..dace44fc --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md @@ -0,0 +1,35 @@ +# The shipped `TestType` group + +These 53 names drive **`InternalsVisibleTo` generation**, not test detection. A non-test project +grants friend access to `{AssemblyName}.{TestType}Tests` for each name here. + +Detection is the pattern `^.*\.([\w|_]\w*)Tests?$`, so a suffix outside this group is still detected, +still runs and still gets its category — it simply cannot reach the target project's `internal` +members. Prefer a name from this group unless the test type genuinely needs its own word. + +```text +Unit Integration E2E EndToEnd Acceptance +Functional Performance Load Smoke Stress +Regression Security Chaos Scenario System +Threat BlackBox WhiteBox GrayBox GreyBox +Accessibility Usability Visual Interactive Localization +Globalization Environment Configuration Deployment Architecture +Contract Component Sanity Property Fuzz +Mutation Snapshot Approval Benchmark Capacity +Concurrency Endurance Soak Spike Failover +Recovery Resilience Compatibility Interoperability Migration +Upgrade Penetration Compliance +``` + +## The five to reach for first + +`Unit`, `Integration`, `E2E`, `Functional`, `Contract`. Everything else is available, but a reader +has to ask what it means — use one only when the test type itself carries operational weight. + +## What a suffix buys you + +| | detected as a test project | gets a category | gets a runner and coverage | sees `internal` members | +| -- | -- | -- | -- | -- | +| `Api.UnitTests` | yes | `Unit` | yes | yes | +| `Api.GoldenTests` | yes | `Golden` | yes | **no** | +| `UnitTests` *(no dot)* | **no** | no | **no** | no | diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 00000000..6c19c3d4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,161 @@ +--- +name: purview-buildsdk-project-layout +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Project layout and naming for Purview.BuildSdk + +Use this skill when physically adding, moving, splitting or renaming a project. + +For *why* the SDK classified an existing project a particular way, use +`purview-buildsdk-project-detection`. For the property that changes a behaviour, use +`purview-buildsdk-configuration`. + +## First principle + +**Treat naming and placement as configuration, not decoration.** + +The SDK infers namespaces, assembly identity, test categories, package wiring and project references +from a small set of conventions. The closer a repository sits to them, the fewer overrides it needs. + +The corollary matters just as much: **preserve the host repository's existing layout first.** Only +introduce new structure when no established pattern exists. + +## Canonical layout + +```text +/ + package.json version source + .editorconfig bootstrapped by the SDK + skills/ authored agent skills, if any + src/ + .slnx + src//.csproj + tests/.Tests/.Tests.csproj +``` + +- The `.csproj` filename must equal its containing directory name, unless + `DisableProjectFileNamingConventionCheck=true`. +- Source projects under `src/`, test projects under `tests/`. +- Place a new project beside similar ones — same language, layer and test type. +- One test type per project by default. + +A repository that already uses a different shape keeps it; add to it rather than converting it. + +## Naming and identity + +`NamespacePrefix` is the root identity source. Keep project names short and let the SDK apply the +prefix: + +| `NamespacePrefix` | Project | `RootNamespace` | +| -- | -- | -- | +| `Aspire` | `Hosting` | `Aspire.Hosting` | +| `Acme.Sales.RegionalPipeline` | `Identity.API` | `Acme.Sales.RegionalPipeline.Identity.API` | +| `Acme.Sales.RegionalPipeline` | `Identity.Core` | `Acme.Sales.RegionalPipeline.Identity` | + +`AssemblyName` and `PackageId` follow the resolved `RootNamespace` — except where suffix-stripping +removed a segment (`Core`, `Shared`, `ServiceDefaults`), in which case they keep the fuller logical +name so the artifacts stay distinct from their parent. Explicit values always win, and +`EnableAssemblyNameGeneration=false` (before the SDK import) restores standard behaviour. + +Do not hand-author an alternate namespace scheme when one is already in use. + +## Test project naming + +Test detection is a **pattern**, not a list: `^.*\.([\w|_]\w*)Tests?$`. A target name, a dot, then +any word before `Test` or `Tests`. The captured word becomes `TestingType` and the baseline category. + +**The dot is required.** A project named plainly `UnitTests`, with no target in front of it, is not +detected as a test project at all — no runner, no coverage, no `InternalsVisibleTo`, no category. +Always name a test project `.Tests`. + +Prefer these by default: `UnitTests`, `IntegrationTests`, `E2ETests`, `FunctionalTests`, +`ContractTests`. Use another suffix only when the test type itself carries operational meaning. + +A suffix outside the SDK's shipped `TestType` group still builds, runs and gets its category — it +just cannot reach the target project's `internal` members, because that group drives +`InternalsVisibleTo` generation. The full list is in `purview-buildsdk-project-detection`. + +## Shared projects: exact names only + +The SDK recognises these names exactly — a near miss gets none of the behaviour. + +Shared: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`. + +Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers`. + +## Automatic project references to preserve + +Layout is what makes these work. Move a project off the convention and you own the reference. + +Test projects probe for their target, in order: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. + +## Creating a new project + +1. Match the SDK and style of sibling projects. +2. Use Central Package Management if the repository has it. +3. Add only the dependencies the project's scope needs — the SDK supplies the testing stack, + SourceLink and telemetry by default. +4. Add the project to the solution. +5. Keep target frameworks, nullability, analyzers and warning settings consistent with neighbours. + +## Moving or splitting + +1. Create the destination using the established layout. +2. Move the files. +3. Update namespaces, usings and references for the destination. +4. Verify the destination still produces the intended `TestingType`, `TargetProjectName` and + `RootNamespace` — ask MSBuild rather than assuming: + `dotnet msbuild -getProperty:RootNamespace -getProperty:TargetProjectName`. +5. Remove dependencies the source no longer needs. +6. Update solution membership and project references. +7. Build, then run the affected tests. + +## Guardrails + +- Prefer minimal, targeted diffs; avoid cross-cutting renames unrelated to the move. +- Keep test intent unchanged while relocating. +- When the structure is ambiguous, infer from the nearest sibling projects and say which assumption + you made. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace and + project-reference behaviour. diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 00000000..ce1fd294 --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- the solution entry point, for what is actually in the build +- `Directory.Packages.props`, for Central Package Management +- the project layout on disk + +## 2. Check the structural signals + +- Source projects under `src/`, test projects under `tests/`. +- `.csproj` filename equals its containing directory name — unless + `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. +- Short project names, with `NamespacePrefix` carrying the repository identity. +- Test project names match `^.*\.([\w|_]\w*)Tests?$`. **The dot is required**; a project named + plainly `UnitTests` is not detected at all and silently gets no runner, no coverage, no + `InternalsVisibleTo` and no category. This is the single highest-value thing to check. +- Test suffixes come from the SDK's shipped `TestType` group where `internal` access is wanted — a + suffix outside it still runs, it just cannot see `internal` members. +- Shared and shared-testing projects use the **exact** recognised names. A near miss gets none of + the behaviour. + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 00000000..66c20a33 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,166 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`. + +## The one thing to understand first + +**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but +as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing +copies it out. + +Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. +Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming +repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The +mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a +lockfile ref against content the mirror had already replaced. The mirror was removed outright rather +than fenced off, because the CLI covers the whole job. + +Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a +restore, a build, a design-time build or a test run. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their +own, something has been wired up that should not be. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.agents/skills//` in the restored package says *which* skills it ships. +- The nuspec's ``, written by SourceLink for every package this + SDK builds, says which git repository and which **exact commit** produced them. + +So the resolved ref is the commit the *restored package version* was built from. An older +`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility +is exact by construction rather than by convention. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +``` + +The caller owns the ref here — append `#` to pin it. This path needs no restore, so +`PRSKL0001` does not apply. + +## Diagnostics + +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | +| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | + +## Recommendations run during an ordinary build + +This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and +delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +Four things keep it quiet, and all of them are load-bearing: + +- Design-time builds, CI builds, non-default TFM passes and + `EnablePurviewSkillRecommendations=false` are skipped outright. + `PurviewSkillRecommendationsInCI=true` re-enables CI. +- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is + reported once per repository, not once per project and again on every rebuild. The explicit target + ignores the stamp. +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. +- A correctly installed set produces no output at all. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends `npx skills update` + +`update` installs the *newest* published skill, which is only correct when the newest skill happens +to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in +a pinned `npx skills add` instead. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. + +| Runner | Invocation | +| -- | -- | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `bun` | `bun x skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then +`$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. +`PATH` is read directly rather than by spawning a version check: deterministic, no process, and it +cannot hang on a cold download. **The runner actually found wins over the configured preference.** +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. + +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and +reporting needs no Node tooling at all. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +6. SourceLink's git `SourceRoot` + +There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent +directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and +silence for recommendations — a build never fails over advice. + +The marker walk is described in [references/repository-root.md](references/repository-root.md); its +two-pass behaviour and its home-directory bound are both load-bearing. + +## Why the CLI rather than a reimplementation + +The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent +and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore +the same set later with `npx skills experimental_install`. + +`PurviewSkillsAgents` (default `*`) narrows the target agents. + +The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 00000000..e90b695c --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,55 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## A target's Condition is evaluated before its DependsOnTargets + +A property a dependency computes is still empty in the depending target's `Condition`. That is why +the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on +`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. + +## The managed port + +`Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same +default markers, so a test that validates a repository's skills resolves the same root the build +installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion +on the default marker list itself — both are defaults nobody sets, so nothing else would notice them +drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 00000000..4043ac4f --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md @@ -0,0 +1,131 @@ +--- +name: purview-buildsdk-skill-tests +description: Author agent skills in a repository that uses Purview.BuildSdk, and validate them with Purview.Skills.Testing or the SDK's pack-time ValidatePurviewSkills target. Use when adding or renaming a skill, writing SKILL.md frontmatter, splitting content into references/ or scripts/, fixing a PRSKL0005 pack failure or a skill-name, skill-description or skill-bundled-resources test failure, or deciding where skills belong in a repository. +license: MIT +metadata: + package: Purview.Skills.Testing + repository: purview-dev/build-sdk +--- + +# Authoring and validating agent skills + +For installing skills a package ships, use `purview-buildsdk-skill-installation`. + +## Where skills live + +**At the repository root, in `skills//SKILL.md`.** Always. + +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package +carries .NET artifacts, and skills are delivered by the CLI from git. + +```text +skills/ + my-skill/ + SKILL.md required + references/ loaded on demand + scripts/ executable, run on demand + assets/ templates and static resources +``` + +Link to a skill by its **authored** path, `./skills//SKILL.md`. A link into `.agents/skills/` +points at a gitignored file that does not exist in a fresh clone. + +## `SKILL.md` format + +```markdown +--- +name: my-skill +description: What it does, and when to use it. Include the words a task would contain. +license: MIT +metadata: + package: My.Package +--- + +# My skill + +Instructions. +``` + +| Field | Required | Constraint | +| -- | -- | -- | +| `name` | yes | 1–64 chars, lowercase `a-z0-9` and single hyphens, no leading, trailing or consecutive hyphen, **must equal the folder name** | +| `description` | yes | 1–1024 chars, non-empty | +| `license` | no | A licence name, or the name of a bundled licence file | +| `compatibility` | no | ≤500 chars, only when the skill has real environment requirements | +| `metadata` | no | A map of string to string. Anything not defined above belongs here | +| `allowed-tools` | no | Space-separated pre-approved tools. Experimental | + +**There are no other top-level fields.** `category`, `roles` and `tags` are common inventions and a +strict client rejects them; put them under `metadata`. + +The full specification is at . +[references/authoring.md](references/authoring.md) covers description writing, naming for a shared +namespace, and progressive disclosure. + +## Progressive disclosure + +An agent loads a skill in three stages: the `name` and `description` at startup, the whole +`SKILL.md` body once the skill activates, and `references/`, `scripts/` or `assets/` only when the +task reaches them. + +So: keep `SKILL.md` **under 500 lines**, and move detail into `references/`. The body is a cost paid +on every activation; a reference file is paid only when it is needed. + +Keep file references one level deep from `SKILL.md`, and relative to the skill root. A path that +climbs out of the skill folder is broken everywhere except the authoring repository, because only +the skill's own folder is installed. + +## Validating + +### In tests — `Purview.Skills.Testing` + +```csharp +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests; +``` + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without +it the class compiles, the run is green and nothing is validated. TUnit reports `TUnit0030`; treat +it as an error. + +| Rule id | What it checks | +| -- | -- | +| `skills-discovery` | The folder, when present, holds at least one skill | +| `skill-structure` | Every folder has a `SKILL.md` whose frontmatter parses | +| `skill-name` | Spec-legal, unique, and equal to the folder name | +| `skill-description` | Present, non-empty, within 1024 characters | +| `skill-optional-fields` | Only spec-defined fields, each within its limit | +| `skill-bundled-resources` | Every linked file exists, inside the skill folder | +| `skill-body-size` | `SKILL.md` stays under the recommended 500 lines | + +A repository with **no** `skills/` folder passes every rule — authoring skills is optional. An +**empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty +catalogue. + +Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), +`SkillsFolderName` (default `skills`), and `SkipRule(string)`. + +The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather +than a real repository — the same three-layer shape this repository uses throughout: a reader, an +evaluator over an in-memory model, and a thin test base class. + +### At pack time — `ValidatePurviewSkills` + +It runs `BeforeTargets="Pack"`, so a malformed skill fails in the repository that owns it rather +than reaching the CLI, which would silently skip it. + +```bash +dotnet msbuild -t:ValidatePurviewSkills +``` + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked. + +The two checks overlap deliberately: the pack-time one runs without a test host, the test one +covers more of the specification. + +## What is deliberately not checked + +Prose quality, and whether an agent actually obeys the skill. A build can only check that a skill is +well formed. Treat a green run as "installable", not "good". diff --git a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 00000000..69d4ee45 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + behaviour; those are greppable when the code changes. +- Where a number or a list is authoritative somewhere else — a default, an enumeration — say where + it comes from, so a reader can check it. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 00000000..5a5a7d53 --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: purview-buildsdk-testing +description: Write and run tests in a repository that uses Purview.BuildSdk. Use when authoring or refactoring TUnit tests (AAA structure, Subject_Scenario_Expectation naming, awaited assertions, CancellationToken), when choosing a test project's framework packages, and when running or filtering tests - the SDK wires Microsoft.Testing.Platform, so `dotnet test --filter` reports "Zero tests ran" and `--treenode-filter` is the one that works. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Testing in a Purview.BuildSdk repository + +Two things to know before anything else: + +1. A test project is detected by **name**, and detection is what wires the runner. See + `purview-buildsdk-project-layout` if the project is new or being moved. +2. The runner is **Microsoft.Testing.Platform (MTP)**, not VSTest. `--filter` does nothing useful. + +## What a test project gets automatically + +With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, +`TestDataFramework=Bogus`), a detected test project receives: + +- `TUnit`, `TUnit.Mocks`, `Bogus` +- Microsoft.Testing.Platform integration +- `CollectCoverage=true` +- test-friendly `NoWarn` defaults (`PurviewTestContextNoWarn`), while keeping the strict style + contract — `IDE0040`, field naming, formatting, `IDE1006` +- `IsPackable=false`, `IsPublishable=false` + +Add nothing else unless the project's purpose demands it. Two that stay explicit: + +- `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests +- `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers + (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, + Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Reference `Testcontainers` directly only where no typed module exists yet. + +## Authoring rules + +- Use TUnit (`TUnit.Core`, `TUnit.Assertions`). Do not introduce xUnit, NUnit, MSTest or + FluentAssertions unless explicitly asked. +- Every test method carries `[Test]`. +- **Every assertion is awaited**: `await Assert.That(actual).IsEqualTo(expected);`. An un-awaited + assertion silently passes. +- Arrange / Act / Assert, with the `// Arrange`, `// Act`, `// Assert` comments present. +- Test classes are `{SubjectName}Tests`, in the **same namespace** as the subject. +- Test methods are `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. Use `ctor` as the subject + for constructor tests. Methods, constructors, properties, operators, conversions and validation + hooks are all valid subjects. +- When any method called in the test body accepts a `CancellationToken`, take + `CancellationToken cancellationToken` as the **last parameter** and pass it through. + +```csharp +[Test] +public async Task ProcessAsync_GivenAnEmptyBatch_ReturnsZero(CancellationToken cancellationToken) +{ + // Arrange + var sut = new BatchProcessor(); + + // Act + var result = await sut.ProcessAsync([], cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(0); +} +``` + +For a suite that is not subject-based — a build, generator, workflow, package or full-system suite — +a broader, more truthful class name is better than a forced `{Subject}Tests`. Use TUnit display +names and categories to keep it navigable. + +When refactoring existing tests, bring them into compliance with all of the above while preserving +test intent. + +## Categories + +The detected test type becomes the baseline category automatically — `Api.IntegrationTests` yields +`Category=Integration`. Add further categories when they make a large suite easier to navigate; +they are what `--treenode-filter` property predicates select on. + +## Running tests + +```bash +dotnet test # the whole suite +dotnet test --treenode-filter "/*/*/LoginTests/*" # one class +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +Prefer the repository's own task (`just test`, or equivalent) for a full run. + +The filter path is `////`, with `*` wildcards, `=`/`!=` property +predicates, `&`/`|` inside a single bracket group, and `**` only at the end. The full grammar, the +`--` separator rules across SDK versions, and the zero-tests checklist are in +[references/treenode-filter.md](references/treenode-filter.md). + +### "Zero tests ran" + +In order: + +1. Are you using `--treenode-filter` and not `--filter`? This is the cause most of the time. +2. Try `dotnet test --treenode-filter "..."`; on an older SDK use `dotnet test -- --treenode-filter "..."`. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Does the project actually contain `[Test]` methods? +5. Is the project **detected** as a test project? A name without the dot — `UnitTests` rather than + `Api.UnitTests` — gets no runner at all. Check with + `dotnet msbuild -getProperty:IsTestProject`. +6. Are all property predicates inside one bracket group, `[(A=1)&(B=2)]`? + +## Cross-platform requirement + +CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. + +- Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no + case-insensitive path assumptions. +- Build paths with `Path.Combine`, `Path.GetTempPath()`, `Path.DirectorySeparatorChar`, + `Path.GetFullPath()`. +- Feed production path-math code only native-format paths. Normalise a fixed Windows-style literal + to the current platform before passing it in. +- Do not rely on a case-insensitive filesystem, a `C:` drive, or trailing-separator behaviour. +- Keep `SyntaxTree` file paths and `build_property.*` values consistent and native when writing + analyzer tests. diff --git a/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 00000000..35d032bc --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 00000000..ba4dd9bd --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,171 @@ +--- +name: purview-engineering-principles +description: >- + Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering + conventions - convention over configuration, derived identity, architecture as tests, warnings as + errors, proving consumer-facing claims, documentation ownership, test data builders, agent + legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a + build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 1. Convention over configuration + +A repository declares as little as possible. One MSBuild SDK supplies the defaults, and a project +file is short because the SDK already knows what a project of that shape needs. + +Configuration copied between repositories diverges. When the rule lives in one package, fixing it +once fixes it everywhere; when it lives in eleven project files, it is fixed eleven times — or, more +often, once. + +*Enforced by:* `` in a single +`Directory.Build.props`. The SDK detects project type from the filename and the `Sdk` attribute. + +*At your scale:* a single-project repository still benefits. The import is two lines and removes the +target framework, language version, nullable context, analyzer set and package metadata. + +## 2. Identity is derived, never repeated + +`RootNamespace` comes from `$(NamespacePrefix).$(ProjectName)`, and `AssemblyName` follows the +resolved `RootNamespace`. A name appears once — in the folder and file name — and everything else is +computed. + +A hand-typed namespace drifts from the assembly it lives in, and the drift is invisible until +something reflects over types: an architecture test, a serializer, a DI scan. + +*Enforced by:* the SDK during `.props` evaluation, plus a build-time check +(`PurviewProjectFileNameMismatch`) when a `.csproj` filename does not match its parent directory. + +*At your scale:* always. It costs nothing and removes a class of bug. + +## 3. Architecture is a test, not a diagram + +The dependencies a layer may have are asserted by tests that read the compiled assemblies. A +`ProjectReference` pointing the wrong way fails the build. + +A diagram is a claim about the past. Dependency direction erodes one convenient reference at a time, +and every such reference looks reasonable in isolation. + +*Enforced by:* a test project that references every layer and inspects their metadata references. + +*At your scale:* small repositories can skip it. Adopt it the moment a **second layer** exists — +that is when the wrong direction first becomes possible. + +## 4. The build is the only authority + +Style is enforced by analyzers and `EnforceCodeStyleInBuild`, and the SDK's own style policy is +re-validated on every compile, so a repository cannot quietly downgrade a rule. + +*At your scale:* always. If a rule matters and cannot be checked, say so plainly. + +## 5. Warnings are not acceptable output + +A clean build has no warnings. One that will never be fixed is either fixed, suppressed *where it +occurs* with a stated justification, or turned off deliberately. + +Warnings are a budget. A build that emits twenty is a build where the twenty-first is invisible — and +the twenty-first is the one that mattered. + +*Enforced by:* severity, partly. The SDK's shipped `.editorconfig` sets many rules to `error`. The +rest is review, backed by the pipeline running `dotnet build` with the repository's configuration. + +*At your scale:* a small repository can tolerate `warning`; a large one cannot. Decide deliberately +rather than by accumulation. + +## 6. Every claim about a consumer is proved by a consumer + +When a package documents how it must be used — a target framework, an asset, a guard, a workaround — +something in the repository builds that documented consumer and asserts the documented outcome. + +Consumer-facing documentation is the part of a library least likely to be exercised by its own tests, +because **the library's own build is not a consumer**. It drifts silently and fails in somebody +else's repository. + +*Enforced by:* a verification script that packs, then builds throwaway consumer projects against the +produced packages, asserting the happy path *and* the documented guard errors. `purview-dev/containers` +is the worked example (`just verify-consumers`), deliberately excluded from the default unit filter +because it restores from nuget.org. + +*At your scale:* only once a package is consumed outside its own repository. Before that, the +repository's own tests already are the consumer. + +## 7. Documentation is a build artefact of the repository + +The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its +`mkdocs.yml`, its navigation and its `edit_uri`. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*Enforced by:* review, plus the navigation file — a page absent from `_Sidebar.md` is not published. + +*At your scale:* always. Even a repository with one page should own that page. + +## 8. Everything a test needs is built by one builder + +Domain content in a test comes from `TestBuilder`. A test never constructs a domain object inline, +and it overrides **only** the value it is proving. + +Inline construction encodes a type's shape into every test that touches it, so one constructor change +becomes a repository-wide edit. It also hides which values the test actually depends on, because +every value looks deliberate. + +*Enforced by:* review. + +*At your scale:* adopt from the first test. Retrofitting is a mechanical edit across every test file, +and exactly the kind of edit that is postponed forever. + +## 9. An agent is a reader with no memory + +A repository states its purpose, its layout and its conventions in `AGENTS.md`. **Nothing important +is discoverable only by reading the code.** + +A coding agent has the repository and nothing else — no colleague to ask, no institutional memory, no +intuition about which of two plausible patterns is intended. It will confidently generalise from +whatever it finds first. That is also every new-joiner failure mode; the agent just makes the cost +visible sooner, because an agent does not ask. + +*Enforced by:* review, plus publishing skills so agent-facing guidance tracks the library version +rather than a repository's memory of it. + +*At your scale:* always. `AGENTS.md` is cheaper than the questions it prevents. + +## 10. A change is verified in proportion to its risk + +Checks are tiered, and a repository declares which tiers its pipeline runs. A pull request runs the +fast tier; the slow tier runs deliberately, locally or on a dedicated runner. + +A pipeline that runs everything on every pull request gets made optional. One that runs too little +gets ignored. Tiering is what keeps the gate credible. + +*Enforced by:* the pipeline's `TestFilter` and the test project's own **name** — the SDK categorises +a `*UnitTests` project as `Unit`, so the tier is a property of the project rather than of a +hand-written attribute. + +*At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by +attribute. + +## Applying these in review + +Three questions catch most of it: + +1. **Is this configuration, or is it a default the SDK should own?** If two repositories would copy + it, it belongs in the SDK. +2. **Is this rule enforced, or merely stated?** If stated, say so where it is stated. +3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in + `AGENTS.md` or the wiki. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance. diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 00000000..4667fa29 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,106 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, deciding + whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific + guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for + projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview persistence + +**Activation condition:** this skill applies only when persistence is real. If the project is a +stateless CLI, a pure library, or a service that calls an API and stores nothing, stop here — none of +this is relevant, and raising a database GUID strategy with that project is noise. + +Confirm persistence from the repository before proceeding (see `purview-architecture`): a data-access +`PackageReference`, a `DbContext`, a connection string, or an explicit requirement to store state. + +## 1. Shape the data access proportionally + +| Scale | Shape | +| -- | -- | +| Small | `DbContext` used directly from the code that needs it. No repository, no unit-of-work wrapper — `DbContext` already is both. | +| Medium | A boundary when domain rules must not know about EF Core, or when a query is complex enough to deserve a name. Often that is a few focused query types, not a generic `IRepository`. | +| Large | Independent infrastructure implementations where there genuinely are several, with the dependency direction enforced by a test. | + +**Do not wrap EF Core in a repository for testability.** `Purview.Containers` gives you a real +database in tests, which is both more faithful and less code than an abstraction built to be mocked. +A repository earns its place when you must swap the store or when `DbContext` is genuinely leaking +into domain decisions — not before. + +## 2. Local dependencies + +`Purview.Containers` provides Aspire resource integrations for the dependencies you need in +development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `Redis`, `Nats`, +`RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory +provider: the in-memory provider does not enforce the constraints that break you in production. + +## 3. GUID keys — only if GUIDs are keys + +Resolve these before recommending anything; most are answered by the repository: + +1. Are GUIDs primary keys, or merely external identifiers over an integer key? +2. Is the value generated by the application or by the database? +3. Which provider, and which version? +4. Does index locality matter at the expected write volume? +5. Must the identifier exist before the row is persisted? + +Question 5 usually decides it. If the application needs the identifier before `SaveChanges` — for an +outbox row, a correlation value, or to build an object graph client-side — the database cannot be the +generator, and the remaining questions are about *which* client-side strategy. + +### The rule that matters most + +**SQL Server and PostgreSQL do not benefit from the same sequential GUID implementation.** Their +index structures and their GUID comparison semantics differ, and a "sequential GUID" that helps one +can be no better than random on the other. Never carry a strategy from one to the other without +re-deriving it. + +- SQL Server specifics: `references/sqlserver.md` +- PostgreSQL specifics: `references/postgresql.md` +- Choosing between them, and when a custom generator is justified: `references/guid-generation.md` + +### Prefer built-in capability + +Use the framework or database capability when it is sufficient — `Guid.CreateVersion7()` on .NET 9+, +`NEWSEQUENTIALID()`, EF Core's own value generators, a provider's native UUIDv7 function. A custom +generator is a maintenance obligation and a correctness risk; introduce one only against a measured +requirement. + +**There is no Purview GUID generation package.** Do not reference one, and do not create one without +first establishing a concrete, measured need that the built-in options cannot meet. If such a +capability is added later, this skill is where the guidance for recommending it belongs. + +## 4. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 5. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- Expect the schema to be ahead of or behind the code during a deployment; design changes to be + compatible in both directions. +- Keep the connection string out of source. Keep it out of telemetry too. + +## Checks before you answer + +- Have you confirmed persistence is actually required? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## See also + +- `purview-architecture` — context discovery and proportional structure +- `purview-telemetry` — instrument the data access too; it is where the latency is diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 00000000..432053a2 --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,93 @@ +# Choosing a GUID generation strategy + +The cross-provider decision procedure. Provider detail lives in `sqlserver.md` and `postgresql.md`. + +## Step 0 — is this question in scope? + +Ask nothing and recommend nothing unless **both** are true: + +- The project persists data, and +- GUIDs are (or are about to be) primary keys. + +A stateless CLI, a pure library, a service with an integer key, or a project using GUIDs only as +external correlation identifiers does not need this decision. Raising it anyway is the failure this +page is written to prevent. + +## Step 1 — must the identifier exist before persistence? + +This question decides more than any other. + +**Yes** — the application needs the value before `SaveChanges` for an outbox row, a correlation +identifier, an object graph built client-side, or an identifier returned before commit. The database +cannot be the generator. Go to step 2 with client-side options only. + +**No** — the database may generate it, and database generation is available to you. + +## Step 2 — which provider, and which version? + +Do not skip the version. It changes the available options materially: + +| Provider | Version matters because | +| -- | -- | +| SQL Server | `NEWSEQUENTIALID()` is long-standing; the comparison semantics that make byte-ordered UUIDs useless have never changed | +| PostgreSQL | Native `uuidv7()` exists only from 18; `gen_random_uuid()` is built in from 13 | +| .NET | `Guid.CreateVersion7()` exists from .NET 9 | + +**The two databases do not share an answer.** SQL Server compares `uniqueidentifier` with the last +six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. +PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy that is +optimal on one is unremarkable or wrong on the other. + +## Step 3 — does locality actually matter? + +Sequential keys exist to reduce index fragmentation and page splits under write pressure. If the +table takes tens of inserts a minute, this is not your problem and a random GUID is the simpler, +more private choice. + +Reach for a sequential strategy when you can point at write volume, measured fragmentation, page +splits, or buffer-pool pressure. "It's best practice" is not a measurement. + +Note also that on SQL Server the problem is often better solved by making the clustered index +something other than the GUID key — see `sqlserver.md`. + +## Step 4 — is unpredictability required? + +Sequential and time-ordered identifiers are guessable, and UUIDv7 discloses creation time to the +millisecond. Where an identifier appears in a URL, is handed to a third party, or acts as a +capability, use a random UUID — or keep the ordered value internal and expose a random surrogate. + +Never use `NEWSEQUENTIALID()` output as a secret. + +## Decision summary + +| Provider | Needs value before insert | Locality matters | Recommendation | +| -- | -- | -- | -- | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| Any | Either | Either, but must be unpredictable | Random v4 | + +## Prefer built-in capability + +In order of preference: + +1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. +2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. +3. A reviewed, tested implementation of a published standard, when neither of the above is available + on your target versions. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## There is no Purview GUID package + +No Purview library provides GUID generation today. Do not reference one and do not invent one. + +If a concrete need emerges that the built-in options cannot meet — established by measurement, not by +anticipation — that is the point at which creating one is worth discussing. Until then this guidance +is the deliverable, and this page is where a recommendation for such a package would be documented. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 00000000..cd557ecc --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,98 @@ +# PostgreSQL UUID keys + +Read this only when the provider is PostgreSQL and UUIDs are primary keys. The SQL Server answer is +different — see `sqlserver.md`. + +## Why PostgreSQL differs from SQL Server + +Two differences drive everything on this page: + +1. **PostgreSQL has no clustered index.** Tables are heaps; the primary key is a separate B-tree. A + random UUID therefore does not cause row-level page splits the way it does on a clustered SQL + Server table. The cost is in the index: random inserts touch scattered index pages, which hurts + cache locality and WAL volume, but it is a smaller and differently-shaped problem. +2. **`uuid` compares in byte order.** Unlike SQL Server, PostgreSQL orders UUIDs by their bytes. A + UUID that is sequential in byte layout *is* sequential to the index. + +The consequence: **UUIDv7 works here.** It is the natural answer on PostgreSQL and the wrong answer +on SQL Server. Never carry a sequential-GUID choice across the two. + +## The options + +### `Guid.CreateVersion7()` — .NET 9+, application-generated + +The default recommendation for new PostgreSQL schemas with UUID keys. + +```csharp +public sealed class Order +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); +} +``` + +- Time-ordered in byte layout, so index locality is good. +- The value exists before persistence, so outbox rows, correlation and client-side graphs all work. +- Standard (RFC 9562), so it is portable and readable by other systems. +- Embeds a millisecond timestamp — which is also its disclosure caveat. + +Tell EF Core the application supplies it: + +```csharp +builder.Property(o => o.Id).ValueGeneratedNever(); +``` + +Without that, EF Core may treat the key as store-generated and skip your value. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`gen_random_uuid()` (v4, from the built-in pgcrypto-derived function) and nothing native for v7. + +**Check the server version before recommending `uuidv7()`.** It is the single most common way this +guidance goes wrong — the function simply does not exist on the version most deployments are running. + +```sql +-- PostgreSQL 18 and later only +ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7(); +``` + +Prefer application-side generation anyway unless rows are inserted by paths that bypass the +application, because the value-before-persistence property is worth more than the marginal +difference. + +### `gen_random_uuid()` — database-generated v4 + +Available without an extension from PostgreSQL 13. Random, so index locality is poor, but +unpredictable and simple. + +### `Guid.NewGuid()` — application-generated v4 + +Random and unpredictable. Fine at low to moderate write volume, which covers most applications. + +## Npgsql and EF Core notes + +- `Guid` maps to `uuid` natively. Do not store UUIDs as `text` or `varchar` — it costs space, loses + index efficiency and defeats comparison. +- Npgsql's `uuid` mapping is byte-order faithful, so a v7 value sorts in the index as you expect. +- If the application generates the key, set `ValueGeneratedNever()` so EF Core does not fetch a + store-generated value that does not exist. +- A legacy database may have UUIDs in `text` columns. Migrating to `uuid` is usually worth it, but it + is a data migration, not a model change. + +## Choosing + +| Situation | Use | +| -- | -- | +| New schema, .NET 9+, UUID keys | `Guid.CreateVersion7()`, `ValueGeneratedNever()` | +| .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | +| Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | +| Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## The timestamp caveat + +A UUIDv7 exposes when the row was created, to the millisecond. Where identifiers are public, that can +leak signup times, order rates or activity patterns — and it makes identifiers partially guessable by +time window. Where that matters, use a random UUID for the public identifier and keep the v7 value +internal, or use a surrogate. diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 00000000..c7282955 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,101 @@ +# SQL Server GUID keys + +Read this only when the provider is SQL Server and GUIDs are primary keys. The PostgreSQL answer is +different — see `postgresql.md`. + +## Why SQL Server is a special case + +A clustered index determines physical row order. A `uniqueidentifier` primary key is clustered by +default, so random GUIDs insert into the middle of the index, causing page splits, fragmentation and +a larger buffer-pool working set. On a write-heavy table this is measurable. + +The complication is that **SQL Server does not compare `uniqueidentifier` values the way the bytes +are laid out.** Its ordering weights the last six bytes (node field) most significantly, then earlier +groups. So a GUID that is sequential in big-endian byte order — including a standard UUIDv7 — is +**not** sequential under SQL Server's comparison. This is precisely why the SQL Server and PostgreSQL +answers diverge. + +## The options + +### `NEWSEQUENTIALID()` — database-generated + +A column default that produces values ascending in SQL Server's own ordering. Best index locality +available. + +```sql +ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] + DEFAULT NEWSEQUENTIALID() FOR [Id]; +``` + +EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to +`NEWSEQUENTIALID()` through the default value-generation strategy. + +Trade-offs: + +- The value does not exist until the row is inserted. EF Core retrieves it, but you cannot build an + object graph around the key beforehand. +- Values are **not** unpredictable: the sequence restarts on service restart but is otherwise + guessable. Never use one as a security token or capability URL. +- It reveals rough insert ordering, which can be an information-disclosure concern on a public + surface. + +### EF Core's `SequentialGuidValueGenerator` — client-generated + +EF Core ships a value generator that produces GUIDs ordered for SQL Server's comparison semantics, +generated in the application. + +```csharp +builder.Property(o => o.Id) + .HasValueGenerator() + .ValueGeneratedOnAdd(); +``` + +Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality +while letting the application own the value. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +Appropriate when write volume is low (the fragmentation never matters), when the key is +non-clustered, or when unpredictability is a requirement. Do not dismiss it by default — for most +applications it is fine, and "most applications" includes more than people expect. + +### `Guid.CreateVersion7()` — .NET 9+ + +Ordered in byte layout, but **not** ordered under SQL Server's comparison. On SQL Server it behaves +roughly like a random GUID for clustered-index purposes. It is the right answer on PostgreSQL and the +wrong answer here, which is the trap this page exists to prevent. + +Use it on SQL Server only when you want the embedded timestamp for application reasons and accept +random-like insert locality. + +## Decoupling the key from the index + +Often the best answer is not a different GUID but a different index. Keep the GUID as the primary key +and make the clustered index something naturally ascending: + +```csharp +builder.HasKey(o => o.Id).IsClustered(false); +builder.HasIndex(o => o.CreatedAt).IsClustered(); +``` + +This removes the page-split problem entirely and leaves the key unpredictable. Consider it before +reaching for a sequential generator. + +## Choosing + +| Situation | Use | +| -- | -- | +| High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## Measure before optimising + +Index fragmentation from random GUIDs is real but frequently irrelevant. Before changing a key +strategy, confirm it matters: check page splits, fragmentation and buffer-pool pressure on the actual +table at the actual volume. Changing a primary key strategy on a live table is expensive; do it +against evidence. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md new file mode 100644 index 00000000..ee363c97 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -0,0 +1,334 @@ +--- +name: purview-sourcegenerator-codewriter +description: >- + Use when implementing, reviewing or refactoring C# source generators and analysers built on + Purview.SourceGeneratorFramework. Covers CodeWriter and XmlCommentWriter emission, scope safety, + incremental pipeline design, value equality, deterministic output, and migrating a legacy + string/StringBuilder emitter to CodeWriter. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Source generator CodeWriter modernization + +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. + +Refactoring an existing emitter off `string`/`StringBuilder`? The step-by-step playbook is in +[references/legacy-emitter-migration.md](references/legacy-emitter-migration.md). + +## Required implementation pattern + +When creating source output, favor this shape: + +1. Build immutable, value-equatable pipeline values first. +2. Register source output. +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` +4. Write header/usings/namespace. +5. Write structured types and members using declaration options records. +6. Add source once per output artifact. + +### CodeWriter lifetime: created in the output callback, never in pipeline state + +The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of +creating a writer or handing it to a helper. + +**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the +callback and pass it to any emitter/helper methods called from that same callback. It may be held in +local variables, passed as a parameter, or wrapped in a short-lived output context: + +```csharp +context.RegisterSourceOutput( + targets.CombineWithContext(contextProvider), + static (spc, pair) => + { + var (model, generationContext) = pair; + var writer = generationContext.CreateCodeWriter(); + EmitHeader(writer, model); + EmitType(writer, model); + spc.AddSource($"{model.Name}.g.cs", writer.ToString()); + } +); +``` + +**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier +in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can +observe it: + +- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. +- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. +- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. +- Do not cache or reuse a writer for a later callback or a different output. + +A cached writer can retain previously written source, mix output from concurrently running +callbacks, and defeat scope tracking. A writer created in the callback and used only within that +callback is safe and expected. + +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter + +### File and namespace + +- `AutoGeneratedHeader(...)` +- `Using(...)` +- `FileScopedNamespace(...)` or `BlockNamespace(...)` +- `OpenPragmasScope(...)` for warning suppression scopes + +### Types and members + +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` +- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` +- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) + +### XML documentation + +Use `XmlCommentWriter` extension methods on `CodeWriter`: + +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` +- `XmlCode(...)` / `XmlCodeBlock(...)` +- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` + +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. + +## Refactoring guide: string/StringBuilder -> CodeWriter + +Apply this checklist in order: + +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReference`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. + +## Anti-patterns to remove during refactors + +- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. +- Manually writing `{` / `}` around methods and types where scope APIs exist. +- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. +- Raw XML tag string composition when XML extension methods can enforce consistency. +- Creating a `CodeWriter` before the output callback and passing it through the pipeline. +- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. +- Sharing one `CodeWriter` across multiple generated outputs or callbacks. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. + +## Review checklist for pull requests + +- Generated declarations use structured APIs for types and members. +- XML docs use XML extension methods rather than raw `///` fragments. +- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it + to helper methods within that callback is expected. +- Header and generated attributes are deterministic and consistent. +- Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md new file mode 100644 index 00000000..54efd09f --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md @@ -0,0 +1,58 @@ +# Migrating a legacy emitter to CodeWriter + +A playbook for moving a source generator's emission from manual `string` / `StringBuilder` +construction to `CodeWriter` plus the `XmlCommentWriter` documentation extensions, with behaviour +parity. + +## Before you start, collect + +- The emitter file(s) being changed. +- The generator type name, and the version string it stamps into generated attributes or headers. +- Whether the output must stay **byte-identical**. If it must, every whitespace decision below is a + constraint rather than a preference, and the diff of the generated snapshots is the acceptance + test. + +## Requirements + +1. Use the structured declaration APIs rather than hand-written declaration text: + `Class` / `Struct` / `RecordClass` / `Interface` / `Enum`, and `Method`, `Property`, `Field`, + `Constructor`. +2. Use the XML helper extensions instead of composing `///` lines: `XmlSummary`, `XmlParam`, + `XmlReturn`, `XmlRemarks`, `XmlCode` / `XmlCodeBlock`. +3. Use `TypeReference` once the type text gets complex — nullability, generics, arrays. +4. Keep the writer **output-scoped**: `generationContext.CreateCodeWriter()` *inside* the output + callback. Never create it earlier in the pipeline, and never store it in incremental provider + state or in a custom context. +5. Preserve behaviour, diagnostics and generated names. +6. Keep the change minimal. Do not reformat logic the migration does not touch. + +## Strategy + +Work phase by phase, because each phase has a different mechanical replacement: + +| Emitter phase | Replace with | +| --- | --- | +| header | the header helper, not a literal banner string | +| namespace | the namespace scope API | +| type declarations | declaration option records, not signature strings | +| member declarations | `Method` / `Property` / `Field` / `Constructor` | +| indentation and braces | scoped APIs — never manual `{`/`}` and `\t` | +| `///` comment composition | `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` | + +Call out any intentional semantic delta explicitly; a migration that quietly changes emitted names or +diagnostics is the failure mode this playbook exists to avoid. + +## Verification + +- Run the impacted tests. +- Confirm the generated files still compile. +- Confirm there are **no scope leaks**: `OpenScopeCount == 0` when the writer is materialized. Enable + CodeWriter scope validation in the test run. +- Where byte-identical output was required, diff the generated snapshots. + +## Report + +1. Files changed. +2. Why each change was necessary. +3. Risks and behaviour differences, if any. +4. The verification that actually ran. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md new file mode 100644 index 00000000..21d9522c --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,142 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Purview.SourceGeneratorFramework diagnostics + +`Purview.SourceGeneratorFramework` ships `Purview.SourceGeneratorFramework.Analyzers` and +`Purview.SourceGeneratorFramework.CodeFixers` as analyzer assets inside the package, under +`analyzers/dotnet/cs/`. They are enabled automatically in any project that references the framework — +there is no separate package to add. + +Two compiler-analyzer families, plus a set of MSBuild-time diagnostics: + +- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`: pipeline design, `CodeWriter` usage, + Roslyn component discovery, extension-class conventions. +- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, with + `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +- **Merge and packaging** — `PSGFR39`–`PSGFR43`, where `PSGFR43` is the merge tool's blocked + public-surface code. + +## Start here when nothing runs + +A compiler host loads a generator, analyzer or code fix provider only when **three** conditions hold. +Missing any one means the component is **silently ignored** — no error, no output, a green build: + +1. **The type is public** (`PSGFR27`). +2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or + `[ExportCodeFixProvider]` (`PSGFR24`). +3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or + referenced with `OutputItemType="Analyzer"` on a `ProjectReference`. + +A code fix provider additionally appears only when a diagnostic ID in its `FixableDiagnosticIds` is +actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart +Visual Studio or reload the project before the fixes show up. + +## Build-time validation diagnostics + +These are MSBuild diagnostics, not compiler analyzers, so they are **not** tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +| --- | --- | --- | +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither in the closure nor a compiler-host assembly. | +| `PSGFR41` | merge tool | A component's public surface exposes framework types the merge internalizes. Reported by the analyzer at design time, and by the merge pass as `message` (default), `warning` or `error`. | +| `PSGFR43` | merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + +## Incremental pipeline rules + +| Rule | Summary | +| --- | --- | +| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | +| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | +| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | +| `PSGFR15` | Pipeline model collection members should use sequence equality (`EquatableArray`). | +| `PSGFR30` | Prefer `static` lambdas in pipeline methods, so the compiler never allocates a closure on the per-item hot path. | +| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | +| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, …); extract the information into value types. | + +## CodeWriter rules + +| Rule | Summary | +| --- | --- | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | +| `PSGFR17` | Consume scope-returning methods (`…Scope`, `IndentedScope`) with `using`. | +| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over hand-constructing `*DeclarationOptions` values. | +| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif`. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning`. | +| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | +| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode`. | +| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | + +## Component, extension and merge rules + +| Rule | Summary | +| --- | --- | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` names a diagnostic no analyzer in the compilation produces; the fix will never be shown. | +| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | +| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | +| `PSGFR36` | Extension classes belong in the extended type's namespace, under an `Extensions` folder. | +| `PSGFR37` | One extension class per receiver type; split a class that extends several. | +| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component opted out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework; otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In a Roslyn component (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) rather than a `cref`: copied documentation must not depend on cref resolution. | +| `PSGFR41` | A public member (or generic constraint) whose signature references an SGF type in a component whose framework implementation is merged. The merge internalizes every SGF type, so the signature ends up referring to an internal type. Make the member, or its declaring type, non-public. | +| `PSGFR42` | A disposable Testing-harness result (`DriverRunResult`) is produced and never disposed, leaking the collectible `AssemblyLoadContext` and `MetadataLoadContext` of a compiled run. `warning` when the run syntactically compiles to an assembly (a `Compile` options member, or `CompileToAssembly = true`), otherwise `info`. Awaiting an assertion, or calling a helper handed the result — an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance back, so only a task-like await or a genuine producer is reported. | + +## Type-library and attribute-model diagnostics + +The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are +documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +## Code fixes + +Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: + +| Fix provider | Rule | +| --- | --- | +| `AddGeneratorAttributeCodeFixProvider` | `PSGFR26` | +| `AddDiagnosticAnalyzerAttributeCodeFixProvider` | `PSGFR25` | +| `AddExportCodeFixProviderAttributeCodeFixProvider` | `PSGFR24` | +| `MakeRoslynComponentPublicCodeFixProvider` | `PSGFR27` | +| `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` | `PSGFR28` | +| `PreferTargetSymbolCodeFixProvider` | `PSGFR31` | +| `PreferStaticLambdaCodeFixProvider` | `PSGFR30` | +| `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | +| `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | +| `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `CodeWriterToStringCodeFixProvider` | `PSGFR29` | +| `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | +| `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | +| `DisposeTestResultCodeFixProvider` | `PSGFR42` | +| `ReorganizeExtensionClassCodeFixProvider` (renames, splits, moves, updates referencing files) | `PSGFR35`, `PSGFR36`, `PSGFR37` | +| `ConvertToExtensionBlockCodeFixProvider` | `PSGFR34` | +| `AddExtensionClassMetadataCodeFixProvider` | `PSGFR38` | +| `MakeTypeLibrarySpecNonPublicCodeFixProvider` | `TLB0021` | +| `AttributeDataModelSymbolPropertyCodeFixProvider` | attribute-data-model symbol properties | +| `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, `TypeLibraryMemberTypeCodeFixProvider` | type-library rules | + +## Related skills + +`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you +towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 00000000..a0e7f802 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,249 @@ +--- +name: purview-sourcegenerator-packaging +description: >- + Use when packaging or referencing a source generator built on Purview.SourceGeneratorFramework - + the self-contained merged analyzer, GetSourceGeneratorAnalyzerFiles, embedding a generator in + another package, PurviewGeneratorVisibleProperty, analyzer-closure validation, Roslyn version + compatibility, and errors such as CS0433, CS8784 or MSB3073 from a loaded analyzer. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Packaging a source generator + +The rule everything else follows from: **a shipped Roslyn component carries its own copy of the +framework and references no loose `Purview.SourceGeneratorFramework.dll`.** Two generators built +against different framework versions must be able to load into one compiler host. A shared DLL under +`analyzers/` cannot give you that — whichever version loads first wins, and the other fails with +`MissingMethodException` or `CS0433`. + +The framework delivers this by IL-merging and internalizing its implementation into each component's +**intermediate** output, leaving the component's `bin` unmerged. + +## Referencing the framework from a component + +```xml + + true + + + + + +``` + +And from a consuming project, as an analyzer: + +```xml + +``` + +`Purview.BuildSdk` invokes `GetSourceGeneratorAnalyzerFiles` automatically — naming +`Targets="GetSourceGeneratorAnalyzerFiles"` explicitly still works but is not required. + +## The three consumption paths + +| Path | Trigger | What you get | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | a consuming project references the component as an analyzer | the **merged** component, from the content-addressed `purview-merged//`. `bin` stays unmerged, so an in-process test harness keeps shared framework type identity and `InternalsVisibleTo` access without `CS0433`. | +| `GetSourceGeneratorAnalyzerFiles` with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | opt-out | unmerged component **plus** the loose framework DLL from the package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | the framework's own bundled-component pack, or a third-party package embedding the generator | the merged component; `bin` is never overwritten. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | packing a standalone packable generator | the merged DLL and PDB contributed straight to `analyzers/dotnet/cs`. `bin` is never mutated, so its shape does not depend on whether `build` or `pack` ran last. | + +Two properties exist and both should normally be left alone: + +- `PurviewEmbedSourceGeneratorFramework=false` — only for a project that *is* the framework + compile-time library. A published generator package must never disable embedding. +- `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` — only when the analyzer-files + consumer must keep the unmerged + loose-DLL shape (a generator shipped into one compiler process + alongside an incompatible framework version). + +`PSGFR39` errors when a **non-packable** component opts out while embedding the framework, because +that shape, packed through the GASF path, forces the loose DLL back under `analyzers/`. + +## A component that references another component + +A code-fix or analyzer component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` **not** `false`) when it needs the generator's internal diagnostic +identity. Both are `IsRoslynComponent`; only the generator references the framework. + +- The referenced component is returned as **its own merged assembly** and is never IL-merged into the + dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, + inside a second analyzer in the same host. +- The framework assembly is copied beside the generator's `bin` (a plain copy, not copy-to-output + content). A dependent component that references the generator as an assembly gets it copied + beside its own `bin` by ordinary assembly-reference resolution, which is what lets Visual Studio + load the dependent code-fix provider without a `FileNotFoundException` for the framework assembly. +- A project that references a component only as an analyzer (`ReferenceOutputAssembly="false"`) + never receives the framework assembly, and neither does any application downstream of it. Since + 1.2.2 it no longer leaks into app build output, `deps.json`, publish output, single-file bundles or + Blazor WebAssembly `_framework`, where it broke Native AOT and WebAssembly AOT publishing. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + closure, so Roslyn only ever receives the merged artifact. Without that removal the unmerged copy + drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and every generator registers twice. + +## Analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure `GetSourceGeneratorAnalyzerFiles` returns: every +file's PE `AssemblyRef` must resolve to another file in the returned set, or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. + +Extend the permitted set with `` or the +`PurviewAnalyzerClosurePermittedReferences` property. Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +## Generator-read MSBuild properties + +A generator reading `build_property.` is only correct if `` is a +`CompilerVisibleProperty` **wherever the generator runs** — including in a consumer's project. +Declare what the component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets — which is what +consumers actually receive. Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. Inside the package +repository every project uses `ProjectReference`, so `Purview.BuildSdk`'s +`ImportProjectReferencedBuildTransitiveAssets` registers the referenced project's +`Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo consumers. Opt +out with `PurviewImportProjectReferenceBuildTransitive=false`. + +## Embedding a generator in another package + +Pack the **merged** assembly, so the outer package ships no loose framework DLL: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator is also embedded at compile time for the outer package's consumers, the outer +package must make the framework's compiler-visible properties visible to them — build assets from +`Purview.SourceGeneratorFramework` are **not** copied into the outer package. Ship a `.props` that +declares the property and its `CompilerVisibleProperty` entry, packed under the outer package's own +ID so NuGet imports it: + +```xml + +``` + +## `IsExternalInit` + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly**, so the +bundled generators can emit `init`-based attribute types into any consumer compilation and the merge +has a single marker definition to internalize. + +**Do not declare your own.** A generator-local marker gives calls to the framework's `init` setters a +different required-custom-modifier identity from the setter definitions. The merge tool normalizes +those modifiers in a temporary copy for compatibility with legacy tooling, but removing the redundant +marker is the preferred configuration. + +## Roslyn version compatibility + +> The version of `Microsoft.CodeAnalysis.*` you compile the component against sets a **minimum +> compiler-host API requirement**. The consumer's `` has nothing to do with it — +> analyzer code runs inside the compiler/IDE host. + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later +(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and +Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. + +## A generator project to start from + +```xml + + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + + + +``` + +`Purview.BuildSdk` already defaults most of this for a project it classifies as a Roslyn component, +so in an SDK repository the project file is usually far shorter. The exact Roslyn baseline is a +product-support decision. + +## Release gates + +Acceptance criteria for self-contained packaging — check metadata, never "the sample compiled": + +1. **No framework reference, no public framework types.** Every `analyzers/dotnet/cs/*.dll` has no + assembly reference to `Purview.SourceGeneratorFramework` and exposes no public type in a + framework-owned namespace (including the generated `Generators` attribute set and the + framework-emitted `Microsoft.CodeAnalysis.EmbeddedAttribute`). The one exception is the + component's own Roslyn entry points, which must stay public — Roslyn only instantiates public + components (`PSGFR27`). +2. **No loose framework DLL in any package**, and no `*.pdb` in the `.nupkg` — symbols ship only + through the `.snupkg`. +3. **Merged entry points survive**: each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer` or `CodeFixProvider`. +4. **Installed-package consumer test**: a generator project referencing the framework by + `PackageReference` packs one self-contained DLL under `analyzers/dotnet/cs`, and a consumer that + installs it builds with the generator producing output. +5. **Two-version coexistence test**: build Generator A against the current framework and Generator B + against an intentionally binary-incompatible version, install both into one consumer, and build + with both package-reference orders. Both must run against their own embedded copy. + +## Going deeper + +The merge pass, the content-addressed artifact, framework type internalization and the properties +that tune it are in [references/merge-internals.md](references/merge-internals.md) — read it when a +merge fails, when a type leaks, or when you hit `MSB3073` / `CS0433` / `CS8784` from a loaded +analyzer. diff --git a/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md new file mode 100644 index 00000000..5d8b6c9e --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md @@ -0,0 +1,139 @@ +# The merge pass and the self-contained analyzer + +## Where the merged artifact lives + +`_PurviewMergeSourceGeneratorFramework` only ever writes to the component's **intermediate** output. +`GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged path into +`TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving `GetTargetPath` — +which resolves assembly references — pointing at the unmerged `bin`. That split is what keeps the +in-repo test harness working while shipped assemblies stay self-contained. + +The artifact is **content-addressed**: + +```text +$(IntermediateOutputPath)purview-merged//$(TargetFileName) +``` + +`` is a SHA-256 over the component, the framework assembly and the merge tool (the tool +computes it in its `--tag` mode). The tag is in the **directory**, never the file name, because +ILRepack derives the merged assembly's simple name from the output file name and an analyzer must +keep `.dll`. + +Two properties follow, and both matter: + +- A rebuild with identical inputs reuses the existing file and **skips the merge**, so + `_PurviewMergeSourceGeneratorFramework` is idempotent across the per-consumer — and potentially + parallel — invocations of `GetSourceGeneratorAnalyzerFiles`. +- Changed inputs select a **new directory**, so the merge never overwrites an assembly a compiler + host (csc, `VBCSCompiler`, Visual Studio) already has loaded. Overwriting such a file fails on + Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code + `-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of + `CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`. + +The tool merges into a per-process `.staging-` directory and publishes it with a +non-overwriting directory rename. A concurrent invocation that loses the race simply observes the +published artifact rather than failing. + +Packaging needs the stable `.dll` name, so the pack paths +(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed +artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy. + +## Framework type internalization + +ILRepack's `Internalize` is best-effort, and two gaps matter: + +- a framework type that reaches the merged component's public API surface stays public; and +- the types the framework's own generators emit **into** the component — the + `Purview.SourceGeneratorFramework.Generators` attribute set, generated type libraries, marker + attributes — are not part of the merged framework assembly at all, so ILRepack never sees them. + +Either gap leaks framework types out of what must be a self-contained analyzer, and any project +loading that analyzer alongside the real framework assembly fails with `CS0433` ambiguity for every +leaked type. + +So the merge tool runs a deterministic internalization pass (`FrameworkTypeInternalizer`) after +ILRepack finishes: + +- Every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children, + including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated + through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type: + the framework's nested containers — generated type-library namespace classes, nested operator/enum + groups, `CodeWriter` scopes — are only reachable through their declaring type. +- Types the framework's generators emitted into the component, recognized by the tool name on their + `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`, + `AttributeDataModelGenerator`), become non-public too, so a generated type library can never leak a + `TypeIdentity`/`TypeReference` member as public API. +- `Microsoft.CodeAnalysis.EmbeddedAttribute`, the framework-emitted marker, becomes non-public. +- **Roslyn component entry points are never internalized.** A generator, analyzer, code fix provider + or refactoring provider reachable from the framework namespace stays public, because Roslyn only + instantiates public components (`PSGFR27`). This is what keeps the framework's own bundled + analyzers working after the pass. +- The pass reports the result. Leftover public framework types fail the merge with **exit code 5** + (`PSGFR43`). Public component members whose signature exposes a framework type are logged + (`PSGFR41`) so the author can make them, or their declaring type, non-public. Visibility is + followed through the declaring chain here too: a public nested type inside an internal type — for + example the compiler-synthesised `$`/`$` extension containers emitted for an extension class + — is not reachable from outside the component, so it is not part of the public surface and is not + reported. + +Ownership is seeded with the framework assembly's own type identities, so a framework type living +*outside* the framework namespace (`Microsoft.CodeAnalysis.*Extensions`, `System.StringExtensions`) +is internalized as well. The artifact's assembly-level `InternalsVisibleTo` / +`IgnoresAccessChecksTo` grants are removed, because a shipped analyzer is not the component's own +assembly. + +## Tuning the pass + +| Property | Default | Effect | +| --- | --- | --- | +| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. | +| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. | +| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none`. | +| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. | +| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; point it at a source or project path for IDE navigation. | + +All five participate in the content tag, so changing one re-runs the merge — and re-emits its +findings — rather than reusing a cached artifact. The compiler analyzer with the same id (`PSGFR41`) +reports the same condition while you edit, so the surface is usually fixed before the merge runs. + +## Generated accessibility: bin versus merged artifact + +The component's own generated types — the type library, the attribute data models — keep their +generated accessibility in the component's **bin output**. `TLB0015` requires a hand-written partial +to be `public static partial` so it can merge with the generated library, and in-repo consumers such +as code fixers and sibling assemblies compile against it. + +Only the **merged analyzer artifact** internalizes them. Self-containment is therefore enforced at +the merge boundary without rewriting the accessibility an in-repo consumer compiles against, and that +split is safe precisely because a merged artifact is never referenced as a compile-time dependency. + +> A merged component is an analyzer artifact. Never reference it as a compile-time dependency. Tests +> that need to run a *packaged* generator load it out of band. + +## How the framework package itself is assembled + +`Purview.SourceGeneratorFramework` is dual-role: the framework assembly ships in `lib/` so generator +projects compile against it, and `analyzers/` carries self-contained generator, analyzer and +code-fixer assemblies. The bundled projects are: + +- `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; +- `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; +- `SourceGeneratorFramework.CodeFixers` — the code fix providers. + +Each has the framework implementation merged and internalized into it. The package deliberately does +**not** put `Purview.SourceGeneratorFramework.dll` under `analyzers/dotnet/cs`, so generators built +against different framework versions never ask Roslyn to load competing versions of a same-named +dependency. + +The shared models and helpers that once shipped as a separate +`Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly +(`SourceGeneratorShared.Link.targets` links the sources). Consumers get one +`Purview.SourceGeneratorFramework.dll`, which removes the version-skew hazard a separately bundled +Shared assembly caused — generator packages loading a different Shared version in-process failed with +binary-incompatibility errors, such as removed `PurviewTypeLibrary` fields. + +Those projects are `IsRoslynComponent = true` and are **not** packable on their own. The +`SourceGeneratorFramework` project builds them via `GetPurviewMergedAnalyzerFile` and packs them +under `analyzers/dotnet/cs/` in `BuildAndPackBundledAnalyzerAssemblies`. They were previously +consumed as analyzer project references, but they now reference the framework assembly for the shared +types, which would form a project-reference cycle. diff --git a/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md new file mode 100644 index 00000000..373942fd --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md @@ -0,0 +1,231 @@ +--- +name: purview-sourcegenerator-pipeline +description: >- + Use when composing an incremental pipeline with Purview.SourceGeneratorFramework - + IncrementalPipeline helpers, GenerationContext and custom contexts, CreateCodeWriter lifetime, + GeneratorResult and blocking versus non-blocking diagnostics, disabling a generator from MSBuild, + scope validation, test logging, and stage tracking names. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Incremental pipeline composition + +`IncrementalPipeline` composes `IncrementalValueProvider` and `IncrementalValuesProvider`: +attribute-based discovery, generation-context creation, disable-property checks, and thin +source-output registration. + +Everything here follows from one rule: **pipeline values must be immutable and value-equatable.** +Roslyn caches the complete value a provider publishes, and offers no way to exclude one property of +it from caching. + +## `GenerationContext` + +A base execution-services context carrying the Roslyn `Compilation`, immutable `GenerationSettings`, +an optional `ISourceGenLogger`, and a factory for independently owned `CodeWriter` instances. + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); +``` + +### Custom contexts + +A custom context does not need to accept or read build properties itself: + +```csharp +public sealed class MyGenerationContext : GenerationContext +{ + public MyGenerationContext(Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger) + : base(compilation, settings, logger) + { + } +} + +var contextProvider = IncrementalPipeline.GenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + factory: static (compilation, settings, logger, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new MyGenerationContext(compilation, settings, logger); + }, + disablePropertyName: "MyGenerator_Disable" +); +``` + +The provider resolves scope validation, generator disabling and test logging from analyzer-config +properties *before* invoking the factory. The logger is created internally only when logging is +enabled and a sink is registered for that run. + +## `CodeWriter` lifetime — the rule that bites + +Treat `GenerationContext` as **cached incremental state** and each `CodeWriter` as **mutable, +output-scoped execution state**. Create the writer inside the registered source-output callback, +after the incremental cache boundary: + +```csharp +IncrementalPipeline.RegisterSourceOutput( + context, + targets, + contextProvider, + static (spc, target, generationContext) => + { + var writer = generationContext.CreateCodeWriter(); + EmitTarget(generationContext, writer, target); + spc.AddSource($"{target.Name}.g.cs", writer.ToString()); + } +); +``` + +Creating it in the callback and passing it to emitter helpers called from that same callback is the +intended pattern. The only forbidden thing is **persisting the writer in pipeline state**. + +Why, concretely: + +- Roslyn caches the whole published value; you cannot exempt a field. +- `CodeWriter` is mutable, so a cached one retains previously written source when the context is + reused for another output or run. +- Source-output callbacks may process independent targets **concurrently**; a shared writer mixes + their output and races. +- A fresh writer gives each generated source independent scope tracking and deterministic ownership. + +`CreateCodeWriter()` returns a new, independently owned instance every call. + +**Never add a `CodeWriter` property or field to a class derived from `GenerationContext`.** A custom +context is still produced by an incremental provider and cached as one complete value. Store only +compilation-derived services and immutable configuration there. + +When an emitter needs both services and writing, pass the context and the writer separately, or +compose a short-lived wrapper created inside the callback — which must never be returned from a +provider: + +```csharp +public sealed class GenerationOutputContext : ISourceGenLogger + where TContext : GenerationContext +{ + public GenerationOutputContext(TContext generation) + { + Generation = generation; + Writer = generation.CreateCodeWriter(); + } + + public TContext Generation { get; } + + public CodeWriter Writer { get; } + + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => + Generation.Log(level, indentation, message, args); +} +``` + +## `GeneratorResult` and diagnostics that do not stop generation + +`RegisterSourceOutput` combines targets with the generation context, reports diagnostics, and runs +the callback only for successful results: + +```csharp +var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + AttributeType, + static (ctx, ct) => + { + var symbol = ctx.TargetSymbol; + + return symbol is null + ? GeneratorResult.Empty + : GeneratorResult.Create(symbol.Name); + } +); +``` + +The callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result carries a value +**and** none of its diagnostics are blocking. + +`ReportableDiagnostic.IsBlocking` is an explicit per-diagnostic decision, **independent of +severity**. An `Error`-severity diagnostic can still allow generation to continue, which is what you +want when the generated code is what helps the developer fix the problem — a generator emitting an +abstract base class can report an error per missing override while still emitting the base class, so +the user can see exactly what to implement: + +```csharp +static readonly DiagnosticDescriptor MissingOverride = new( + "MYGEN001", + "Missing override", + "Type '{0}' must override '{1}'", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true +); + +// IsBlocking is false, so ShouldProcess stays true and the base class is still emitted. +var diagnostic = ReportableDiagnostic.Create(MissingOverride, isBlocking: false, symbol, symbol.Name, "Execute"); + +return GeneratorResult.Create(model, diagnostic); +``` + +`isBlocking: true` stops generation for that target while still reporting. +`HasBlockingDiagnostics` reports whether any carried diagnostic blocked; +`HasErrorDiagnostics` reports the severity-based view. + +## Disabling a generator at build time + +Pass the compiler-visible disable property to the context provider; its resolved value lands in +`GenerationSettings` automatically: + +```xml + + true + +``` + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + disablePropertyName: "MyGenerator_Disable" +); + +// In the output stage: +if (generationContext.Settings.IsSourceGeneratorDisabled) + return; +``` + +`IsDisabledValueProvider` is still there for when expensive upstream transforms must be filtered +before being combined with the generation context. + +Declare the property with `PurviewGeneratorVisibleProperty` so it is compiler-visible wherever the +generator runs — `PSGF0003` otherwise fails the build. See the `purview-sourcegenerator-packaging` +skill. + +## Scope validation + +The default context provider reads +`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` and threads it into +`GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws +`CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. + +## Test logging + +Framework logging is off in ordinary compiler runs. The testing integration enables it by registering +an isolated sink and supplying a per-run session ID through analyzer config. Context providers create +the internal logger automatically — generators implement no logging interface and no logging-support +source is generated. + +The sink registry stores **callbacks only**; it never buffers entries. With logging disabled, a +missing session ID, or no matching sink, the provider supplies no logger and log calls are discarded. +Test sinks own whatever they capture and are removed when the run completes. + +## Tracking names + +The pipeline helpers assign a tracking name to every stage, so a cache test can assert which stages +were recomputed. The `purview-sourcegenerator-testing` skill covers the stage matrix and the four +scenarios a cache test should assert. + +## Related skills + +`purview-sourcegenerator-codewriter` for emission, `purview-sourcegenerator-diagnostics` for the +`PSGFR` rules that enforce most of the above, and `purview-sourcegenerator-testing` for proving the +pipeline caches. diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md new file mode 100644 index 00000000..aaeff09e --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,404 @@ +--- +name: purview-sourcegenerator-testing +description: >- + Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting + incremental pipeline caching stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`purview-sourcegenerator-tunit-tests` skill. + +Modernising an existing suite onto `CodeQuery` and adding a stage-by-stage cache test? The playbook +is in [references/test-modernisation.md](references/test-modernisation.md). + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `purview-sourcegenerator-tunit-tests`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +**Always write `using var result = ...`.** `PSGFR42` ("Dispose test harness results with a using +declaration") flags a `DriverRunResult` that is produced and never disposed — including the indirect case +where a helper creates a result, projects a value out of it and returns only the projection. It is a +`warning` when the run syntactically compiles to an assembly (`.Compile()`, a `Compile` options member, or +`CompileToAssembly = true`) and `info` otherwise; the `DisposeTestResultCodeFixProvider` fix inserts the +`using`. A result that is returned to the caller, or disposed explicitly, is not flagged. Neither is awaiting +an assertion, nor calling a helper that is handed the result — an extension on it, or a method taking it as a +parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and +`result.AssertNoGenerationExceptions()` are not flagged. + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). +- **Dispose results**: `using var result = ...`, enforced by `PSGFR42`. +- **Cap parallelism**: the harness shares one `MetadataReference` set per process, so memory is largely flat + in the degree of parallelism, but the platform's auto-detected default is both slower and heavier than an + explicit ceiling. Wall clock saturates around four concurrent tests; prefer + `--maximum-parallel-tests 8` (or a `TestingPlatformCommandLineArguments` equivalent) for a project that + multi-targets and runs several TFM executables at once. + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### Log entries per run + +`IncrementalCacheRun.LogEntries` carries the framework log entries that run wrote, in order. The sink is +registered for the whole sequence and drained between runs, so entries never accumulate. A rerun whose +output step is cached logs nothing, which makes it a second signal alongside the step reasons: + +```csharp +await Assert.That(result.Runs[0].LogEntries).IsNotEmpty(); +await Assert.That(result.Runs[1].LogEntries).IsEmpty(); +``` + +`DriverRunResult.LogEntries` (the `RunAsync` path) is likewise in write order. Both are empty when +`EnableLogging` is disabled. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md new file mode 100644 index 00000000..6db47803 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md @@ -0,0 +1,63 @@ +# Modernising a Roslyn test suite + +A playbook for moving an existing test suite onto `CodeQuery` plus the TUnit assertion extensions, +and adding a stage-by-stage incremental cache test. + +## Before you start, collect + +- The test file(s) being changed. +- The Roslyn component under test — generator, analyzer, code fix or refactor (infer it from the + base class if the task does not say). +- The component's type name. + +## Requirements + +### 1. The right base class and method + +| Component | Base class | Method | +| --- | --- | --- | +| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync` | +| Analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync` | +| Code fix | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync` / `ApplyFixAllAsync` | +| Refactor | `TUnitRefactoringTestBase` | `RefactorAsync` | + +### 2. `CodeQuery` instead of string matching + +Replace `GetGeneratedTree(...)` + `string.Contains(...)` with `CodeQuery` +(`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions — +`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)` — which return the +node so a follow-up assertion has something to work with. + +### 3. `TypeReference` instead of signature strings + +Match parameter and return types structurally. A signature string breaks on a whitespace or +nullability change that means nothing. + +### 4. Options in one place + +Derive a `SourceGeneratorTestOptions` record that seeds the required namespaces and additional +assemblies, and remove the per-test duplication it replaces. + +### 5. A stage-by-stage cache test + +Use `RunIncrementalAsync`, or `GenerateIncrementalAsync` on the TUnit base, and assert the four +scenarios. `ServiceRegistrationCacheTests` and `IncrementalPipelineCacheTests` are the reference +pattern. + +| Scenario | Expected | +| --- | --- | +| First run | every framework stage `New` | +| Identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) | framework stages `Cached`/`Unchanged` | +| Source-only change | `ForAttribute_*` `Modified`; property/config stages stay `Cached` | +| Property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) | `GetMSBuildPropertyValue_*`, `GetGenerationConfiguration`, `GetGenerationContext_*` `Modified`; `ForAttribute_*` stays `Cached` | + +Use the `StepReasons(IncrementalCacheRun)` flattening helper. If the generator depends on its own +post-init output, assert on the framework-named stages rather than on every tracked step. + +### 6. Minimal change + +Keep behaviour equivalent, and do not reformat unrelated tests. + +## Verification + +Build the test project and run its suite before finishing. diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md new file mode 100644 index 00000000..bc711c59 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,230 @@ +--- +name: purview-sourcegenerator-tunit-tests +description: >- + Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + modernising existing tests. +metadata: + product: Purview.SourceGeneratorFramework.Testing.TUnit + repository: purview-dev/sourcegenerator-framework +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`purview-sourcegenerator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +Write `using var result = await GenerateAsync(...)`: `DriverRunResult` is `IDisposable`, and `PSGFR42` +flags a result that is produced and never disposed (a `warning` once the run compiles to an assembly, +`info` otherwise). It also catches the indirect case — a helper that creates a result, projects a value out +of it and returns only the projection. Awaiting an assertion, or calling a helper that is handed the result — +an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance +back, so neither is flagged. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `purview-sourcegenerator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `purview-sourcegenerator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- `result.Runs[n].LogEntries` carries the framework log entries *that run* wrote, in order, so a rerun whose + output step is cached can be asserted to have logged nothing. Empty when `EnableLogging` is disabled. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `purview-sourcegenerator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `purview-sourcegenerator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md new file mode 100644 index 00000000..6d831731 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,123 @@ +--- +name: purview-telemetry +description: >- + Use when adding, reviewing or scaling observability in a .NET project — logging, tracing, metrics, + OpenTelemetry wiring — or when deciding whether a project is "too small for telemetry". Recommends + Purview.Telemetry.SourceGenerator at every project size, keeps libraries free of global provider + configuration, and scales the export infrastructure rather than the instrumentation. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview telemetry + +**Telemetry is a baseline, not an advanced topic.** Instrument from the first commit at every project +size. What changes with scale is the *export and operations* story — collectors, dashboards, sampling +— not whether the code emits anything. + +A project that defers telemetry until it becomes distributed acquires its observability at exactly +the moment it can least afford the refactor. + +## Instrumentation versus export + +Keep these separate; conflating them is what produces "we're too small for telemetry". + +| | What it is | Who owns it | +| -- | -- | -- | +| **Instrumentation** | The log, span and measurement the code emits | Every project, every size, from day one | +| **Export** | Where signals go: console, OTLP, a backend, a dashboard | The application host, sized to the operation | + +A small CLI is fully instrumented and exports to the console. That is not a reduced form of +telemetry; it is the correctly sized export for a CLI. + +## Use the generator + +`Purview.Telemetry.SourceGenerator` turns an attributed interface into logging, tracing and metrics +implementations plus a DI registration extension. One declaration, three signals, no hand-written +`ILogger` message templates or `ActivitySource` plumbing. + +Do **not** reinvent its API here. The three shipped skills are authoritative and travel with the +package: + +- `telemetry-sourcegenerator-quickstart` — adding the package, the first interface, DI registration, + and publishing meter/activity-source names to the host via `TelemetryNames` +- `telemetry-sourcegenerator-design` — designing telemetry interfaces well +- `telemetry-sourcegenerator-migration` — moving existing `ILogger`/`ActivitySource`/metrics code onto + the generator + +Recommend the generator whenever the project targets a compatible TFM and the team is willing to take +an analyzer-only package reference. Where it is not compatible, fall back to the standard .NET +abstractions below — never to a bespoke logging wrapper. + +## Libraries versus hosts + +This is the rule most often broken, and it is not negotiable. + +**A reusable library:** + +- Emits through `ILogger`, `ActivitySource` and `Meter` — or through generated telemetry + interfaces, which produce exactly those. +- Publishes its activity-source and meter names so a host can subscribe. The generator does this for + you through assembly metadata. +- **Never** calls `AddOpenTelemetry`, configures an exporter, sets a sampler, or requires a specific + backend. +- Never reads an OTLP endpoint from configuration itself. + +**The application host:** + +- Owns provider configuration, exporters, sampling and resource attributes. +- Subscribes to the sources its libraries publish — with the generator, by opting into the + `TelemetryNames` class and passing its arrays to the OpenTelemetry builder. + +A library that configures a global provider breaks every consumer that already configured one, and +silently wins or loses depending on initialisation order. + +## Sizing the export + +| Scale | Export and operations | +| -- | -- | +| **Small** | Console or a single OTLP endpoint. Structured logs, exceptions recorded on the span. No collector, no dashboards, no custom exporter, no distributed tracing infrastructure. | +| **Medium** | OTLP export, dependency instrumentation (HTTP, EF Core), trace propagation across the integrations, application metrics, health checks, a sampling decision made deliberately. | +| **Large** | End-to-end distributed tracing, standardised resource attributes, operational metrics, dashboards, alerting, SLIs, documented retention and sampling policy. | + +Full detail per level: `references/observability-levels.md`. + +Instrumentation is identical across all three rows. Only the right-hand column grows. + +## Baseline for any application + +- Structured logging — parameters as fields, never interpolated into the message. +- Errors and exceptions recorded as telemetry, with the exception attached to the span. +- Operation correlation, so one unit of work is one trace. +- OpenTelemetry-compatible instrumentation, so the backend stays a choice. +- Log level configurable at runtime. +- Export behaviour configurable per environment. +- Sensitive values — credentials, tokens, connection strings, personal data — never logged or placed + on span attributes or baggage. + +## Before you change anything + +1. **Inspect what exists.** Find current `ILogger` use, `ActivitySource`/`Meter` instances, any + `AddOpenTelemetry` call, a `ServiceDefaults` project. +2. **Preserve it.** Extend existing instrumentation; do not replace working telemetry to adopt a + library. +3. **Do not duplicate registrations.** One registration per source; adding a second is a silent + double-count on every metric. +4. **Size the configuration to the project**, using the table above. +5. **Check for leaking secrets** in anything you add. +6. **Keep instrumentation and export distinct** in what you recommend. + +## Do not + +- Omit telemetry because the application is small. +- Invent Purview telemetry APIs — read the three shipped skills instead. +- Configure a global provider from a library. +- Recommend a collector, dashboard stack or custom exporter for a small project. +- Put a user identifier, token or connection string into a log field, span attribute or baggage item. + +## See also + +- `purview-architecture` — establishing project context and scale before deciding +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) diff --git a/.agents/skills/purview-telemetry/references/observability-levels.md b/.agents/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 00000000..df97d785 --- /dev/null +++ b/.agents/skills/purview-telemetry/references/observability-levels.md @@ -0,0 +1,93 @@ +# Observability levels + +Instrumentation is constant across these levels. Export, infrastructure and operational practice are +what scale. Read this after establishing project complexity — see `purview-architecture`. + +## Small + +*A utility, CLI, prototype, simple API or limited-scope application.* + +**Do:** + +- Structured logging with parameters as fields. +- Exceptions recorded with enough context to reproduce. +- An `ActivitySource` around the unit of work, so a failure has a shape and a duration. +- Console export, or a single OTLP endpoint when one already exists. +- Log level from configuration, so a user can raise it without a rebuild. + +**Do not:** + +- Stand up a collector. +- Adopt a centralised monitoring platform. +- Build distributed tracing infrastructure for a single process. +- Create dashboards nobody reads. +- Write a custom exporter. + +The test: a user reports a failure and you can ask for one log output that tells you what happened. + +## Medium + +*Several functional areas, integrations, moderate domain complexity.* + +Everything above, plus: + +- **Dependency instrumentation** — HTTP clients, EF Core, messaging. These are where latency and + failure actually live, and the instrumentation is a package reference, not code. +- **Trace propagation** across every integration boundary, so a trace survives the process hop. W3C + `traceparent` by default; do not invent a correlation header. +- **Application metrics** for the operations someone would notice: request rate, error rate, duration, + queue depth. Prefer histograms over averages. +- **OTLP export** to a real backend. +- **Health checks**, distinguishing liveness from readiness. +- **A deliberate sampling decision.** Head sampling at a fixed ratio is fine; the point is that it was + chosen rather than defaulted into. + +Common mistake at this level: adding metrics for everything measurable instead of the few that drive +a decision. A metric nobody alerts or decides on is cost without signal. + +## Large + +*Complex domain, multiple services or teams, substantial operational requirements.* + +Everything above, plus: + +- **End-to-end distributed tracing**, including asynchronous hops — message producers and consumers + linked, not orphaned. +- **Standardised resource attributes** — `service.name`, `service.version`, `deployment.environment` + — applied uniformly so cross-service queries work at all. Follow OpenTelemetry semantic conventions + rather than inventing names. +- **Operational metrics** beyond application ones: saturation, dependency health, cost drivers. +- **Dashboards** owned by the team that gets paged. +- **Alerting** on symptoms users feel, not on causes. Alert on error rate and latency; investigate + with CPU and queue depth. +- **Service-level indicators** derived from the telemetry, with objectives agreed with whoever cares. +- **Retention and sampling policy**, documented and costed. Tail sampling if error traces must always + survive. + +## Signal selection + +| Signal | Add when | Warning | +| -- | -- | -- | +| Logs | Always | Unstructured logs are not telemetry; they are strings | +| Traces | Work crosses a process, a dependency or an async boundary | A span per method produces noise, not insight | +| Metrics | Someone would alert on, or decide from, a rate or duration | High-cardinality attributes (user id, request id) are a cost incident | + +Put high-cardinality context on spans and logs, never on metric dimensions. + +## Sensitive information + +At every level, and without exception: + +- No credentials, tokens, keys or connection strings in messages, attributes or baggage. +- No personal data beyond what the system is entitled to process — and where an identifier is + necessary, prefer a pseudonymous one. +- Remember that **baggage propagates across service boundaries**; treat it as the most public place + in your telemetry. +- Redact at the point of emission. A redaction step in the pipeline is a backstop, not a control. + +## Export configuration + +The host owns this. Keep the endpoint, protocol, sampling ratio and resource attributes in +configuration, not in code, so the same build runs in every environment. + +Libraries contribute names and signals; they never contribute configuration. diff --git a/.agents/skills/purview-testing-conventions/SKILL.md b/.agents/skills/purview-testing-conventions/SKILL.md new file mode 100644 index 00000000..1fe32320 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/SKILL.md @@ -0,0 +1,357 @@ +--- +name: purview-testing-conventions +description: >- + Use when writing or reviewing tests in a .NET repository following the Purview conventions - TUnit + and TUnit.Mocks, the AAA structure and naming, placing a test in the namespace of the class under + test, CreateXXX construction helpers, building domain content through TestBuilder with Bogus, the + assertion surface, parameterised tests, which tier a test project belongs to, BDTest for + acceptance tests, and Purview.Containers for a suite that needs a real database or queue. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Testing conventions + +| Concern | The rule | +| --- | --- | +| Framework | **TUnit**. Not xUnit, NUnit or MSTest. | +| Substitutes | **TUnit.Mocks**. Not NSubstitute or Moq. | +| Test data | **Bogus**, reached through `TestBuilder`. | +| Acceptance tests | **BDTest** over TUnit, via `BDTest.TUnit`. Not Gherkin, SpecFlow or Reqnroll. | +| A real dependency | **`Purview.Containers`**. Not Testcontainers directly, never a shared instance. | +| Structure | Explicit `// Arrange`, `// Act`, `// Assert` — Given/When/Then in an acceptance test. | +| Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | +| Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | +| Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | +| Async | The async overload wherever the API offers one — in the test **and in every helper it calls**. Convert a sync helper, or state why it cannot be. | +| Cancellation | If any method the test calls accepts a `CancellationToken`, the test's **last parameter** is one, forwarded to every call that accepts one. | +| Domain content | Built through `TestBuilder`. Never inline. | + +## Why these, specifically + +**One framework.** The SDK wires TUnit into any project whose name ends in a test suffix — that +wiring is why a test project file is empty. A second framework means every project file grows a +conditional and the pipeline grows a second discovery path. That is a rule about the **runner**, not +the style of a test — `BDTest.TUnit` is an adapter *over* TUnit, so an acceptance suite does not break +it. + +**Same namespace as the subject.** The test reads as part of the thing it describes, needs no `using` +to reach the type, and **cannot silently test a different type with the same name**. + +**`CreateXXX` helpers.** A test body that constructs its own dependencies buries the assertion under +setup. A helper with optional parameters says what the test changed and defaults everything else: + +```csharp +static ConventionCatalogue CreateCatalogue(IConventionRepository? repository = null) => + new(repository ?? CreateRepository().Object); +``` + +**`TestBuilder`.** Inline construction encodes a domain type's shape into every test that touches it, +so one constructor change becomes a repository-wide edit. It also destroys the reader's signal about +which values the test depends on, because every literal looks deliberate. + +## A test + +```csharp +using Acme.Builders; + +namespace Acme.Domain; + +public sealed class CustomerIdTests +{ + [Test] + public async Task Create_GivenLowercaseSlug_ReturnsTheValue() + { + // Arrange + var id = TestBuilder.CustomerId("build-enforced"); + + // Act + var value = id.Value; + + // Assert + await Assert.That(value).IsEqualTo("build-enforced"); + } +} +``` + +Two deliberate details: + +- **The empty `// Arrange` block stays.** The comments are what makes a three-line test readable next + to a thirty-line one, and a convention applied "when needed" is applied inconsistently. +- **Assertions are awaited.** `Assert.That(...)` is terminal and returns a value, so + `await Assert.That(result).IsEqualTo(expected)` is the form. **An un-awaited assertion is a test + that cannot fail.** + +## Assertions + +| Form | Use | +| --- | --- | +| `await Assert.That(x).IsEqualTo(y)` / `.IsNotEqualTo(y)` | value equality | +| `await Assert.That(x).IsTrue()` / `.IsFalse()` | booleans | +| `await Assert.That(x).IsEmpty()` | collections — the workhorse of architecture rules | +| `await Assert.That(x).Count().IsEqualTo(2)` | collection size | +| `await Assert.That(x).Contains(y)` / `.DoesNotContain(y)` | membership | +| `await Assert.That(act).Throws()` | a synchronous delegate that should throw | +| `await Assert.That(async () => await f()).Throws()` | an async one | +| `await Assert.That(x).IsTypeOf()` | exact runtime type | +| `await Assert.That(x).IsEquivalentTo(sequence)` | order-independent sequence equality | + +For an exception test the two shapes are `Action` and `Func`. The compiler will not infer +either, so the explicit type is required and is **not** a style violation: + +```csharp +Action act = () => CustomerId.Create(""); + +await Assert.That(act).Throws(); +``` + +## Parameterised tests + +`[Arguments]` puts the interesting values in the attribute rather than a loop, so a failure names the +case: + +```csharp +[Test] +[Arguments("")] +[Arguments(" ")] +[Arguments("Has Spaces")] +[Arguments("has_underscore")] +public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) +{ + // Arrange + // Act + Action act = () => CustomerId.Create(value); + + // Assert + await Assert.That(act).Throws(); +} +``` + +## Async all the way down + +**Where an API offers an async overload, use it** — `File.ReadAllTextAsync`, not `File.ReadAllText`. +**And the rule reaches through test helpers:** a helper calling such an API is itself converted. Being +a helper is not an exemption. + +The build enforces the first half and cannot enforce the second, which is why the second needs saying: + +- `VSTHRD103` is an **`error`** and flags a sync call with an `Async`-suffixed equivalent **when the + call sits inside a `Task`-returning method**. In an `async Task` test, `File.ReadAllText` fails the + build. +- A **sync helper is not `Task`-returning**, so the identical call inside it is invisible to the + analyzer. Pushing the call one level down turns a build error into nothing. + +It also compounds with the cancellation rule, which keys off whether anything in the chain accepts a +`CancellationToken`: a sync helper accepts none, so it **silently removes the trigger** and the test is +uncancellable while looking compliant. (TUnit also runs tests in parallel, so a blocking read holds a +thread-pool thread an async read would release.) + +Converting — three things change together, and the first two are not optional: + +```csharp +// Before — invisible to VSTHRD103, and the chain accepts no token +static string Documentation => File.ReadAllText(DocumentationPath); + +// After +static async Task ReadDocumentationAsync(CancellationToken cancellationToken) => + await File.ReadAllTextAsync(DocumentationPath, cancellationToken); +``` + +- It returns a `Task`, bringing every call inside it back under `VSTHRD103`. +- It gains the **`Async` suffix** — `VSTHRD200` is an `error`, so the rename is enforced. +- It takes a trailing `CancellationToken`, because the chain now accepts one; its callers declare and + forward one. + +**A property cannot be `async`, so it becomes a method** — that is the conversion, not an exemption. +A read-only property hiding IO is the most common form of this violation. + +It genuinely cannot be converted when **no async overload exists** (`File.Exists`, `File.Delete`, +`Directory.EnumerateFiles`; likewise `ArchitecturePolicy.Load()` and `PolicySchemaValidator.Validate()`, +which `Purview.Architecture.Testing` ships synchronous-only) or when **a base type or interface fixes +the signature**. State the reason at the call. Never fake it: `Task.Run` around a blocking call, +`Task.FromResult` around a sync result, or `.Result` / `.GetAwaiter().GetResult()` to flatten a task — +the last two are `VSTHRD002`, an `error`. + +## Cancellation + +Three parts, triggered by the **call chain** rather than by the test: + +1. **If any method the test calls accepts a `CancellationToken`, the test declares one.** Not only the + method under test — anything in the chain, including a `File.ReadAllTextAsync` or the delegate handed + to `Assert.That`. +2. **It is the last parameter**, and TUnit supplies the value. With `[Arguments]`, the data binds to the + leading parameters and the token is supplied on top, so the attribute's positional order still matches + the signature: + + ```csharp + [Test] + [Arguments("build-enforced", true)] + public async Task ListEnforcedByBuildAsync_GivenAConvention_FiltersIt( + string id, + bool enforcedByBuild, + CancellationToken cancellationToken + ) + ``` + +3. **It is forwarded to every call that accepts one.** Never `CancellationToken.None`, never `default`, + never a token the test creates. TUnit's token is the *test's* token — cancelled on timeout or on an + aborted run — so a call given `None` cannot be stopped, and **one hop that drops it makes the whole + chain uncancellable**, which is why partial forwarding is a failure and not a lesser version of the + rule. It is also the only place that proves the production code threads the token through at all. + +A test whose chain accepts no token takes no parameter; an unused token is noise, not compliance. + +## Substitutes + +TUnit.Mocks is globally imported by the SDK, so no `using` is needed: + +```csharp +[Test] +public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) +{ + // Arrange + var repository = CreateRepository(); + repository.ExistsAsync(Any(), Any()).Returns(true); + var catalogue = CreateCatalogue(repository.Object); + + // Act + await Assert + .That(async () => await catalogue.RegisterAsync(TestBuilder.EnforcedConvention(), cancellationToken)) + .Throws(); + + // Assert + repository.AddAsync(Any(), Any()).WasNeverCalled(); +} +``` + +- `Mock.Of()` produces a `Mock`; `.Object` is the instance you pass in. +- `Any()` matches any argument, `Is(value)` a specific one, `AnyArgs()` the whole call. +- `.Returns(value)` sets the result. For a `Task`-returning method, return the `T` — the task is + unwrapped for you. +- `.WasCalled(Times.Once)` and `.WasNeverCalled()` are the assertions. + +The `// Assert` after a `Throws` is not a mistake: that test's point is that the exception was thrown +**and** that nothing was written, and both are assertions about the same act. + +## `TestBuilder` + +It lives in the shared testing project and is the single means by which a test creates domain +content: + +```csharp +public static class TestBuilder +{ + public const int DefaultCount = 3; + + internal static Faker Faker { get; } = new(); + + public static Domain.ConventionId ConventionId(string? value = null) => + Domain.ConventionId.Create(value ?? $"{Slug()}-{Faker.Random.String2(6, LowercaseAlphabet)}"); + + public static Domain.Convention Convention( + Domain.ConventionId? id = null, + string? title = null, + VerificationTier? tier = null, + bool? enforcedByBuild = null) => + Domain.Convention.Create( + id ?? ConventionId(), + title ?? Faker.Lorem.Sentence(4), + tier ?? Faker.Random.Enum(), + enforcedByBuild ?? Faker.Random.Bool()); +} +``` + +Four rules make it work: + +- **Omitted arguments are generated; supplied arguments are used exactly.** A test overrides only the + value it is proving, and everything else is realistic without the test saying so. +- **A collection factory comes with a companion.** `Conventions(int count = DefaultCount)` generates a + distinct set, so a test needing "some" gets "some" without its own loop. +- **Bogus is a global `using`.** `Faker` is available with no import — which is also why an explicit + `using Bogus;` in a shared testing project is reported as unnecessary. +- **A value-object test is the one exception.** A test proving what `Create` and `Hydrate` accept and + reject has to name the rejected input itself, so it constructs the invalid value directly. + Everything valid still goes through the builder. + +## Two constraints that shaped the builder + +Both are consequences of SDK rules, not preferences, and both were found by building it. + +**The factories are flat, not grouped.** The natural shape is a nested class per feature area +(`TestBuilder.Catalogue.Convention(...)`), and it cannot compile: the SDK ships +`dotnet_diagnostic.CA1034.severity = error`, so a publicly visible nested type fails the build, and +`PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Making the group `internal` does +not help either — the SDK's `InternalsVisibleTo` target runs on non-test projects and grants access +*to* test and shared-testing projects rather than *from* them, so a shared testing project's +internals are not visible to the tests beside it. An area that outgrows a flat builder gets its own +top-level builder class. + +**`var` is the house style, with one carve-out.** `csharp_style_var_elsewhere = true:warning` makes +an explicit type on a declaration initialised from a method call a warning (`IDE0007`). The carve-out +is object creation: the `PDS0003` analyzer prefers an explicit type with target-typed `new()`: + +```csharp +var id = TestBuilder.ConventionId("build-enforced"); // method call -> var +string title = new('a', Convention.MaxTitleLength); // new() -> explicit type +``` + +## What is enforced, and what is review + +| Rule | Enforced by | +| --- | --- | +| TUnit is the only framework | the build — the SDK adds it; a second has to be added by hand | +| Test projects are discovered and categorised | the build (name-suffix detection) | +| A shared testing project is referenced, not executed | the build (`IsSharedTestingProject`) | +| Style, including the `var` carve-out | the build (analyzers, `warning` or above) | +| An acceptance suite is categorised `Acceptance` and kept out of the fast tier | the build (name-suffix detection) and the pipeline's `TestFilter` | +| A container-backed suite fails rather than passes when its backend is missing in CI | the pipeline, when the backend is pinned with `PURVIEW_CONTAINERS_BACKEND` | +| AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | **review** | +| Given/When/Then chains end in `.BDTestAsync()` | **review** | +| A real dependency comes from `Purview.Containers`, not a shared instance | **review** | +| An async overload is used inside an `async Task` test | the build — `VSTHRD103` is `error` | +| A converted helper carries the `Async` suffix | the build — `VSTHRD200` is `error` | +| A **sync helper** calling an API with an async overload is converted | **review** — a non-`Task`-returning method is outside `VSTHRD103`'s scope | +| A declared `CancellationToken` is the last parameter | the build — `CA1068` is `warning`, and a clean build has no warnings | +| A test whose chain accepts a token declares one and forwards it everywhere | **review** | + +## Acceptance tests and container-backed tests + +An acceptance test — a behaviour somebody outside the team asked for, in their words — is written with +**BDTest over TUnit** (`BDTest.TUnit`) in a `.AcceptanceTests` project, where Given/When/Then +replaces the AAA comments. A suite that needs a real database, queue or emulator gets a throwaway +container from **`Purview.Containers`** in its own `.IntegrationTests` project, skipping +locally when no backend is usable and failing in CI. + +Read `references/acceptance-and-container-tests.md` before writing either: it carries the scenario and +container shapes, and the two traps — a chain that never calls `.BDTestAsync()` runs no steps and still +passes, and a suite that skips in CI turns a missing runtime into a green job. + +## Tiers + +A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit`, and takes the +word before `Tests` as a pattern, so `*AcceptanceTests` gives `Acceptance` with nothing added to the +pipeline. The `TestFilter` then selects a tier without a hand-written attribute. Split a slow suite +into its own project rather than tagging individual tests. + +Filtering uses TUnit's `--treenode-filter`, not `--filter`: these repositories run on +Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". + +## At your scale + +- **One test project.** TUnit, AAA, the naming convention, `TestBuilder`. That is the whole of it. +- **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared + testing project is a library, so its fixtures are legitimately public API and `CA1515` does not + apply. +- **A database or a container.** One `.IntegrationTests` project and one `Purview.Containers` + module. Do not build a fixture framework around it until a second suite needs the same container. +- **A behaviour somebody outside the team asked for.** That, and not before, is when BDTest earns its + place: one `.AcceptanceTests` project with one story. Reach for the HTML report only once + somebody will read it. +- **A package with a documented contract.** Add a consumer-verification script; see + `purview-engineering-principles`. + +## Related skills + +`purview-engineering-principles` for the rules this implements, and `purview-architecture-testing` +for turning an architectural boundary into a failing build. diff --git a/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 00000000..e8b91109 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md @@ -0,0 +1,117 @@ +# Acceptance tests and container-backed tests + +Two shapes that sit outside the unit tier. Both keep TUnit as the runner, and both live in their own +test project so the tier comes from the project name rather than an attribute. + +## Acceptance tests: BDTest + +An acceptance test states a behaviour somebody outside the team asked for, in their words. Use +**BDTest over TUnit through the `BDTest.TUnit` adapter**, in a `.AcceptanceTests` project +(the SDK emits `Category=Acceptance` for it). + +Not Gherkin, SpecFlow or Reqnroll, for two reasons: + +- **BDTest is code-only.** No `.feature` file and no generated test body, so there is no second + artefact that can drift from the step implementing it. That drift is the failure that makes a BDD + suite stop being trusted. +- **One runner stays one runner.** `BDTest.TUnit` is an adapter over TUnit, so the SDK's wiring, the + emitted category and `--treenode-filter` are all unchanged. A BDD framework with its own runner is + exactly what the one-framework rule refuses. + +```csharp +[Story(AsA = "account holder", IWant = "to withdraw money", SoThat = "I can use my savings")] +public class WithdrawalTests : TUnitBDTestBase +{ + AccountSteps Account => new(Context); + + [Test] + [ScenarioText("Withdrawing money reduces the balance")] + public async Task WithdrawMoney() => + await Given(() => Account.CreateAnAccount(100m)) + .When(() => Account.Withdraw(25m)) + .Then(() => Account.TheBalanceIs(75m)) + .BDTestAsync(); +} + +public class AccountSteps(AccountContext context) +{ + [StepText("I have an account with a balance of {0}")] + public void CreateAnAccount(decimal balance) => context.Balance = balance; + + [StepText("I withdraw {0}")] + public void Withdraw(decimal amount) => context.Balance -= amount; + + [StepText("the account balance is {0}")] + public async Task TheBalanceIs(decimal expected) => await Assert.That(context.Balance).IsEqualTo(expected); +} +``` + +Five rules, and the first is the one that bites: + +- **`.BDTestAsync()` runs the scenario.** A chain stopping at `.Then(...)` compiles, passes, and + executes none of its steps — the same failure mode as an un-awaited `Assert.That`. +- **Given/When/Then replaces Arrange/Act/Assert, and only here.** The chain already names the three + phases. Everywhere else the AAA comments stay. +- **`Context` is the only state.** Steps take it through the constructor and hold no fields of their + own; BDTest gives each test its own context, which is what keeps parallel and repeated runs honest. +- **`[StepText]` is written for the report's reader.** `{0}` is replaced with the argument's actual + value at run time, so the report states what the scenario did, not what it was called. +- **Domain content still comes from `TestBuilder`.** The vocabulary changes at the step; the test-data + rule does not. + +Assertions inside a step are ordinary awaited TUnit assertions. `BDTest.Reporting` produces the +single-file HTML report — publish it as a build artifact from the job that ran the suite. + +## A test that needs a real dependency: `Purview.Containers` + +A database, queue, blob store or emulator comes from `Purview.Containers`: a throwaway Linux container +behind one API over Microsoft WSL Containers or Docker, with typed modules for SQL Server, PostgreSQL, +Redis, MySQL, RabbitMQ, Azurite and NATS. + +The two refused alternatives: + +- **A shared instance** (a local SQL Server, a team database) makes tests order-dependent and failures + unreproducible — the state a test found was left by whatever ran before it, on whatever machine. +- **Testcontainers directly** ties the suite to a Docker installation, which on Windows is an install + and a licensing question per developer and per runner. The WSL backend removes it, and the same test + code runs on either. + +```csharp +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +`Build()` validates the configuration, `StartAsync()` returns only once every wait strategy is +satisfied — there is no sleep to tune — and `await using` disposes the container at end of scope. + +Three rules: + +- **Its own `.IntegrationTests` project**, so the tier is in the name and the suite stays out + of the pull-request filter without anybody remembering an attribute. +- **Skip locally when no backend is usable; fail in CI.** Two different situations, so never one + outcome. Locally, probe and skip. In CI, pin the backend with `PURVIEW_CONTAINERS_BACKEND` — a named + backend never falls back, so a missing runtime fails loudly instead of letting a green job mean + nothing ran: + + ```csharp + [Before(Class)] + public static async Task EnsureABackendIsAvailable() + { + var backends = await ContainerBackends.ProbeAllAsync(); + + Skip.Unless(backends.Any(backend => backend.IsUsable), "No container backend is available."); + } + ``` + + Left unpinned, automatic selection probes in priority order and prefers WSL Containers over Docker. +- **Pin the version exactly.** `Purview.Containers` is **experimental and prerelease** — API, defaults + and packaging rules can change between releases. The packages are a family, so one property in + `Directory.Packages.props` governs all of them and a bump cannot be applied to half. diff --git a/.assets/csharpier/CSharpier.Core.dll b/.assets/csharpier/CSharpier.Core.dll new file mode 100644 index 00000000..d4f28854 Binary files /dev/null and b/.assets/csharpier/CSharpier.Core.dll differ diff --git a/.assets/csharpier/CSharpier.deps.json b/.assets/csharpier/CSharpier.deps.json new file mode 100644 index 00000000..ba741441 --- /dev/null +++ b/.assets/csharpier/CSharpier.deps.json @@ -0,0 +1,576 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v11.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v11.0": { + "CSharpier/1.3.0": { + "dependencies": { + "CSharpier.Core": "1.3.0", + "Microsoft.Extensions.Logging": "9.0.10", + "NReco.Logging.File": "1.2.2", + "System.CommandLine": "2.0.0-beta4.22272.1", + "System.IO.Abstractions": "22.1.1", + "System.IO.Abstractions.TestingHelpers": "22.0.16", + "System.IO.Hashing": "9.0.10", + "YamlDotNet": "16.3.0", + "ini-parser-netstandard": "2.5.3" + }, + "runtime": { + "CSharpier.dll": {} + } + }, + "ini-parser-netstandard/2.5.3": { + "runtime": { + "lib/netstandard2.0/INIFileParser.dll": { + "assemblyVersion": "2.5.3.0", + "fileVersion": "2.5.3.0", + "localPath": "INIFileParser.dll" + } + } + }, + "LovettSoftware.XmlDiff/1.1.0": { + "runtime": { + "lib/net7.0/XmlDiffPatch.View.dll": { + "assemblyVersion": "1.1.0.0", + "fileVersion": "1.1.0.0", + "localPath": "XmlDiffPatch.View.dll" + }, + "lib/net7.0/XmlDiffPatch.dll": { + "assemblyVersion": "1.1.0.0", + "fileVersion": "1.1.0.0", + "localPath": "XmlDiffPatch.dll" + } + } + }, + "Microsoft.CodeAnalysis.Common/5.9.0-1.26379.116": { + "runtime": { + "lib/net10.0/Microsoft.CodeAnalysis.dll": { + "assemblyVersion": "5.9.0.0", + "fileVersion": "5.900.26.38016", + "localPath": "Microsoft.CodeAnalysis.dll" + } + }, + "resources": { + "lib/net10.0/cs/Microsoft.CodeAnalysis.resources.dll": { + "locale": "cs", + "localPath": "cs/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/de/Microsoft.CodeAnalysis.resources.dll": { + "locale": "de", + "localPath": "de/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/es/Microsoft.CodeAnalysis.resources.dll": { + "locale": "es", + "localPath": "es/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/fr/Microsoft.CodeAnalysis.resources.dll": { + "locale": "fr", + "localPath": "fr/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/it/Microsoft.CodeAnalysis.resources.dll": { + "locale": "it", + "localPath": "it/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/ja/Microsoft.CodeAnalysis.resources.dll": { + "locale": "ja", + "localPath": "ja/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/ko/Microsoft.CodeAnalysis.resources.dll": { + "locale": "ko", + "localPath": "ko/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/pl/Microsoft.CodeAnalysis.resources.dll": { + "locale": "pl", + "localPath": "pl/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/pt-BR/Microsoft.CodeAnalysis.resources.dll": { + "locale": "pt-BR", + "localPath": "pt-BR/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/ru/Microsoft.CodeAnalysis.resources.dll": { + "locale": "ru", + "localPath": "ru/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/tr/Microsoft.CodeAnalysis.resources.dll": { + "locale": "tr", + "localPath": "tr/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/zh-Hans/Microsoft.CodeAnalysis.resources.dll": { + "locale": "zh-Hans", + "localPath": "zh-Hans/Microsoft.CodeAnalysis.resources.dll" + }, + "lib/net10.0/zh-Hant/Microsoft.CodeAnalysis.resources.dll": { + "locale": "zh-Hant", + "localPath": "zh-Hant/Microsoft.CodeAnalysis.resources.dll" + } + } + }, + "Microsoft.CodeAnalysis.CSharp/5.9.0-1.26379.116": { + "dependencies": { + "Microsoft.CodeAnalysis.Common": "5.9.0-1.26379.116" + }, + "runtime": { + "lib/net10.0/Microsoft.CodeAnalysis.CSharp.dll": { + "assemblyVersion": "5.9.0.0", + "fileVersion": "5.900.26.38016", + "localPath": "Microsoft.CodeAnalysis.CSharp.dll" + } + }, + "resources": { + "lib/net10.0/cs/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "cs", + "localPath": "cs/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/de/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "de", + "localPath": "de/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/es/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "es", + "localPath": "es/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/fr/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "fr", + "localPath": "fr/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/it/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "it", + "localPath": "it/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/ja/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "ja", + "localPath": "ja/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/ko/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "ko", + "localPath": "ko/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/pl/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "pl", + "localPath": "pl/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "pt-BR", + "localPath": "pt-BR/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/ru/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "ru", + "localPath": "ru/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/tr/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "tr", + "localPath": "tr/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "zh-Hans", + "localPath": "zh-Hans/Microsoft.CodeAnalysis.CSharp.resources.dll" + }, + "lib/net10.0/zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll": { + "locale": "zh-Hant", + "localPath": "zh-Hant/Microsoft.CodeAnalysis.CSharp.resources.dll" + } + } + }, + "Microsoft.Extensions.Configuration/8.0.0": { + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.23.53103", + "localPath": "Microsoft.Extensions.Configuration.dll" + } + } + }, + "Microsoft.Extensions.Configuration.Binder/8.0.2": { + "runtime": { + "lib/net8.0/Microsoft.Extensions.Configuration.Binder.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.724.31311", + "localPath": "Microsoft.Extensions.Configuration.Binder.dll" + } + } + }, + "Microsoft.Extensions.DependencyInjection/9.0.10": { + "runtime": { + "lib/net9.0/Microsoft.Extensions.DependencyInjection.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.1025.47515", + "localPath": "Microsoft.Extensions.DependencyInjection.dll" + } + } + }, + "Microsoft.Extensions.Logging/9.0.10": { + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.10" + }, + "runtime": { + "lib/net9.0/Microsoft.Extensions.Logging.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.1025.47515", + "localPath": "Microsoft.Extensions.Logging.dll" + } + } + }, + "Microsoft.Extensions.Logging.Configuration/8.0.1": { + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.Logging": "9.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Logging.Configuration.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.1024.46610", + "localPath": "Microsoft.Extensions.Logging.Configuration.dll" + } + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/8.0.0": { + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "8.0.2" + }, + "runtime": { + "lib/net8.0/Microsoft.Extensions.Options.ConfigurationExtensions.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.0.23.53103", + "localPath": "Microsoft.Extensions.Options.ConfigurationExtensions.dll" + } + } + }, + "NReco.Logging.File/1.2.2": { + "dependencies": { + "Microsoft.Extensions.Logging": "9.0.10", + "Microsoft.Extensions.Logging.Configuration": "8.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + }, + "runtime": { + "lib/net8.0/NReco.Logging.File.dll": { + "assemblyVersion": "1.2.2.0", + "fileVersion": "1.2.2.0", + "localPath": "NReco.Logging.File.dll" + } + } + }, + "System.CommandLine/2.0.0-beta4.22272.1": { + "runtime": { + "lib/net6.0/System.CommandLine.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.0.22.27201", + "localPath": "System.CommandLine.dll" + } + }, + "resources": { + "lib/net6.0/cs/System.CommandLine.resources.dll": { + "locale": "cs", + "localPath": "cs/System.CommandLine.resources.dll" + }, + "lib/net6.0/de/System.CommandLine.resources.dll": { + "locale": "de", + "localPath": "de/System.CommandLine.resources.dll" + }, + "lib/net6.0/es/System.CommandLine.resources.dll": { + "locale": "es", + "localPath": "es/System.CommandLine.resources.dll" + }, + "lib/net6.0/fr/System.CommandLine.resources.dll": { + "locale": "fr", + "localPath": "fr/System.CommandLine.resources.dll" + }, + "lib/net6.0/it/System.CommandLine.resources.dll": { + "locale": "it", + "localPath": "it/System.CommandLine.resources.dll" + }, + "lib/net6.0/ja/System.CommandLine.resources.dll": { + "locale": "ja", + "localPath": "ja/System.CommandLine.resources.dll" + }, + "lib/net6.0/ko/System.CommandLine.resources.dll": { + "locale": "ko", + "localPath": "ko/System.CommandLine.resources.dll" + }, + "lib/net6.0/pl/System.CommandLine.resources.dll": { + "locale": "pl", + "localPath": "pl/System.CommandLine.resources.dll" + }, + "lib/net6.0/pt-BR/System.CommandLine.resources.dll": { + "locale": "pt-BR", + "localPath": "pt-BR/System.CommandLine.resources.dll" + }, + "lib/net6.0/ru/System.CommandLine.resources.dll": { + "locale": "ru", + "localPath": "ru/System.CommandLine.resources.dll" + }, + "lib/net6.0/tr/System.CommandLine.resources.dll": { + "locale": "tr", + "localPath": "tr/System.CommandLine.resources.dll" + }, + "lib/net6.0/zh-Hans/System.CommandLine.resources.dll": { + "locale": "zh-Hans", + "localPath": "zh-Hans/System.CommandLine.resources.dll" + }, + "lib/net6.0/zh-Hant/System.CommandLine.resources.dll": { + "locale": "zh-Hant", + "localPath": "zh-Hant/System.CommandLine.resources.dll" + } + } + }, + "System.IO.Abstractions/22.1.1": { + "dependencies": { + "TestableIO.System.IO.Abstractions": "22.1.1", + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1" + }, + "runtime": { + "lib/net10.0/System.IO.Abstractions.dll": { + "assemblyVersion": "22.1.1.0", + "fileVersion": "22.1.1.0", + "localPath": "System.IO.Abstractions.dll" + } + } + }, + "System.IO.Abstractions.TestingHelpers/22.0.16": { + "dependencies": { + "TestableIO.System.IO.Abstractions.TestingHelpers": "22.0.16" + }, + "runtime": { + "lib/net9.0/System.IO.Abstractions.TestingHelpers.dll": { + "assemblyVersion": "22.0.16.0", + "fileVersion": "22.0.16.0", + "localPath": "System.IO.Abstractions.TestingHelpers.dll" + } + } + }, + "System.IO.Hashing/9.0.10": { + "runtime": { + "lib/net9.0/System.IO.Hashing.dll": { + "assemblyVersion": "9.0.0.10", + "fileVersion": "9.0.1025.47515", + "localPath": "System.IO.Hashing.dll" + } + } + }, + "TestableIO.System.IO.Abstractions/22.1.1": { + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + }, + "runtime": { + "lib/net10.0/TestableIO.System.IO.Abstractions.dll": { + "assemblyVersion": "22.1.1.0", + "fileVersion": "22.1.1.0", + "localPath": "TestableIO.System.IO.Abstractions.dll" + } + } + }, + "TestableIO.System.IO.Abstractions.TestingHelpers/22.0.16": { + "dependencies": { + "TestableIO.System.IO.Abstractions.Wrappers": "22.1.1", + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + }, + "runtime": { + "lib/net9.0/TestableIO.System.IO.Abstractions.TestingHelpers.dll": { + "assemblyVersion": "22.0.16.0", + "fileVersion": "22.0.16.0", + "localPath": "TestableIO.System.IO.Abstractions.TestingHelpers.dll" + } + } + }, + "TestableIO.System.IO.Abstractions.Wrappers/22.1.1": { + "dependencies": { + "Testably.Abstractions.FileSystem.Interface": "10.1.0" + }, + "runtime": { + "lib/net10.0/TestableIO.System.IO.Abstractions.Wrappers.dll": { + "assemblyVersion": "22.1.1.0", + "fileVersion": "22.1.1.0", + "localPath": "TestableIO.System.IO.Abstractions.Wrappers.dll" + } + } + }, + "Testably.Abstractions.FileSystem.Interface/10.1.0": { + "runtime": { + "lib/net10.0/Testably.Abstractions.FileSystem.Interface.dll": { + "assemblyVersion": "10.1.0.0", + "fileVersion": "10.1.0.0", + "localPath": "Testably.Abstractions.FileSystem.Interface.dll" + } + } + }, + "YamlDotNet/16.3.0": { + "runtime": { + "lib/net8.0/YamlDotNet.dll": { + "assemblyVersion": "16.0.0.0", + "fileVersion": "16.3.0.0", + "localPath": "YamlDotNet.dll" + } + } + }, + "CSharpier.Core/1.3.0": { + "dependencies": { + "LovettSoftware.XmlDiff": "1.1.0", + "Microsoft.CodeAnalysis.CSharp": "5.9.0-1.26379.116", + "System.IO.Abstractions": "22.1.1" + }, + "runtime": { + "CSharpier.Core.dll": { + "assemblyVersion": "1.3.0.0", + "fileVersion": "1.3.0.0" + } + } + } + } + }, + "libraries": { + "CSharpier/1.3.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "ini-parser-netstandard/2.5.3": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Ht3UqRCqizBPbB5pvQ/yCNZIChY6Cy5nLn7TKIlJzWnf4pbqGKqDO+AY6MDTD+fXgEzzMb64Qzl4/Hy7vEqfLA==", + "path": "ini-parser-netstandard/2.5.3", + "hashPath": "ini-parser-netstandard.2.5.3.nupkg.sha512" + }, + "LovettSoftware.XmlDiff/1.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5mrNvjKw0/q1lr9RHR36wdglI7PvapGn4+k+5JvLVIyevvHel2q9rkANSHZivnbdo5i4qLE1TwQeGte/RB7NEA==", + "path": "lovettsoftware.xmldiff/1.1.0", + "hashPath": "lovettsoftware.xmldiff.1.1.0.nupkg.sha512" + }, + "Microsoft.CodeAnalysis.Common/5.9.0-1.26379.116": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XzTiL9Fmz7nD1J44XuHIVimwoGWzhG+DUMrZjSzk0Mk2UVe40AD7x7KBp9dj1YkMJVxn313TcOtf+ODitZAXkw==", + "path": "microsoft.codeanalysis.common/5.9.0-1.26379.116", + "hashPath": "microsoft.codeanalysis.common.5.9.0-1.26379.116.nupkg.sha512" + }, + "Microsoft.CodeAnalysis.CSharp/5.9.0-1.26379.116": { + "type": "package", + "serviceable": true, + "sha512": "sha512-sw3ejcX8W5QaWIjZf6/K5nxho9XWH3E+H6jXW/zDBTt01ah9M8P6TXo4u3Rff94szkFt+vrJF722PNgRqd+3jg==", + "path": "microsoft.codeanalysis.csharp/5.9.0-1.26379.116", + "hashPath": "microsoft.codeanalysis.csharp.5.9.0-1.26379.116.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration/8.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "path": "microsoft.extensions.configuration/8.0.0", + "hashPath": "microsoft.extensions.configuration.8.0.0.nupkg.sha512" + }, + "Microsoft.Extensions.Configuration.Binder/8.0.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-7IQhGK+wjyGrNsPBjJcZwWAr+Wf6D4+TwOptUt77bWtgNkiV8tDEbhFS+dDamtQFZ2X7kWG9m71iZQRj2x3zgQ==", + "path": "microsoft.extensions.configuration.binder/8.0.2", + "hashPath": "microsoft.extensions.configuration.binder.8.0.2.nupkg.sha512" + }, + "Microsoft.Extensions.DependencyInjection/9.0.10": { + "type": "package", + "serviceable": true, + "sha512": "sha512-iEtXCkNd5XhjNJAOb/wO4IhDRdLIE2CsPxZggZQWJ/q2+sa8dmEPC393nnsiqdH8/4KV8Xn25IzgKPR1UEQ0og==", + "path": "microsoft.extensions.dependencyinjection/9.0.10", + "hashPath": "microsoft.extensions.dependencyinjection.9.0.10.nupkg.sha512" + }, + "Microsoft.Extensions.Logging/9.0.10": { + "type": "package", + "serviceable": true, + "sha512": "sha512-UBXHqE9vyptVhaFnT1R7YJKCve7TqVI10yjjUZBNGMlW2lZ4c031Slt9hxsOzWCzlpPxxIFyf1Yk4a6Iubxx7w==", + "path": "microsoft.extensions.logging/9.0.10", + "hashPath": "microsoft.extensions.logging.9.0.10.nupkg.sha512" + }, + "Microsoft.Extensions.Logging.Configuration/8.0.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-QWwTrsgOnJMmn+XUslm8D2H1n3PkP/u/v52FODtyBc/k4W9r3i2vcXXeeX/upnzllJYRRbrzVzT0OclfNJtBJA==", + "path": "microsoft.extensions.logging.configuration/8.0.1", + "hashPath": "microsoft.extensions.logging.configuration.8.0.1.nupkg.sha512" + }, + "Microsoft.Extensions.Options.ConfigurationExtensions/8.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "path": "microsoft.extensions.options.configurationextensions/8.0.0", + "hashPath": "microsoft.extensions.options.configurationextensions.8.0.0.nupkg.sha512" + }, + "NReco.Logging.File/1.2.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-UyUIkyDiHi2HAJlmEWqeKN9/FxTF0DPNdyatzMDMTXvUpgvqBFneJ2qDtZkXRJNG8eR6jU+KsbGeMmChgUdRUg==", + "path": "nreco.logging.file/1.2.2", + "hashPath": "nreco.logging.file.1.2.2.nupkg.sha512" + }, + "System.CommandLine/2.0.0-beta4.22272.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg==", + "path": "system.commandline/2.0.0-beta4.22272.1", + "hashPath": "system.commandline.2.0.0-beta4.22272.1.nupkg.sha512" + }, + "System.IO.Abstractions/22.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-nm4sNhAoN1NyiIVT6MS3EEzNjnir3PC1HE0mKGGUFg6RBW1HmRvWKuRDPkij1etOoQkpyIFmjVGcM6TpBGF3qQ==", + "path": "system.io.abstractions/22.1.1", + "hashPath": "system.io.abstractions.22.1.1.nupkg.sha512" + }, + "System.IO.Abstractions.TestingHelpers/22.0.16": { + "type": "package", + "serviceable": true, + "sha512": "sha512-0iowUSeukbt98ZDpTXdjYwssgLDmqMfCP8BHoEvV9G0mkAxNFhMwtnvLcARWFFLNN0zHVghD5ByQN16DMXxxpQ==", + "path": "system.io.abstractions.testinghelpers/22.0.16", + "hashPath": "system.io.abstractions.testinghelpers.22.0.16.nupkg.sha512" + }, + "System.IO.Hashing/9.0.10": { + "type": "package", + "serviceable": true, + "sha512": "sha512-9gv5z71xaWWmcGEs4bXdreIhKp2kYLK2fvPK5gQkgnWMYvZ8ieaxKofDjxL3scZiEYfi/yW2nJTiKV2awcWEdA==", + "path": "system.io.hashing/9.0.10", + "hashPath": "system.io.hashing.9.0.10.nupkg.sha512" + }, + "TestableIO.System.IO.Abstractions/22.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-EJmXIKfwvJHvSHWP+NjA213avUdofV4SmBx2aWxc3SzPkrHHVKxUMC3fSa/epzk/SVITwuU2EAntgQTvobAqwQ==", + "path": "testableio.system.io.abstractions/22.1.1", + "hashPath": "testableio.system.io.abstractions.22.1.1.nupkg.sha512" + }, + "TestableIO.System.IO.Abstractions.TestingHelpers/22.0.16": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dIZ2qtrvWbilx2HGkBIp5LNZmEZT2rve2M/NEg3RAzR4WbTa/Gis1x/qRggnoqfDrZ0VnPaybJJKh6r9POQ1EQ==", + "path": "testableio.system.io.abstractions.testinghelpers/22.0.16", + "hashPath": "testableio.system.io.abstractions.testinghelpers.22.0.16.nupkg.sha512" + }, + "TestableIO.System.IO.Abstractions.Wrappers/22.1.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-J91zmSwuMY75ybtkqAFMC3vDNMkLJ/kiDDLhORITEwTKNhEwEYfxAQGv+f0jGYZg/H0YbnGcx5Ky0FgFJwrrWQ==", + "path": "testableio.system.io.abstractions.wrappers/22.1.1", + "hashPath": "testableio.system.io.abstractions.wrappers.22.1.1.nupkg.sha512" + }, + "Testably.Abstractions.FileSystem.Interface/10.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-0R+m7DvS2QuDcFOfDGtyGErRrgXOl4/z2tbNLX2RfvlYJjlVcKG+wMIBfZO8vxGtTNwNxffTG4ibFRwd0jkM7w==", + "path": "testably.abstractions.filesystem.interface/10.1.0", + "hashPath": "testably.abstractions.filesystem.interface.10.1.0.nupkg.sha512" + }, + "YamlDotNet/16.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==", + "path": "yamldotnet/16.3.0", + "hashPath": "yamldotnet.16.3.0.nupkg.sha512" + }, + "CSharpier.Core/1.3.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/.assets/csharpier/CSharpier.dll b/.assets/csharpier/CSharpier.dll new file mode 100644 index 00000000..bff88c6e Binary files /dev/null and b/.assets/csharpier/CSharpier.dll differ diff --git a/.assets/csharpier/CSharpier.exe b/.assets/csharpier/CSharpier.exe new file mode 100644 index 00000000..b892eed9 Binary files /dev/null and b/.assets/csharpier/CSharpier.exe differ diff --git a/.assets/csharpier/CSharpier.runtimeconfig.json b/.assets/csharpier/CSharpier.runtimeconfig.json new file mode 100644 index 00000000..ede73072 --- /dev/null +++ b/.assets/csharpier/CSharpier.runtimeconfig.json @@ -0,0 +1,14 @@ +{ + "runtimeOptions": { + "tfm": "net11.0", + "rollForward": "Major", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "11.0.0-rc.3.26522.148" + }, + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false + } + } +} \ No newline at end of file diff --git a/.assets/csharpier/INIFileParser.dll b/.assets/csharpier/INIFileParser.dll new file mode 100644 index 00000000..475c57ed Binary files /dev/null and b/.assets/csharpier/INIFileParser.dll differ diff --git a/.assets/csharpier/Microsoft.CodeAnalysis.CSharp.dll b/.assets/csharpier/Microsoft.CodeAnalysis.CSharp.dll new file mode 100644 index 00000000..da9d4cda Binary files /dev/null and b/.assets/csharpier/Microsoft.CodeAnalysis.CSharp.dll differ diff --git a/.assets/csharpier/Microsoft.CodeAnalysis.dll b/.assets/csharpier/Microsoft.CodeAnalysis.dll new file mode 100644 index 00000000..92ebd17c Binary files /dev/null and b/.assets/csharpier/Microsoft.CodeAnalysis.dll differ diff --git a/.assets/csharpier/Microsoft.Extensions.Configuration.Binder.dll b/.assets/csharpier/Microsoft.Extensions.Configuration.Binder.dll new file mode 100644 index 00000000..557d6d9e Binary files /dev/null and b/.assets/csharpier/Microsoft.Extensions.Configuration.Binder.dll differ diff --git a/.assets/csharpier/Microsoft.Extensions.Configuration.dll b/.assets/csharpier/Microsoft.Extensions.Configuration.dll new file mode 100644 index 00000000..d3e5c229 Binary files /dev/null and b/.assets/csharpier/Microsoft.Extensions.Configuration.dll differ diff --git a/.assets/csharpier/Microsoft.Extensions.DependencyInjection.dll b/.assets/csharpier/Microsoft.Extensions.DependencyInjection.dll new file mode 100644 index 00000000..a4b40ddb Binary files /dev/null and b/.assets/csharpier/Microsoft.Extensions.DependencyInjection.dll differ diff --git a/.assets/csharpier/Microsoft.Extensions.Logging.Configuration.dll b/.assets/csharpier/Microsoft.Extensions.Logging.Configuration.dll new file mode 100644 index 00000000..7a759d7c Binary files /dev/null and b/.assets/csharpier/Microsoft.Extensions.Logging.Configuration.dll differ diff --git a/.assets/csharpier/Microsoft.Extensions.Logging.dll b/.assets/csharpier/Microsoft.Extensions.Logging.dll new file mode 100644 index 00000000..ed6be279 Binary files /dev/null and b/.assets/csharpier/Microsoft.Extensions.Logging.dll differ diff --git a/.assets/csharpier/Microsoft.Extensions.Options.ConfigurationExtensions.dll b/.assets/csharpier/Microsoft.Extensions.Options.ConfigurationExtensions.dll new file mode 100644 index 00000000..cbb29a1a Binary files /dev/null and b/.assets/csharpier/Microsoft.Extensions.Options.ConfigurationExtensions.dll differ diff --git a/.assets/csharpier/NReco.Logging.File.dll b/.assets/csharpier/NReco.Logging.File.dll new file mode 100644 index 00000000..f3396ed9 Binary files /dev/null and b/.assets/csharpier/NReco.Logging.File.dll differ diff --git a/.assets/csharpier/System.CommandLine.dll b/.assets/csharpier/System.CommandLine.dll new file mode 100644 index 00000000..3c8597b6 Binary files /dev/null and b/.assets/csharpier/System.CommandLine.dll differ diff --git a/.assets/csharpier/System.IO.Abstractions.TestingHelpers.dll b/.assets/csharpier/System.IO.Abstractions.TestingHelpers.dll new file mode 100644 index 00000000..20f12d63 Binary files /dev/null and b/.assets/csharpier/System.IO.Abstractions.TestingHelpers.dll differ diff --git a/.assets/csharpier/System.IO.Abstractions.dll b/.assets/csharpier/System.IO.Abstractions.dll new file mode 100644 index 00000000..d065cbe6 Binary files /dev/null and b/.assets/csharpier/System.IO.Abstractions.dll differ diff --git a/.assets/csharpier/System.IO.Hashing.dll b/.assets/csharpier/System.IO.Hashing.dll new file mode 100644 index 00000000..4860654d Binary files /dev/null and b/.assets/csharpier/System.IO.Hashing.dll differ diff --git a/.assets/csharpier/TestableIO.System.IO.Abstractions.TestingHelpers.dll b/.assets/csharpier/TestableIO.System.IO.Abstractions.TestingHelpers.dll new file mode 100644 index 00000000..a68484ea Binary files /dev/null and b/.assets/csharpier/TestableIO.System.IO.Abstractions.TestingHelpers.dll differ diff --git a/.assets/csharpier/TestableIO.System.IO.Abstractions.Wrappers.dll b/.assets/csharpier/TestableIO.System.IO.Abstractions.Wrappers.dll new file mode 100644 index 00000000..fde6cbd8 Binary files /dev/null and b/.assets/csharpier/TestableIO.System.IO.Abstractions.Wrappers.dll differ diff --git a/.assets/csharpier/TestableIO.System.IO.Abstractions.dll b/.assets/csharpier/TestableIO.System.IO.Abstractions.dll new file mode 100644 index 00000000..851bdc24 Binary files /dev/null and b/.assets/csharpier/TestableIO.System.IO.Abstractions.dll differ diff --git a/.assets/csharpier/Testably.Abstractions.FileSystem.Interface.dll b/.assets/csharpier/Testably.Abstractions.FileSystem.Interface.dll new file mode 100644 index 00000000..bc6173ef Binary files /dev/null and b/.assets/csharpier/Testably.Abstractions.FileSystem.Interface.dll differ diff --git a/.assets/csharpier/XmlDiffPatch.View.dll b/.assets/csharpier/XmlDiffPatch.View.dll new file mode 100644 index 00000000..aeb1584c Binary files /dev/null and b/.assets/csharpier/XmlDiffPatch.View.dll differ diff --git a/.assets/csharpier/XmlDiffPatch.dll b/.assets/csharpier/XmlDiffPatch.dll new file mode 100644 index 00000000..e2859f4f Binary files /dev/null and b/.assets/csharpier/XmlDiffPatch.dll differ diff --git a/.assets/csharpier/YamlDotNet.dll b/.assets/csharpier/YamlDotNet.dll new file mode 100644 index 00000000..07454d5f Binary files /dev/null and b/.assets/csharpier/YamlDotNet.dll differ diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f8a47855..3fc1c2ce 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.0", "commands": [ "purview-build" ], "rollForward": false } } -} \ No newline at end of file +} diff --git a/.config/purview-build.json b/.config/purview-build.json index 332e7fa1..cd1f9282 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -3,7 +3,11 @@ "Solution": "src/ZodSharp.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", - "TestFilter": "/*/*/*/*" + "TestFilter": "/*/*/*/*", + // The vendored CSharpier (an unreleased build that parses C# 15 `union`), the same binary the Justfile + // lint recipes run. Without this the pipeline lints with the tool-manifest 1.3.0, which formats lambda + // arguments differently, so `just lint-fix` output fails the pipeline's lint gate. + "CSharpierPath": ".assets/csharpier/CSharpier.dll" }, "PackValidation": { // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier @@ -19,6 +23,7 @@ "analyzers/dotnet/cs/Purview.ZodSharp.CodeFixes.dll", "analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll", "buildTransitive/Purview.ZodSharp.props", + "buildTransitive/Purview.ZodSharp.targets", "lib/$(TFM)/Purview.ZodSharp.dll", "lib/$(TFM)/Purview.ZodSharp.xml", "purview-logo-light.png", @@ -30,6 +35,39 @@ "purview-logo-light.png", "README.md" ], + "purview.zodsharp.blazor": [ + "build/Microsoft.AspNetCore.StaticWebAssets.targets", + "build/Purview.ZodSharp.Blazor.PackageAssets.json", + "build/Purview.ZodSharp.Blazor.targets", + "buildMultiTargeting/Purview.ZodSharp.Blazor.targets", + "buildTransitive/Purview.ZodSharp.Blazor.targets", + "lib/$(TFM)/Purview.ZodSharp.Blazor.dll", + "lib/$(TFM)/Purview.ZodSharp.Blazor.xml", + "staticwebassets/zodsharp-validation.js", + // The JavaScript initializer is fingerprinted by content, so its name carries a hash. + "staticwebassets/Purview.ZodSharp.Blazor.*.lib.module.js", + "purview-logo-light.png", + "README.md" + ], + "purview.zodsharp.extensions.validation": [ + "lib/$(TFM)/Purview.ZodSharp.Extensions.Validation.dll", + "lib/$(TFM)/Purview.ZodSharp.Extensions.Validation.xml", + "purview-logo-light.png", + "README.md" + ], + "purview.zodsharp.http": [ + "lib/$(TFM)/Purview.ZodSharp.Http.dll", + "lib/$(TFM)/Purview.ZodSharp.Http.xml", + "purview-logo-light.png", + "README.md" + ], + "purview.zodsharp.aspnetcore.openapi": [ + "buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets", + "lib/$(TFM)/Purview.ZodSharp.AspNetCore.OpenApi.dll", + "lib/$(TFM)/Purview.ZodSharp.AspNetCore.OpenApi.xml", + "purview-logo-light.png", + "README.md" + ], "purview.zodsharp.newtonsoftjson": [ "lib/$(TFM)/Purview.ZodSharp.NewtonsoftJson.dll", "lib/$(TFM)/Purview.ZodSharp.NewtonsoftJson.xml", @@ -43,9 +81,51 @@ "README.md" ] }, + // Every package ("*"). RequireExplicitContent (on by default) already rejects anything RequiredContent does not + // declare; these rules hold even if a RequiredContent glob is ever widened. Globs are + // Microsoft.Extensions.FileSystemGlobbing patterns: "*" stays within one path segment, "**" spans any number, + // including none. scripts/test-pack-validation-rules.ps1 injects a file for every rule into a scratch copy of + // each package and proves the validation fails. "ForbiddenContent": { "*": [ - "analyzers/**/Purview.SourceGeneratorFramework.dll" + // The generator ships IL-merged, with Purview.SourceGeneratorFramework internalized into it; a loose copy + // under analyzers/ means the merge did not run. + "analyzers/**/Purview.SourceGeneratorFramework*", + // The compiler supplies Roslyn to analyzers; a packed copy would shadow or conflict with it. + "analyzers/**/Microsoft.CodeAnalysis*", + // Compiler-host assemblies never appear outside analyzers/: anywhere else NuGet hands them to the + // application, which is how Purview.SourceGeneratorFramework.dll reached app and Blazor WebAssembly + // output and broke WebAssembly AOT compilation. + "Purview.SourceGeneratorFramework*", + "Microsoft.CodeAnalysis*", + "lib/**/Purview.SourceGeneratorFramework*", + "lib/**/Microsoft.CodeAnalysis*", + "ref/**/Purview.SourceGeneratorFramework*", + "ref/**/Microsoft.CodeAnalysis*", + "runtimes/**/Purview.SourceGeneratorFramework*", + "runtimes/**/Microsoft.CodeAnalysis*", + "build/**/Purview.SourceGeneratorFramework*", + "build/**/Microsoft.CodeAnalysis*", + "buildTransitive/**/Purview.SourceGeneratorFramework*", + "buildTransitive/**/Microsoft.CodeAnalysis*", + "buildMultiTargeting/**/Purview.SourceGeneratorFramework*", + "buildMultiTargeting/**/Microsoft.CodeAnalysis*", + "tools/**/Purview.SourceGeneratorFramework*", + "tools/**/Microsoft.CodeAnalysis*", + "content/**/Purview.SourceGeneratorFramework*", + "content/**/Microsoft.CodeAnalysis*", + "contentFiles/**/Purview.SourceGeneratorFramework*", + "contentFiles/**/Microsoft.CodeAnalysis*", + "staticwebassets/**/Purview.SourceGeneratorFramework*", + "staticwebassets/**/Microsoft.CodeAnalysis*", + // The generator and the code fixes run only inside the compiler, so they ship under analyzers/ alone: + // under lib/, ref/ or runtimes/ every application would load them. + "lib/**/Purview.ZodSharp.SourceGenerators*", + "lib/**/Purview.ZodSharp.CodeFixes*", + "ref/**/Purview.ZodSharp.SourceGenerators*", + "ref/**/Purview.ZodSharp.CodeFixes*", + "runtimes/**/Purview.ZodSharp.SourceGenerators*", + "runtimes/**/Purview.ZodSharp.CodeFixes*" ] } }, diff --git a/.config/wasm-size-baseline.json b/.config/wasm-size-baseline.json new file mode 100644 index 00000000..e8e2fcf4 --- /dev/null +++ b/.config/wasm-size-baseline.json @@ -0,0 +1,11 @@ +{ + "description": "Brotli-compressed sizes (bytes) of the Purview.ZodSharp assemblies in the trimmed publish of Examples.Blazor.Client. Checked by scripts/check-wasm-size.ps1; regenerate with -Update after an intended change.", + "tolerancePercent": 10, + "assemblies": { + "Purview.ZodSharp.Blazor.wasm": 18023, + "Purview.ZodSharp.Extensions.Validation.wasm": 6768, + "Purview.ZodSharp.Http.wasm": 17824, + "Purview.ZodSharp.SystemTextJson.wasm": 6611, + "Purview.ZodSharp.wasm": 26759 + } +} diff --git a/.csharpierignore b/.csharpierignore index f9b18582..51db78c8 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1,3 +1,10 @@ # CSharpier 1.3.0 (latest) cannot parse the C# 15 `union` keyword, so files containing native # union declarations are excluded from formatting. Revisit once CSharpier supports C# 15 unions. src/src/ZodSharp/Unions/NativeUnion.cs +src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs +src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs +src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs +src/tests/TrimSafety.Verification/Models.cs + +# Agent worktrees and local tooling state are not part of the repository. +.claude/ diff --git a/.editorconfig b/.editorconfig index b5117897..ea030585 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6aac13eb..4389112b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,5 +15,5 @@ Keep product, architecture, and general engineering standards centralized in `AG - Apply the `AGENTS.md` testing bar strictly (TUnit/TUnit.Mocks, AAA comments, naming, cancellation token rule). - Treat work as incomplete until relevant tests pass. -- Consult the repository `.agents/` folder for additional skills/workflows that may improve execution quality. +- Before specialist work, load the relevant installed skill from `.agents/skills/` (committed, with `skills-lock.json`); `AGENTS.md` ("Installed skills") lists each one, when to load it, and how to add or update them. Install only into `.agents/` (`-a universal`), never into `.claude/` or another per-agent folder. - Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db498c65..f5e1608d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ jobs: # install`. Before global.json pinned a version the same gap failed later instead, with # NETSDK1045 on the net11.0 target, because the 10.0.x SDK cannot build it. # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100-rc.3.26522.148" run-pack: true validate-pack: true secrets: inherit @@ -48,7 +48,7 @@ jobs: - run: bun install --frozen-lockfile - # There are THREE producers of the fixtures vitest reads, in different assemblies, namespaces and + # There are FOUR producers of the fixtures vitest reads, in different assemblies, namespaces and # classes, so they cannot be selected by one treenode filter: # # tests/ts/cross-platform.test.ts <- src/tests/cross-platform/output/ @@ -58,6 +58,8 @@ jobs: # ZodSharp.SourceGenerators/ZodSchemaGeneratorTests # tests/ts/temporal-parity.test.ts <- src/tests/cross-platform/temporal-parity/ # ZodSharp.SourceGenerators/ZodSchemaGeneratorTests + # tests/ts/client-validators.test.ts <- src/tests/cross-platform/client-rules/ + # ZodSharp.Blazor.Ssr/ClientRuleParityFixtureTests # # They run as separate steps rather than one OR-ed filter: `a|b` is not honoured as a top-level # alternation by the treenode filter — it silently selects a different, broader set and skips a @@ -80,9 +82,188 @@ jobs: --treenode-filter '/*/ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts' + - name: Run the C# client-validator parity test (writes cross-platform/client-rules) + run: >- + dotnet test --solution src/ZodSharp.slnx --configuration Debug + --treenode-filter + '/*/ZodSharp.Blazor.Ssr/ClientRuleParityFixtureTests/ClientRules_GivenEveryMappedRule_WritesParityFixtures' + - name: Run the TypeScript/Zod cross-platform tests run: bun run test + # The only reliable proof that the minimal-API integration is trim- and Native AOT-safe end to end + # (generated registration, the Microsoft.Extensions.Validation bridge, ProblemDetails and the OpenAPI + # transformers): publish the sample with PublishAot=true, fail on any IL2xxx/IL3xxx warning, then run the + # native binary and check one failing request still returns the ProblemDetails contract. The analyzers that + # run during the normal build only see each assembly on its own; ILC sees the whole application. + aot-sample: + name: Native AOT sample (publish and smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Same reason as the cross-platform job: the SDK comes from global.json, not a floating + # version plus a feed quality. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # -warnaserror turns every warning ILC reports (IL2xxx trim, IL3xxx AOT) into an error, so the publish + # itself fails. The grep is a second gate in case a warning is ever logged without failing the step. + - name: Publish Examples.MinimalApi with Native AOT (fails on any trim or AOT warning) + shell: bash + run: | + dotnet publish src/src/Examples.MinimalApi/Examples.MinimalApi.csproj \ + --configuration Release --runtime linux-x64 --output artifacts/aot-sample \ + -warnaserror 2>&1 | tee publish.log + if grep -E 'warning IL[23][0-9]{3}' publish.log; then + echo "::error::The Native AOT publish reported trim or AOT warnings." + exit 1 + fi + test -x artifacts/aot-sample/ZodSharp.Examples.MinimalApi + + # Compiler-host assemblies (the generator, the code fixes, Purview.SourceGeneratorFramework, Roslyn) must never + # reach build or publish output: Purview.SourceGeneratorFramework.dll once did, through analyzer-only references, + # and broke Blazor WebAssembly AOT. Scans file names and every deps/runtimeconfig/boot manifest. + - name: Scan the build and publish output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path artifacts/aot-sample, src/src/Examples.MinimalApi/bin/Release + + - name: Run the native binary and check a failing request returns ProblemDetails + shell: bash + working-directory: artifacts/aot-sample + run: | + ASPNETCORE_URLS=http://127.0.0.1:5080 ./ZodSharp.Examples.MinimalApi > app.log 2>&1 & + app_pid=$! + trap 'kill "$app_pid" 2>/dev/null || true' EXIT + + for _ in $(seq 1 30); do + curl --silent --fail --output /dev/null http://127.0.0.1:5080/openapi/v1.json && break + sleep 1 + done + + result=$(curl --silent --show-error --output body.json \ + --write-out '%{http_code} %{content_type}' \ + --request POST http://127.0.0.1:5080/orders \ + --header 'Content-Type: application/json' \ + --data '{"customerEmail":"not-an-email","lines":[{"sku":"abc","quantity":0}]}') + echo "$result" + cat body.json; echo + + if [[ "$result" != "400 application/problem+json"* ]] || ! grep -q '"issues"' body.json; then + echo "::error::Expected a 400 application/problem+json response with ZodSharp issues." + cat app.log + exit 1 + fi + + # The only reliable proof that the Blazor integration is trim-safe in the browser: publish the WebAssembly half of + # the Blazor sample (Examples.Blazor.Client - ZodValidator, ZodValidationMessage, AddZodProblemDetails, the generated + # registration and the service-aware async rule) trimmed, and fail on any IL2xxx/IL3xxx warning. Then hold the + # Brotli-compressed size of every Purview.ZodSharp assembly in the output to .config/wasm-size-baseline.json (+10%). + # The RunAOTCompilation publish and the browser test are slower and run nightly (wasm-nightly.yml). `just wasm-size` + # runs the same publish and check locally. + wasm-trimmed: + name: Blazor WebAssembly trimmed publish and size budget + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Same reason as the cross-platform job: the SDK comes from global.json, not a floating + # version plus a feed quality. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # -warnaserror fails the publish on any trim warning ILLink reports. The grep is a second gate in case a warning + # is ever logged without failing the step. + - name: Publish Examples.Blazor.Client trimmed (fails on any trim or AOT warning) + shell: bash + run: | + dotnet publish src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj \ + --configuration Release --output artifacts/wasm-trimmed \ + -warnaserror 2>&1 | tee publish.log + if grep -E 'warning IL[23][0-9]{3}' publish.log; then + echo "::error::The trimmed WebAssembly publish reported trim or AOT warnings." + exit 1 + fi + + - name: Check the Purview.ZodSharp size budget + shell: pwsh + run: ./scripts/check-wasm-size.ps1 -PublishDirectory artifacts/wasm-trimmed + + # Compiler-host assemblies (the generator, the code fixes, Purview.SourceGeneratorFramework, Roslyn) must never + # reach build or publish output: Purview.SourceGeneratorFramework.dll once did, through analyzer-only references, + # and broke Blazor WebAssembly AOT. Scans file names and every deps/runtimeconfig/boot manifest. + - name: Scan the build and publish output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path artifacts/wasm-trimmed, src/src/Examples.Blazor.Client/bin/Release + + # Proves the whole trim-safe surface, not just what one sample calls. The harness publishes with PublishAot=true twice: + # once with every Purview.ZodSharp assembly except NewtonsoftJson rooted whole, so ILC analyses all of their code, and + # once as a plain consumer. Any IL2xxx/IL3xxx warning fails the publish, and each native binary exercises runtime + # schemas, generated validators, System.Text.Json, JSON Schema, the validation bridge, ProblemDetails and options + # validation, exiting non-zero on any mismatch. See src/tests/TrimSafety.Verification. + verify-trim: + name: Trim and Native AOT verification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Same reason as the cross-platform job: the SDK comes from global.json, not a floating + # version plus a feed quality. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # -warnaserror and the project's TreatWarningsAsErrors fail the publish on any trim or AOT warning. The grep is a + # second gate in case a warning is ever logged without failing the step. + - name: Publish and run the harness (every ZodSharp assembly rooted) + shell: bash + run: | + dotnet publish src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj \ + --configuration Release --runtime linux-x64 --output artifacts/trim-verification/rooted \ + -p:TrimVerificationMode=Rooted -warnaserror 2>&1 | tee publish-rooted.log + if grep -E 'warning IL[23][0-9]{3}' publish-rooted.log; then + echo "::error::The rooted Native AOT publish reported trim or AOT warnings." + exit 1 + fi + ./artifacts/trim-verification/rooted/ZodSharp.TrimSafety.Verification + + - name: Publish and run the harness (as a consumer) + shell: bash + run: | + dotnet publish src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj \ + --configuration Release --runtime linux-x64 --output artifacts/trim-verification/consumer \ + -p:TrimVerificationMode=Consumer -warnaserror 2>&1 | tee publish-consumer.log + if grep -E 'warning IL[23][0-9]{3}' publish-consumer.log; then + echo "::error::The consumer Native AOT publish reported trim or AOT warnings." + exit 1 + fi + ./artifacts/trim-verification/consumer/ZodSharp.TrimSafety.Verification + + # Compiler-host assemblies (the generator, the code fixes, Purview.SourceGeneratorFramework, Roslyn) must never + # reach build or publish output: Purview.SourceGeneratorFramework.dll once did, through analyzer-only references, + # and broke Blazor WebAssembly AOT. Scans file names and every deps/runtimeconfig/boot manifest. + - name: Scan the build and publish output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path artifacts/trim-verification/rooted, artifacts/trim-verification/consumer, + src/tests/TrimSafety.Verification/bin/Release + + # Pack validation checks the IL-merged generator is present in the .nupkg; it cannot check that the # assembly loads and generates. That merge has regressed twice (#36, #38), both times breaking consumers # while the in-repo tests stayed green, because those run against the unmerged generator. @@ -101,3 +282,53 @@ jobs: - name: Build and run a consumer against the packed generator shell: pwsh run: ./scripts/test-packed-generator.ps1 -Configuration Release + + # Every other publish job builds against ProjectReferences; this one builds what ships. It packs every shipping + # package to an isolated feed and package cache, then publishes and runs throwaway consumers that reference the + # packages only - a Native AOT console, a Blazor WebAssembly app (trimmed and RunAOTCompilation) and a Native AOT + # minimal API - and leak-scans every bin and publish folder for compiler-host assemblies. It then proves every + # PackValidation ForbiddenContent rule rejects a package, against the packages the consumers used. + # `just smoke-packed-consumers` and `just verify-pack-rules` run the same locally. See docs/wiki/Release-Flow.md. + packed-consumers: + name: Packed consumers (Native AOT, Blazor WebAssembly, minimal API) + runs-on: ubuntu-latest + env: + # An extra feed for Purview.SourceGeneratorFramework only, for a version that is not on nuget.org yet. Unset, the + # gate restores it from nuget.org alone. + ZODSHARP_SGF_FEED: ${{ vars.ZODSHARP_SGF_FEED }} + steps: + - uses: actions/checkout@v7 + + # Same reason as the cross-platform job: the SDK comes from global.json, not a floating + # version plus a feed quality. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # First, before any toolchain is installed: the generator is built against the Purview.SourceGeneratorFramework + # version Directory.Packages.props pins, so nothing can be packed until that version restores. This fails with + # what to do (publish it to nuget.org, or set the ZODSHARP_SGF_FEED repository variable) rather than skipping. + - name: Check Purview.SourceGeneratorFramework restores + shell: pwsh + run: ./scripts/test-packed-consumers.ps1 -PreflightOnly + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. -WasmAot Require below makes a + # missing workload an error instead of a skipped step. + - name: Install the wasm-tools workload + run: dotnet workload install wasm-tools + + - name: Pack, then publish, run and leak-scan consumers of the packages + shell: pwsh + run: >- + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require + -PackOutput artifacts/packed + + - name: Prove every pack-validation forbidden-content rule rejects a package + shell: pwsh + run: | + dotnet tool restore + ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59b20ce6..e75fa876 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: # The shared workflow defaults to the 10.0.x SDK, but this repository targets net11.0 and pins # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100-rc.3.26522.148" release-mode: NuGet release-branch: main secrets: inherit diff --git a/.github/workflows/wasm-nightly.yml b/.github/workflows/wasm-nightly.yml new file mode 100644 index 00000000..3358f89c --- /dev/null +++ b/.github/workflows/wasm-nightly.yml @@ -0,0 +1,112 @@ +name: WebAssembly (nightly) + +# The slow half of the Blazor WebAssembly verification. pr.yml's wasm-trimmed job publishes the sample client trimmed +# and checks its size on every pull request; these jobs AOT-compile it and drive the published sample in a browser, +# which take minutes, so they run nightly and on demand rather than per PR. +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # RunAOTCompilation compiles every assembly the trimmed client keeps to WebAssembly; a construct the Mono AOT + # compiler cannot handle fails here even when the interpreter runs it. Any IL2xxx/IL3xxx warning fails the publish. + wasm-aot: + name: Blazor WebAssembly AOT publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # The SDK comes from global.json, not a floating version plus a feed quality (see pr.yml). + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # AOT compilation needs the Emscripten toolchain and the Mono AOT cross compiler. + - name: Install the wasm-tools workload + run: dotnet workload install wasm-tools + + - name: Publish Examples.Blazor.Client with RunAOTCompilation (fails on any trim or AOT warning) + shell: bash + run: | + dotnet publish src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj \ + --configuration Release --output artifacts/wasm-aot \ + -p:RunAOTCompilation=true -warnaserror 2>&1 | tee publish.log + if grep -E 'warning IL[23][0-9]{3}' publish.log; then + echo "::error::The AOT WebAssembly publish reported trim or AOT warnings." + exit 1 + fi + + # The AOT build keeps the trimmed IL beside the native code, so the same budget applies. + - name: Check the Purview.ZodSharp size budget + shell: pwsh + run: ./scripts/check-wasm-size.ps1 -PublishDirectory artifacts/wasm-aot + + # Compiler-host assemblies (the generator, the code fixes, Purview.SourceGeneratorFramework, Roslyn) must never + # reach build or publish output: Purview.SourceGeneratorFramework.dll once did, through analyzer-only references, + # and broke exactly this AOT compile. Scans file names and every deps/runtimeconfig/boot manifest. + - name: Scan the build and publish output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path artifacts/wasm-aot, src/src/Examples.Blazor.Client/bin/Release + + # One form per render mode in a real browser, against the published sample (so the WebAssembly form runs the + # trimmed client): debounced validation with codes and the async pending state, server ProblemDetails mapped onto + # fields, interactive-server nested placement, static-SSR client rules, and French satellite resources. + blazor-e2e: + name: Blazor sample in a browser + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + - name: Publish the sample + run: >- + dotnet publish src/src/Examples.Blazor/Examples.Blazor.csproj + --configuration Release --output artifacts/blazor-sample + + # The server publish carries the WebAssembly client too, so it is scanned like every other publish output. + # BlazorDebugProxy/ is allowed: Microsoft.AspNetCore.Components.WebAssembly.Server publishes the browser debugging + # proxy there, and its expression evaluator ships Roslyn. The application never loads it. + - name: Scan the build and publish output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path artifacts/blazor-sample, src/src/Examples.Blazor/bin/Release + -Allow '*BlazorDebugProxy/*' + + # playwright-core is not a repository dependency; it is installed for this job only, with its Chromium. + - name: Install Playwright and Chromium + run: | + npm install --no-save --no-package-lock playwright-core@1 + npx playwright-core install --with-deps chromium + + # Run from the publish folder: it is the content root, which serves wwwroot and the static web assets. + - name: Run the sample and the browser checks + shell: bash + working-directory: artifacts/blazor-sample + run: | + ASPNETCORE_URLS=http://127.0.0.1:5090 dotnet ZodSharp.Examples.Blazor.dll > app.log 2>&1 & + app_pid=$! + trap 'kill "$app_pid" 2>/dev/null || true' EXIT + + for _ in $(seq 1 30); do + curl --silent --fail --output /dev/null http://127.0.0.1:5090/ && break + sleep 1 + done + + if ! node ../../tests/e2e/blazor-sample.e2e.mjs http://127.0.0.1:5090; then + cat app.log + exit 1 + fi diff --git a/.gitignore b/.gitignore index 1a5096a9..87387da9 100644 --- a/.gitignore +++ b/.gitignore @@ -661,3 +661,9 @@ BenchmarkDotNet.Artifacts/ .tools/purview-build/** src/tests/cross-platform/ + +!.assets/** + +# Agent skills are installed into the universal .agents/skills/ folder only, and that folder and +# skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. +/.claude/ diff --git a/AGENTS.md b/AGENTS.md index 75dc4e29..b9bff242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Purview.ZodSharp is a high-performance schema validation library for C#, ported - The project is maintained at `purview-dev/zodsharp`. - Public API namespaces are `ZodSharp.*`; packages and assemblies are published under the `Purview.ZodSharp.*` package IDs. -- Multi-targets `net8.0`, `net9.0`, `net10.0` and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. +- Multi-targets `net10.0` and `net11.0` (Purview.BuildSdk's `Supported` set; `net8.0` and `net9.0` were dropped in 3.0.0); the .NET 11 integrations are `net11.0`-only, and the source generator targets `netstandard2.0` so it runs in any compiler host. ## Repository layout @@ -22,9 +22,11 @@ src/ src/ ZodSharp/ Core validation library + JSON Schema export (Z.ToJsonSchema) SourceGenerators/ Compile-time [ZodSchema] generator (netstandard2.0, Roslyn) - SystemTextJson/ System.Text.Json integration + JSON Schema import (Z.FromJsonSchema) + SystemTextJson/ System.Text.Json integration + JSON Schema import (Z.FromJsonSchema); browser-safe + Http/ HttpClient integration (browser-safe): validated bodies, ZodValidationProblemDetails reading NewtonsoftJson/ Newtonsoft.Json integration + JSON Schema import - AspNetCore/ ASP.NET Core ProblemDetails integration + AspNetCore/ ASP.NET Core ProblemDetails integration (+ static-SSR client rules on net11) + Blazor/ Blazor forms integration (net11): ZodValidator, message components, zodsharp-validation.js Examples.CLI/ Usage examples Benchmarks/ BenchmarkDotNet performance suite tests/ @@ -33,7 +35,7 @@ src/ src/ts/ TypeScript (Zod) schema + fixture generation; consumed by C# and vitest tests tests/ts/ Vitest cross-platform tests skills/ Agent skills this repository authors and publishes -.agents/skills/ Skills installed from other repositories by the skills CLI (gitignored) +.agents/skills/ Skills installed from other repositories by the skills CLI (committed, with skills-lock.json) ``` ## Build system @@ -47,7 +49,7 @@ The build is driven by `Purview.BuildSdk`, imported via `src/Directory.Build.pro - `global.json` pins the `Purview.BuildSdk` MSBuild SDK version. - Pack layout for all packages is validated by `purview-build.json` (`PackValidation.RequiredContent`). -Before changing build/SDK configuration or adding/moving projects, install and consult the SDK's own skills: `npx skills add purview-dev/build-sdk` (`purview-buildsdk-configuration`, `purview-buildsdk-project-detection`, `purview-buildsdk-project-layout`). +Before changing build/SDK configuration or adding/moving projects, consult the SDK's own skills, which are committed under `.agents/skills/` (see [Installed skills](#installed-skills-agentsskills)): `purview-buildsdk-configuration`, `purview-buildsdk-project-detection`, `purview-buildsdk-project-layout`. ## Commands @@ -84,15 +86,15 @@ Formatting is enforced with CSharpier; `.editorconfig` at the repo root defines - `// Arrange`, `// Act`, `// Assert` comments. - Meaningful, descriptive method names (`Action_GivenCondition_ExpectedResult`). - Treat work as incomplete until the relevant tests pass. -- For source generators, diagnostic analyzers, code fixes and refactorings, use the `Purview.SourceGeneratorFramework.Testing.TUnit` base classes (`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, `TUnitRefactoringTestBase`) and assert with `CodeQuery`. Install the framework's skills with `npx skills add purview-dev/sourcegenerator-framework` and load `purview-sourcegenerator-testing` and `purview-sourcegenerator-tunit-tests` before writing or changing these tests. +- For source generators, diagnostic analyzers, code fixes and refactorings, use the `Purview.SourceGeneratorFramework.Testing.TUnit` base classes (`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, `TUnitRefactoringTestBase`) and assert with `CodeQuery`. Load the framework's `purview-sourcegenerator-testing` and `purview-sourcegenerator-tunit-tests` skills (committed under `.agents/skills/`; see [Installed skills](#installed-skills-agentsskills)) before writing or changing these tests. ## Source generators -The `[ZodSchema]` attribute (generated into the `ZodSharp` namespace) marks a class or struct for compile-time validator generation. The generator produces a `{TypeName}Schema` static class with `Validate`/`Parse`, optional value-first composition methods (`ApplyAnd`, `ApplyOr`, `ApplyRefine`, controlled by `EnableComposition`, default `true`), and optional `IValidateOptions` support. +The `[ZodSchema]` attribute (`ZodSharp.ZodSchemaAttribute`, defined in the core `Purview.ZodSharp` assembly, like `[ErrorType]`) marks a class or struct for compile-time validator generation. The generator produces a `{TypeName}Schema` static class with `Validate`/`Parse`, optional value-first composition methods (`ApplyAnd`, `ApplyOr`, `ApplyRefine`, controlled by `EnableComposition`, default `true`), and optional `IValidateOptions` support. The generator and analyzer are built with `Purview.SourceGeneratorFramework`: -- Use `CodeWriter` emission and incremental pipelines; load `purview-sourcegenerator-codewriter` (`npx skills add purview-dev/sourcegenerator-framework`) before implementing, reviewing, or refactoring generator/analyzer code. +- Use `CodeWriter` emission and incremental pipelines; load `purview-sourcegenerator-codewriter` (committed under `.agents/skills/`) before implementing, reviewing, or refactoring generator/analyzer code. - Keep pipeline values immutable and value-equatable; never retain `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxNode`, or `Location` in pipeline models. - Use `ForAttributeWithMetadataName` for attribute-driven discovery. - Test incrementally, not just generated text (see the skills above). @@ -121,15 +123,16 @@ Keep `purview-build.json`'s `PackValidation` requirements in sync with any packa Schemas and fixtures shared between TypeScript/Zod and C#/Purview.ZodSharp live under `src/ts/`. The fixture generator (`bun run generate-fixtures`) writes JSON fixtures and a `manifest.json` that the C# tests consume. Keep the TS `UserSchema` and the C# `CrossPlatformUserSchema` in sync. -`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **three** producer/consumer pairs, in different assemblies, namespaces and classes: +`src/tests/cross-platform/` is gitignored, so it starts empty on a clean checkout and in CI: **the C# producers must run before vitest**, in the same workspace. There are **four** producer/consumer pairs, in different assemblies, namespaces and classes: | Vitest consumer | Reads | C# producer | | --- | --- | --- | | `tests/ts/cross-platform.test.ts` | `src/tests/cross-platform/output/` | `ZodSharp.Json/*CrossPlatformTests` (the SystemTextJson and NewtonsoftJson unit tests) | | `tests/ts/guid-uuid-parity.test.ts` | `src/tests/cross-platform/guid-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedGuidSchemas_WritesZodParityArtifacts` | | `tests/ts/temporal-parity.test.ts` | `src/tests/cross-platform/temporal-parity/` | `ZodSharp.SourceGenerators/ZodSchemaGeneratorTests/JsonSchema_GivenGeneratedTemporalSchemas_WritesZodParityArtifacts` | +| `tests/ts/client-validators.test.ts` | `src/tests/cross-platform/client-rules/` | `ZodSharp.Blazor.Ssr/ClientRuleParityFixtureTests/ClientRules_GivenEveryMappedRule_WritesParityFixtures` | -`just test` runs the whole suite, so it covers all three producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping a parity producer. Adding a producer means adding a step. All vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. +`just test` runs the whole suite, so it covers all four producers. The `cross-platform` job in `.github/workflows/pr.yml` selects them with treenode filters instead, and therefore needs **one step per producer** — a single `a|b` filter is not honoured as a top-level alternation and silently selects a broader set while skipping a parity producer. Adding a producer means adding a step. All vitest suites fail loudly rather than passing vacuously when their directory is empty, which is the backstop for exactly this mistake. `src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs` additionally drives the vitest suites **from the .NET test run**, as child processes through CliWrap, so `just test` alone proves both halves of the guarantee and a TypeScript-side regression fails a local run. It is a deliberate duplicate of the CI job, not a replacement — the job stays the authoritative gate because it controls producer ordering and installs bun from scratch. Three rules keep the duplication honest: @@ -153,17 +156,64 @@ Commits must follow [Conventional Commits](https://www.conventionalcommits.org/) - Relevant tests pass. - `just lint-check` (CSharpier) reports no formatting changes. - Generated code is deterministic and reviewable; no scope leaks in `CodeWriter` output. -- Packed packages match `purview-build.json` `PackValidation` (including per-package `README.md` and `purview-logo-light.png`). +- Packed packages match `purview-build.json` `PackValidation` (including per-package `README.md` and `purview-logo-light.png`, and no `ForbiddenContent`: compiler-host assemblies outside `analyzers/`, generator or code-fix assemblies under `lib/`). After changing `PackValidation`, `just verify-pack-rules` proves every forbidden-content rule still rejects a package. +- No build or publish output carries a compiler-host assembly (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`, `Purview.ZodSharp.SourceGenerators*`, `Purview.ZodSharp.CodeFixes*`): every publish recipe and CI publish job runs `scripts/assert-no-compiler-host-assemblies.ps1`, and `ReleaseGateTests` fails a workflow job that publishes without it. +- Changes that affect what ships (packaging, `buildTransitive/`, package dependencies, the generator, trimming or AOT) also pass the gates that consume the real `.nupkg`: `just smoke-packed-generator` and `just smoke-packed-consumers` (Native AOT console, Blazor WebAssembly trimmed and AOT, Native AOT minimal API), plus `just wasm-size`, `just verify-trim ` and `just publish-aot-sample`. +- Before a release, run the pre-release checklist in `docs/wiki/Release-Flow.md` ("Release gates"): `just scrub`, `just pipeline-local-release ...`, then every gate above. -## Agent skills +## Installed skills (`.agents/skills/`) + +Specialist SDK, source-generator and engineering guidance is published by the repositories that own +it, and installed here by the skills CLI. The installed skills are **committed**: `.agents/skills/**` +and the CLI's `skills-lock.json` (source, skill path and content hash of every entry) are both in +source, so a clone has them without running anything. + +| Skill | Source | Load before | +| --- | --- | --- | +| `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | +| `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing generator/analyzer emitter code (`CodeWriter`, scopes) | +| `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | +| `purview-sourcegenerator-diagnostics` | `purview-dev/sourcegenerator-framework` | adding or changing a `ZODSGEN` diagnostic or analyzer | +| `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator is packed | +| `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or code-fix tests (`CodeQuery`) | +| `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit test base classes and assertion extensions | +| `purview-architecture`, `purview-architecture-testing`, `purview-engineering-principles`, `purview-testing-conventions`, `purview-persistence`, `purview-telemetry` | `purview-dev/engineering-playbook` | solution shape, architecture tests, the Purview engineering and testing conventions | + +This repository's own skills in `skills/` are authored for **consumers** of its packages, not for use while working on this repository. They are never installed into this repository's `.agents/skills/`, which holds only skills installed from other repositories; `InstalledSkillTests` enforces this. + +Skills are installed into the universal `.agents/skills/` location **only**. Never install copies +into `.claude/` or any other per-agent folder, and never commit a `CLAUDE.md`: this repository is not +agent-specific, and `AGENTS.md` is its one instruction file. `/.claude/` is gitignored for that +reason. Pass `-a universal`, which writes `.agents/skills/` and nothing else: + +```bash +# add or update every skill from a source +npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y +npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y +npx -y skills@1 add purview-dev/engineering-playbook -s '*' -a universal -y + +# restore the committed set from skills-lock.json +npx -y skills@1 experimental_install +``` + +Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. +Do not edit anything under `.agents/skills/` by hand - the CLI is its only writer, and a hand edit no +longer matches the lockfile's hash. This repository's *own* skills are never installed here; they live +in `skills/` (below), and a second copy would drift. `Purview.BuildSdk`'s `InstallPurviewSkills` +target delegates to the same CLI; `src/Directory.Build.props` sets `PurviewSkillsAgents=universal` so +it is restricted the same way rather than defaulting to every agent (`*`). + +`src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` +entry has its `.agents/skills//SKILL.md`, every installed folder is in the lockfile, no +authored `skills/` skill is copied into `.agents/skills/`, no `.claude/skills/` copy exists, and no +`CLAUDE.md` is present. + +## Authoring skills Authored skills live in the repository-root `skills/` folder. **Nothing packs them into a NuGet package** - `Purview.BuildSdk` 1.0.6 removed skill packaging outright. The skills CLI clones the *git* -repository and only discovers a root `skills/` folder, so that is the one delivery route. - -`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is -gitignored. This repository's own skills are never installed into it - that would leave two copies to -drift apart. Read them at their authored path, `skills//SKILL.md`. +repository and only discovers a root `skills/` folder, so that is the one delivery route. Read them at +their authored path, `skills//SKILL.md`. ```bash npx skills add purview-dev/zodsharp @@ -171,11 +221,12 @@ npx skills add purview-dev/zodsharp | Skill | Answers | | --- | --- | -| `skills/purview-zodsharp-core/` | the runtime API: packages, the `Z` factory, `Validate`/`Parse`, `ValidationResult`, composition, coercion | -| `skills/purview-zodsharp-source-generator/` | `[ZodSchema]`: what it emits, its options, DataAnnotations, `OnZodValidate`, enum validation, `IValidateOptions` | +| `skills/purview-zodsharp-core/` | the runtime API: packages, the `Z` factory, `Validate`/`Parse`, `ValidationResult`, the error model (codes, categories, `ZodPath`, bounds, message formats, localization), composition, coercion | +| `skills/purview-zodsharp-source-generator/` | `[ZodSchema]`: what it emits (member validation, `Paths`, `JsonNames`, trim-safe registration, async graph), its options, DataAnnotations, `OnZodValidate`, enum validation, `IValidateOptions` | | `skills/purview-zodsharp-custom-rules/` | writing a rule, `ErrorCode`/`MessageFormat`, `IZodRule`, `[ZodRule]` attributes, scalar value objects | -| `skills/purview-zodsharp-json/` | System.Text.Json and Newtonsoft integrations, JSON Schema export and import, TypeScript Zod interop | -| `skills/purview-zodsharp-aspnetcore/` | ProblemDetails, minimal-API validation, `IZodSchemaFactory` registration, options validation, `ErrorType` | +| `skills/purview-zodsharp-json/` | System.Text.Json and Newtonsoft integrations, `invalid_json` and converter exceptions, JSON Schema export and import, TypeScript Zod interop | +| `skills/purview-zodsharp-aspnetcore/` | ProblemDetails, minimal-API validation (filter and .NET 11 pipeline), OpenAPI, `IZodSchemaFactory` registration, options validation, `ErrorType` | +| `skills/purview-zodsharp-blazor/` | Blazor forms: `DataAnnotationsValidator` vs `ZodValidator`, `GetZodErrors`, server errors onto fields, static-SSR client rules | | `skills/purview-zodsharp-diagnostics/` | what a `ZODSGEN` or `ZODSASP` diagnostic means and how to fix it | Every skill carries the `purview-zodsharp` prefix. Skills install into one flat directory per agent, diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e04c37..9ce85743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,656 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## 3.0.0-prerelease.1 + +### Added + +- **Trimming and Native AOT across every package.** Every shipping package is `IsTrimmable`/`IsAotCompatible` on + every target framework and analyzer-clean; APIs that cannot be trimmed are annotated with the alternative rather than + suppressed. `src/tests/TrimSafety.Verification` publishes with `PublishAot=true` (every ZodSharp assembly except + NewtonsoftJson rooted, then as a plain consumer), fails on any IL warning and runs the native binary + (`just verify-trim`, CI job `verify-trim`). See [Trimming and Native AOT](docs/wiki/Trimming-and-AOT.md). +- **`[MinCount]`, `[MaxCount]` and `[CountRange]`** (`ZodSharp.Rules`, generated from the new `MinCountRule`, + `MaxCountRule` and `CountRangeRule`): trim-safe collection/dictionary count attributes. The generator validates + them exactly like `[MinLength]`/`[MaxLength]`/`[Length]` on a collection (same code, origin, bounds, `count` + parameter, message and JSON Schema keywords), without the BCL attributes' `[RequiresUnreferencedCode]` + constructors. **`ZODSGEN053`** (warning, with a code fix) suggests them for a collection member of a + `[ZodSchema]` type when `PublishTrimmed`, `PublishAot`, `IsTrimmable` or `IsAotCompatible` is set; the package + exposes those properties to analyzers through `CompilerVisibleProperty`. +- **`OptionsBuilder.ValidateWithZod(configurationSection, validateOnStart, missingValidatorBehavior)`**: + the trim-safe counterpart of `BindAndValidateWithZod`. It records the section so failures name configuration + keys but never binds, so it pairs with `BindConfiguration(section)`, which the configuration binder source + generator intercepts. +- **`ZodDiscriminatedUnionBuilder.Option` reads a POCO discriminator without unannotated reflection**: `T` is + annotated `[DynamicallyAccessedMembers(PublicProperties)]` and the accessor is built from it, so POCO unions are + trim- and AOT-safe. +- **The `ZodSharp.Reflection.IsSupported` feature switch**, set `false` by the new + `buildTransitive/Purview.ZodSharp.targets` when `PublishTrimmed` or `PublishAot` is `true`, so ILLink and ILC + remove the reflection fallbacks (see *Breaking behaviour changes*). +- **`Purview.ZodSharp.Extensions.Validation`** (new package, `net11.0`): the bridge to .NET 11's + Microsoft.Extensions.Validation, so minimal APIs, Blazor Server and Blazor WebAssembly validate ZodSharp types + with ZodSharp. `AddZodSharpValidation()` registers `ZodValidatableInfoResolver` as the first resolver whether + `AddValidation()` is called before, after or not at all; errors carry the owning `Container` and leaf `Name` + Blazor needs to place messages on nested, collection and dictionary fields; parameters follow + Microsoft.Extensions.Validation's path conventions; `MaxDepth` and synchronous-validation behaviour match the + platform. Full-fidelity errors are available through `ValidateContext.ZodReport` and an optional + `IZodValidationReportSink`; `StringLocalizerZodMessageFormatter` localizes messages (auto-enabled when an + `IStringLocalizerFactory` is registered, honouring `ValidationOptions.LocalizerProvider`); and + `ValidationOptions.ValidateAsync` validates outside HTTP and Blazor. The platform's endpoint filter validates every + parameter a resolver claims, so `AddZodSharpValidation()` turns on minimal-API validation for ZodSharp-typed + parameters; `AddZodSharpValidation(configure, validateParameters: false)` and `ZodValidationOptions.ValidateParameters` + register type validation only. References the + `Microsoft.Extensions.Validation` package only - never the ASP.NET Core shared framework - so it works in + WebAssembly clients. +- **`Purview.ZodSharp.Blazor`** (new package, `net11.0`): Blazor forms validated by ZodSharp on Blazor Server, + Blazor WebAssembly and Auto render mode. `AddZodSharpBlazor()` makes the standard `` + validate `[ZodSchema]` models (form models only: it does not turn on minimal-API validation in the host; call + `AddZodSharpValidation()` as well for that) - nested objects, collections and dictionaries included - with no + `[ValidatableType]` and no Microsoft.Extensions.Validation generator, which is what nested validation in + WebAssembly needs. `` keeps the full error behind every message (`EditContext.GetZodErrors`), + validates on `Submit`, `Change` or debounced `Input` through the platform's async validation APIs (pending, + faulted, supersession), accepts a runtime `Schema` (`ZodModelSchema.For`), can re-validate a field's + owning object (`ValidateNestedOnFieldChange`) and coexists with `DataAnnotationsValidator` without duplicating + messages. `` and `` match the built-in markup and add + `data-code`/`data-category`, path grouping and union details. `EditContext.AddZodProblemDetails` maps API validation + problems (read with `Purview.ZodSharp.Http`'s `TryReadZodValidationProblemAsync()`) onto fields, cleared per field on + change. Ships `zodsharp-validation.js`, browser validators that mirror the C# rules + (verified by a C#-to-JavaScript parity suite), registered through a Blazor JavaScript initializer. +- **`Purview.ZodSharp.Http`** (new package, `net10.0` and `net11.0`, browser-safe): `HttpClient` integration for + WebAssembly and other clients. `PostAsJsonValidatedAsync`/`PutAsJsonValidatedAsync`/`PatchAsJsonValidatedAsync` and + `ZodJsonContent.Create`/`CreateAsync` validate a body before it is sent (an invalid value returns the errors and + sends nothing); `HttpContent.ReadFromJsonAndValidateAsync` reads and validates a response body, reporting unreadable + JSON as `invalid_json`; and the validation-problem contract (`ZodValidationProblemDetails`, + `ZodValidationProblemIssue`, `HttpResponseMessage.TryReadZodValidationProblemAsync()`, new `ToValidationResult()`) + moved here from `Purview.ZodSharp.Blazor`, so a client without Blazor - or on `net10.0` - reads server validation + problems back into ZodSharp errors. Every API takes a `JsonTypeInfo`; there are no reflection-based overloads. A + parity test proves every error field `Purview.ZodSharp.AspNetCore` writes (code, category, typed path, origin, + bounds, parameters, union options) reads back unchanged. Each send method, `ZodJsonContent.CreateAsync` and + `ReadFromJsonAndValidateAsync` has an overload taking an `IZodSchemaValidator` and an `IServiceProvider`, so + service-aware rules (an async uniqueness check) run before a request is sent and as a response is read. +- **`Purview.ZodSharp.SystemTextJson` is browser-safe** (`PurviewBrowserSafe`): trim, AOT and browser-unsupported APIs + fail its build, and Blazor WebAssembly clients may reference it. `DeserializeAndValidateAsync` and + `ValidateAndSerializeAsync` gain service-aware overloads on `IZodSchemaValidator` (with a `JsonTypeInfo` and an + `IServiceProvider`). +- **`EditContext.AddZodErrors(errors)`** (`Purview.ZodSharp.Blazor`): places ZodSharp errors produced outside the form - + such as a `PostAsJsonValidatedAsync` that refused to send - onto its fields by their typed paths, sharing + `AddZodProblemDetails`'s store, replacement and clear-on-change behaviour. +- **The Blazor sample uses `Purview.ZodSharp.Http` in both interactive render modes**: `RegisterCustomerForm` and the + typed `CustomerAPIClient` run on `/register` (WebAssembly, posting from the browser) and the new `/register-server` + (Interactive Server, a typed client from `IHttpClientFactory` calling the API service-to-service), covered by + integration tests against the API and by the browser end-to-end checks. +- **Static-SSR client-side validation for ZodSharp rules.** On `net11.0`, `Purview.ZodSharp.AspNetCore` adds + `AddZodSharpClientValidation()`: statically rendered forms carry client rules for ZodSharp rule attributes + (`[StartsWith]`, `[UUID]`, `[IPAddress]`, `[MinValue]`, …), derived from each rule's `IZodRule` metadata, and for + registered runtime schemas, merged into ASP.NET Core's `data-rules` payload. It works before or after + `AddRazorComponents` and warns at startup in Development when a later registration replaces it. + `ZodClientValidationRules` exposes the mapping for custom `IClientValidationRuleProvider` attributes. +- **Minimal APIs on .NET 11: full-fidelity ProblemDetails from the built-in validation pipeline.** On + `net11.0`, `Purview.ZodSharp.AspNetCore` now depends on `Purview.ZodSharp.Extensions.Validation` (that target + framework only). `AddZodSharpProblemDetails()` registers a scoped `ZodRequestValidationReport` (the bridge's + `IZodValidationReportSink`) and composes `ProblemDetailsOptions.CustomizeProblemDetails`. The ZodSharp callback + runs first, then any callback configured before or after it. It rewrites the pipeline's + `HttpValidationProblemDetails` with `ZodProblemDetailsWriter.Apply`: DataAnnotations entries are merged with + `dataannotations` issues, and the `ErrorType`, `StatusCodeSelector` and `StatusCodeOverride` status is mirrored + to the response. A DataAnnotations-only failure only gains the `issues` projection. Endpoints and route + groups validated by `AddZodSharpValidation()` therefore return the same body, byte for byte apart from + `traceId`, as the endpoint filter, `ZodExceptionHandler` and the manual results. `WithZodSharpValidation()` + on an endpoint the pipeline also validates skips the parameters the bridge covers, logged once at `Debug`, so + nothing is validated twice. +- **`IZodMessageFormatter`** in `ZodSharp.Core`: formats an error's user-facing message for a culture at a host + boundary. +- **`Purview.ZodSharp.AspNetCore.OpenApi`** (new package, `net11.0`). `options.AddZodSharp()` on + `OpenApiOptions` registers schema, operation and document transformers so the ASP.NET Core 11 OpenAPI + document (3.1 and 3.2) describes what ZodSharp enforces: lengths, bounds (exclusive bounds in the 3.1+ form), + `multipleOf`, integer narrowing, patterns (several promote to `allOf`), formats (including `[EmailAddress]`, + which the platform does not map), `enum`/`not`, `contentEncoding`, required members and `type: [x, "null"]` + nullability, with ZodSharp winning when a DataAnnotation disagrees. Polymorphic types are described with + `oneOf` + `discriminator`; C# unions keep the platform's `anyOf` and get their cases constrained. Rules with + no JSON Schema keyword are described with `x-zod-*` vendor extensions. Operations that bind a ZodSharp-validated + parameter list `400` (and `422` when a registered `ErrorType` uses it) as `application/problem+json` with the + shared `ZodValidationProblemDetails`/`ZodValidationIssue` components, merging into a declared + `ProducesValidationProblem()` response. Types ZodSharp does not know are left exactly as the platform produced + them. +- **JSON Schema export.** `ToJsonSchemaConverter.ConvertUntyped(object)` converts a schema or validator known only + at runtime (including the `ZodSchemaValidator`/`ZodUntypedValidator` adapters, which now unwrap); + `JsonSchemaDefinition.GetVendorExtensions()`/`SetVendorExtension()` record `x-zod-*` information for hosts that + carry it, without changing the serialized JSON Schema. `ZodDiscriminatedUnion` exports as `oneOf` (it exported + the empty schema). `IPAddressRule` exports `format: ipv4`/`ipv6`, `DurationRule` `format: duration`, + `DateStringRule` `format: date`, `DatetimeStringRule` `format: date-time` plus its UTC pattern, + `TimeStringRule` its local-time pattern, and `Base64StringRule` `contentEncoding: base64`; `CidrRule`, + `JWTRule` and an any-family `IPAddressRule` stay listed as unsupported and record `x-zod-cidr`, `x-zod-jwt` + and `x-zod-ipVersion`. +- **Source generator — field-level validation and member resolution.** Every generated `{Type}Schema` now has + `ValidateMember(value, memberName)`, `ValidateMemberAsync(value, memberName, services, ct)` and + `IsValidatableMember(memberName)` (the member's own rules only: no nested recursion, type rules, + `IValidatableObject` or refinement), `TryResolveMember(value, path, out container, out memberName)` + (reflection-free), `Paths.{Member}` `ZodPath` constants, a `JsonNames.Get(member)` table and + `ToJsonNamedPath(path, namingPolicy)`. `Validate` calls one method per member. The `{Type}SchemaValidator` + adapter implements `IZodMemberValidator`, `IZodMemberResolver`, `IZodUntypedValidator` and the new + `IZodJsonPathMapper`. +- **Source generator — async graph.** `{Type}Schema.ValidateAsync(value, services, ct)` awaits nested schemas' + async validation and threads the caller's services; it completes synchronously when nothing in the graph is + asynchronous (`{Type}Schema.RequiresAsyncValidation` is a compile-time constant). A custom async method may take + `(T value, IServiceProvider services, CancellationToken ct)`, and `[ZodAsyncMember(nameof(X))]` makes + `ValidateMemberAsync` run it for that member only. +- **Source generator — DataAnnotations inputs.** `IValidatableObject` runs after the members and type rules (as in + DataAnnotations and Microsoft.Extensions.Validation); on .NET 11 `IAsyncValidatableObject` and + `AsyncValidationAttribute` members run in `ValidateAsync`. `[Display(Name, ResourceType)]` reads the resource + property at runtime. +- **Source generator — C# 15.** A member typed as a union dispatches to the active case's schema, and a member + typed as a closed class to the derived type's schema (paths do not include the union's `Value`). +- **Source generator — trim-safe registration.** Each assembly gets + `ZodSharp.Generated.{Assembly}ZodSharpSchemas.Register(IZodSchemaFactory)` and, with dependency injection, + `services.AddZodSharp{Assembly}Schemas()`; both name every validator directly (no attribute scanning or + `Activator`). `AddZodSharpFactory` applies every `ZodSchemaRegistration` added with `AddZodSchemaRegistration`. +- **Source generator — options.** On .NET 11 the generated `{Type}Validator` also implements + `IAsyncValidateOptions` when the type has asynchronous rules. +- `ZodValidationDepth` (`MaxDepth`, default 32): generated validators throw `InvalidOperationException` at the + limit, like Microsoft.Extensions.Validation, instead of overflowing the stack on a cyclic graph. +- `ErrorType.Origin` (init-only); the generated `Create*` helpers apply it and the `MessageFormat`. +- `RegexRule.DefaultMatchTimeoutMilliseconds`. +- Diagnostics `ZODSGEN047` (redundant `[ValidatableType]`, with a code fix), `ZODSGEN048` (async-only rules + behind a synchronous `Validate`), `ZODSGEN049` (closed-hierarchy derived type without `[ZodSchema]`), + `ZODSGEN050` (generated options validator conflicts with `[OptionsValidator]`, SYSLIB1205), + `ZODSGEN051` (`[ZodSchema]` type in a Razor file), `ZODSGEN052` (an `ErrorMessage` placeholder the + attribute does not supply) and `ZODSGEN054` (a service-aware custom validation method whose second parameter is + not `IServiceProvider`; it was reported as `ZODSGEN009`, whose title is about the parameter count). +- **Shared error identities.** `ZodIssueCodes` (codes), `ZodMessages` (named-placeholder templates), + `ZodIssues` (factories for null/invalid-type/missing-field/refinement/union/collection-size errors and the + `GetUnionErrors()` extension) and `ZodTypeNames` (`Of(Type)`/`OfValue(object)`: Zod type names such as + `string`, `number`, `array`) in `ZodSharp.Core`, so every producer reports the same failure identically. +- **`ZodErrorOptions.IncludeReceivedValues`** (default `false`). When enabled, rule failures carry the raw input + as the `received` parameter. Development only; built-in messages never contain the input either way. +- **`IZodFailureDescriber` / `IZodStringFailureDescriber` / `ZodRuleFailure`.** A rule can describe the + value-dependent part of its failure (category by direction, `count`); `ZodRuleErrors` applies it. +- **Structured rule identity.** Every built-in rule reports `Origin` (`string`, `number`, `date`, `enum`, …), + `Category` (the Zod v4 issue code), `Lower`/`Upper` bounds with the correct inclusivity, and parameters such as + `format`, `divisor`, `pattern`, `prefix`, `options`, `version` and `count`. +- **`RefineCtx.AddIssue(ValidationError)`** and **`AddIssue(code, message, ZodPath, parameters, category)`**, + plus `RefineCtx.FullPath` and a `RefineCtx(T, ZodPath)` constructor. +- `ZodIssueParameters.Format`, `Divisor`, `Pattern`, `Prefix`, `Suffix`, `Includes`, `Version` and `Key`. +- `ZodMessageFormat` renders a sequence parameter (an options or keys list) as its elements joined by `, `. + +- **One ProblemDetails writer, `ZodProblemDetailsWriter`** (`Purview.ZodSharp.AspNetCore`). The endpoint filter, + `ZodExceptionHandler`, `ZodTypedResults` and every `ToHttpValidationProblemDetails`/`ToValidationProblemDetails`/ + `ToValidationProblem` overload delegate to it, so all paths produce the same body (snapshot-tested). Public + `Create(ReadOnlySpan|IEnumerable, HttpContext?)`, `Apply(existing, + ReadOnlySpan|IEnumerable, ...)` (the sequence form takes an `ImmutableArray` + without copying, and neither form is ambiguous for arrays, lists or collection expressions) + (rewrites an existing `HttpValidationProblemDetails` and keeps non-ZodSharp `errors` entries, projected as + `dataannotations` issues), `WriteAsync(...)` (through `IProblemDetailsService`, so `CustomizeProblemDetails` runs) + and `GetOrCreate(IServiceProvider?)`. +- **`WithZodSharpValidation()` for any `IEndpointConventionBuilder`**, including route groups. An endpoint filter + factory selects the parameters to validate when the endpoint is built (skipping services, `HttpContext`, + `CancellationToken`, `[FromServices]`, keyed services and `[SkipValidation]`), validates every one through + `IZodSchemaFactory.TryGetUntypedValidator`, keys errors like Microsoft.Extensions.Validation (complex parameters + unprefixed, sequences and root errors prefixed with the parameter name) and adds 400 + `application/problem+json` response metadata for OpenAPI. `ZodEndpointValidationOptions` (`ThrowIfNoValidator`, + `OnBindingFailure`, `ProducesStatusCode`) and `ZodBindingFailureBehavior`. +- **`ZodTypedResults.ValidationProblem(result|exception, HttpContext)`** (typed `ValidationProblem` for + `Results<…>` unions) and **`ZodTypedResults.Problem(...)`** (`ZodProblemDetailsResult`, keeps the computed status). + New `ToValidationProblem(HttpContext)`, `ToHttpValidationProblemDetails(HttpContext)` and + `ZodProblemDetailsOptions` overloads. +- **`ZodProblemDetailsOptions`:** `PathNaming` (`ZodPathNaming.MemberName` default, or `Json` from the minimal-API + `JsonOptions` naming policy), `IncludeIssues`, `MergeParametersIntoExtensions` (default `false`), + `ProjectDataAnnotationsIssues`, `ErrorTypeResolver`, `DefaultStatusCode` and `StatusCodeOverride`. +- **`ValidationIssue` serializes a Zod-style shape:** `path` (dotted key), `segments` (strings and integer + indexes), `code`, `category`, `origin`, typed `minimum`/`maximum` from `Lower`/`Upper` (numbers, or ISO-8601 for + dates), `inclusive`, `message`, safe camel-cased `parameters` and nested `unionErrors`. The CLR `Path`, + `Minimum`, `Maximum` and `Parameters` members are kept, unserialized. +- **Native AOT:** the ProblemDetails payload has source-generated JSON metadata, added to the minimal-API + `JsonOptions` by `AddZodSharp`/`AddZodSharpProblemDetails`. +- **`IZodMessageFormatter` and `DefaultZodMessageFormatter`** (core). The writer renders messages through the + registered formatter in `CultureInfo.CurrentUICulture`; without one, messages are used as produced. +- **`ZodPathNaming`** (core): `MemberName` or `Json`, shared by every host. +- **Options validation on .NET 11:** `ZodSchemaOptionsValidator` implements `IAsyncValidateOptions`, so + `ValidateOnStart()` runs asynchronous and service-aware rules (in a scope); `OptionsBuilder.AddZodSchemaValidator` + registers through `Validate()`. New `AddZodSchemaValidator(missingValidatorBehavior, validateOnStart, + configurationSection)` overload, `BindAndValidateWithZod(section)` (AspNetCore package) and + `ZodValidateOptionsResult` (the failure with its full `Errors`). `Microsoft.Extensions.Options` 11 on net11.0. +- **Examples.MinimalApi sample** (`src/src/Examples.MinimalApi`, `net11.0`, not packed), with its `[ZodSchema]` + request models in `src/src/Examples.Shared` for reuse by later samples. It shows generated trim-safe registration + (`AddZodSharp{Assembly}Schemas()`), route groups validated by the .NET 11 pipeline (`AddZodSharpValidation()`), the + `WithZodSharpValidation()` filter, a manual `ZodTypedResults.ValidationProblem`, a service-aware async uniqueness + rule, async options validation on start, French messages, the OpenAPI transformers and an `.http` file. It + publishes with `PublishAot=true` and zero IL2xxx/IL3xxx warnings: a new `aot-sample` PR job publishes it for + `linux-x64` with `-warnaserror` and smoke-tests the native binary, `just publish-aot-sample` runs the same publish + locally, and `Examples.MinimalApi.IntegrationTests` starts it in memory. +- **Examples.Blazor sample** (`src/src/Examples.Blazor` and its WebAssembly client `src/src/Examples.Blazor.Client`, + `net11.0`, not packed): a Blazor Web App over the same `Examples.Shared` models (plus a new `ReturnRequest` with + `[StartsWith]` and `[UUID]`). An interactive Server form uses `` over a nested address, + order lines and a dictionary of addresses; an interactive WebAssembly form uses `` with debounced + `Change | Input` validation, `ZodValidationMessage` codes, a service-aware async uniqueness check with + `IsValidationPending`, and server ProblemDetails mapped back with `AddZodProblemDetails`; a static-SSR form gets + ZodSharp client rules from `AddZodSharpClientValidation()`. Messages switch between English and French. Both + `Program.cs` files register with `AddZodSharpZodSharpExamplesSharedSchemas()` and `AddZodSharpBlazor()`. + `Examples.Blazor.IntegrationTests` covers the client rules, the posted static-SSR form and the API contract. +- **WebAssembly CI:** the `wasm-trimmed` PR job publishes the client trimmed with `-warnaserror` (zero IL2xxx/IL3xxx + warnings) and holds the Brotli size of every `Purview.ZodSharp*` assembly to `.config/wasm-size-baseline.json` + within 10% (`scripts/check-wasm-size.ps1`, `just wasm-size`). A nightly workflow (`wasm-nightly.yml`, also on + demand) publishes it with `RunAOTCompilation=true` and drives the published sample in Chromium, one form per render + mode. +- **`ZODSUP001`, a CA1849 suppressor.** The analyzer package now suppresses CA1849 ("call async methods when in an + async method") for ZodSharp's synchronous validation entry points: generated `Validate`/`ValidateMember`, the + generated and core validator adapters, and the fluent schemas. An async method can call the cheap synchronous path + without a warning, and the suppression is by call target rather than by rule, so `Task.Wait`, `Thread.Sleep` and + another library's `Validate`/`ValidateAsync` pair still report CA1849. +- **Strict deserialization reports the member path.** A converter that rejects a value with + `new JsonException(message, zodException)` - the contract Purview.ValueObjects' strict converters adopt - now + surfaces the `ZodException`'s errors under the member that held the value, exactly as validating the hydrated model + does: `CustomerEmail.Value` and `"Field 'CustomerEmail' ..."` instead of `Value` and `"Field 'Value' ..."`. JSON names + in System.Text.Json's `JsonException.Path` are mapped back to member names through the root contract's metadata + (no reflection); collections keep their indexes and dictionary entries become key segments. + - **`ZodJsonExceptions`** (core, `ZodSharp.Core`): `TryGetErrors`, `TryReroot`, `Reroot` and `ToMemberPath`. + - **`DeserializeAndValidate*`** (SystemTextJson) and **`ReadFromJsonAndValidateAsync`** (Http) return those errors + rather than `invalid_json`. + - **`ZodExceptionHandler`** also handles a `BadHttpRequestException`/`JsonException` chain carrying a + `ZodException`, mapping names through the endpoint's body contract (`IAcceptsMetadata`); any other bad request is + still left to the next handler. + - **.NET 11:** minimal-API binding writes its own validation problem for an unreadable body (keyed by `$.customerEmail`, + quoting the converter's message); `AddZodSharpProblemDetails` now rewrites it with the ZodSharp errors when the + exception carries a `ZodException`. Other binding problems are unchanged. + - **`ZodProblemDetailsOptions.HandleBindingFailures`** (default `false`): sets `RouteHandlerOptions.ThrowOnBadRequest` + so .NET 10 Production binding failures reach the handler; non-ZodSharp ones are answered with their own status and + a standard ProblemDetails body. A bare `ZodException` is handled exactly as before. +### Breaking behaviour changes + +- **Dropped net8.0 and net9.0; supported target frameworks are net10.0 and net11.0.** Every package ships + `lib/net10.0` and `lib/net11.0` only (the .NET 11 integrations stay `net11.0`-only); the source generator and + analyzers stay on `netstandard2.0`. The `Microsoft.Extensions.*` dependency lines for 8.x and 9.x are gone, and + the net8.0-only trim suppressions were replaced by the feature guards every remaining target understands. +- **Newly annotated APIs (not binary breaks; trimmed and AOT applications now get IL2026/IL3050 at the call site).** + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` were added to: every `Purview.ZodSharp.NewtonsoftJson` API + that reaches Newtonsoft serialization (`NewtonsoftJsonExtensions.DeserializeAndValidate`, + `DeserializeAndValidateAsync`, `ValidateAndSerialize`, `ValidateAndSerializeAsync`, `CreateValidatingConverter`, + `Z.FromJsonSchema`, `FromJsonSchemaParser.Parse`, the `JsonSchemaSerializerOptions` class) - use + `Purview.ZodSharp.SystemTextJson`; `AddZodSharpAssemblyGraph` (all overloads) and + `ZodSchemaFactoryOptions.ScanAssemblyGraphs` - use the generated `AddZodSharp{AssemblyId}Schemas()` or + `AddZodSharpAssembly`; `ZodModelSchema.For(ZodObject)` (Blazor) - use a `[ZodSchema]` validator or + `For(IZodSchema)`; `AddZodSharpClientValidation`, `ZodClientValidationProvider` and + `ZodClientValidationRules.FromAttribute` (static-SSR client validation needs `AddRazorComponents`, itself not + trim-compatible) - use `ZodClientValidationRules.For(IZodRule)`/`FromRule`. `BindAndValidateWithZod` keeps its + annotations and now names `ValidateWithZod`. +- **`ZodDiscriminatedUnionBuilder.Option`'s `T` is annotated `[DynamicallyAccessedMembers(PublicProperties)]`.** + Generic code that forwards an unannotated type parameter to it gets IL2091 when trim-analyzed. +- **Reflection fallbacks are removed when trimming.** With `PublishTrimmed` or `PublishAot` (including Blazor + WebAssembly, which trims by default), `ZodSharp.Reflection.IsSupported` is `false`: a discriminated union reports + a POCO whose type was not added with `Option` as missing its discriminator; the Blazor and + Microsoft.Extensions.Validation field walkers no longer place errors from a validator that does not resolve + members itself (generated validators do), so such an error stays on the nearest resolved object or the model; + and assembly reference-graph scanning registers nothing. Untrimmed applications are unchanged. +- **The discriminator accessor cache is per union instance** rather than process-wide, so two unions over the same + type no longer share a cached accessor. +- **ProblemDetails no longer copies error parameters to the top level.** In 2.4 every error's parameters were also merged (camel-cased, last error wins) into the root of the ProblemDetails body. They are now only written per issue, in `issues[].parameters`. Set `ZodProblemDetailsOptions.MergeParametersIntoExtensions = true` to restore the old top-level copy. +- **`[Required]` on a string now matches `RequiredAttribute`** (source generator, defect G1). The generated + check was inverted: the default (`AllowEmptyStrings = false`) only rejected `null`, so `""` passed, while + `AllowEmptyStrings = true` rejected `""`. The default now rejects `null`, `""` and whitespace-only text, and + `AllowEmptyStrings = true` rejects only `null`. *Migration:* a model that relied on `""` passing a bare + `[Required]` must either supply a value or set `AllowEmptyStrings = true`. +- **Generated error messages are formatted at runtime** from a named template kept in + `ValidationError.MessageFormat`, with `displayName`, `member`, `declaringType` and the rule's values in + `Parameters`. Attribute-specific texts are unchanged; positional placeholders in a custom `ErrorMessage` keep + their DataAnnotations meaning. Shared failures now use the runtime schemas' identity: + - `[Required]`: `"{displayName} is required"` (was `"Required field '{0}' is null"`), category `invalid_type`. + - A null non-nullable member, a null root and a rejected null element: `"Expected {expected}, received null"` + (was `"Field '…' cannot be null"`, `"Value cannot be null"`, `"Field '…' cannot contain null …"`). + - `ApplyAnd`/`ApplyOr`/`ApplyRefine` failures: code `refinement_failed`, category `custom` (was + `validation_failed`). + - `[StringLength]`/`[Length]` on a string report the runtime rules' code `invalid_length` (was + `too_small`/`too_big`), with the direction in `Category` and both bounds. +- **Typed generated paths.** Collection and dictionary errors are located with index and key segments + (`Items[0].Name`, `Map["a.b"].Value` from `FullPath.ToDottedString()`); keys are rendered with the invariant + culture. Nested re-pathing keeps every field of the nested error. +- **`[Range]`** reports `Category` `too_small`/`too_big`, origin from the value's Zod type and `Lower`/`Upper` + with `MinimumIsExclusive`/`MaximumIsExclusive` honoured. **`[Length]`'s custom message** binds `{1}` to the + minimum and `{2}` to the maximum, like `LengthAttribute` (it was swapped). +- **Generated options validators** report `"{path}: {message} ({code})"` per failure (was the message only). +- **Async-only inputs fail loudly**: a synchronous `Validate` of a type with an `AsyncValidationAttribute` member or + `IAsyncValidatableObject` throws `InvalidOperationException`. +- **Nested types' async rules run**: `ValidateAsync` now awaits nested schemas, so a nested type's custom async + method reports its errors (under the member's path) where they were silently dropped. +- **Index paths.** `ZodArray` and `ZodTuple` element errors are located with typed index segments; the legacy + `ValidationError.Path` renders them as `"0"` (was `"[0]"`), matching Zod. `FullPath.ToDottedString()` renders + `Items[0].Name`. +- **Tuple element errors are prefixed with their index** (they were reported at the tuple root). +- **Null messages** are `"Expected {type}, received null"` for strings, arrays, records, objects, enums, tuples and + discriminated unions (were `"Expected array, but got null"` and similar). +- **Invalid-type messages use Zod type names** (`"Expected number, received string"`), never CLR names such as + `Int32`, `Double` or `JsonElement`; `expected`/`received` parameters carry the same names. +- **Missing fields:** `ZodObject` reports `"{key} is required"` (was `"Required field '{key}' is missing"`), category + `invalid_type`. `RequiredRule` reports `"Value is required"` (was `"Field is required"`) with the shared + `"{displayName} is required"` template as `MessageFormat`; its `{0}` placeholder is now the display name. +- **Unrecognized keys:** a strict object reports one `unrecognized_keys` issue at the object's path with a `keys` + parameter (was one `unrecognized_key` issue per key, at the key's path). +- **Unions** report per-option issues under `unionErrors` (`ImmutableArray>`) instead + of a flattened mutable list under `errors`; typed and native unions now keep them (they were discarded) and name + the options with Zod type names. Category `invalid_union`. +- **Discriminated unions** report `missing_discriminator`/`invalid_discriminator` at the discriminator's path with + `discriminator` and `options` parameters, list options in declaration order, and no longer echo the received + discriminator value. +- **Enums and literals** no longer echo the received value (`"Expected one of: a, b"`, `"Expected literal 1.5"`); + `ZodEnum` lists its options in ordinal order; `ZodNativeEnum` reports exactly what `EnumRule` reports. +- **Array size messages:** `Min`, `Max` and `Length` keep separate custom messages (a shared one reported the wrong + message), and the defaults report the count (`"Array must have at least 2 elements, but got 1"`). +- **Default rule messages no longer contain the input** (email, credit card, JWT, phone, compare, starts/ends + with, includes, regex, UUID/GUID and every other string format, allowed/denied values, null-or-non-whitespace). + A custom message can still use the positional placeholders. `UUIDRule` custom and default messages now receive + the same arguments (`{0}` value, `{1}` version as an integer); `RegexRule`'s `{0}` is the pattern, as documented. +- **Number rule defaults no longer contain the input either.** `RangeRule`, `MultipleOfRule`, `IntRule`, + `SafeIntegerRule`, `MinValueRule`/`MaxValueRule`, `GreaterThanRule`/`LessThanRule` (and their `OrEqual` + forms), `EvenRule`, `OddRule` and `FiniteRule` read `"Value must be between 1 and 10"`, + `"Number must be a multiple of 5"`, `"Expected integer, received float"`, `"Value must be at least 3"` and so on + (was `"…, but got 11"`); `EnumRule` reads `"Value is not a defined member of Colour"` (was `"'42' is not …"`). + The value is still passed in the same position, so a custom message using `{1}` (or `{2}` for `RangeRule`) is + unchanged. +- **Every built-in rule reports a `MessageFormat`.** Runtime-schema errors (`Z.String().Min(3)`, `.Email()`, + `.Regex()`, `Z.Number().Range()`, …) carried `MessageFormat = null`; each rule now implements + `IZodRule.MessageFormat` with a named template (`"String must be at least {minimum} characters long, but got + {count}"`) and the `IZodRule.Parameters` it needs, so `DefaultZodMessageFormatter` and + `StringLocalizerZodMessageFormatter` localize them by template. A custom message is reported with its positional + placeholders renamed (`"At least {0}"` → `"At least {minimum}"`; the value's position becomes `{received}`). The + count rules report `count`, and `CountRangeRule` the `too_small`/`too_big` category by direction. +- Rules no longer store their default message as the custom message (`message.Or(MessageFormat)`), so a rule built + without a message and `default(TRule)` behave the same. +- `ZodIntersection` reports an issue raised identically by both sides once. +- Refinement and `SuperRefine` issues carry category `custom`; `ZodTransform` failures carry category `custom`. + + +- **JSON read failures use one code, `invalid_json`.** `DeserializeAndValidate`/`DeserializeAndValidateAsync` + in both JSON packages used to report `json_error` (with the serializer's exception text in the message) or + `deserialization_failed` (for a `null` document). Every failure to read the JSON now reports a single + `invalid_json` error (`SystemTextJsonExtensions.InvalidJsonCode`, `NewtonsoftJsonExtensions.InvalidJsonCode`) + with category `invalid_type` and the fixed message `"The JSON value could not be converted to {expected}."`, + where `expected` is a Zod type name. The serializer's message is no longer surfaced, because it names CLR + types and can quote the input. +- **Validating converters throw a structured exception.** The System.Text.Json converter throws + `ZodJsonValidationException` (a `JsonException`) and the Newtonsoft converter throws + `ZodNewtonsoftJsonValidationException` (a `JsonSerializationException`). Both still carry the + `"Validation failed: …"` message, so existing `catch` blocks keep working, and `DeserializeAndValidate` now + returns the schema's full errors instead of one flattened message. +- **JSON Schema import messages describe JSON kinds.** Type mismatches read `"Expected string, but got number"` + instead of naming `JsonElement`/`JToken`/CLR types, `minItems`/`maxItems` failures read + `"Array must have at least 2 elements, but got 1"` (matching `ZodArray`), and a literal mismatch no longer + echoes the received value. + +- **ProblemDetails messages from an `ErrorType.MessageFormat`** replace the error's message only when every + placeholder resolves (from parameters or the built-ins `path`, `code`, `origin`, `minimum`, `maximum`, + `displayName`); a partly-filled template used to be returned with `{placeholders}` left in. +- **`StatusCodeSelector` wins.** It runs after the highest `ErrorType.HttpStatus` is computed (it was overridden by + it, contrary to the docs). +- **With `MergeParametersIntoExtensions` enabled, parameters are merged into the top-level extensions on every + overload** (they were merged only by the registry/lookup overloads), and only safe scalar values are serialized. A parameter whose camel-cased name is a + reserved member (`type`, `title`, `status`, `detail`, `instance`, `errors`, `issues`, `traceId`) is written as + `param_{name}`. `issues[].parameters` keys are camel-cased (they kept the raw key). +- **`issues[].path` is the dotted key** (`"Items[0].Quantity"`, was a string array); the array is `segments`, with + integer indexes. `minimum`/`maximum` come from the typed bounds. +- **`traceId` is on every path** and matches ASP.NET Core's writer: `Activity.Current?.Id`, else + `HttpContext.TraceIdentifier` (the exception handler used `TraceIdentifier` only). +- **The endpoint filter honours `ZodProblemDetailsOptions` and `ErrorTypeRegistry`** (it always answered 400), writes + through `IProblemDetailsService`, and validates every matching argument (only the first was validated). + `WithZodSharpValidation()` resolves the validator when the endpoint is built and fails then - naming the + handler and parameter - when no factory or validator is registered (it threw on every request). +- **MVC `ValidationProblemDetails` copies every extension** (only `issues` was copied). +- **`ToValidationProblem()` returns a `ProblemHttpResult` that keeps the computed status** (it was always 400). +- **Options failures name the setting:** each failure reads `{name}: {path}: {message} ({code})`, with the + configuration key (`Smtp:Servers:0:Host`) when a section was recorded; the name is omitted for the default + options name. `OptionsBuilder.AddZodSchemaValidator()` validates only the builder's options name (it validated + every name). +### Deprecated + +- **The option-less ProblemDetails conversion overloads** (`Purview.ZodSharp.AspNetCore`), for both + `ValidationResult` and `ZodException`: `ToHttpValidationProblemDetails(int)`, `(ErrorTypeRegistry, int)`, + `(Func, int)`, the matching `ToValidationProblemDetails` overloads, and + `ValidationResult.ToValidationProblem()`. They take neither options nor a request, so they cannot see the + `ZodProblemDetailsOptions` an application configures, and their body diverged from the exception handler and the + endpoint filter (`FormatMessages`, `PathNaming`, `IncludeIssues`, status selectors). There is no ambient options + instance they could read without a request, so rather than guess they keep their 2.x output, now documented + exactly, and are `[Obsolete]` (CS0618) with a pointer to the `HttpContext` and `ZodProblemDetailsOptions` overloads. + Nothing changes at runtime. See the upgrade note. +### Fixed + +- **Generated validators compile for member types with their own equality operators.** The generator tested for null + with `value.Member == null` (and `!= null`), which binds a user-defined operator when the member type declares + one. A `[Required]` `Nullable` member whose type defines `==(string?, T)` and an implicit conversion to `string` + (every string-backed Purview.ValueObjects scalar does) failed to compile with CS9342 (ambiguous between + `==(string?, T)` and `==(T, T)`), and a reference type with a `==(T?, string?)` overload failed the same way. + Every generated null test is now a pattern (`is null`, `is not null`), which never binds an operator; the + remaining value comparisons already go through `EqualityComparer.Default` or `CompareTo`. **2.x is affected + too** (2.4.2 emitted the same comparison). +- **Blazor WebAssembly AOT publishes a scalar value object with a type-level rule.** For an adapted rule (a + Purview.ValueObjects scalar with `[NonSentinel]` or another type-level `[ZodRule]`) and for a required rule the + generator passed the rule's identity as `(object)rule as IZodRule`, a runtime type test on a boxed struct that + crashed the Mono AOT compiler (`Precompiling failed ... exit code -1073741819`, 0xC0000005) under + `RunAOTCompilation=true`. The generator knows whether the rule implements `IZodRule`, so it now emits a direct cast + `(IZodRule)rule`, or `null` when the rule has no identity; no emitted code type-tests a value type against an + interface. The packed-consumer gate's Blazor WebAssembly consumer now carries such a scalar, so its AOT publish + proves it. +- **Blazor places an error inside a struct member on the owning field.** An error inside a value object on a form + model (`Email.Value`, where `Email` is a struct) resolved to the struct itself. A boxed copy of a struct cannot be a + `FieldIdentifier` model (the constructor throws), so with `` (any host) and with + `` on Blazor Server the form was invalid but the message was dropped, and with `` on + WebAssembly it reached only the summary. The generated `TryResolveMember`, the Microsoft.Extensions.Validation + bridge's `Container`/`Name`, and the Blazor field locator (used by `` and `AddZodProblemDetails`) + now attribute such an error to the nearest reference-type owner and its member, `FieldIdentifier(model, "Email")`; + a struct element of a collection is attributed to the collection member. The error's path is unchanged. + `ZodGeneratedValidation.ResolveToReferenceOwner` is the new generated-code helper. +- **ProblemDetails no longer sends CLR type names to API clients.** Generated validators attach `declaringType` (the + full CLR name of the declaring type) and `member` to every error, for localization, and `issues[].parameters` (and + the top level, with `MergeParametersIntoExtensions`) carried both. They are now left out by default: they stay on + `ValidationError.Parameters`, the issue's `path` already names the member, and the new + `ZodProblemDetailsOptions.IncludeDiagnosticParameters = true` writes them again. `displayName` is still sent.- **A scalar value object's messages name the owning member.** A nested Purview.ValueObjects scalar's errors read + "Field 'Value' ..." (its own member); they now use the owning member's display name ("Email is required", or the + member's `[Display(Name)]`), through the new `ZodGeneratedValidation.AddNested`/`WithNested` overloads that take the + owning member. The `displayName` parameter is renamed too, and a message produced from the error's template is + re-formatted; `member` and `declaringType` still name the value object's member, and a rule's own message text is + kept. A struct that is not a scalar keeps its members' names. +- **A multi-project solution's OpenAPI document no longer crashes with "An item with the same key has already been + added. Key: T:ZodSharp.Core.ErrorTypeAttribute".** The generators emitted `ZodSharp.ZodSchemaAttribute` and + `ZodSharp.Core.ErrorTypeAttribute` - public, with XML documentation - into every compilation as + post-initialization output, which every other generator sees. So every project in a solution documented the same + type IDs. The ASP.NET Core OpenAPI XML-comment generator (`Microsoft.AspNetCore.OpenApi` 10 and later, referenced + directly) reads an app's own documented public *and internal* types plus the XML file of every referenced project, + emits one `cache.Add` per entry, and threw the first time `/openapi/v1.json` was requested. `[Embedded]` already + kept the copies from colliding at compile time (no CS0436), but not in documentation. Making the copies `internal` + would not have helped either, because the generator also reads internal types and the XML file still documents + them. Both attributes are now defined once, in `Purview.ZodSharp`. The generators emit no marker types (and no + `Microsoft.CodeAnalysis.EmbeddedAttribute`) into a consumer compilation. This is binary compatible: the package + already exposed both types, with the same members, as generated `[Embedded]` copies. **2.x is affected too** + (reproduced with 2.4.2 on `net10.0` and Microsoft.AspNetCore.OpenApi 10.0.13): any app whose OpenAPI XML-comment + generator reads a referenced ZodSharp project's XML documentation file. A per-assembly generated type + (`ZodSharp.Generated.{Assembly}ZodSharpSchemas`, `{Assembly}ZodSharpServiceCollectionExtensions`, + `{Type}Schema` with its `Paths`/`JsonNames`, `{Type}SchemaValidator`) is named for its assembly or type, so it + cannot collide. The built-in rule attributes are compiled into `Purview.ZodSharp`, and only a rule declared in the + compilation gets a generated attribute. The packed-consumer gate (`just smoke-packed-consumers`) adds a + multi-project consumer: a `[ZodSchema]`/`[ErrorType]` class library under a minimal API with XML documentation and + a direct Microsoft.AspNetCore.OpenApi reference. It failed with this error before the fix. +- **`Purview.ZodSharp.AspNetCore.OpenApi` alone is enough to call `AddOpenApi`.** A consumer that installed only + `Purview.ZodSharp.AspNetCore.OpenApi` (as its README shows) got Microsoft.AspNetCore.OpenApi's source generator, + transitively, but not its `build/` targets, which enable the generator's interceptors, so `AddOpenApi(...)` failed + to compile with CS9137. The package now ships `buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets`, which + adds the same `InterceptorsNamespaces` entry and nothing else; a direct Microsoft.AspNetCore.OpenApi reference keeps + working. Found by the new packed-consumer release gate (`just smoke-packed-consumers`), which builds a Native AOT + minimal API against the packed packages only. +- **Referencing both JSON packages no longer makes option-less calls ambiguous.** Both packages declare + `DeserializeAndValidate`, `DeserializeAndValidateAsync`, `ValidateAndSerialize` and `ValidateAndSerializeAsync` in + namespace `ZodSharp` with an optional options/settings parameter, so `schema.DeserializeAndValidate(json)` failed + with CS0121. `Purview.ZodSharp.SystemTextJson` adds option-less overloads (and `CancellationToken`-only stream + overloads), which win, so those calls resolve to System.Text.Json; a call that passes Newtonsoft settings still + resolves to Newtonsoft. No existing member changed. `CreateValidatingConverter(schema)` has one signature in both + and stays ambiguous: call `NewtonsoftJsonExtensions.CreateValidatingConverter(schema)` or + `SystemTextJsonExtensions.CreateValidatingConverter(schema)`. +- **Generated collection size failures report the Zod origin.** `[MinLength]`/`[MaxLength]`/`[Length]` (and + `[MinCount]`/`[MaxCount]`/`[CountRange]`) on a `List` or other collection reported `Origin = "collection"`, + which no other producer uses; they now report `array` (`record` for a dictionary), like `ZodArray`, `ZodRecord`, + the count rules and the generator's own null-element errors. *Behaviour change* for code matching `"collection"`. +- **Generated `[Range]` parameters are typed.** A number range's `minimum`/`maximum` parameters were strings + (`"1"`), so `issues[].parameters` in ProblemDetails carried JSON strings. They are now numbers of the member's type + (`1`, `0.5`); messages are unchanged. A converted non-number range (dates, times) keeps the declared text. +- **OpenAPI: enums follow the application's System.Text.Json settings.** An enum's `enum` values were always + numbers (the generator cannot see a global `JsonStringEnumConverter`), so a document for an API that writes + member names described values it never sends. They are now written as System.Text.Json writes them: numbers by + default, and member names (with the converter's naming policy) when a `JsonStringEnumConverter` applies globally or + to the enum type. +- **OpenAPI: the `ZodValidationIssue` `code` and `category` descriptions were swapped.** `code` was described as the + Zod issue code; it is the ZodSharp error code, and `category` (now described) carries the Zod issue code. +- **`ZODSGEN011` says `CancellationToken` must be the *last* parameter** (it said "second", which is wrong for the + service-aware three-parameter form). +- **Source generator: `[ZodSchema]` types with the same simple name no longer fail with `ZODSGEN001`.** Two types + named `Widget` in different namespaces (or two nested `Item` types in different containing types) produced the + same hint name, so generation failed for the compilation. Each schema's outputs are now named by its namespace, + containing types and name (`Alpha.WidgetSchema.g.cs`, `Alpha.Outer.ItemSchema.g.cs`), deterministically. +- **`Z.String().Required()` reports a resolvable template.** Its `"{displayName} is required"` `MessageFormat` had + nothing to resolve `displayName` from at the root, so a host re-formatting it got the placeholder back. + `ZodMessageFormat`'s built-in `displayName` now falls back to `"Value"` (matching the message) when the path has + no member segment; under a member it still names the member. +- **The `ZodSharp*` MSBuild switches now reach the generator and analyzers.** They were read with the key + `build_property:Name`, but the compiler exposes a `CompilerVisibleProperty` as `build_property.Name`, so + `ZodSharpAutoGenerateOptionsValidators`, `ZodSharpAutoGenerateOptionsValidatorSuffixes`, + `ZodSharpGenerateJsonSchemas`, `ZodSharpEnumSerialization`, `ZodSharpEnumNamingPolicy`, `ZodSharpAllowNulls` and + `ZodSharpDateTimeOffsetSerialization` set in a project file had no effect. `DisableErrorTypeGenerator` is now + declared compiler-visible by `buildTransitive/Purview.ZodSharp.props` too. *Behaviour change:* a project that + already sets one of these switches now gets the behaviour it asked for. +- Source generator: a custom `ErrorMessage` containing `{1}` or a stray brace no longer fails the build or throws at + validation time (G2, G14); `[Display(ResourceType)]` is honoured (G19); `[JsonPropertyName]` is available to + hosts through `JsonNames`/`ToJsonNamedPath` (G3); allowed/denied enum values are listed by name (G17); custom + string rules and enum rules report an origin (G16, G18); `IValidatableObject` is no longer ignored (G23); + cyclic graphs no longer overflow the stack (G24); a rule without constructor arguments no longer breaks the + generated JSON Schema. +- Re-pathing an element, field, catchall, record value or tuple element error no longer drops its origin, + bounds, inclusivity, category or message format (`WithPathPrefix` everywhere). +- `ZodArray.Min(2).Max(5)` reported a too-small failure with the maximum's message. +- A null tuple element is handed to the element schema when the element type can hold `null`. +- `GreaterThanRule`/`LessThanRule` report exclusive bounds; `SafeIntegerRule` reports `invalid_type` for a + fraction and `too_small`/`too_big` by direction (was always `too_big`), in both its `Category` and its `Code` + (the code stayed `too_big` below the range). A code given to the rule is kept. The new + `ZodRuleFailure.Code` lets any `IZodFailureDescriber` choose a direction-dependent code the same way. +- `ZodString` span validation reports the rule's full structured identity. +- `default(IPAddressRule)` no longer formats a `null` message. + + + +- **JSON read failures keep their location.** The serializer's path becomes the error's `FullPath` + (`$.items[1].age` and Newtonsoft's `items[1].age` both become `items[1].age`), and its line and position + become the `line` and `position` parameters. The path is the serializer's: a value that cannot be converted, or + a syntax error while reading a value (`"age":}`), is located at the value (`items[1].age`); a System.Text.Json + syntax error between members (`"age":1,}`) is located at the element being read (`items[1]`), and one inside a + value read by a validating converter at the converted value, because that value is buffered first. +- **JSON read failures under a validating converter keep the full path.** A value that could not be converted + inside a value read by `CreateValidatingConverter` (both JSON packages) was reported at its path within that + value (`Age`); it is now reported under the converted value's path too (`items[1].age`). +- **`invalid_json` names the expected type in more places.** `expected` is found through list, array and + dictionary element contracts, and binary members (`byte[]`, `Memory`) are `string`. +- **System.Text.Json `NotSupportedException` and `InvalidOperationException`** thrown while deserializing are + reported as `invalid_json` instead of escaping `DeserializeAndValidate`. +- **Validating-converter errors keep every field and their path**, located under the converter's JSON path + (`Owner.Name` for a failing nested `Owner`), including when validating converters are nested. +- **Newtonsoft `DeserializeAndValidateAsync` validates asynchronously.** It called the synchronous `Validate`, + so asynchronous refinements never ran; it now awaits `ValidateAsync`. +- **JSON Schema import paths and metadata.** Array element and record value errors are re-pathed with typed + index and key segments and keep every field (origin, bounds, category, parameters); `minItems`/`maxItems` + failures carry origin `array`, both bounds, inclusivity and the `too_small`/`too_big` category, like + `ZodArray`. A Newtonsoft `JValue` is no longer treated as an empty array. + +- **ASP.NET Core (D1-D12):** dotted ProblemDetails keys with escaping, complete-template-only message formatting, + consistent parameter handling, reserved-name protection, filter/registry parity, `FormatMessages` honoured, + selector precedence, full MVC copies, a safe issue DTO, .NET 11 binding failures passed through (a route, query, + header or form binding failure keeps ASP.NET Core's 400 unless `OnBindingFailure = ConvertToProblemDetails`), and + every argument validated with a build-time registration check. +- **Options (O1):** failures keep their path, code and options name; asynchronous rules run at startup on .NET 11. + +### Upgrade notes + +Each note names the old form, the new form, and the edit to make. + +- **Target frameworks.** `net8.0`/`net9.0` → `net10.0`/`net11.0` only. Retarget the consuming project to `net10.0` or + later, or stay on 2.4.x. +- **Trim/AOT annotations.** The APIs listed under *Breaking behaviour changes* now raise IL2026/IL3050 in a trimmed + or AOT build. Move to the named alternative: `Purview.ZodSharp.SystemTextJson` with a `JsonTypeInfo` instead of + Newtonsoft; the generated `AddZodSharp{Assembly}Schemas()` or `AddZodSharpAssembly` instead of + `AddZodSharpAssemblyGraph`/`ScanAssemblyGraphs`; `ZodModelSchema.For(IZodSchema)` instead + of the `ZodObject` overload; `BindConfiguration(section).ValidateWithZod(section)` instead of + `BindAndValidateWithZod`. +- **`Option` forwarding.** Generic code that passes an unannotated type parameter to `Option` gets IL2091 → + annotate it `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]`. +- **Reflection fallbacks under trimming.** A discriminated-union POCO not added with `Option` is now reported as + missing its discriminator, and graph scanning registers nothing → add each option with `Option` and use + generated validators and registration. +- **ProblemDetails top-level parameters.** Parameters copied to the body root → only `issues[].parameters`. Read + them per issue, or set `ZodProblemDetailsOptions.MergeParametersIntoExtensions = true`. +- **ProblemDetails `issues[].path`.** A string array → the dotted key (`"Items[0].Quantity"`); the array is now + `segments`. Read `segments` where you indexed `path`. +- **Collection paths.** `ZodArray`/`ZodTuple` element `ValidationError.Path` `"[0]"` → `"0"`, and collection/dictionary errors use + typed segments (`Items[0].Name`). Update code that matched path strings; use `FullPath.ToDottedString()`. +- **`[Required]` on a string.** `""` passed a bare `[Required]` → it is rejected, like `RequiredAttribute`. Supply a + value or set `AllowEmptyStrings = true`. +- **Error codes.** `validation_failed` (`ApplyAnd`/`ApplyOr`/`ApplyRefine`) → `refinement_failed`; `too_small`/ + `too_big` from generated `[StringLength]`/`[Length]` on a string → `invalid_length`; `unrecognized_key` per key → + one `unrecognized_keys` with a `keys` parameter; `json_error`/`deserialization_failed` → `invalid_json`; generated + collection-size origin `collection` → `array` (`record` for a dictionary). Update code that switches on them. +- **Messages.** Default messages no longer contain the input and use Zod type names (`"{displayName} is + required"`, `"Expected number, received string"`). Match on `Code`/`Category`, not message text; custom messages + keep their positional placeholders. +- **Union errors.** A flattened `errors` parameter → `unionErrors` (`ImmutableArray>`). + Read the per-option arrays. +- **JSON converters.** Validation failures throw `ZodJsonValidationException` (System.Text.Json) or + `ZodNewtonsoftJsonValidationException` (Newtonsoft); both derive from the previous exception types, so existing + `catch` blocks keep working. Catch the new types to read the full errors. +- **Options failures.** `"{message}"` → `"{name}: {path}: {message} ({code})"`, and + `OptionsBuilder.AddZodSchemaValidator()` validates only the builder's name. Update log or test assertions, and + register a validator per name if you relied on the old scope. +- **Endpoint filter.** `WithZodSharpValidation()` threw on every request when no validator was registered → it + fails when the endpoint is built. Register the validator (or factory) before mapping the endpoint. +- **CA1849 on synchronous `Validate` calls.** Every generated schema and validator adapter now has `ValidateAsync`, + so CA1849 ("call async methods when in an async method"), when enabled, reports each `Schema.Validate(value)`, + `Schema.ValidateMember(...)`, `validator.Validate(value)` and fluent `schema.Validate(value)` inside async code. + The analyzer package now suppresses those reports (`ZODSUP001`), because the synchronous pipeline is the cheap + path and never blocks; `ValidateAsync` earns its `ValueTask` only when the graph has asynchronous rules. Remove + any `NoWarn`, `#pragma` or `SuppressMessage` added for CA1849 on those calls. CA1849 for anything else — `Task.Wait`, + `Thread.Sleep`, another library's `Validate`/`ValidateAsync` pair — is untouched. A type that does have asynchronous + rules still has to use `ValidateAsync`: `ZODSGEN048` says so at build time, and `Validate` throws. +- **Option-less ProblemDetails overloads (CS0618).** `result.ToHttpValidationProblemDetails()` and the other overloads + that take only a status code, an `ErrorTypeRegistry` or a lookup (and `result.ToValidationProblem()`) still return + their 2.x body, built from defaults rather than your `ZodProblemDetailsOptions` → in a request call + `ToHttpValidationProblemDetails(httpContext)` / `ToValidationProblem(httpContext)`, which apply the registered + options like the exception handler does. Outside a request, pass options: `(statusCode)` is + `new ZodProblemDetailsOptions { Registry = new(), FormatMessages = false, DefaultStatusCode = statusCode }`, + `(registry, statusCode)` is `{ Registry = registry, DefaultStatusCode = statusCode }` and `(lookup, statusCode)` is + `{ ErrorTypeResolver = lookup, DefaultStatusCode = statusCode }`. +- **`[ZodSchema]` and `[ErrorType]` come from `Purview.ZodSharp`.** They were generated into each project as + `[Embedded]` types, which the compiler hides from other assemblies; they are now ordinary public types in the + package (same names, namespaces and members), so no source edit is needed. A project that declared its own + `partial class ZodSchemaAttribute` or `ErrorTypeAttribute` to extend the generated one now gets CS0436 → remove + the declaration. A library compiled with 2.x still carries hidden `[Embedded]` copies, which do not conflict. +- **Package validation reports CP0001 for a library that upgrades.** A library packed against ZodSharp 2 contains the generated + `[Embedded]` copies of `ZodSharp.ZodSchemaAttribute` and `ZodSharp.Core.ErrorTypeAttribute` (the 2.x generators + emitted both into every compilation that referenced the package, whether or not it used them). Rebuilt against + ZodSharp 3 it no longer does, because the generators stopped emitting them, so `EnablePackageValidation` with a `PackageValidationBaselineVersion` from the 2.x era fails with + `CP0001: Type 'ZodSharp.ZodSchemaAttribute' exists on lib/net10.0/My.Library.dll but not on + lib/net10.0/My.Library.dll` (and the same for `ErrorTypeAttribute`, per target framework). The removal cannot break + a consumer: the compiler never binds to an `[Embedded]` type in a referenced assembly, so no compiled code could + reference the copies (consumers bind to the package's own types instead). Suppress it as a baseline difference → + regenerate the suppression file with `dotnet pack -p:ApiCompatGenerateSuppressionFile=true` (it writes + `CompatibilitySuppressions.xml` beside the project; check it in), or add one entry per type and target framework: + + ```xml + + CP0001 + T:ZodSharp.ZodSchemaAttribute + lib/net10.0/My.Library.dll + lib/net10.0/My.Library.dll + true + + + CP0001 + T:ZodSharp.Core.ErrorTypeAttribute + lib/net10.0/My.Library.dll + lib/net10.0/My.Library.dll + true + + ``` + + Repeat both for every target framework the package ships (`lib/net11.0/...`). The suppressions can be removed + once the baseline is a version built against ZodSharp 3. ## 2.4.1 ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index a52669e9..f84dd403 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,51 +2,74 @@ 5.9.0 5.9.0 - 1.75.0 - 1.2.1 - 10.0.13 + 1.76.0 + 1.2.2 + 10.0.14 $(DotnetRuntimeVersion) - 8.0.2 - 9.0.20 - $(MicrosoftExtensionsVersion) - 8.0.1 + + 11.0.0-rc.3.26522.148 + $(DotnetRuntimeVersion) + 11.0.0-rc.3.26522.148 + + - + + + + + + + + + + + + + + + - + + + + + - + diff --git a/Justfile b/Justfile index 12096afb..32217dee 100644 --- a/Justfile +++ b/Justfile @@ -3,10 +3,20 @@ set quiet root_folder := "src" solution := root_folder / "ZodSharp.slnx" perf_tests_project := root_folder / "src" / "Benchmarks" / "Benchmarks.csproj" +aot_sample_project := root_folder / "src" / "Examples.MinimalApi" / "Examples.MinimalApi.csproj" +trim_verification_project := root_folder / "tests" / "TrimSafety.Verification" / "TrimSafety.Verification.csproj" +wasm_client_project := root_folder / "src" / "Examples.Blazor.Client" / "Examples.Blazor.Client.csproj" +# Native AOT cannot cross-compile between operating systems, so the sample publishes for this machine by default. +default_rid := (if os_family() == "windows" { "win" } else if os() == "macos" { "osx" } else { "linux" }) + "-" + (if arch() == "aarch64" { "arm64" } else { "x64" }) build_configuration := "Debug" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*" +# CSharpier is vendored in .assets/ rather than taken from the tool manifest: the released 1.3.0 +# cannot parse C# 15 `union` declarations and fails with CS1001, which would fail linting on the +# four files that declare one. Build:CSharpierPath points the pipeline at the same binary. +csharpier := "dotnet .assets/csharpier/CSharpier.dll" + pipeline_tool := "dotnet purview-build" [private] @@ -20,29 +30,25 @@ ensure-pipeline-tool: # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] -pipeline-pr *args: - just ensure-pipeline-tool +pipeline-pr *args: ensure-pipeline-tool echo "Running PR pipeline..." {{ pipeline_tool }} {{ args }} # Run the build pipeline (restore, build, lint) [group('Pipeline')] -pipeline-build *args: - just ensure-pipeline-tool +pipeline-build *args: ensure-pipeline-tool echo "Running build pipeline..." {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) [group('Pipeline')] -pipeline-release *args: - just ensure-pipeline-tool +pipeline-release *args: ensure-pipeline-tool echo "Running release pipeline..." {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} # Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing [group('Pipeline')] -pipeline-pack-validate *args: - just ensure-pipeline-tool +pipeline-pack-validate *args: ensure-pipeline-tool echo "Running pack + validate pipeline..." {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} @@ -56,14 +62,39 @@ smoke-packed-generator *args: echo "Running the packed generator smoke test..." pwsh -NoProfile -File scripts/test-packed-generator.ps1 {{ args }} +# Pack every shipping package to an isolated feed and cache, then publish and run throwaway consumers that reference +# the packages only: a Native AOT console, a Blazor WebAssembly app (trimmed, plus RunAOTCompilation when wasm-tools is +# installed), a Native AOT minimal API and a multi-project API (a [ZodSchema] class library under an OpenAPI app with +# XML documentation), each leak-scanned for compiler-host assemblies. +# +# Every other publish gate builds against ProjectReferences; this is the one that proves what ships. The CI +# `packed-consumers` job runs it for linux-x64. Purview.SourceGeneratorFramework is restored from nuget.org, plus +# ZODSHARP_SGF_FEED or LOCAL_NUGET_FEED_PATH when set (or pass -SourceGeneratorFrameworkFeed). +[group('Pipeline')] +smoke-packed-consumers *args: + echo "Running the packed consumer gate..." + pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} + +# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching +# file into scratch copies of the packed packages and runs the real purview-build validation on them. Reads artifacts/, +# so pack first (`just pipeline-pack-validate`, or `just pack ./artifacts -c Release`). `just pipeline-local-release` +# moves its packages to the local feed, so pass -PackagesFolder p:/_sync-projects/.local-nuget after it. +[group('Pipeline')] +verify-pack-rules *args: ensure-pipeline-tool + echo "Proving the pack-validation forbidden-content rules..." + pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} + +# Scan build or publish output for compiler-host assemblies (generators, analyzers, Roslyn) that must never ship. +[group('Build and Test')] +scan-compiler-host-leaks +paths: + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ paths }} + # Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) # Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. # Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. # just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ [group('Pipeline')] -pipeline-local-release *args: - just ensure-pipeline-tool - just lint-fix +pipeline-local-release *args: ensure-pipeline-tool lint-fix echo "Running local release pipeline..." {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} @@ -111,6 +142,37 @@ pack publish_folder=artifacts_folder *args: echo "Packing {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }} to {{ GREEN }}{{ publish_folder }}{{ NORMAL }}" dotnet pack {{ solution }} -c {{ build_configuration }} -o {{ publish_folder }} {{ args }} +# The CI `aot-sample` job runs the same publish for linux-x64. Windows needs the MSVC C++ build tools, Linux clang. +# Publish the Examples.MinimalApi sample with Native AOT, failing on any trim (IL2xxx) or AOT (IL3xxx) warning +[group('Build and Test')] +publish-aot-sample rid=default_rid *args: + echo "Publishing {{ BLUE }}{{ aot_sample_project }}{{ NORMAL }} with Native AOT for {{ YELLOW }}{{ rid }}{{ NORMAL }}" + dotnet publish {{ aot_sample_project }} -c Release -r {{ rid }} -o {{ artifacts_folder }}/aot-sample/{{ rid }} -warnaserror {{ args }} + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ artifacts_folder }}/aot-sample/{{ rid }} {{ root_folder }}/src/Examples.MinimalApi/bin/Release + +# The CI `verify-trim` job runs the same recipe for linux-x64. Windows needs the MSVC C++ build tools, Linux clang. +# The harness publishes twice - every ZodSharp assembly rooted, then as a plain consumer - and each binary exits non-zero on any mismatch. +# Publish and run the trim and Native AOT verification harness, failing on any IL2xxx/IL3xxx warning +[group('Build and Test')] +verify-trim rid=default_rid *args: + echo "Publishing {{ BLUE }}{{ trim_verification_project }}{{ NORMAL }} with Native AOT for {{ YELLOW }}{{ rid }}{{ NORMAL }} (rooted)" + dotnet publish {{ trim_verification_project }} -c Release -r {{ rid }} -o {{ artifacts_folder }}/trim-verification/{{ rid }}/rooted -p:TrimVerificationMode=Rooted -warnaserror {{ args }} + {{ artifacts_folder }}/trim-verification/{{ rid }}/rooted/ZodSharp.TrimSafety.Verification + echo "Publishing {{ BLUE }}{{ trim_verification_project }}{{ NORMAL }} with Native AOT for {{ YELLOW }}{{ rid }}{{ NORMAL }} (consumer)" + dotnet publish {{ trim_verification_project }} -c Release -r {{ rid }} -o {{ artifacts_folder }}/trim-verification/{{ rid }}/consumer -p:TrimVerificationMode=Consumer -warnaserror {{ args }} + {{ artifacts_folder }}/trim-verification/{{ rid }}/consumer/ZodSharp.TrimSafety.Verification + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ artifacts_folder }}/trim-verification/{{ rid }}/rooted {{ artifacts_folder }}/trim-verification/{{ rid }}/consumer {{ root_folder }}/tests/TrimSafety.Verification/bin/Release + +# The CI `wasm-trimmed` job runs the same publish and check; the nightly `wasm-aot` job adds -p:RunAOTCompilation=true (needs `dotnet workload install wasm-tools`). +# Publish the Blazor WebAssembly sample trimmed, failing on any IL2xxx/IL3xxx warning, then check the ZodSharp size budget +[group('Build and Test')] +wasm-size *args: + echo "Publishing {{ BLUE }}{{ wasm_client_project }}{{ NORMAL }} trimmed for WebAssembly" + rm -rf {{ artifacts_folder }}/wasm-trimmed + dotnet publish {{ wasm_client_project }} -c Release -o {{ artifacts_folder }}/wasm-trimmed -warnaserror {{ args }} + pwsh -NoProfile -File scripts/check-wasm-size.ps1 -PublishDirectory {{ artifacts_folder }}/wasm-trimmed + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ artifacts_folder }}/wasm-trimmed {{ root_folder }}/src/Examples.Blazor.Client/bin/Release + # Displays the current version of the project. # Requires Bun. [group('Build and Test')] @@ -120,14 +182,12 @@ version: # Check code formatting using CSharpier [group('Utilities')] lint-check: - dotnet csharpier check . - # dotnet format --verify-no-changes {{ solution }} + {{ csharpier }} check . # Fix code formatting issues using CSharpier [group('Utilities')] lint-fix: - dotnet csharpier format . - # dotnet format {{ solution }} + {{ csharpier }} format . # Open the solution in Visual Studio/ Registered application [group('Utilities')] diff --git a/README.md b/README.md index deb46f45..b24ca06d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The library is developed and maintained at [github.com/purview-dev/zodsharp](htt - **Fluent API** - Fluent and extensible API similar to original Zod - **Type-safe** - Strong typing with advanced C# generics - **High performance** - Sub-microsecond validation times; see [the measurements](#performance) -- **Cross-platform** - Works on .NET 8.0, .NET 9.0, .NET 10.0 and .NET 11.0 +- **Cross-platform** - Works on .NET 10.0 and .NET 11.0 - **Source Generators** - Compile-time validator generation with `[ZodSchema]` attribute - **DataAnnotations Support** - Automatic validation from `[Required]`, `[StringLength]`, `[Length]`, `[MinLength]`, `[MaxLength]`, `[Range]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, etc. @@ -52,7 +52,9 @@ dotnet add package Purview.ZodSharp.AspNetCore - **Purview.ZodSharp** — Core validation library and source generator (`[ZodSchema]`). See its [package README](src/src/ZodSharp/Sdk/README.md). - **Purview.ZodSharp.SystemTextJson** — System.Text.Json integration and JSON Schema import. See its [package README](src/src/SystemTextJson/Sdk/README.md). - **Purview.ZodSharp.NewtonsoftJson** — Newtonsoft.Json integration and JSON Schema import. See its [package README](src/src/NewtonsoftJson/Sdk/README.md). +- **Purview.ZodSharp.Http** — `HttpClient` integration: validated request and response bodies, and server validation problems read back into ZodSharp errors; trim-, AOT- and WebAssembly-safe. See its [package README](src/src/Http/Sdk/README.md). - **Purview.ZodSharp.AspNetCore** — ASP.NET Core ProblemDetails integration. See its [package README](src/src/AspNetCore/Sdk/README.md). +- **Purview.ZodSharp.Blazor** — Blazor forms validation for Server, WebAssembly and Auto render mode (`net11.0`). See its [package README](src/src/Blazor/Sdk/README.md). ## TypeScript and fixture tooling @@ -69,13 +71,29 @@ The fixture generator script is intentionally run via Bun rather than `npx tsx` ## What's new in v2 - **Packages are published under the `Purview.*` IDs.** The core `Purview.ZodSharp` package ships the validator and the source generator, with optional `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` integration packages. -- **Targets `net8.0`, `net9.0`, `net10.0`, and `net11.0`.** The source generator remains on `netstandard2.0` so it can run in any compiler host. +- **Targets `net10.0` and `net11.0`** (3.0.0 dropped `net8.0` and `net9.0`; stay on 2.4.x for those). The source generator remains on `netstandard2.0` so it can run in any compiler host. - **System.Text.Json integration.** JSON deserialize-and-validate is available for both major JSON libraries, including validating `JsonConverter` instances. - **JSON Schema interoperability.** Schemas can be exported via `Z.ToJsonSchema` and imported via `Z.FromJsonSchema`, enabling cross-language reuse with TypeScript/Zod. The import API lives in the JSON integration package's namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`); export stays in the core package. -- **ASP.NET Core ProblemDetails integration.** Failed validation results convert directly to `HttpValidationProblemDetails` via `result.ToHttpValidationProblemDetails()`. +- **ASP.NET Core ProblemDetails integration.** Failed validation results convert directly to `HttpValidationProblemDetails` via `result.ToHttpValidationProblemDetails(httpContext)`, which applies the application's `ZodProblemDetailsOptions`. - **Expanded DataAnnotations support.** `[Length]`, `[MinLength]`, `[MaxLength]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, and more, with structured size failures (`Code`, `Origin`, `Minimum`/`Maximum`, `Inclusive`, `Path`). - **Value-first composition methods.** `.ApplyAnd()`, `.ApplyOr()`, and `.ApplyRefine()` are the supported composition surface. +## .NET 11 validation + +On `net11.0`, ZodSharp plugs into the platform's own validation (Microsoft.Extensions.Validation), so minimal APIs and +Blazor validate ZodSharp types with ZodSharp and every host reports the same error contract: Zod-style `Code`/`Category`, +canonical paths (`Items[0].Name`, `Map[en]`, `Map["0"]`), typed bounds and localizable messages. + +| Package | What it adds | +| --- | --- | +| `Purview.ZodSharp.Extensions.Validation` | The Microsoft.Extensions.Validation bridge (`AddZodSharpValidation()`); WebAssembly-safe. See [Microsoft.Extensions.Validation](docs/wiki/Microsoft-Extensions-Validation.md) | +| `Purview.ZodSharp.AspNetCore.OpenApi` | OpenAPI transformers that describe ZodSharp's constraints and validation problem responses (`options.AddZodSharp()`). See [OpenAPI Integration](docs/wiki/OpenAPI-Integration.md) | +| `Purview.ZodSharp.Blazor` | Blazor Server and WebAssembly form validation. See [Blazor Integration](docs/wiki/Blazor-Integration.md) | + +`Purview.ZodSharp.AspNetCore` uses the bridge on `net11.0` to produce full-fidelity ProblemDetails from the built-in +pipeline ([ASP.NET Core Integration](docs/wiki/AspNetCore-Integration.md)), and messages are localized through +`IZodMessageFormatter` ([Localization](docs/wiki/Localization.md)). + ## Usage Examples ### Basic Validation @@ -189,7 +207,7 @@ catch (ZodException ex) Console.WriteLine($"Validation failed: {ex.Message}"); foreach (var error in ex.Errors) { - Console.WriteLine($" - {string.Join(".", error.Path)}: {error.Message}"); + Console.WriteLine($" - {error.FullPath.ToDottedString()}: {error.Message} ({error.Code})"); } } @@ -322,7 +340,7 @@ src/ └── tests/ # TUnit test projects ``` -The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net8.0`, `net9.0`, `net10.0` and `net11.0`. +The source generator itself targets `netstandard2.0` so it can run in any compiler. The library packages target `net10.0` and `net11.0`. ## Advanced Features @@ -581,7 +599,7 @@ Other supported DataAnnotations validators: Structured size failures expose: - `Code`: `too_small` or `too_big` -- `Origin`: `string`, `array`, or `collection` +- `Origin`: `string`, `array` or `record` - `Minimum` / `Maximum` - `Inclusive` - `Path` @@ -609,22 +627,22 @@ var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); Install `Purview.ZodSharp.AspNetCore` to convert failed validation results into standard ASP.NET Core payloads while preserving structured issues: ```csharp -using ZodSharp.AspNetCore; - -var result = BasketSchema.Validate(basket); +using ZodSharp; // ToHttpValidationProblemDetails / ToValidationProblem +using ZodSharp.AspNetCore; // ZodTypedResults -if (!result.IsSuccess) +app.MapPost("/baskets", (Basket basket, HttpContext httpContext) => { - var problem = result.ToHttpValidationProblemDetails(); - return Results.ValidationProblem( - problem.Errors, - extensions: new Dictionary - { - ["issues"] = problem.Extensions["issues"] - }); -} + var result = BasketSchema.Validate(basket); + return result.IsSuccess + ? Results.Ok(result.Value) + : result.ToValidationProblem(httpContext); // errors + issues + traceId, ErrorType status +}); ``` +Every path - this extension, `ZodTypedResults`, the `WithZodSharpValidation()` endpoint filter, `ZodExceptionHandler` +and, on .NET 11, the built-in validation pipeline - writes the same body. See +[ASP.NET Core Integration](docs/wiki/AspNetCore-Integration.md). + ### Span Validation Zero-allocation string validation using spans: diff --git a/commitlint.config.mts b/commitlint.config.mts index 1d3240bf..69d78677 100644 --- a/commitlint.config.mts +++ b/commitlint.config.mts @@ -1,4 +1,14 @@ export default { + // Accept the Conventional Commits `!` breaking-change marker (`build!: ...`, `feat(api)!: ...`). Without a + // parser preset commitlint's default header pattern has no `!`, so such a header parses as having no type and + // no subject and fails type-empty/subject-empty. + parserPreset: { + parserOpts: { + headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/, + breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/, + headerCorrespondence: ['type', 'scope', 'subject'], + }, + }, rules: { 'body-leading-blank': [1, 'always'], 'body-max-line-length': [2, 'always', 100], diff --git a/docs/wiki/Arrays-and-Other-Schemas.md b/docs/wiki/Arrays-and-Other-Schemas.md index 2c67ef73..df05c452 100644 --- a/docs/wiki/Arrays-and-Other-Schemas.md +++ b/docs/wiki/Arrays-and-Other-Schemas.md @@ -2,7 +2,7 @@ ## Arrays -`ZodArray` (namespace `ZodSharp.Schemas`) validates `T[]` values. Each element is validated against the element schema; failures carry index paths such as `["0"]`. A rebuilt array is produced only when a transform changed an element. +`ZodArray` (namespace `ZodSharp.Schemas`) validates `T[]` values. Each element is validated against the element schema; failures are re-pathed under a typed index segment, keeping every field of the element's error, so `FullPath.ToDottedString()` renders `[1]` (or `items[1]` inside an object) and the legacy `Path` is `["1"]` (it used to be `["[1]"]`). A `null` array reports `"Expected array, received null"`. A rebuilt array is produced only when a transform changed an element. ```csharp using ZodSharp; @@ -13,11 +13,15 @@ var result = numbers.Validate(new[] { 1.0, 2.0, 3.0 }); | Method | Behaviour | |---|---| -| `Min(int minLength, string? message)` | `too_small` when count < min | -| `Max(int maxLength, string? message)` | `too_big` when count > max | -| `Length(int length, string? message)` | exact length (both bounds) | +| `Min(int minLength, string? message)` | `too_small` when count < min: `"Array must have at least 2 elements, but got 1"` | +| `Max(int maxLength, string? message)` | `too_big` when count > max: `"Array must have at most 2 elements, but got 3"` | +| `Length(int length, string? message)` | exact length (both bounds): `"Array must have exactly 2 elements, but got 3"` | | `NonEmpty(string? message)` | `minLength = 1` | +Size failures carry origin `array`, both configured bounds as `Lower`/`Upper` (inclusive) and the element count in +the `count` parameter. `Min`, `Max` and `Length` keep separate custom messages, so `Min(2, "few").Max(5, "many")` +reports the message of the bound that failed. + ## Boolean `ZodBoolean` validates `bool`; there are no fluent methods. @@ -53,7 +57,7 @@ var schema = Z.BigInt().Positive().Max(9_000_000_000); ## Enum (string values) -`ZodEnum` validates against a set of allowed strings. Failure produces `invalid_enum_value`. +`ZodEnum` validates against a set of allowed strings. Failure produces `invalid_enum_value` (category `invalid_value`, origin `enum`) with the message `"Expected one of: admin, guest, user"` (options in ordinal order) and the `options` parameter; the received value is not echoed. ```csharp var schema = Z.Enum("admin", "user", "guest"); @@ -61,7 +65,7 @@ var schema = Z.Enum("admin", "user", "guest"); ## Native enum -`ZodNativeEnum` validates a native `System.Enum` using `Enum.IsDefined`. Failure produces `invalid_enum_value`. +`ZodNativeEnum` validates a native `System.Enum` using `Enum.IsDefined`. Failure produces `invalid_enum_value`, reported exactly as `EnumRule` reports it, with the options listed in ordinal order. ```csharp var schema = Z.Enum(); // Color : struct, Enum @@ -69,7 +73,7 @@ var schema = Z.Enum(); // Color : struct, Enum ## Literal -`ZodLiteral` (where `T : IEquatable`) accepts exactly one value. Failure produces `invalid_literal`. +`ZodLiteral` (where `T : IEquatable`) accepts exactly one value. Failure produces `invalid_literal` (category `invalid_value`) with the message `"Expected literal active"` and the literal in the `expected` parameter; the received value is not echoed. ```csharp var schema = Z.Literal("active"); @@ -78,7 +82,7 @@ var schema2 = Z.Literal(42); ## Record -`ZodRecord` validates `Dictionary`, validating every value against the value schema with key-prefixed error paths. It always produces a fresh validated dictionary. +`ZodRecord` validates `Dictionary`, validating every value against the value schema. Errors are re-pathed under a typed key segment (rendered with the invariant culture): `[en]`, or `["0"]`/`["a.b"]` when the key would otherwise be ambiguous. It always produces a fresh validated dictionary. ```csharp var schema = Z.Record(Z.Number()); @@ -101,7 +105,12 @@ var result = schema.Validate(new object?[] { "John", 30.0 }); // (string, double) success value ``` -Failures: `invalid_type` on `null`, `invalid_tuple_length` on wrong length, and per-index `invalid_type` with paths like `["[0]"]`. +Failures: + +- `null` input → `invalid_type` (`"Expected tuple, received null"`). +- Wrong length → `invalid_tuple_length` with category `too_small`/`too_big`, origin `array` and both bounds equal to the arity (`"Expected tuple of length 2, but got 3"`). +- Element failures are prefixed with the element's index (`[0]`; legacy `Path` `["0"]`). A type mismatch reads `"Expected string, received number"`. They used to be reported at the tuple root. +- A `null` element is handed to the element schema when its type can hold `null`, so `Z.Optional(...)` elements accept it. ## Lazy diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 563d4cd4..d821b067 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -10,73 +10,332 @@ dotnet add package Purview.ZodSharp.AspNetCore ## ProblemDetails -```csharp -using ZodSharp.AspNetCore; +Every path that turns ZodSharp errors into ProblemDetails delegates to one writer, `ZodProblemDetailsWriter`: +the endpoint filter, `ZodExceptionHandler`, `ZodTypedResults`, the .NET 11 pipeline enrichment, and every +`ToHttpValidationProblemDetails`/`ToValidationProblemDetails`/`ToValidationProblem` overload. Given the same options +they all produce the same body (see [The error contract](#the-error-contract)); use an overload that takes the +request (`HttpContext`) so the options the application registers apply. -var result = BasketSchema.Validate(basket); +```csharp +using ZodSharp; -if (!result.IsSuccess) +app.MapPost("/baskets", (Basket basket, HttpContext httpContext) => { - var problem = result.ToHttpValidationProblemDetails(); - return Results.ValidationProblem( - problem.Errors, - extensions: new Dictionary - { - ["issues"] = problem.Extensions["issues"], - }); -} + var result = BasketSchema.Validate(basket); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToValidationProblem(httpContext); +}); ``` +The extensions are in the `ZodSharp` namespace and exist for both `ValidationResult` and `ZodException`: + | Member | Behaviour | |---|---| -| `ToHttpValidationProblemDetails(ValidationResult result, int statusCode = 400)` | `HttpValidationProblemDetails`; error paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | -| `ToValidationProblemDetails(ValidationResult result, int statusCode = 400)` | `ValidationProblemDetails` | +| `ToHttpValidationProblemDetails(HttpContext httpContext)` | Uses the writer, and therefore the options, registered in the request services (`ValidationResult` only). **Prefer this in a request.** | +| `ToHttpValidationProblemDetails(ZodProblemDetailsOptions options, HttpContext? httpContext = null)` | Uses the supplied options; the form to use outside a request | +| `ToValidationProblemDetails(...)` | The same overloads, returning MVC `ValidationProblemDetails` with every extension copied | +| `ToValidationProblem(HttpContext httpContext)` | An `IResult` written by the registered writer, keeping the computed status (prefer this) | +| `ToHttpValidationProblemDetails(int statusCode = 400)` | *Obsolete.* Built-in defaults, no `ErrorType`s, messages unformatted | +| `ToHttpValidationProblemDetails(ErrorTypeRegistry registry, int statusCode = 400)` | *Obsolete.* Resolves `ErrorType`s from `registry` and formats their messages; every other option at its default | +| `ToHttpValidationProblemDetails(Func lookup, int statusCode = 400)` | *Obsolete.* Resolves `ErrorType`s through `lookup`; every other option at its default | +| `ToValidationProblem()` | *Obsolete.* An `IResult` with built-in defaults (`ValidationResult` only) | + +The `ValidationResult` overloads throw `InvalidOperationException` when the result `IsSuccess`. + +### The option-less overloads + +The 2.x overloads that take only a status code, an `ErrorTypeRegistry` or a lookup (and `ToValidationProblem()`) take +neither options nor a request, so they **cannot see the `ZodProblemDetailsOptions` an application configures** +(`AddZodSharpProblemDetails`, `Configure`): `FormatMessages`, `PathNaming`, `IncludeIssues`, +`MergeParametersIntoExtensions`, `StatusCodeSelector` and the configured registry are ignored, so their body can differ +from the endpoint filter and the exception handler. There is no ambient options instance they could read without a +request, so they keep their 2.x output unchanged and are marked `[Obsolete]` (CS0618). Each one is exactly equivalent +to the options overload with these options: + +| Obsolete call | Equivalent | +|---|---| +| `ToHttpValidationProblemDetails(statusCode)` | `ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = new(), FormatMessages = false, DefaultStatusCode = statusCode })` | +| `ToHttpValidationProblemDetails(registry, statusCode)` | `ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = registry, DefaultStatusCode = statusCode })` | +| `ToHttpValidationProblemDetails(lookup, statusCode)` | `ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { ErrorTypeResolver = lookup, DefaultStatusCode = statusCode })` | +| `ToValidationProblem()` | `ToValidationProblem(httpContext)` (applies the registered options) | -Both throw `InvalidOperationException` when the result `IsSuccess`. The structured metadata is preserved in the `issues` extension as a `ValidationIssue[]`: +In a request, move to the `HttpContext` overload; elsewhere (a background job, a test), pass the options you want. + +### Typed results + +`ZodTypedResults` produces the same body for handlers that declare their results: ```csharp -public sealed class ValidationIssue +using Microsoft.AspNetCore.Http.HttpResults; +using ZodSharp.AspNetCore; + +app.MapPost("/orders", Results, ValidationProblem> (Order order, HttpContext httpContext) => { - public required string Code { get; init; } - public string? Category { get; init; } - public string? Origin { get; init; } - public int? Minimum { get; init; } - public int? Maximum { get; init; } - public bool? Inclusive { get; init; } - public required string[] Path { get; init; } - public required string Message { get; init; } - public IReadOnlyDictionary? Parameters { get; init; } + var result = OrderSchema.Validate(order); + return result.IsSuccess + ? TypedResults.Ok(result.Value) + : ZodTypedResults.ValidationProblem(result, httpContext); +}); +``` + +| Member | Returns | +|---|---| +| `ZodTypedResults.ValidationProblem(result, httpContext)` / `ValidationProblem(exception, httpContext)` | ASP.NET Core's `ValidationProblem`, for `Results<...>` unions. It is always `400`, even when a mapped `ErrorType` yields another status. | +| `ZodTypedResults.Problem(result, httpContext)` / `Problem(exception, httpContext)` | `ZodProblemDetailsResult`, which keeps the computed status (for example a mapped `409`) and writes through `IProblemDetailsService`. | + +### `ValidationIssue` + +The `issues` extension is a `ValidationIssue[]`. `ValidationIssue` is a `readonly record struct` in +`ZodSharp.AspNetCore`, and a safe projection of `ValidationError`: + +| JSON property | CLR member | Value | +|---|---|---| +| `path` | `DottedPath` | The `errors` key (`Items[0].Quantity`); empty for the root | +| `segments` | `Segments` | Zod's path shape: strings for members and keys, integers for indexes | +| `code` | `Code` | The ZodSharp code | +| `category` | `Category` | The Zod issue code for built-in errors (`too_small`, `invalid_type`, ...) | +| `origin` | `Origin` | `string`, `number`, `array`, `date`, ... | +| `minimum`/`maximum` | `Lower`/`Upper` | From the error's typed bounds: JSON numbers, or ISO-8601 strings for dates | +| `inclusive` | `Inclusive` | Whether the bound is inclusive | +| `message` | `Message` | The message, rendered by the registered `IZodMessageFormatter` | +| `parameters` | `SafeParameters` | Camel-cased, restricted to invariant scalars and arrays of them | +| `unionErrors` | `UnionErrors` | For a failed union, each option's issues, with paths relative to the union | + +The CLR-only members `Path` (`string[]`), `Minimum`, `Maximum` (`int?`) and `Parameters` (the raw +`ErrorTypeParameters`) keep their 2.x meaning for in-process code and are not serialized. + +## The error contract + +A request with a short `CustomerName`, an empty `Lines[0].Sku`, and out-of-range quantities in `Lines[0]` and +`ByCode["en"]` produces this body. It is real output from the .NET 11 pipeline with `AddZodSharpProblemDetails()`. +The `WithZodSharpValidation()` filter produces the same body. + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "CustomerName": ["Field 'CustomerName' must contain at least 3 characters."], + "Lines[0].Sku": ["Sku is required"], + "Lines[0].Quantity": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."], + "ByCode[en].Quantity": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."] + }, + "issues": [ + { + "path": "CustomerName", + "segments": ["CustomerName"], + "code": "too_small", + "category": "too_small", + "origin": "string", + "minimum": 3, + "inclusive": true, + "message": "Field 'CustomerName' must contain at least 3 characters.", + "parameters": { "count": 2, "displayName": "CustomerName", "declaringType": "Order", "member": "CustomerName" } + }, + { + "path": "Lines[0].Sku", + "segments": ["Lines", 0, "Sku"], + "code": "missing_field", + "category": "invalid_type", + "message": "Sku is required", + "parameters": { "displayName": "Sku", "declaringType": "OrderLine", "member": "Sku" } + }, + { + "path": "Lines[0].Quantity", + "segments": ["Lines", 0, "Quantity"], + "code": "invalid_range", + "category": "too_small", + "origin": "number", + "minimum": 1, + "maximum": 100, + "inclusive": true, + "message": "Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100.", + "parameters": { "minimum": "1", "maximum": "100", "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity" } + }, + { + "path": "ByCode[en].Quantity", + "segments": ["ByCode", "en", "Quantity"], + "code": "invalid_range", + "category": "too_big", + "origin": "number", + "minimum": 1, + "maximum": 100, + "inclusive": true, + "message": "Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100.", + "parameters": { "minimum": "1", "maximum": "100", "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity" } + } + ], + "count": 2, + "displayName": "Quantity", + "declaringType": "OrderLine", + "member": "Quantity", + "minimum": "1", + "maximum": "100", + "traceId": "00-3b87d0e8fa5f0b442bf75ffb97d44b3d-103774b7a225269d-02" +} +``` + +A failed union reports its options' issues under `unionErrors`: + +```json +{ + "path": "Contact", + "segments": ["Contact"], + "code": "invalid_union", + "category": "invalid_union", + "message": "Value does not match any of the union options (string, number)", + "parameters": { "options": ["string", "number"] }, + "unionErrors": [ + [{ "path": "", "segments": [], "code": "invalid_string", "category": "invalid_format", "origin": "string", "message": "Invalid email format", "parameters": { "format": "email" } }], + [{ "path": "", "segments": [], "code": "invalid_type", "category": "invalid_type", "message": "Expected number, received string", "parameters": { "expected": "number", "received": "string" } }] + ] } ``` +The contract: + +- **Keys.** `errors` keys and `issues[].path` are `ValidationError.FullPath.ToDottedString()`: members joined with + `.`, indexes as `[0]`, and keys as `[en]` or, when ambiguous, quoted (`Map["0"]`, `Map["a.b"]`). The root is `""`. + See [Core Concepts](Core-Concepts.md#paths). By default member names are the C# names, matching + Microsoft.Extensions.Validation and MVC model state. `PathNaming = ZodPathNaming.Json` applies the minimal-API + `JsonOptions` naming policy. +- **`code` and `category`.** `code` is ZodSharp's specific identity (`invalid_range`). `category` is the Zod issue + code (`too_small`), so a client can treat ZodSharp and Zod issues alike. +- **Top-level parameters.** Parameters are written per issue only. With `MergeParametersIntoExtensions = true` (the 2.4 + behaviour, off by default) every issue's parameters are also merged into the top-level extensions, and the last + issue wins. A parameter whose camel-cased name is reserved (`type`, `title`, `status`, `detail`, `instance`, + `errors`, `issues`, `traceId`) is written as `param_{name}`. +- **No implementation details.** `issues[].parameters` leaves out `declaringType` (a full CLR type name) and `member`, + which generated validators attach for localization: they describe the server's types, not the request, and the + issue's `path` already names the member. `displayName` is kept. Set `IncludeDiagnosticParameters = true` to send them. +- **`traceId`.** It is present on every path: `Activity.Current?.Id`, else `HttpContext.TraceIdentifier`. +- **No input values.** Messages and parameters never contain the input unless `ZodErrorOptions.IncludeReceivedValues` + is enabled. See [Core Concepts](Core-Concepts.md#pii). +- **Messages.** They come from the registered `IZodMessageFormatter` in `CultureInfo.CurrentUICulture`. See + [Localization](Localization.md). +- **OpenAPI.** The body is described by the `ZodValidationProblemDetails` and `ZodValidationIssue` component schemas + (see [OpenAPI Integration](OpenAPI-Integration.md)). + ## Minimal API validation -Use `WithZodSharpValidation()` to validate the request DTO at the endpoint and short-circuit to a standard -validation-problem response, or `ToValidationProblem()` to return a `Results.ValidationProblem` `IResult` -directly from a handler: +There are two ways to validate minimal-API parameters: the `WithZodSharpValidation()` endpoint filter, on every +target framework, and on .NET 11 the built-in validation pipeline +([below](#net-11-the-built-in-validation-pipeline)). ```csharp -using Microsoft.AspNetCore.Builder; -using ZodSharp; using ZodSharp.AspNetCore; -// Validate the bound request DTO automatically (requires AddZodSharp to register the validator): -app.MapPost("/users", (UserDto dto) => dto) - .WithZodSharpValidation(); - -// Or validate manually and return a minimal-API IResult: -app.MapPost("/users", (UserDto dto) => +// Every endpoint of a group: each bound parameter with a registered validator is validated. +var api = app.MapGroup("/api").WithZodSharpValidation(options => { - var result = UserDtoSchema.Validate(dto); - return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); + options.ThrowIfNoValidator = false; + options.OnBindingFailure = ZodBindingFailureBehavior.PassThrough; }); + +// One endpoint, one type: every bound argument assignable to UserDto is validated. +app.MapPost("/users", (UserDto dto) => dto).WithZodSharpValidation(); +``` + +`WithZodSharpValidation()` works on any `IEndpointConventionBuilder`, including route groups: + +- **Which parameters.** They are selected when the endpoint is built. Services (`[FromServices]`, keyed services, + anything the container resolves), `HttpContext`, `HttpRequest`, `HttpResponse`, `ClaimsPrincipal`, + `CancellationToken`, form files, streams and `[SkipValidation]` parameters are skipped. `[FromBody]`, + `[FromRoute]`, `[FromQuery]`, `[FromHeader]` and `[FromForm]` force a parameter to be considered. +- **How.** Every selected parameter is validated through `IZodSchemaFactory.TryGetUntypedValidator`. +- **Error keys.** They follow Microsoft.Extensions.Validation: a complex parameter's errors are not prefixed + (`Name`, `Address.Street`), a sequence parameter's are (`items[0].Name`), and an error about the value itself is + keyed by the parameter name. +- **Response metadata.** The endpoint advertises a `400` `application/problem+json` response (status + `ProducesStatusCode`) for OpenAPI. +- **The body.** The filter honours `ZodProblemDetailsOptions` and the `ErrorTypeRegistry`, and writes through + `IProblemDetailsService`. + +`WithZodSharpValidation()` (on a `RouteHandlerBuilder`) validates every bound argument assignable to `T`. The +validator is resolved when the endpoint is built. When no `IZodSchemaFactory` or no validator for `T` is +registered, building the endpoint fails with a message naming the handler and the parameter. + +| `ZodEndpointValidationOptions` | Default | Effect | +|---|---|---| +| `ThrowIfNoValidator` | `false` | Fail endpoint building when a complex bound parameter has no registered validator. | +| `OnBindingFailure` | `PassThrough` | What to do when binding already failed (see below). | +| `ProducesStatusCode` | `400` | The status advertised in response metadata; set it when a mapped `ErrorType` changes the status. | + +On .NET 11, endpoint filters also run after a route, query, header or form binding failure, with the status +already set to 400 and the argument left at its default. A malformed JSON body is answered by ASP.NET Core itself +and never reaches a filter. Before .NET 11 this option has no effect. + +| `ZodBindingFailureBehavior` | Effect | +|---|---| +| `PassThrough` | Do not validate; ASP.NET Core's binding-failure response stands. | +| `ConvertToProblemDetails` | Write the ZodSharp contract instead: an `invalid_type` issue (`invalid_json` for a body) for each unbound validated parameter, or one root `invalid_type` issue when the failed parameter cannot be identified. | + +## .NET 11: the built-in validation pipeline + +On .NET 11, ASP.NET Core validates minimal-API parameters itself through Microsoft.Extensions.Validation. +`AddZodSharpValidation()` (package `Purview.ZodSharp.Extensions.Validation`) makes ZodSharp the first +validation resolver, and on `net11.0` `AddZodSharpProblemDetails()` turns the pipeline's response into the +full ZodSharp contract: + +```csharp +builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpValidation(); +builder.Services.AddValidation(); // when DataAnnotations parameters/types or union bodies are used +builder.Services.AddZodSharpProblemDetails(); + +var app = builder.Build(); + +var orders = app.MapGroup("/orders"); // every endpoint is validated - no per-endpoint call +orders.MapPost("/", (Order order, [Range(1, 100)] int page) => TypedResults.Ok()); +orders.MapPost("/import", (ImportRequest request) => TypedResults.Ok()).DisableValidation(); ``` -`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type `T` -against the `IZodSchemaFactory` registered by `AddZodSharp` (or `AddZodSharpFactory`). On failure it returns -the same `HttpValidationProblemDetails` payload produced by `ToHttpValidationProblemDetails()`; on success it -passes through to the handler. `ToValidationProblem()` is the handler-side equivalent, returning a status-400 -`Results.ValidationProblem` `IResult`. +How it works: + +- `AddZodSharpProblemDetails()` registers the bridge's `IZodValidationReportSink`. It appends every + full-fidelity ZodSharp error to a `ZodRequestValidationReport` stored on the current request + (`HttpContext.Features`), so the report lives and dies with the request. Writes made outside an HTTP request that + is still being handled (an interactive Blazor circuit, WebAssembly, a background service) are discarded. +- It composes `ProblemDetailsOptions.CustomizeProblemDetails` with a post-configure step. The ZodSharp callback + runs **first**, then whatever callback the application configured, whether before or after the call or + through its `problemDetails` argument. The application can therefore still change the result. A later + `PostConfigure` that replaces the callback outright removes the ZodSharp one. +- It calls `AddProblemDetails()`, without which the callback never runs. + +In the callback, for an `HttpValidationProblemDetails`: + +| Report | Result | +|---|---| +| ZodSharp errors reported | `ZodProblemDetailsWriter.Apply` rebuilds `errors` and `issues`. ZodSharp keys are replaced and DataAnnotations entries are kept, each with a `dataannotations` issue. `status`, `title`, `type` and `detail` come from the highest `ErrorType`, then `StatusCodeSelector` and `StatusCodeOverride` are applied. The status is mirrored to the response. | +| Empty (DataAnnotations-only failure) | The payload is left untouched, apart from the `issues` projection (`ProjectDataAnnotationsIssues`, default `true`). | + +The body is byte-identical (apart from `traceId`) to the one the endpoint filter, `ZodExceptionHandler` and +`ToValidationProblem(httpContext)` produce. + +Notes: + +- **Localization**: register `AddLocalization()` and an `IZodMessageFormatter`, for example + `new StringLocalizerZodMessageFormatter(factory, sharedResourceType: typeof(ValidationMessages))`. Messages + follow `Accept-Language` (with `UseRequestLocalization`); codes and paths are unchanged. See + [Localization](Localization.md). +- **The bridge**: resolver placement, `ZodReport` and non-HTTP validation are covered in + [Microsoft.Extensions.Validation](Microsoft-Extensions-Validation.md). +- **Unions**: a union parameter is claimed by the runtime parameter resolver, which the application's + `AddValidation()` call adds. The active case is reported under `Value` (`Value.Email`). +- **Double validation**: `WithZodSharpValidation()` on an endpoint the pipeline validates skips the parameters + the bridge covers, logging that once at `Debug` when the endpoint is built. On a `DisableValidation()` endpoint + it validates as usual. +- **OpenAPI**: the `Purview.ZodSharp.AspNetCore.OpenApi` operation transformer adds the validation problem + response for endpoints with a ZodSharp-known parameter. No endpoint metadata is added by the pipeline path. See + [OpenAPI Integration](OpenAPI-Integration.md). + +| | `WithZodSharpValidation()` filter | Built-in pipeline | +|---|---|---| +| Frameworks | net10.0 and net11.0 | net11.0 | +| Scope | endpoints and groups you mark | every endpoint; opt out with `DisableValidation()` | +| DataAnnotations parameters | not validated | validated and merged into one body | +| Binding failures | `OnBindingFailure` | ASP.NET Core's own response | +| Response metadata | adds the 400 response | the OpenAPI transformer | ## Exception handling @@ -84,7 +343,7 @@ A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a val `Create` under strict deserialization) can be mapped to ProblemDetails on demand: ```csharp -using ZodSharp.AspNetCore; +using ZodSharp; try { @@ -92,7 +351,7 @@ try } catch (ZodException ex) { - return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); + return ex.ToValidationProblem(httpContext); } ``` @@ -108,18 +367,95 @@ app.UseExceptionHandler(); ``` `AddZodSharpProblemDetails(Action? configure = null, Action? problemDetails = null)` -registers `ZodExceptionHandler`, calls `services.AddProblemDetails(problemDetails)`, and configures -`ZodProblemDetailsOptions`: +does the following: + +- registers `ZodExceptionHandler` and the shared `ZodProblemDetailsWriter`; +- calls `services.AddProblemDetails(problemDetails)` and configures `ZodProblemDetailsOptions`; +- adds the ProblemDetails payload's source-generated JSON metadata to the minimal-API `JsonOptions`, so it + serializes under Native AOT; +- on `net11.0`, enriches the built-in validation pipeline ([above](#net-11-the-built-in-validation-pipeline)). + +`AddZodSharp` also registers the writer and the JSON metadata. -- `ErrorTypeRegistry Registry` — resolves error codes to `ErrorType`s. Defaults to - `ErrorTypeRegistry.Default`. -- `bool FormatMessages` — when `true`, messages are formatted from the matched `ErrorType.MessageFormat`. -- `Func, int>? StatusCodeSelector` — an escape hatch that takes complete - control of the response status code. +| `ZodProblemDetailsOptions` | Default | Effect | +|---|---|---| +| `Registry` | `ErrorTypeRegistry.Default` | Resolves error codes to `ErrorType`s. | +| `ErrorTypeResolver` | `null` | A lookup used instead of `Registry`. | +| `FormatMessages` | `true` | Format messages from the matched `ErrorType.MessageFormat`, only when every placeholder resolves. | +| `PathNaming` | `ZodPathNaming.MemberName` | `Json` renders keys with the minimal-API `JsonOptions` naming policy. | +| `IncludeIssues` | `true` | Include the `issues` extension. | +| `MergeParametersIntoExtensions` | `false` | Also merge every issue's parameters into the top-level extensions (last wins). | +| `IncludeDiagnosticParameters` | `false` | Also write the diagnostic parameters generated validators attach (`declaringType`, the full CLR name of the declaring type, and `member`, the C# member name). They stay on `ValidationError.Parameters` either way, for localization and logging. | +| `ProjectDataAnnotationsIssues` | `true` | Give non-ZodSharp `errors` entries an `issues` entry with code `dataannotations` (`ZodProblemDetailsWriter.DataAnnotationsCode`). | +| `DefaultStatusCode` | `400` | The status when no matched `ErrorType` supplies one. | +| `StatusCodeSelector` | `null` | `Func, int>`: takes complete control of the status. It runs after the highest `ErrorType.HttpStatus` is computed, and its result wins. | +| `StatusCodeOverride` | `null` | `Func, int, int>`: like `StatusCodeSelector`, but also receives the computed status. It runs last and wins. | +| `HandleBindingFailures` | `false` | Sets `RouteHandlerOptions.ThrowOnBadRequest`, so a body rejected during deserialization reaches `ZodExceptionHandler` in every environment. See [Strict deserialization](#strict-deserialization-value-objects-rejected-while-reading). | + +`MapErrorType(ErrorType)` registers an `ErrorType` with the configured `Registry`. + +### Strict deserialization: value objects rejected while reading + +A value object read by a strict converter (Purview.ValueObjects' `ValueObjectDeserializationMode.Strict`) is +validated while the body is deserialized. A converter cannot know which member of the document it is reading, so it +wraps the `ZodException` in a `JsonException` (`new JsonException(message, zodException)`), and System.Text.Json +records the location in `JsonException.Path` (`$.customerEmail`). ZodSharp joins the two: the errors are reported under +the member that held the value, exactly as validating the bound model reports them. + +```http +POST /orders +{"customerEmail":"not-an-email","quantity":5} +``` + +```json +{ + "status": 400, + "errors": { "CustomerEmail.Value": ["Field 'CustomerEmail' must be a valid email address."] }, + "issues": [{ "path": "CustomerEmail.Value", "segments": ["CustomerEmail", "Value"], "code": "invalid_string", "origin": "string", ... }] +} +``` + +- **Member names.** The serializer's path uses JSON names. The endpoint's body type (its `IAcceptsMetadata`) is + resolved from the minimal-API `JsonOptions`, and each JSON name is mapped back to its member name through that + contract's metadata - no reflection. `PathNaming` then applies as it does everywhere, so the keys match the + `WithZodSharpValidation()` body for the same model under either setting. When the body type cannot be resolved, + the JSON names are kept. Collections keep their indexes (`$.lines[0].quantity` -> `Lines[0].Quantity.Value`). +- **Display name.** An error at the value object's own member (`Value`) is named after the member holding it, as nested + validation does: `Field 'CustomerEmail' ...`, never `Field 'Value' ...`. +- **A bare `ZodException`** (older value object releases, or any other caller) is unchanged: its own path. +- **Any other bad request** (malformed JSON, a `JsonException` without ZodSharp errors) is left as ASP.NET Core + answers it, unless `HandleBindingFailures` is set (below). + +Whether the failure reaches ZodSharp depends on the framework: + +| | Development | Production | +|---|---|---| +| **.NET 11** | Rewritten | Rewritten: binding writes its own validation problem through the ProblemDetails service, with the exception attached, and `AddZodSharpProblemDetails` rewrites it. No opt-in needed. | +| **.NET 10** | Rewritten (`ThrowOnBadRequest` is on in Development) | A bare `400` with no body, unless `HandleBindingFailures = true`. | + +```csharp +builder.Services.AddZodSharpProblemDetails(options => options.HandleBindingFailures = true); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +`HandleBindingFailures` sets `RouteHandlerOptions.ThrowOnBadRequest`, which is application-wide: **every** +minimal-API binding failure becomes a `BadHttpRequestException` that travels through the exception handler middleware +instead of a status code set in place. `ZodExceptionHandler` then answers the ones without ZodSharp errors with their +own status (`BadHttpRequestException.StatusCode`) and a standard ProblemDetails body that never includes the exception's message (it can +quote the request). The costs are an exception per bad request and the middleware's diagnostics, which follow +`ExceptionHandlerOptions.SuppressDiagnosticsCallback`. An unsupported content type (`415`) is always answered by +binding itself. MVC (`SystemTextJsonInputFormatter`) is not covered: it records the failure in `ModelState` under the +JSON path. + +Outside ASP.NET Core, `ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo, out errors)` (core package) performs +the same re-rooting for any exception chain, and `DeserializeAndValidate*` / `ReadFromJsonAndValidateAsync` apply it, +so they return the re-rooted errors rather than `invalid_json`. > [!NOTE] -> `AddZodSharpProblemDetails` only wires up exception handling and ProblemDetails services — it does -> **not** register `IZodSchemaFactory`. If you also want DI-based validator resolution, register the +> `AddZodSharpProblemDetails` only wires up exception handling and ProblemDetails services (and, on .NET 11, +> the pipeline enrichment). It does **not** register `IZodSchemaFactory`. If you also want DI-based validator resolution, register the > factory separately with `AddZodSharp` (below) or the core `AddZodSharpFactory`. ## Mapping error types to status codes @@ -215,7 +551,7 @@ var error = ConcurrentErrorType.CreateSaveFailed( "agg-123", "Invoice", path: ["order", "items", "[0]"], - origin: "collection", + origin: "array", minimum: 1, maximum: 10, inclusive: true); @@ -245,39 +581,49 @@ Produces a `409 Conflict` `HttpValidationProblemDetails` with: ```json { + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10", + "title": "One or more validation errors occurred.", "status": 409, "detail": "The aggregate could not be saved.", "errors": { "": ["Aggregate 'agg-123' (of type Invoice) failed to save"] }, - "traceId": "...", - "aggregateId": "agg-123", "issues": [ { + "path": "", + "segments": [], "code": "aggregate_save_failed", - "path": [], + "category": "invalid_value", "message": "Aggregate 'agg-123' (of type Invoice) failed to save", - "parameters": { "AggregateId": "agg-123", "AggregateType": "Invoice" } + "parameters": { "aggregateId": "agg-123", "aggregateType": "Invoice" } } - ] + ], + "aggregateId": "agg-123", + "aggregateType": "Invoice", + "traceId": "..." } ``` Mapping rules: -- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to the default - (`400`). Override with `ZodProblemDetailsOptions.StatusCodeSelector`. -- **Title / Type / Detail** — taken from the highest-status matched `ErrorType`; otherwise defaulted. -- **Message** — `MessageFormat` named placeholders (for example `{AggregateId}`) are substituted from - `ValidationError.Parameters`. Placeholders without a matching value are left as-is so templating gaps - stay visible. The analyzer `ZODSASP001` (shipped with the core package) warns at compile time when a +- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to + `DefaultStatusCode` (`400`). `StatusCodeSelector` and then `StatusCodeOverride` run after that computation, and + their result wins. +- **Title / Type / Detail** — taken from the highest-status matched `ErrorType`; otherwise defaulted. `type` is the + RFC 9110 URI for the status. +- **Message** — with `FormatMessages`, the `MessageFormat` replaces the error's message only when **every** + placeholder resolves, so a template is never returned with `{placeholders}` left in. Placeholders resolve from + `ValidationError.Parameters`, then from the built-ins `path`, `code`, `origin`, `minimum`, `maximum` and + `displayName`. The analyzer `ZODSASP001` (shipped with the core package) warns at compile time when a `MessageFormat` placeholder is not declared in `Parameters`. -- **Parameters** — the error's `ValidationError.Parameters` are surfaced both per-issue in the `issues` - extension and merged (camel-cased) into the top-level ProblemDetails extensions for client correlation. +- **Parameters** — the error's `ValidationError.Parameters` are surfaced per issue in `issues[].parameters` + (camel-cased, safe scalars only). With `MergeParametersIntoExtensions` they are also merged into the top-level + extensions, and reserved names are written as `param_{name}`. -`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` accept an `ErrorTypeRegistry` or a -`Func` lookup for on-demand mapping, and the same mapping applies to -`ValidationResult`. +For on-demand mapping outside a request, pass `ZodProblemDetailsOptions` with a `Registry` or an +`ErrorTypeResolver` lookup to `ToHttpValidationProblemDetails` / `ToValidationProblemDetails`; the same mapping +applies to `ValidationResult`. The 2.x registry and lookup overloads still work but are obsolete (see +[The option-less overloads](#the-option-less-overloads)). ## Dependency injection @@ -290,7 +636,16 @@ builder.Services.AddZodSharp(options => }); ``` -`AddZodSharp(Action? configure)` registers `IZodSchemaFactory` as a singleton, applies `options.ConfigureFactory`, and calls `factory.RegisterFromAssembly(assembly)` for each distinct assembly discovered from the configured assembly sources. This auto-discovers source-generated `[assembly: ZodSchemaGenerated(typeof(...))]` registrations. +`AddZodSharp(Action? configure)` registers `IZodSchemaFactory` as a singleton. When the factory is built, it does three things in order: + +1. It applies every `ZodSchemaRegistration`, including the trim-safe ones added by the generated + `services.AddZodSharp{Assembly}Schemas()` extensions. +2. It applies `options.ConfigureFactory`. +3. It calls `factory.RegisterFromAssembly(assembly)` for each distinct assembly discovered from the configured + assembly sources, which auto-discovers source-generated `[assembly: ZodSchemaGenerated(typeof(...))]` + registrations. + +Assembly scanning is not trim-safe. In a trimmed or Native AOT host, use the generated `AddZodSharp{Assembly}Schemas()` (see [Source Generator](Source-Generator.md)). `ZodSchemaFactoryOptions`: @@ -326,7 +681,10 @@ builder.Services.AddZodSharpLoadedAssemblies(); | `AddZodSharp(options)` | `Purview.ZodSharp.AspNetCore` | singleton `IZodSchemaFactory` + auto-registers generated validators from configured assembly sources | ASP.NET Core apps that want factory registration plus optional assembly-source configuration. | | `AddZodSharpAssembly(...)`, `AddZodSharpAssemblyGraph(...)`, `AddZodSharpLoadedAssemblies()` | `Purview.ZodSharp.AspNetCore` | additive generated-validator assembly source contributions | Modular ASP.NET Core apps where deeper layers or implementation packages contribute their own schema assemblies. | | `AddZodSharpFactory(configure)` | core `Purview.ZodSharp` | singleton `IZodSchemaFactory` | Any .NET host where you want manual control — register validators yourself in the `configure` callback. | -| `AddZodSharpProblemDetails(...)` | `Purview.ZodSharp.AspNetCore` | `ZodExceptionHandler` + ProblemDetails services only — does **not** register the factory | Mapping thrown `ZodException`s to `ProblemDetails`; pair it with `AddZodSharp` when you also need DI validator resolution. | -| `AddZodSchemaOptionsValidator(...)` | core `Purview.ZodSharp` | singleton `IValidateOptions` | Validating options objects; requires a factory registered first via `AddZodSharp` or `AddZodSharpFactory`. | +| `AddZodSharp{Assembly}Schemas()` | generated into each assembly with `[ZodSchema]` types | a trim-safe `ZodSchemaRegistration` naming every generated validator | Trimmed or Native AOT hosts, and anywhere assembly scanning is unwanted. | +| `AddZodSharpProblemDetails(...)` | `Purview.ZodSharp.AspNetCore` | `ZodExceptionHandler`, the ProblemDetails writer and services, and the .NET 11 pipeline enrichment — does **not** register the factory | Mapping thrown `ZodException`s to `ProblemDetails`; pair it with `AddZodSharp` when you also need DI validator resolution. | +| `AddZodSharpValidation(...)` | `Purview.ZodSharp.Extensions.Validation` (`net11.0`) | the Microsoft.Extensions.Validation resolver | Validating with the .NET 11 pipeline; see [Microsoft.Extensions.Validation](Microsoft-Extensions-Validation.md). | +| `AddZodSchemaOptionsValidator(...)` | core `Purview.ZodSharp` | singleton `IValidateOptions` | Validating options objects; requires a factory registered first via `AddZodSharp` or `AddZodSharpFactory`. Validates every options name; on .NET 11 `ValidateOnStart()` runs its asynchronous rules too. | +| `OptionsBuilder.BindAndValidateWithZod(section)` | `Purview.ZodSharp.AspNetCore` | binds a configuration section and adds the ZodSharp options validator | Options whose failures should name the configuration key (`Smtp:Servers:0:Host`). Uses the reflection-based binder; under Native AOT call `BindConfiguration` and `AddZodSchemaValidator` yourself. | See [Dependency Injection](Dependency-Injection.md) for the underlying factory and options-validation wiring. diff --git a/docs/wiki/Blazor-Integration.md b/docs/wiki/Blazor-Integration.md new file mode 100644 index 00000000..2f3cb518 --- /dev/null +++ b/docs/wiki/Blazor-Integration.md @@ -0,0 +1,352 @@ +# Blazor Integration + +`Purview.ZodSharp.Blazor` validates Blazor `EditForm` models with ZodSharp on **Blazor Server**, **Blazor +WebAssembly** and **Auto** render mode. It targets `net11.0` and references the component packages +(`Microsoft.AspNetCore.Components.Forms` and `.Web`), never the ASP.NET Core shared framework, so the same +package runs in the browser. + +```bash +dotnet add package Purview.ZodSharp.Blazor +dotnet add package Purview.ZodSharp.AspNetCore # server project only, for static-SSR client rules +``` + +## Setup + +```csharp +// Program.cs - the server AND the client project. +builder.Services.AddZodSharpMyAppSharedSchemas(); // generated, trim-safe registration of [ZodSchema] models +builder.Services.AddZodSharpBlazor(); +``` + +A Blazor Web App typically looks like this: + +``` +MyApp/ (server) -> Purview.ZodSharp.Blazor, Purview.ZodSharp.AspNetCore +MyApp.Client/ (WASM) -> Purview.ZodSharp.Blazor +MyApp.Shared/ (models) -> Purview.ZodSharp (the generator runs here) +``` + +`AddZodSharpBlazor(configureValidation, configure)` registers the Microsoft.Extensions.Validation bridge for **type +validation only** (`AddZodSharpValidation(configureValidation, validateParameters: false)`), registers +`ZodBlazorOptions` and registers `TimeProvider.System` when no `TimeProvider` is registered. It never registers an +`IZodValidationReportSink`. + +> [!IMPORTANT] +> **`AddZodSharpBlazor` does not turn on minimal-API validation.** Forms and minimal APIs share one +> `ValidationOptions`, and its endpoint filter validates every handler parameter a registered resolver claims, before +> the handler runs. So the bridge `AddZodSharpBlazor` registers claims no parameters: a Blazor Server host that also +> maps endpoints keeps them exactly as they were. To have ZodSharp-typed endpoint parameters validated by the .NET 11 +> pipeline as well (an invalid request answered with `400` before the handler), opt in: +> +> ```csharp +> builder.Services.AddZodSharpBlazor(); +> builder.Services.AddZodSharpValidation(); // either order +> ``` +> +> or `AddZodSharpBlazor(validation => validation.ValidateParameters = true)`. Once it is on, opt single endpoints or +> groups out with `DisableValidation()`, or every parameter with `ValidateParameters = false`. A WebAssembly client +> maps no endpoints, so this only matters on the server. + +## Two routes + +| Need | Route | +| --- | --- | +| Standard forms, `[ZodSchema]` models, minimal setup | 1: `` | +| Codes, categories, origins or bounds in the UI; custom message rendering | 2: `` | +| A runtime `Z.Object(...)` schema for a CLR model | 2: `Schema` parameter | +| API (server) validation errors on fields | either, plus `AddZodProblemDetails` | +| Debounced per-keystroke validation | 2: `Input` + `DebounceMilliseconds` | +| Field-level re-check of cross-field rules | 2: `ValidateNestedOnFieldChange` | + +### Route 1: `` + +```razor + + + + + @foreach (var line in order.Lines) + { + + + } + + +``` + +- **No `[ValidatableType]` and no Microsoft.Extensions.Validation generator** are needed for `[ZodSchema]` models. + The ZodSharp resolver supplies the root type info, so Blazor takes its full-validation path: nested objects, + collections (`Lines[0].Quantity`), dictionaries and unions each place their message on the right + `FieldIdentifier`. In WebAssembly this is the difference between nested validation and top-level-only + validation, because the Microsoft.Extensions.Validation generator does not flow to a client project. +- A field change runs only that member's own rules (`ValidateMember`), never nested schemas. +- Async rules use the platform's async validation, so `IsValidationPending`, `IsValidationFaulted` and the + `pending`/`faulted` CSS classes work. +- Messages are localized through the shared formatter when `AddLocalization()` is configured. + +The platform's message store holds strings only, so Route 1 cannot carry codes or other metadata. + +### Route 2: `` + +```razor + + + + + + +``` + +`Input` debounces changes, so the input has to report one per keystroke. `InputText` commits on `onchange`, and a +component binding has no event to choose (`@bind-Value:event="oninput"` on `InputText` fails at runtime), so derive +from it: + +```razor +@* LiveInputText.razor *@ +@inherits InputText + +``` + +| Parameter | Default | Meaning | +| --- | --- | --- | +| `Schema` | `null` | An explicit `IZodUntypedValidator` for the model (see `ZodModelSchema`); otherwise the registered validator. | +| `ValidateOn` | `Change` | `Submit` (field changes don't validate), `Change`, `Input`. Submit-time validation always runs. | +| `DebounceMilliseconds` | `0` | Delay for `Input`-triggered field validation; each change supersedes and cancels the pending one. | +| `EnableClientValidation` | `true` | Emit static-SSR client rules, exactly as `DataAnnotationsValidator` does. | +| `ValidateNestedOnFieldChange` | `false` | Re-validate the whole object that owns the changed field and update all its fields. | + +Behaviour: + +- Form validation uses `ValidationRequestedEventArgs.AddAsyncValidator`; field validation uses + `EditContext.RegisterAsyncFieldValidator`. Pending, faulted, cancellation and supersession come from the + platform. Store updates are marshalled through `InvokeAsync`. +- A field change on a member the validator can validate on its own runs only that member's rules. For runtime + schemas without member validation the whole model is validated and only the changed field's errors are kept; + other fields' messages are left exactly as they were. +- The full `ValidationError` of every message is kept in `EditContext.Properties[ZodValidator.ErrorsKey]` and read + with `GetZodErrors(field)`, `GetZodErrors(() => model.Member)` or `GetZodErrors()`. +- A validator that throws is logged through `ILogger` with the field, and the field (or form) is + marked faulted. +- With a `DataAnnotationsValidator` on the same form, `ZodValidator` records the full errors for the types the bridge + validates but leaves the messages to `DataAnnotationsValidator`, so nothing is duplicated, and logs one warning + (`ZodBlazorOptions.WarnOnValidatorCoexistence`). A `DataAnnotationsValidator` with client validation disabled is + not detected. + +### Runtime schemas + +```csharp +var schema = Z.Object() + .Field("Reference", Z.String().Min(3)) + .Field("Customer", Z.Object().Field("Email", Z.String().Email()).Build()) + .Build(); +IZodUntypedValidator validator = ZodModelSchema.For(schema); +``` + +`ZodModelSchema.For(ZodObject)` reads the model's public properties (and nested objects) into the key/value +shape the object schema validates and places the errors on the CLR fields. Collections are passed as they are. +`ZodModelSchema.For(IZodSchema)` adapts a schema of the model's own type. + +### Value objects and other struct members + +A struct cannot be a `FieldIdentifier` model (Blazor rejects a value-type model, and a boxed copy is not the instance +the form binds), so an error located inside a struct member is placed on the nearest reference-type owner and its +member. Given a scalar value object on the form model: + +```csharp +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required] + [EmailAddress] + public string Value { get; } +} + +[ZodSchema] +public sealed class Registration +{ + public EmailAddress Email { get; set; } +} +``` + +the error at `Email.Value` is shown by `` (or `ZodValidationMessage`), +next to the value object's input. Both routes, both hosts (Server and WebAssembly) and `AddZodProblemDetails` place +it the same way; `GetZodErrors(() => model.Email)` returns it, with its full path `Email.Value` unchanged. A struct +inside a collection (`Stops[1].Latitude`) is placed on the collection member (`Stops`). + +A scalar value object's own member is an implementation detail, so its messages use the **owning member's display +name**: `"Email is required"`, `"Field 'Contact email' must be a valid email address."` with +`[Display(Name = "Contact email")]`, rather than `"Value is required"`. The `displayName` parameter carries the +owner's name too; `member` and `declaringType` still identify the value object's member, for localization. A rule's +own message text (one not produced from its template) is kept. A struct that is not a scalar keeps its members' +names (`"Field 'Latitude' must be greater than or equal to -90 and less than or equal to 90."`), since it may have several. +## Message components + +`` and `` render the same markup and CSS classes as +`ValidationMessage` and `ValidationSummary` (interactive and static, including the static-SSR `data-valmsg-for` +placeholders), plus `data-code` and `data-category` when the full error is known. + +- `ZodValidationMessage`: `ShowCode` (a `span.validation-code`), `Template` (`RenderFragment`). +- `ZodValidationSummary`: `Model`/`ShowModelOnly` (model-level messages only), `GroupByPath` + (`li.validation-group[data-path]`), `ShowUnionDetails` (each union option's issues). + +With Route 1 both fall back to plain messages. + +## Server errors onto forms + +```csharp +var response = await http.PostAsJsonAsync("/orders", order); +if (await response.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); +``` + +- `TryReadZodValidationProblemAsync` reads an unsuccessful JSON response with `errors` or `issues` - the contract + [ASP.NET Core Integration](AspNetCore-Integration.md) writes - using source-generated JSON metadata. It and + `ZodValidationProblemDetails` (namespace `ZodSharp.Http`) come from `Purview.ZodSharp.Http`, which this package + references; see [HttpClient Integration](Http-Integration.md) for validating request and response bodies too. +- `AddZodProblemDetails` places issues by their typed `segments`, so `Lines[0].Quantity` lands on + `order.Lines[0].Quantity`; without `issues` it parses the dotted `errors` keys. Camel-cased member names match + case-insensitively, and dictionary keys resolve. When a `ZodValidator` is on the form its validator resolves the + paths. +- Server errors replace the previous server errors and are cleared per field when the field changes + (`clearOnFieldChange: false` keeps them). `ClearZodErrors()` removes every ZodSharp error. +- `AddZodErrors(errors)` places errors produced outside the form the same way - for example those + `PostAsJsonValidatedAsync` returns when it refuses to send an invalid value: + + ```csharp + var sent = await http.PostAsJsonValidatedAsync("/orders", order, + new OrderSchemaValidator(), AppJsonContext.Default.Order, services); + if (!sent.IsSuccess) + editContext.AddZodErrors(sent.Errors); + else if (await sent.Value!.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); + ``` + + It shares `AddZodProblemDetails`'s store and rules: each call replaces the previous one's errors, they clear per + field on change, and `GetZodErrors` returns the full errors. + +## Static SSR client-side validation + +```csharp +// Server Program.cs +builder.Services.AddRazorComponents(); +builder.Services.AddZodSharpClientValidation(); // Purview.ZodSharp.AspNetCore; either order works +``` + +A statically rendered form with `` (or ``) then carries, beside ASP.NET +Core's DataAnnotations rules, client rules for: + +| Attribute | Client validator | +| --- | --- | +| `[StartsWith]`, `[EndsWith]` (ordinal or ordinal-ignore-case) | `zod-startswith`, `zod-endswith` | +| `[Includes]` | `zod-includes` | +| `[UUID]` (optionally versioned) | `zod-uuid` | +| `[Cuid2]`, `[ULID]` | `zod-cuid2`, `zod-ulid` | +| `[IPAddress]`, `[Cidr]` | `zod-ip`, `zod-cidr` | +| `[Regex]` | `zod-regex` (a match anywhere, like `Regex.IsMatch`) | +| `[Int]`, `[MultipleOf]` | `zod-int`, `zod-multipleof` | +| `[MinValue]`, `[MaxValue]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]` | `zod-min`, `zod-max` | +| `[GreaterThan]`, `[LessThan]` | `zod-gt`, `zod-lt` | + +Custom rule attributes get the same client rules when their rule reports the same `IZodRule` metadata (format +parameters and bounds); a custom attribute can also implement `IClientValidationRuleProvider` with +`ZodClientValidationRules.For(rule)`. Runtime schemas have no attributes: register them with +`AddZodSharpClientValidation(o => o.AddSchema(schema))` and the rules are derived from the schema's JSON +Schema (`minLength`, `maxLength`, `pattern`, bounds, `multipleOf`, `integer`, `uuid`/`ipv4`/`ipv6`). + +The browser validators live in `_content/Purview.ZodSharp.Blazor/zodsharp-validation.js` and register through a +Blazor JavaScript initializer, so no ` + + diff --git a/scripts/packed-consumers/MinimalApi/MinimalApi.csproj b/scripts/packed-consumers/MinimalApi/MinimalApi.csproj new file mode 100644 index 00000000..65ad3727 --- /dev/null +++ b/scripts/packed-consumers/MinimalApi/MinimalApi.csproj @@ -0,0 +1,38 @@ + + + + net11.0 + PackedApi + PackedApi + enable + enable + true + true + false + true + true + true + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/MinimalApi/Program.cs b/scripts/packed-consumers/MinimalApi/Program.cs new file mode 100644 index 00000000..ce4aa938 --- /dev/null +++ b/scripts/packed-consumers/MinimalApi/Program.cs @@ -0,0 +1,63 @@ +// Packed-consumer gate, consumer (c): a Native AOT minimal API built from the packages alone. The script that publishes +// it starts the native binary, posts an invalid order and reads /openapi/v1.json. +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OpenApi; +using PackedApi; +using ZodSharp; +using ZodSharp.Rules; + +var builder = WebApplication.CreateSlimBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default) +); + +// The generated, trim-safe registration; ZodSharp as the Microsoft.Extensions.Validation resolver, so the built-in +// pipeline validates the endpoint parameter; the ZodSharp ProblemDetails contract; and the OpenAPI transformers. +builder.Services.AddZodSharp(); +builder.Services.AddZodSharpPackedApiSchemas(); +builder.Services.AddZodSharpValidation(); +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddOpenApi(static options => options.AddZodSharp()); + +var app = builder.Build(); +app.UseExceptionHandler(); +app.MapOpenApi(); +app.MapPost("/orders", static (CreateOrder order) => TypedResults.Ok(new OrderAccepted(order.Lines.Count))); + +await app.RunAsync(); + +namespace PackedApi +{ + [ZodSchema] + public sealed class CreateOrder + { + [Required] + [EmailAddress] + public string? CustomerEmail { get; set; } + + [MinCount(1)] + [MaxCount(10)] + public List Lines { get; set; } = []; + } + + [ZodSchema] + public sealed class CreateOrderLine + { + [Required] + [RegularExpression("^[A-Z]{3}-[0-9]{4}$")] + public string? Sku { get; set; } + + [Range(1, 100)] + public int Quantity { get; set; } + } + + public sealed record OrderAccepted(int Lines); + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] + [JsonSerializable(typeof(CreateOrder))] + [JsonSerializable(typeof(OrderAccepted))] + sealed partial class ApiJsonContext : JsonSerializerContext; +} diff --git a/scripts/packed-consumers/MultiProject/Api/Api.csproj b/scripts/packed-consumers/MultiProject/Api/Api.csproj new file mode 100644 index 00000000..294b84da --- /dev/null +++ b/scripts/packed-consumers/MultiProject/Api/Api.csproj @@ -0,0 +1,43 @@ + + + + net11.0 + PackedMultiApi + PackedMultiApi + enable + enable + true + true + + true + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/MultiProject/Api/Program.cs b/scripts/packed-consumers/MultiProject/Api/Program.cs new file mode 100644 index 00000000..feb3fc00 --- /dev/null +++ b/scripts/packed-consumers/MultiProject/Api/Program.cs @@ -0,0 +1,53 @@ +// Packed-consumer gate, consumer (d): a minimal API over a referenced [ZodSchema] class library, with the OpenAPI +// XML-comment generator reading both projects' documentation. The script reads /openapi/v1.json, posts an invalid +// registration and registers a customer whose email is taken. +using Microsoft.AspNetCore.OpenApi; +using PackedContracts; +using PackedMultiApi; +using ZodSharp; +using ZodSharp.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +// Both assemblies' generated registrations; ZodSharp as the Microsoft.Extensions.Validation resolver; the ZodSharp +// ProblemDetails contract; and the OpenAPI transformers. +builder.Services.AddZodSharp(); +builder.Services.AddZodSharpPackedContractsSchemas(); +builder.Services.AddZodSharpPackedMultiApiSchemas(); +builder.Services.AddZodSharpValidation(); +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddOpenApi(static options => options.AddZodSharp()); + +ErrorTypeRegistry.Default.Register(CustomerErrors.CustomerExists); + +var app = builder.Build(); +app.UseExceptionHandler(); +app.MapOpenApi(); + +app.MapPost("/customers", static (RegisterCustomer customer) => +{ + // The [ErrorType] helper generated into the referenced library, raised from this assembly. + if (string.Equals(customer.Email, "taken@example.com", StringComparison.OrdinalIgnoreCase)) + CustomerErrors.ThrowCustomerExists(customer.Email!); + + return TypedResults.Ok(new CustomerRegistered(customer.Email!, customer.DisplayName!)); +}); + +await app.RunAsync(); + +namespace PackedMultiApi +{ + /// The acknowledgement returned once a customer is registered. + /// The registered email address. + /// The registered display name. + public sealed record CustomerRegistered(string Email, string DisplayName); + + /// A note this application validates itself, so both projects generate schemas. + [ZodSchema] + public sealed class AccountNote + { + /// The note text. + [System.ComponentModel.DataAnnotations.Required] + public string? Text { get; set; } + } +} diff --git a/scripts/packed-consumers/MultiProject/Contracts/Contracts.csproj b/scripts/packed-consumers/MultiProject/Contracts/Contracts.csproj new file mode 100644 index 00000000..7edaeccf --- /dev/null +++ b/scripts/packed-consumers/MultiProject/Contracts/Contracts.csproj @@ -0,0 +1,31 @@ + + + + net11.0 + PackedContracts + PackedContracts + enable + enable + true + true + + + + + + + + + + diff --git a/scripts/packed-consumers/MultiProject/Contracts/Models.cs b/scripts/packed-consumers/MultiProject/Contracts/Models.cs new file mode 100644 index 00000000..aa108a5e --- /dev/null +++ b/scripts/packed-consumers/MultiProject/Contracts/Models.cs @@ -0,0 +1,49 @@ +// Packed-consumer gate, consumer (d): the class library the multi-project API references. +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Core; + +namespace PackedContracts; + +/// A request to register a customer. +[ZodSchema] +public sealed class RegisterCustomer +{ + /// The customer's email address, used to sign in. + [Required] + [EmailAddress] + public string? Email { get; set; } + + /// The name shown on the customer's account. + [Required] + [StringLength(40, MinimumLength = 2)] + public string? DisplayName { get; set; } + + /// Where the customer's orders are delivered. + [Required] + public DeliveryAddress? Address { get; set; } +} + +/// A delivery address. +[ZodSchema] +public sealed class DeliveryAddress +{ + /// The postal code, five digits. + [Required] + [RegularExpression("^[0-9]{5}$")] + public string? PostalCode { get; set; } +} + +/// The errors the customer contracts raise. +public static partial class CustomerErrors +{ + /// A customer with the same email address is already registered. + [ErrorType] + public static readonly ErrorType CustomerExists = new( + Code: "customer_exists", + Description: "A customer with this email address is already registered.", + HttpStatus: 409, + MessageFormat: "A customer with the email address '{Email}' is already registered", + Parameters: [new ErrorTypeParameter("Email", typeof(string))] + ); +} diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 new file mode 100644 index 00000000..8eba5da9 --- /dev/null +++ b/scripts/test-pack-validation-rules.ps1 @@ -0,0 +1,182 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves every PackValidation ForbiddenContent rule in .config/purview-build.json actually fails a package. + +.DESCRIPTION + A forbidden-content glob that matches nothing - a typo, a wrong folder, a "*" where "**" was meant - passes + silently forever. This script proves each rule bites, with the real tool (purview-build, the version + .config/dotnet-tools.json pins) and the real rules. Each run validates copies of the packed packages in a scratch + repository that holds the repository's purview-build.json verbatim and an empty solution, so the pipeline's build is + a no-op and only pack validation does any work. RequireExplicitContent is turned off in every run, so the + explicit-content rule cannot be what fails one: only ForbiddenContent can. + + 1. Control: every package, untouched. Must pass, or the harness itself is broken. + 2. Per package: each package on its own, with one file per rule injected - a concrete path the rule's glob should + match (lib/**/Microsoft.CodeAnalysis* becomes lib/probe/Microsoft.CodeAnalysis.dll). Must fail, reporting every + rule. This proves the rules apply to every package id. + 3. Per rule: Purview.ZodSharp with only that rule's file injected. Must fail, reporting that rule. This proves each + rule is triggered by its own file rather than by another rule's. + + The packages in -PackagesFolder are never modified. + +.PARAMETER PackagesFolder + Folder holding the packed .nupkg and .snupkg files of the current version. Defaults to artifacts/, where + `just pipeline-pack-validate` and `just pack` write them. `just pipeline-local-release` moves them to the local feed, + so pass that folder after it. + +.PARAMETER KeepArtifacts + Leave the scratch repositories in place for inspection. +#> +[CmdletBinding()] +param ( + [string] $PackagesFolder = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'), + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$configPath = Join-Path $repositoryRoot '.config/purview-build.json' +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$config = Get-Content $configPath -Raw | ConvertFrom-Json + +$rules = @($config.PackValidation.ForbiddenContent.PSObject.Properties['*'].Value) +if ($rules.Count -eq 0) { throw "No ForbiddenContent rules for '*' in $configPath." } + +$packages = @(Get-ChildItem $PackagesFolder -Filter "*.$version.nupkg" -File | Sort-Object Name) +if ($packages.Count -eq 0) { + throw ( + "No $version packages in '$PackagesFolder'. Pack first (just pipeline-pack-validate), or pass -PackagesFolder: " + + 'just pipeline-local-release moves its packages to the local feed.' + ) +} + +# A concrete entry each glob must match: "**" spans one made-up folder, "*" becomes an assembly extension. +$cases = foreach ($rule in $rules) { + [pscustomobject]@{ Rule = $rule; Entry = ($rule -replace '\*\*', 'probe' -replace '\*', '.dll') } +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) "zs-pack-rules-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$runs = 0 + +# Validates $nupkgs (and their .snupkg) with $entries injected into each, and returns the rules the tool reported. +function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [string[]] $entries) { + $root = Join-Path $scratch $name + $artifacts = Join-Path $root 'artifacts' + New-Item -ItemType Directory -Path (Join-Path $root '.config'), $artifacts -Force | Out-Null + Copy-Item $configPath (Join-Path $root '.config/purview-build.json') + Copy-Item (Join-Path $repositoryRoot '.config/dotnet-tools.json') (Join-Path $root '.config/dotnet-tools.json') + Copy-Item (Join-Path $repositoryRoot 'global.json'), (Join-Path $repositoryRoot 'package.json') $root + '' | Set-Content (Join-Path $root 'empty.slnx') -Encoding utf8 + & git -C $root init --quiet + + foreach ($nupkg in $nupkgs) { + $copy = Join-Path $artifacts $nupkg.Name + Copy-Item $nupkg.FullName $copy + $symbols = [System.IO.Path]::ChangeExtension($nupkg.FullName, '.snupkg') + if (Test-Path $symbols) { Copy-Item $symbols $artifacts } + + if ($entries) { + $archive = [System.IO.Compression.ZipFile]::Open($copy, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $entries) { + $writer = [System.IO.StreamWriter]::new($archive.CreateEntry($entry).Open()) + try { $writer.Write('injected by scripts/test-pack-validation-rules.ps1') } finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + } + } + + Push-Location $root + try { + & dotnet tool restore | Out-Null + $output = & dotnet purview-build ` + --Build:Solution=empty.slnx ` + --Build:ArtifactsFolder=artifacts ` + --Build:CleanArtifacts=false ` + --Build:RunPack=false ` + --Build:ValidatePack=true ` + --Build:RunTests=false ` + --Build:RunLint=false ` + --PackValidation:RequireExplicitContent=false ` + --Release:Mode=None 2>&1 | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + finally { + Pop-Location + } + + $script:runs++ + # The console wraps long lines, so rejoin them before reading "Forbidden content '' must not be in ...". + $text = ($output -join "`n") -replace '\r?\n\s*', ' ' + $reported = @([regex]::Matches($text, "Forbidden content '(?[^']+)'") | ForEach-Object { $_.Groups['rule'].Value } | Sort-Object -Unique) + return [pscustomobject]@{ ExitCode = $exitCode; Reported = $reported; Output = ($output -join "`n") } +} + +$failures = [System.Collections.Generic.List[string]]::new() +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +try { + Write-Host "Control: $($packages.Count) untouched package(s)..." -ForegroundColor Cyan + $control = Invoke-Validation 'control' $packages @() + if ($control.ExitCode -ne 0) { + Write-Host $control.Output + throw 'The control run failed on untouched packages, so the harness cannot prove anything. Fix that first.' + } + Write-Host ' passed' + + Write-Host "Per package: each with all $($cases.Count) rule file(s) injected..." -ForegroundColor Cyan + foreach ($package in $packages) { + $result = Invoke-Validation "package-$($package.BaseName)" @($package) @($cases.Entry) + $missed = @($cases.Rule | Where-Object { $result.Reported -notcontains $_ }) + if ($result.ExitCode -eq 0 -or $missed.Count -gt 0) { + $failures.Add("$($package.Name): exit $($result.ExitCode); rules not reported: $($missed -join ', ')") + Write-Host " NOT REJECTED $($package.Name) ($($missed.Count) rule(s) missed)" -ForegroundColor Red + } + else { + Write-Host " rejected $($package.Name) ($($result.Reported.Count) of $($cases.Count) rules)" + } + } + + $core = $packages | Where-Object { $_.Name -ieq "Purview.ZodSharp.$version.nupkg" } + if (-not $core) { throw "Purview.ZodSharp.$version.nupkg is not in '$PackagesFolder'." } + + Write-Host "Per rule: $($core.Name) with one rule's file injected..." -ForegroundColor Cyan + $index = 0 + foreach ($case in $cases) { + $index++ + $result = Invoke-Validation "rule-$index" @($core) @($case.Entry) + $others = @($result.Reported | Where-Object { $_ -ne $case.Rule }) + if ($result.ExitCode -eq 0 -or $result.Reported -notcontains $case.Rule) { + $failures.Add("$($case.Rule): injecting '$($case.Entry)' was not rejected by it (exit $($result.ExitCode)).") + Write-Host (" NOT REJECTED {0,-55} {1}" -f $case.Entry, $case.Rule) -ForegroundColor Red + } + else { + $also = $others.Count -gt 0 ? " (also matched: $($others -join ', '))" : '' + Write-Host (" rejected {0,-55} {1}{2}" -f $case.Entry, $case.Rule, $also) + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + if ($KeepArtifacts) { Write-Host "Scratch repositories kept at $scratch" -ForegroundColor Yellow } + else { Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host ("{0} validation run(s) in {1:N1}s." -f $runs, $stopwatch.Elapsed.TotalSeconds) +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$failure" } else { Write-Host "ERROR: $failure" -ForegroundColor Red } + } + exit 1 +} + +Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green +# Every rejection run leaves dotnet purview-build's non-zero exit in $LASTEXITCODE, which a caller (GitHub's pwsh step +# wrapper, or `pwsh -File`) would otherwise report as this script's result. +exit 0 diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 new file mode 100644 index 00000000..033b9f24 --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,691 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves every shipping Purview.ZodSharp package works for real consumers: Native AOT, Blazor WebAssembly and a + Native AOT minimal API, built from the .nupkg files alone. + +.DESCRIPTION + Every other publish gate (`just wasm-size`, the WebAssembly AOT publish, `just verify-trim`, + `just publish-aot-sample`) builds against ProjectReferences. What ships is different: packed assemblies, an + IL-merged generator under analyzers/, buildTransitive imports, static web assets and nuspec dependencies. A + ProjectReference build exercises none of that, which is how Purview.SourceGeneratorFramework.dll - a + compiler-host-only assembly - leaked into application output and broke Blazor WebAssembly AOT while every gate + stayed green. + + This gate consumes the packages exactly as a user does: + + 1. Preflight: checks Purview.SourceGeneratorFramework (the generator's build-time framework) can be restored, and + fails early, with what to do, if it cannot. + 2. Packs every shipping package (the ids in .config/purview-build.json PackValidation.RequiredContent) to an + isolated feed, with an isolated NUGET_PACKAGES, so no previously extracted package of the same version can be + reused. Package validation against the API baseline runs as part of the pack. The feed must hold exactly the + expected packages, and no package may carry a compiler-host assembly outside analyzers/. + 3. Copies the consumers in scripts/packed-consumers/ outside the repository, beside guard + Directory.Build.props/.targets and Directory.Packages.props files that stop any inheritance, with a nuget.config + that resolves Purview.ZodSharp* from the isolated feed only: + (a) AotConsole - a Native AOT console over Purview.ZodSharp, SystemTextJson, Extensions.Validation, AspNetCore + and AspNetCore.OpenApi. Published with -warnaserror, then run: it checks generated validation, member + resolution, count attributes, JSON Schema export, the validation bridge and ProblemDetails, and exits + non-zero on any mismatch. + (b) BlazorWasm - a Blazor WebAssembly app with , whose model includes a scalar value object with a + type-level [ZodRule] (the shape that once crashed the Mono AOT compiler). Published trimmed with -warnaserror, + and with RunAOTCompilation=true when the wasm-tools workload is installed. _framework must hold the ZodSharp + assemblies and _content the Blazor package's static web assets. + (c) MinimalApi - a Native AOT minimal API. Published with -warnaserror and started; an invalid POST must return + 400 application/problem+json with issues, and /openapi/v1.json must describe ZodValidationProblemDetails. + (d) MultiProject - a class library of [ZodSchema] models and an [ErrorType] (Contracts), referenced by a minimal + API (Api) that references Microsoft.AspNetCore.OpenApi directly with XML documentation on in both projects. + Published and started: /openapi/v1.json must be served (it crashed when every project carried its own + generated copy of the ZodSharp marker attributes), describe both projects' schemas and carry their XML + comments; an invalid body must return the library's issue paths and a taken email the library's ErrorType. + 4. Scans every consumer bin and publish folder with scripts/assert-no-compiler-host-assemblies.ps1. + + The temporary workspace is deleted afterwards unless -KeepArtifacts is passed. Packing restores the shipping + projects against the isolated cache, so they are restored again against the normal cache at the end. + + It is separate from scripts/test-packed-generator.ps1 on purpose: that script is a fast net10.0 check of the + IL-merged generator that needs no native toolchain; this one needs the Native AOT toolchain (MSVC build tools on + Windows, clang and zlib on Linux) and, for the WebAssembly AOT step, the wasm-tools workload. + +.PARAMETER Configuration + Configuration the packages are packed in. Defaults to Release: this gate checks what ships. + +.PARAMETER RuntimeIdentifier + RID for the Native AOT consumers. Native AOT cannot cross-compile between operating systems, so it defaults to this + machine's RID (win-x64, linux-x64, osx-arm64, ...). + +.PARAMETER SourceGeneratorFrameworkFeed + A NuGet feed (folder or URL) to restore Purview.SourceGeneratorFramework from, in addition to nuget.org - for a + version that is not on nuget.org yet. The feed is used for that package only. Defaults to $env:ZODSHARP_SGF_FEED, + then $env:LOCAL_NUGET_FEED_PATH. + +.PARAMETER WasmAot + Auto (default) runs the WebAssembly AOT publish when the wasm-tools workload is installed and says so when it is + skipped. Require fails when the workload is missing (CI installs it). Skip never runs it. + +.PARAMETER PackOutput + Also copy the packed .nupkg/.snupkg files here, so a later step can inspect exactly what this gate consumed - CI + runs scripts/test-pack-validation-rules.ps1 against them. + +.PARAMETER PreflightOnly + Run only the Purview.SourceGeneratorFramework restore check, so CI can fail on it before installing toolchains. + +.PARAMETER KeepArtifacts + Leave the temporary workspace in place for inspection. + +.EXAMPLE + just smoke-packed-consumers + +.EXAMPLE + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -KeepArtifacts +#> +[CmdletBinding()] +param ( + [string] $Configuration = 'Release', + [string] $RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, + [string] $SourceGeneratorFrameworkFeed = ($env:ZODSHARP_SGF_FEED ? $env:ZODSHARP_SGF_FEED : $env:LOCAL_NUGET_FEED_PATH), + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $WasmAot = 'Auto', + [string] $PackOutput, + [switch] $PreflightOnly, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$leakScan = Join-Path $PSScriptRoot 'assert-no-compiler-host-assemblies.ps1' +$consumerTemplates = Join-Path $PSScriptRoot 'packed-consumers' + +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$packageVersions = [xml] (Get-Content (Join-Path $repositoryRoot 'Directory.Packages.props') -Raw) +$sgfVersion = $packageVersions.SelectSingleNode('//PurviewSourceGenFramework')?.InnerText +$aspNetCoreVersion = $packageVersions.SelectSingleNode( + "//PackageVersion[@Include='Microsoft.AspNetCore.Components.WebAssembly']" +)?.GetAttribute('Version') +$sdk = (Get-Content (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json).sdk + +if (-not $sgfVersion) { throw 'Could not read PurviewSourceGenFramework from Directory.Packages.props.' } +if (-not $aspNetCoreVersion) { throw 'Could not read the Microsoft.AspNetCore.Components.WebAssembly version from Directory.Packages.props.' } + +# The shipping packages are exactly the ids pack validation knows about, so a new package is covered without editing +# this script. Each id maps to src/src/ (Purview.ZodSharp itself to src/src/ZodSharp). +$packValidation = (Get-Content (Join-Path $repositoryRoot '.config/purview-build.json') -Raw | ConvertFrom-Json).PackValidation +$sourceProjects = Get-ChildItem (Join-Path $repositoryRoot 'src/src') -Directory +$shipping = foreach ($id in $packValidation.RequiredContent.PSObject.Properties.Name) { + $folder = $id -ieq 'purview.zodsharp' ? 'ZodSharp' : ($id -ireplace '^purview\.zodsharp\.', '') + $directory = $sourceProjects | Where-Object { $_.Name -ieq $folder } + $project = $directory ? (Join-Path $directory.FullName "$($directory.Name).csproj") : $null + if (-not $project -or -not (Test-Path $project)) { + throw "No project for the shipping package '$id' (expected src/src/$folder/$folder.csproj)." + } + + [pscustomobject]@{ Id = $id; Project = $project } +} + +# Packed through Purview.ZodSharp (the IL-merged generator and the code fixes), not on their own. CodeFixes is reached +# through an MSBuild task rather than a ProjectReference, so it must be restored explicitly (NETSDK1004 otherwise). +$packInputs = @( + $shipping.Project + (Join-Path $repositoryRoot 'src/src/SourceGenerators/SourceGenerators.csproj') + (Join-Path $repositoryRoot 'src/src/CodeFixes/CodeFixes.csproj') +) + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "zs-packed-consumers-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$feed = Join-Path $workspace 'feed' +$packages = Join-Path $workspace 'packages' +$logs = Join-Path $workspace 'logs' +$consumers = Join-Path $workspace 'consumers' +$output = Join-Path $workspace 'out' +$exe = $IsWindows ? '.exe' : '' + +$timings = [System.Collections.Generic.List[object]]::new() +$scanned = [System.Collections.Generic.List[string]]::new() +$previousNuGetPackages = $env:NUGET_PACKAGES +$repositoryRestoreTouched = $false + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +function Invoke-Step([string] $name, [scriptblock] $body) { + if ($env:GITHUB_ACTIONS) { Write-Host "::group::$name" } else { Write-Host "`n=== $name" -ForegroundColor Cyan } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $status = 'passed' + try { + & $body + } + catch { + $status = 'FAILED' + throw + } + finally { + $stopwatch.Stop() + $timings.Add([pscustomobject]@{ Step = $name; Result = $status; Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 1) }) + if ($env:GITHUB_ACTIONS) { Write-Host '::endgroup::' } + } +} + +function Invoke-Skipped([string] $name, [string] $reason) { + Write-Host "`n=== $name - SKIPPED: $reason" -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host "::warning::$name skipped: $reason" } + $timings.Add([pscustomobject]@{ Step = $name; Result = 'skipped'; Seconds = 0 }) +} + +# Runs dotnet, tees its output to a log, and fails on a non-zero exit. -warnaserror already fails the publish on any +# trim or AOT warning; the log check is a second gate in case one is ever logged without failing the command. +function Invoke-Dotnet([string] $logName, [string[]] $arguments) { + $log = Join-Path $logs "$logName.log" + Write-Host "dotnet $($arguments -join ' ')" -ForegroundColor DarkGray + & dotnet @arguments 2>&1 | Tee-Object -FilePath $log | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet $($arguments[0]) failed with exit code $LASTEXITCODE (log: $log)." } + + $warnings = Select-String -Path $log -Pattern 'warning IL[23][0-9]{3}' | Select-Object -First 5 + if ($warnings) { + throw "dotnet $($arguments[0]) reported trim or AOT warnings:`n$($warnings.Line -join "`n")" + } +} + +function Assert-NoLeaks([string[]] $directories) { + $global:LASTEXITCODE = 0 + & $leakScan -Path $directories + if ($LASTEXITCODE -ne 0) { throw "The compiler-host leak scan failed for: $($directories -join ', ')." } + $scanned.AddRange($directories) +} + +function Assert-Contains([string] $directory, [string] $pattern, [string] $what) { + $found = @(Get-ChildItem $directory -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $pattern }) + if ($found.Count -eq 0) { throw "$what is missing from '$directory' (no file matches '$pattern')." } + Write-Host " found $what ($($found[0].Name))" +} + +function New-NuGetConfig([string] $path, [hashtable[]] $sources) { + $sourceXml = ($sources | ForEach-Object { "`t`t" }) -join "`n" + $mappingXml = ($sources | ForEach-Object { + $patterns = ($_.Patterns | ForEach-Object { "`t`t`t" }) -join "`n" + "`t`t`n$patterns`n`t`t" + }) -join "`n" + + @" + + + + + +$sourceXml + + + + + +$mappingXml + + +"@ | Set-Content $path -Encoding utf8 +} + +function Start-Api([string] $directory, [int] $port, [string] $executable = "PackedApi$exe", [string] $logName = 'minimal-api') { + $env:ASPNETCORE_URLS = "http://127.0.0.1:$port" + try { + return Start-Process -FilePath (Join-Path $directory $executable) -WorkingDirectory $directory -PassThru ` + -RedirectStandardOutput (Join-Path $logs "$logName.stdout.log") ` + -RedirectStandardError (Join-Path $logs "$logName.stderr.log") + } + finally { + Remove-Item Env:\ASPNETCORE_URLS -ErrorAction SilentlyContinue + } +} + +# Polls until the OpenAPI document answers, returning the first response - a 500 included, so the caller can report +# why the document failed rather than only that it timed out. +function Wait-OpenApi($process, [string] $base) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline) { + if ($process.HasExited) { throw "The API exited with $($process.ExitCode) before it was ready." } + try { return Invoke-WebRequest "$base/openapi/v1.json" -TimeoutSec 5 -SkipHttpErrorCheck } + catch { Start-Sleep -Milliseconds 500 } + } + throw "The API did not serve $base/openapi/v1.json within 30 seconds." +} + +# PowerShell returns a body it does not recognise as text - application/problem+json among them - as bytes. +function Get-Body($response) { + return $response.Content -is [byte[]] ? [System.Text.Encoding]::UTF8.GetString($response.Content) : [string] $response.Content +} + +function Get-FreePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port } finally { $listener.Stop() } +} + +$exitCode = 0 +$totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +try { + New-Item -ItemType Directory -Path $feed, $packages, $logs, $consumers, $output -Force | Out-Null + + Write-Host "Packed-consumer gate: Purview.ZodSharp $version ($Configuration), RID $RuntimeIdentifier" -ForegroundColor Cyan + Write-Host "Workspace: $workspace" + + # Guards at the workspace root: MSBuild stops at the first Directory.Build.props/.targets and + # Directory.Packages.props it finds walking up, so nothing above the workspace - a stray file in the temp folder, + # a user profile - can change how the consumers build. The consumers must see the packages and nothing else. + foreach ($guard in 'Directory.Build.props', 'Directory.Build.targets') { + @" + + + +"@ | Set-Content (Join-Path $workspace $guard) -Encoding utf8 + } + + @' + + + + false + + +'@ | Set-Content (Join-Path $workspace 'Directory.Packages.props') -Encoding utf8 + + # The repository's SDK, without its MSBuild SDKs (Purview.BuildSdk): the consumers are plain projects. + [ordered]@{ sdk = [ordered]@{ version = $sdk.version; rollForward = $sdk.rollForward; allowPrerelease = $true } } | + ConvertTo-Json | Set-Content (Join-Path $workspace 'global.json') -Encoding utf8 + + # Pack-time sources: nuget.org for everything, plus the optional feed for Purview.SourceGeneratorFramework only. + $sgfPatterns = @('Purview.SourceGeneratorFramework', 'Purview.SourceGeneratorFramework.*') + $packSources = @(@{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') + $sgfPatterns }) + if ($SourceGeneratorFrameworkFeed) { + $packSources += @{ Key = 'sourcegeneratorframework'; Value = $SourceGeneratorFrameworkFeed; Patterns = $sgfPatterns } + } + $packConfig = Join-Path $workspace 'pack.nuget.config' + New-NuGetConfig $packConfig $packSources + + # Consumer sources: Purview.ZodSharp* from the packages just packed and nowhere else - not nuget.org, not a local + # feed holding an older build of the same version. + $consumerConfig = Join-Path $consumers 'nuget.config' + New-NuGetConfig $consumerConfig @( + @{ Key = 'packed'; Value = $feed; Patterns = @('Purview.ZodSharp', 'Purview.ZodSharp.*') } + @{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') } + ) + + # An isolated extraction directory: NuGet reuses an already-extracted package of the same id and version, so a + # stale copy from an earlier run - or an earlier SourceGeneratorFramework build - would be used instead. + $env:NUGET_PACKAGES = $packages + + + Invoke-Step "Preflight: restore Purview.SourceGeneratorFramework $sgfVersion" { + $probe = Join-Path $workspace 'sgf-probe' + New-Item -ItemType Directory -Path $probe -Force | Out-Null + @" + + + net10.0 + + + + + +"@ | Set-Content (Join-Path $probe 'probe.csproj') -Encoding utf8 + + & dotnet restore (Join-Path $probe 'probe.csproj') "-p:RestoreConfigFile=$packConfig" 2>&1 | + Tee-Object -FilePath (Join-Path $logs 'sgf-probe.log') | Out-Host + if ($LASTEXITCODE -ne 0) { + $from = ($packSources | ForEach-Object Value) -join ', ' + throw ( + "Purview.SourceGeneratorFramework $sgfVersion (Directory.Packages.props) could not be restored from $from. " + + 'The generator is built against it, so nothing can be packed. If that version is not on nuget.org yet, ' + + 'publish it there, or point this gate at a feed that has it: -SourceGeneratorFrameworkFeed , ' + + 'or set ZODSHARP_SGF_FEED (LOCAL_NUGET_FEED_PATH is used when set).' + ) + } + } + + if ($PreflightOnly) { + Write-Host "Purview.SourceGeneratorFramework $sgfVersion restores; stopping after the preflight (-PreflightOnly)." -ForegroundColor Green + return + } + + if ($IsLinux -and -not (Get-Command clang -ErrorAction SilentlyContinue)) { + throw 'Native AOT on Linux needs clang and the zlib headers: sudo apt-get install -y clang zlib1g-dev.' + } + + Invoke-Step 'Pack every shipping package to the isolated feed' { + $script:repositoryRestoreTouched = $true + foreach ($project in $packInputs) { + Invoke-Dotnet "restore-$([System.IO.Path]::GetFileNameWithoutExtension($project))" @( + 'restore', $project, "-p:RestoreConfigFile=$packConfig", '-nr:false' + ) + } + + foreach ($package in $shipping) { + Invoke-Dotnet "pack-$($package.Id)" @( + 'pack', $package.Project, '--configuration', $Configuration, '--output', $feed, '--no-restore', + '-m:4', '-nr:false' + ) + } + + $expected = @($shipping.Id | ForEach-Object { "$_.$version.nupkg".ToLowerInvariant() } | Sort-Object) + $actual = @(Get-ChildItem $feed -Filter '*.nupkg' | ForEach-Object { $_.Name.ToLowerInvariant() } | Sort-Object) + $difference = Compare-Object $expected $actual + if ($difference) { + throw "The feed does not hold exactly the shipping packages:`n$($difference | Out-String)" + } + + # The bytes the consumers restore: a compiler-host assembly anywhere but analyzers/, or a generator or code-fix + # assembly under lib/, would reach every application that references the package. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $hostOnly = '(^|/)(Purview\.SourceGeneratorFramework|Microsoft\.CodeAnalysis)[^/]*$' + $generatorOnly = '^lib/.*(Purview\.ZodSharp\.SourceGenerators|Purview\.ZodSharp\.CodeFixes)[^/]*$' + foreach ($nupkg in Get-ChildItem $feed -Filter '*.nupkg') { + $archive = [System.IO.Compression.ZipFile]::OpenRead($nupkg.FullName) + try { + $bad = @($archive.Entries.FullName | Where-Object { + ($_ -match $hostOnly -and $_ -notmatch '^analyzers/') -or $_ -match $generatorOnly + }) + } + finally { $archive.Dispose() } + + if ($bad.Count -gt 0) { throw "$($nupkg.Name) ships compiler-host content outside analyzers/: $($bad -join ', ')." } + Write-Host " $($nupkg.Name): no compiler-host content outside analyzers/" + } + + if ($PackOutput) { + New-Item -ItemType Directory -Path $PackOutput -Force | Out-Null + Copy-Item (Join-Path $feed '*') $PackOutput -Force + Write-Host " copied the packages to $PackOutput" + } + } + + Copy-Item (Join-Path $consumerTemplates '*') $consumers -Recurse -Force + $consumerProperties = @( + "-p:ZodSharpPackageVersion=$version" + "-p:AspNetCorePackageVersion=$aspNetCoreVersion" + "-p:RestoreConfigFile=$consumerConfig" + '-nr:false' + ) + + Invoke-Step '(a) Native AOT console: publish and run' { + $project = Join-Path $consumers 'AotConsole/AotConsole.csproj' + $publish = Join-Path $output 'aot-console' + + # A framework-dependent build first: its bin holds the deps.json and runtimeconfig.json that list every + # runtime dependency, which is where an analyzer-only assembly that leaks shows up first. + Invoke-Dotnet 'aot-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + Assert-Contains (Join-Path $consumers 'AotConsole/bin/Release/net11.0') '^PackedAot\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'aot-console-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + & (Join-Path $publish "PackedAot$exe") | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Native AOT console exited with ${LASTEXITCODE}: a packed behaviour is wrong." } + + Assert-NoLeaks @((Join-Path $consumers 'AotConsole/bin'), $publish) + } + + $wasmProject = Join-Path $consumers 'BlazorWasm/BlazorWasm.csproj' + function Assert-WasmOutput([string] $publish) { + $framework = Join-Path $publish 'wwwroot/_framework' + Assert-Contains $framework '^Purview\.ZodSharp\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp' + Assert-Contains $framework '^Purview\.ZodSharp\.Blazor\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp.Blazor' + Assert-Contains $framework '^Purview\.ZodSharp\.Extensions\.Validation\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp.Extensions.Validation' + Assert-Contains $framework '^PackedWasm\.[a-z0-9]+\.wasm$' 'the app assembly' + $content = Join-Path $publish 'wwwroot/_content/Purview.ZodSharp.Blazor' + Assert-Contains $content '^zodsharp-validation\.js$' 'the Blazor static web asset' + Assert-Contains $content '^Purview\.ZodSharp\.Blazor\.[a-z0-9]+\.lib\.module\.js$' 'the Blazor JavaScript initializer' + } + + Invoke-Step '(b) Blazor WebAssembly: trimmed publish' { + $publish = Join-Path $output 'wasm-trimmed' + Invoke-Dotnet 'wasm-trimmed-publish' (@( + 'publish', $wasmProject, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-NoLeaks @((Join-Path $consumers 'BlazorWasm/bin'), $publish) + } + + $wasmTools = $false + if ($WasmAot -ne 'Skip') { + Push-Location $workspace + try { $wasmTools = [bool] (& dotnet workload list 2>$null | Select-String -Pattern '^\s*wasm-tools\s') } + finally { Pop-Location } + } + + if ($WasmAot -eq 'Skip') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' 'disabled with -WasmAot Skip.' + } + elseif (-not $wasmTools -and $WasmAot -eq 'Auto') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' ( + 'the wasm-tools workload is not installed, so the WebAssembly AOT compile was NOT checked. ' + + 'Install it with `dotnet workload install wasm-tools` and rerun, or pass -WasmAot Require to make this an error.' + ) + } + else { + Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish' { + if (-not $wasmTools) { + throw 'The wasm-tools workload is not installed (-WasmAot Require): run `dotnet workload install wasm-tools`.' + } + + # A copy of the project, so the AOT build starts from clean obj/bin rather than the trimmed build's. + $aotProjectDirectory = Join-Path $consumers 'BlazorWasmAot' + Copy-Item (Join-Path $consumerTemplates 'BlazorWasm') $aotProjectDirectory -Recurse -Force + $publish = Join-Path $output 'wasm-aot' + Invoke-Dotnet 'wasm-aot-publish' (@( + 'publish', (Join-Path $aotProjectDirectory 'BlazorWasm.csproj'), '--configuration', 'Release', + '--output', $publish, '-p:RunAOTCompilation=true', '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-Contains (Join-Path $publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' + Assert-NoLeaks @((Join-Path $aotProjectDirectory 'bin'), $publish) + } + } + + Invoke-Step '(c) Native AOT minimal API: publish, start and call' { + $project = Join-Path $consumers 'MinimalApi/MinimalApi.csproj' + $publish = Join-Path $output 'minimal-api' + + # As for (a): the framework-dependent build's deps.json is scanned too. + Invoke-Dotnet 'minimal-api-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + Assert-Contains (Join-Path $consumers 'MinimalApi/bin/Release/net11.0') '^PackedApi\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'minimal-api-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + $port = Get-FreePort + $base = "http://127.0.0.1:$port" + $process = Start-Api $publish $port + try { + $openApi = $null + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not $openApi -and [DateTime]::UtcNow -lt $deadline) { + if ($process.HasExited) { throw "The minimal API exited with $($process.ExitCode) before it was ready." } + try { $openApi = Invoke-WebRequest "$base/openapi/v1.json" -TimeoutSec 5 } + catch { Start-Sleep -Milliseconds 500 } + } + if (-not $openApi) { throw "The minimal API did not serve $base/openapi/v1.json within 30 seconds." } + + $invalid = Invoke-WebRequest "$base/orders" -Method Post -ContentType 'application/json' -SkipHttpErrorCheck ` + -Body '{"customerEmail":"not-an-email","lines":[{"sku":"abc","quantity":0}]}' + $contentType = [string] ($invalid.Headers['Content-Type'] -join ',') + Write-Host " invalid order: $($invalid.StatusCode) $contentType" + $invalidBody = Get-Body $invalid + Write-Host " $invalidBody" + if ($invalid.StatusCode -ne 400 -or -not $contentType.StartsWith('application/problem+json')) { + throw "An invalid order returned $($invalid.StatusCode) $contentType, not 400 application/problem+json." + } + + $problem = $invalidBody | ConvertFrom-Json + if (-not ($problem.PSObject.Properties['issues'] -and @($problem.issues).Count -gt 0)) { + throw 'The ProblemDetails body carries no ZodSharp issues.' + } + $paths = @($problem.issues | ForEach-Object { $_.path }) + foreach ($expectedPath in 'CustomerEmail', 'Lines[0].Sku', 'Lines[0].Quantity') { + if ($paths -notcontains $expectedPath) { + throw "The ProblemDetails issues do not include '$expectedPath' (got: $($paths -join ', '))." + } + } + + $valid = Invoke-WebRequest "$base/orders" -Method Post -ContentType 'application/json' -SkipHttpErrorCheck ` + -Body '{"customerEmail":"buyer@example.com","lines":[{"sku":"ABC-1234","quantity":2}]}' + if ($valid.StatusCode -ne 200) { throw "A valid order returned $($valid.StatusCode), not 200: $(Get-Body $valid)" } + Write-Host " valid order: $($valid.StatusCode) $(Get-Body $valid)" + + if (-not (Get-Body $openApi).Contains('ZodValidationProblemDetails')) { + throw 'The OpenAPI document does not describe ZodValidationProblemDetails.' + } + if (-not (Get-Body $openApi).Contains('"minItems"')) { + throw 'The OpenAPI document does not carry the [MinCount] constraint (minItems).' + } + Write-Host ' OpenAPI document describes ZodValidationProblemDetails and the [MinCount] constraint' + } + finally { + if (-not $process.HasExited) { $process.Kill($true) } + $process.WaitForExit() + } + + Assert-NoLeaks @((Join-Path $consumers 'MinimalApi/bin'), $publish) + } + + Invoke-Step '(d) Multi-project API with OpenAPI XML comments: publish, start and call' { + $project = Join-Path $consumers 'MultiProject/Api/Api.csproj' + $publish = Join-Path $output 'multi-project-api' + + Invoke-Dotnet 'multi-project-api-publish' (@( + 'publish', $project, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + # The library's XML documentation file must have reached the OpenAPI XML-comment generator, or the document + # could not have collided in the first place - and this step would prove nothing. + $commentCache = Get-ChildItem (Join-Path $consumers 'MultiProject/Api/obj') -Recurse -File ` + -Filter 'OpenApiXmlCommentSupport.generated.cs' | Select-Object -First 1 + if (-not $commentCache) { throw 'The OpenAPI XML-comment generator produced no comment cache for the API.' } + $cacheSource = Get-Content $commentCache.FullName -Raw + if (-not $cacheSource.Contains('"T:PackedContracts.RegisterCustomer"')) { + throw "The OpenAPI comment cache holds no entry for the library's types: its XML file was not read." + } + $keys = [regex]::Matches($cacheSource, 'cache\.Add\(@"([^"]+)"') | ForEach-Object { $_.Groups[1].Value } + $duplicates = @($keys | Group-Object | Where-Object Count -gt 1 | ForEach-Object Name) + if ($duplicates.Count -gt 0) { + throw "The OpenAPI comment cache adds the same documentation ID twice: $($duplicates -join ', ')." + } + # Per-assembly generated types (ZodSharp.Generated.{Assembly}ZodSharpSchemas) are named for their assembly; a + # marker attribute is not, so a consumer that documents one repeats it in every project of the solution. + $markerKeys = @($keys | Where-Object { $_ -match '^[A-Z]:(ZodSharp\.ZodSchemaAttribute|ZodSharp\.Core\.ErrorTypeAttribute|Microsoft\.CodeAnalysis\.EmbeddedAttribute)\b' }) + if ($markerKeys.Count -gt 0) { + throw "A consumer project documents a ZodSharp marker attribute, so every project repeats it: $($markerKeys -join ', ')." + } + Write-Host " OpenAPI comment cache: $($keys.Count) entries, none duplicated, no ZodSharp marker attribute" + + $port = Get-FreePort + $base = "http://127.0.0.1:$port" + $process = Start-Api $publish $port "PackedMultiApi$exe" 'multi-project-api' + try { + $openApi = Wait-OpenApi $process $base + $document = Get-Body $openApi + if ($openApi.StatusCode -ne 200) { + throw "/openapi/v1.json returned $($openApi.StatusCode), not 200: $($document.Substring(0, [Math]::Min(400, $document.Length)))" + } + + $schemas = ($document | ConvertFrom-Json -AsHashtable).components.schemas + foreach ($name in 'RegisterCustomer', 'DeliveryAddress', 'CustomerRegistered', 'ZodValidationProblemDetails') { + if (-not $schemas.ContainsKey($name)) { + throw "The OpenAPI document has no '$name' schema (got: $(($schemas.Keys | Sort-Object) -join ', '))." + } + } + + # ZodSharp's constraints from the library's [ZodSchema] model, and the XML comments from both projects. + $email = $schemas['RegisterCustomer'].properties['email'] + $displayName = $schemas['RegisterCustomer'].properties['displayName'] + $postalCode = $schemas['DeliveryAddress'].properties['postalCode'] + if ($email.format -ne 'email') { throw "RegisterCustomer.email is not described as an email ($($email | ConvertTo-Json -Compress))." } + if ($displayName.minLength -ne 2 -or $displayName.maxLength -ne 40) { + throw "RegisterCustomer.displayName does not carry [StringLength(40, MinimumLength = 2)] ($($displayName | ConvertTo-Json -Compress))." + } + if ($postalCode.pattern -ne '^[0-9]{5}$') { throw "DeliveryAddress.postalCode does not carry its pattern ($($postalCode | ConvertTo-Json -Compress))." } + foreach ($description in @( + @{ Subject = 'RegisterCustomer'; Text = 'A request to register a customer.'; Value = $schemas['RegisterCustomer'].description } + @{ Subject = 'RegisterCustomer.email'; Text = "The customer's email address, used to sign in."; Value = $email.description } + @{ Subject = 'CustomerRegistered'; Text = 'The acknowledgement returned once a customer is registered.'; Value = $schemas['CustomerRegistered'].description } + )) { + if ($description.Value -ne $description.Text) { + throw "$($description.Subject) has description '$($description.Value)', not the XML comment '$($description.Text)'." + } + } + Write-Host ' OpenAPI document: both projects'' schemas, the ZodSharp constraints and the XML-comment descriptions' + + $invalid = Invoke-WebRequest "$base/customers" -Method Post -ContentType 'application/json' -SkipHttpErrorCheck ` + -Body '{"email":"not-an-email","displayName":"x","address":{"postalCode":"abc"}}' + $invalidBody = Get-Body $invalid + Write-Host " invalid registration: $($invalid.StatusCode) $invalidBody" + if ($invalid.StatusCode -ne 400) { throw "An invalid registration returned $($invalid.StatusCode), not 400." } + $paths = @(($invalidBody | ConvertFrom-Json).issues | ForEach-Object { $_.path }) + foreach ($expectedPath in 'Email', 'DisplayName', 'Address.PostalCode') { + if ($paths -notcontains $expectedPath) { + throw "The ProblemDetails issues do not include '$expectedPath' (got: $($paths -join ', '))." + } + } + + $taken = Invoke-WebRequest "$base/customers" -Method Post -ContentType 'application/json' -SkipHttpErrorCheck ` + -Body '{"email":"taken@example.com","displayName":"Taken","address":{"postalCode":"12345"}}' + $takenBody = Get-Body $taken + Write-Host " taken email: $($taken.StatusCode) $takenBody" + if ($taken.StatusCode -ne 409 -or -not $takenBody.Contains('customer_exists')) { + throw "A taken email returned $($taken.StatusCode), not 409 with the library's customer_exists ErrorType." + } + + $valid = Invoke-WebRequest "$base/customers" -Method Post -ContentType 'application/json' -SkipHttpErrorCheck ` + -Body '{"email":"new@example.com","displayName":"New","address":{"postalCode":"12345"}}' + if ($valid.StatusCode -ne 200) { throw "A valid registration returned $($valid.StatusCode), not 200: $(Get-Body $valid)" } + Write-Host " valid registration: $($valid.StatusCode) $(Get-Body $valid)" + } + finally { + if (-not $process.HasExited) { $process.Kill($true) } + $process.WaitForExit() + } + + Assert-NoLeaks @((Join-Path $consumers 'MultiProject/Api/bin'), (Join-Path $consumers 'MultiProject/Contracts/bin'), $publish) + } +} +catch { + $exitCode = 1 + Write-Failure "Packed-consumer gate failed: $($_.Exception.Message)" + if (Test-Path $logs) { Write-Host "Logs: $logs" } +} +finally { + if ($null -eq $previousNuGetPackages) { Remove-Item Env:\NUGET_PACKAGES -ErrorAction SilentlyContinue } + else { $env:NUGET_PACKAGES = $previousNuGetPackages } + + # Packing restored the shipping projects against the isolated cache that is about to be deleted; restore them + # against the normal cache, so a later --no-restore build does not point at a missing folder. + if ($repositoryRestoreTouched) { + Write-Host "`nRestoring the shipping projects against the normal package cache..." -ForegroundColor Cyan + foreach ($project in $packInputs) { + & dotnet restore $project -nr:false | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Failure "Restoring $project against the normal cache failed; run dotnet restore." } + } + } + + $totalStopwatch.Stop() + Write-Host "`nPacked-consumer gate timings:" -ForegroundColor Cyan + $timings | Format-Table -AutoSize | Out-Host + Write-Host ("Total: {0:N1}s" -f $totalStopwatch.Elapsed.TotalSeconds) + if ($scanned.Count -gt 0) { Write-Host "Leak-scanned $($scanned.Count) folder(s)." } + + if ($KeepArtifacts) { + Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow + } + else { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($exitCode -ne 0) { exit $exitCode } + +Write-Host 'Packed-consumer gate passed.' -ForegroundColor Green diff --git a/scripts/test-packed-generator.ps1 b/scripts/test-packed-generator.ps1 index 5def0018..9cb1ce2f 100644 --- a/scripts/test-packed-generator.ps1 +++ b/scripts/test-packed-generator.ps1 @@ -81,6 +81,7 @@ if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed with exit code $LASTEXITCOD enable true + TreatAsEmpty @@ -95,6 +96,7 @@ if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed with exit code $LASTEXITCOD # it emit rules rather than an empty schema. @' using System.ComponentModel.DataAnnotations; +using System.Collections.Immutable; using ZodSharp; namespace PackedSmoke; @@ -111,6 +113,13 @@ public sealed class Person [EmailAddress] public string Email { get; set; } = string.Empty; + + [MinLength(1)] + public ImmutableArray? Aliases { get; set; } = default(ImmutableArray); + + [Required] + [CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.ValidationError)] + public ImmutableArray Tags { get; set; } = ImmutableArray.Empty; } public static class Program @@ -118,8 +127,17 @@ public static class Program public static int Main() { // The generated validator. If the merged generator did not run, this does not compile. - var valid = PersonSchema.Validate(new Person { Name = "Ada", Age = 36, Email = "ada@example.com" }); + var valid = PersonSchema.Validate(new Person { Name = "Ada", Age = 36, Email = "ada@example.com", Aliases = ImmutableArray.Create("Ada") }); var invalid = PersonSchema.Validate(new Person { Name = "A", Age = 500, Email = "nope" }); + var missing = PersonSchema.Validate(new Person { Name = "Ada", Age = 36, Email = "ada@example.com", Tags = default }); + + if (missing.Errors.Length != 2 + || !System.Linq.Enumerable.Any(missing.Errors, error => error.Path[0] == "Aliases" && error.Code == "too_small") + || !System.Linq.Enumerable.Any(missing.Errors, error => error.Path[0] == "Tags" && error.Code == "missing_field")) + { + System.Console.Error.WriteLine("FAIL: immutable-array build/property defaults were not honored."); + return 1; + } if (!valid.IsSuccess) { diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..49155c15 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,125 @@ +{ + "version": 1, + "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + }, + "purview-architecture-testing": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture-testing/SKILL.md", + "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + }, + "purview-buildsdk-adoption": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", + "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + }, + "purview-buildsdk-configuration": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + }, + "purview-buildsdk-repository-review": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", + "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + }, + "purview-buildsdk-skill-installation": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", + "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + }, + "purview-buildsdk-skill-tests": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", + "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + }, + "purview-buildsdk-testing": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-testing/SKILL.md", + "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + }, + "purview-engineering-principles": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-engineering-principles/SKILL.md", + "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + }, + "purview-persistence": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-persistence/SKILL.md", + "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + }, + "purview-sourcegenerator-codewriter": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", + "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + }, + "purview-sourcegenerator-diagnostics": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", + "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + }, + "purview-sourcegenerator-packaging": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", + "computedHash": "ee2440f1e0aa0cea731eb3eae2ea3c4e9ea77502867abc21e9f0a3c61f20c640" + }, + "purview-sourcegenerator-pipeline": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", + "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + }, + "purview-sourcegenerator-testing": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", + "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + }, + "purview-sourcegenerator-tunit-tests": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", + "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + }, + "purview-telemetry": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-telemetry/SKILL.md", + "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + }, + "purview-testing-conventions": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-testing-conventions/SKILL.md", + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + } + } +} diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index a11c2587..a9de1304 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -2,9 +2,12 @@ name: purview-zodsharp-aspnetcore description: >- Use when wiring Purview.ZodSharp into ASP.NET Core or any DI host - ProblemDetails from a - ValidationResult or a thrown ZodException, minimal-API endpoint validation, registering - IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects with - IValidateOptions, and mapping error codes to HTTP statuses with ErrorType. + ValidationResult or a thrown ZodException, minimal-API endpoint and route-group validation with + WithZodSharpValidation or the .NET 11 built-in validation pipeline (AddZodSharpValidation), ZodTypedResults, + the shared ZodProblemDetailsWriter, registering + IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects (including .NET 11 + async startup validation), describing validated endpoints in OpenAPI with Purview.ZodSharp.AspNetCore.OpenApi, + and mapping error codes to HTTP statuses with ErrorType. license: MIT metadata: product: Purview.ZodSharp.AspNetCore @@ -13,230 +16,292 @@ metadata: # ASP.NET Core and dependency injection -`Purview.ZodSharp.AspNetCore` turns failed validation into standard `ProblemDetails` / -`HttpValidationProblemDetails` payloads **without losing the structured issues**, and registers -schema resolution into DI. +`Purview.ZodSharp.AspNetCore` turns failed validation into standard `HttpValidationProblemDetails` +payloads **without losing the structured issues**, validates minimal-API parameters, and registers schema +resolution into DI. ```bash dotnet add package Purview.ZodSharp.AspNetCore ``` -## ProblemDetails from a result +Namespaces: the `To…ProblemDetails`/`ToValidationProblem` extensions are in **`ZodSharp`**; the DI +extensions in `Microsoft.Extensions.DependencyInjection`; `WithZodSharpValidation` in +`Microsoft.AspNetCore.Builder`; the types (`ZodProblemDetailsWriter`, `ZodProblemDetailsOptions`, +`ValidationIssue`, `ZodTypedResults`, `ErrorTypeRegistry`) in `ZodSharp.AspNetCore`. -```csharp -using ZodSharp.AspNetCore; - -var result = BasketSchema.Validate(basket); +## Setup -if (!result.IsSuccess) +```csharp +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpProblemDetails(o => { - var problem = result.ToHttpValidationProblemDetails(); + o.PathNaming = ZodPathNaming.MemberName; // default; Json applies the JsonOptions naming policy +}); - return Results.ValidationProblem( - problem.Errors, - extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] }); -} +var app = builder.Build(); +app.UseExceptionHandler(); // lets ZodExceptionHandler map a thrown ZodException + +var orders = app.MapGroup("/orders").WithZodSharpValidation(); // every endpoint in the group +orders.MapPost("/", (Order order) => TypedResults.Created($"/orders/{order.Id}", order)); ``` -| Member | Produces | -| --- | --- | -| `ToHttpValidationProblemDetails(result, statusCode = 400)` | `HttpValidationProblemDetails`; paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | -| `ToValidationProblemDetails(result, statusCode = 400)` | `ValidationProblemDetails` | +`AddZodSharpProblemDetails(configure?, problemDetails?)` calls `AddProblemDetails`, registers +`ZodExceptionHandler` and the shared `ZodProblemDetailsWriter`, and adds source-generated JSON metadata to +the minimal-API `JsonOptions` (Native AOT safe). It does **not** register `IZodSchemaFactory` - pair it +with `AddZodSharp`/`AddZodSharpFactory`. -Both **throw `InvalidOperationException` when the result is successful** — they are failure -converters, not general mappers. The structured metadata survives in the `issues` extension as a -`ValidationIssue[]`: +## One contract on every path -```csharp -public sealed class ValidationIssue +The endpoint filter, `ZodExceptionHandler`, `ZodTypedResults` and every extension method delegate to +`ZodProblemDetailsWriter`, so they produce the **same body**: + +```json { - public required string Code { get; init; } - public string? Category { get; init; } - public string? Origin { get; init; } - public int? Minimum { get; init; } - public int? Maximum { get; init; } - public bool? Inclusive { get; init; } - public required string[] Path { get; init; } - public required string Message { get; init; } - public IReadOnlyDictionary? Parameters { get; init; } + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { "Email": ["Email must be a valid email address"], "Items[0].Quantity": ["Quantity must be at least 1"] }, + "issues": [ + { "path": "Items[0].Quantity", "segments": ["Items", 0, "Quantity"], "code": "too_small", + "category": "too_small", "origin": "number", "minimum": 1, "inclusive": true, + "message": "Quantity must be at least 1" } + ], + "traceId": "00-…" } ``` +- **Keys** are `ValidationError.FullPath.ToDottedString()`: `Items[0].Name`, `Map[en]`, `Map["a.b"]`; + the root is `""`. C# member names by default, matching Microsoft.Extensions.Validation and MVC. +- **`issues[]`** is a `ValidationIssue` (a `readonly record struct`): `path` (dotted), `segments` + (strings and integer indexes, Zod's shape), `code`, `category`, `origin`, typed `minimum`/`maximum` + (numbers, or ISO-8601 for dates), `inclusive`, `message`, `parameters` (camel-cased, safe scalars only) + and `unionErrors` (nested issues per union option, paths relative to the union). In-process code can + still read the CLR `Path` (string array), `Minimum`, `Maximum` and raw `Parameters` (`ErrorTypeParameters`). +- **`traceId`** is `Activity.Current?.Id`, else `HttpContext.TraceIdentifier`. +- Bodies go through `IProblemDetailsService`, so `ProblemDetailsOptions.CustomizeProblemDetails` runs. + ## Minimal APIs ```csharp -// Validate the bound DTO automatically - needs AddZodSharp to have registered the validator. -app.MapPost("/users", (UserDto dto) => dto) - .WithZodSharpValidation(); +app.MapPost("/users", (UserDto dto, [FromServices] IClock clock) => …) + .WithZodSharpValidation(o => o.ThrowIfNoValidator = true); -// Or validate in the handler and return the IResult directly. -app.MapPost("/users", (UserDto dto) => -{ - var result = UserDtoSchema.Validate(dto); - return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); -}); +app.MapPost("/legacy", (UserDto dto) => …).WithZodSharpValidation(); // only T ``` -`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type -`T` against the `IZodSchemaFactory` registered by `AddZodSharp`/`AddZodSharpFactory`. On failure it -returns the same payload as `ToHttpValidationProblemDetails()`; on success it passes through. +`WithZodSharpValidation()` works on any `IEndpointConventionBuilder` (endpoint or `RouteGroupBuilder`). An +endpoint filter **factory** picks the parameters once, when the endpoint is built: + +- Skipped: services (`[FromServices]`, keyed services, anything the container resolves), `HttpContext`, + `HttpRequest`, `HttpResponse`, `ClaimsPrincipal`, `CancellationToken`, form files, streams and + `[SkipValidation]`. `[FromBody]`/`[FromRoute]`/`[FromQuery]`/`[FromHeader]`/`[FromForm]` force a parameter in. +- Validators resolve through `IZodSchemaFactory.TryGetUntypedValidator(Type)`; **every** selected argument + is validated with `ValidateAsync(value, RequestServices, RequestAborted)`. +- Error keys follow Microsoft.Extensions.Validation: complex parameter unprefixed (`Name`), sequence + parameter prefixed (`items[0].Name`), an error about the value itself keyed by the parameter name. +- `ThrowIfNoValidator` (or the `` form) **fails when the endpoint is built**, naming the handler and + parameter, instead of a 500 per request. +- **.NET 11** runs filters after a route/query/header/form binding failure (status already 400): + `OnBindingFailure = PassThrough` (default) keeps ASP.NET Core's response; `ConvertToProblemDetails` + writes the ZodSharp contract. A malformed JSON body never reaches filters. +- Adds 400 `application/problem+json` response metadata (`ProducesStatusCode`), so OpenAPI shows it. + +## .NET 11: the built-in validation pipeline (Mode A) -## Thrown `ZodException` +```csharp +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpValidation(); // Purview.ZodSharp.Extensions.Validation bridge +builder.Services.AddValidation(); // only for DataAnnotations parameters/types and unions +builder.Services.AddZodSharpProblemDetails(); -A `ZodException` from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under -strict deserialization maps the same way: +var orders = app.MapGroup("/orders"); // validated automatically - no per-endpoint call +orders.MapPost("/", (Order order, [Range(1, 100)] int page) => …); +orders.MapPost("/import", (Import i) => …).DisableValidation(); // the standard opt-out +``` + +On `net11.0`, `AddZodSharpProblemDetails()` also registers the bridge's `IZodValidationReportSink`, which +keeps errors in a per-request `ZodRequestValidationReport` (`HttpContext.Features`) and ignores writes outside a +request, and composes `ProblemDetailsOptions.CustomizeProblemDetails`: + +- **ZodSharp errors reported** - the pipeline's `HttpValidationProblemDetails` is rewritten with + `ZodProblemDetailsWriter.Apply`: ZodSharp keys replaced, DataAnnotations entries kept (with + `dataannotations` issues), status from `ErrorType`/selector/override mirrored to the response. The body + is byte-identical to the filter, exception and manual paths (apart from `traceId`). +- **DataAnnotations only** - left as written, plus the `issues` projection (`ProjectDataAnnotationsIssues`). +- **Ordering** - ZodSharp's callback runs first, then any callback configured before or after (including + the `problemDetails` argument), so the application has the last word. It is a post-configure step: a + later `PostConfigure` that *replaces* the callback drops ZodSharp's. +- `AddProblemDetails()` is registered for you; without it the callback never runs. +- Localize with `AddLocalization()` plus an `IZodMessageFormatter` such as the bridge's + `StringLocalizerZodMessageFormatter(factory, sharedResourceType: typeof(Messages))`. +- Union parameters are claimed by the runtime parameter resolver, so call `AddValidation()`; the active + case is reported under `Value` (`Value.Email`). +- `WithZodSharpValidation()` on a pipeline-validated endpoint skips the parameters the bridge covers (one + `Debug` log when the endpoint is built), so nothing validates twice. With `DisableValidation()` it + validates as usual. +- OpenAPI: the `Purview.ZodSharp.AspNetCore.OpenApi` operation transformer adds the error response (below). + +| | Filter (`WithZodSharpValidation`) | Pipeline (`AddZodSharpValidation`) | +| --- | --- | --- | +| Frameworks | net10.0-net11.0 | net11.0 | +| Scope | endpoints/groups you mark | every endpoint; opt out with `DisableValidation()` | +| DataAnnotations parameters | not validated | validated and merged into one body | +| Binding-failure handling | `OnBindingFailure` | ASP.NET Core's own | +| Endpoint metadata | adds the 400 response | via the OpenAPI transformer | + +The bridge itself (`Purview.ZodSharp.Extensions.Validation`, `net11.0`, WebAssembly-safe) also works outside +HTTP: `ZodValidationOptions` sets `Placement` (`First`/`AfterGenerated`), `AlsoRunDataAnnotations`, `PathNamer`, +`MessageFormatter` and trim-safe `Sources`; `ValidateContext.ZodReport` holds the full ZodSharp errors of a run; +`ValidationOptions.ValidateAsync(value, services)` validates a value in a worker or test. `[ValidatableType]` on a +`[ZodSchema]` type is redundant (`ZODSGEN047`). Blazor forms are covered by the `purview-zodsharp-blazor` skill. + +## OpenAPI (.NET 11) + +```bash +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi +``` ```csharp -catch (ZodException ex) +using Microsoft.AspNetCore.OpenApi; // AddZodSharp on OpenApiOptions lives here + +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddOpenApi(options => options.AddZodSharp(zod => { - return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); -} + zod.EmitVendorExtensions = true; // x-zod-* for rules with no JSON Schema keyword + zod.DiscriminatedUnionsAsOneOf = true; // oneOf + discriminator for [JsonPolymorphic] types + zod.DescribeValidationProblems = true; // 400/422 problem responses on validated operations +})); +app.MapOpenApi(); ``` -Or handle it globally: +`ZodOpenApiOptions` defaults all three to `true`. Schema, operation and document transformers make the 3.1 and +3.2 documents describe what ZodSharp enforces, from the type's own JSON Schema (`BuildJsonSchema()` or a factory +registered runtime schema): lengths, bounds (exclusive bounds in the 3.1+ form), `multipleOf`, integer +narrowing, patterns (several become `allOf`), formats (including `[EmailAddress]`), `enum`/`not`, +`contentEncoding`, `required` and `type: [x, "null"]`. ZodSharp wins when a DataAnnotation disagrees. C# unions +keep the platform's `anyOf` with each case constrained. Operations binding a ZodSharp-validated parameter list +`400` (and `422` when a registered `ErrorType` uses it) as `application/problem+json` with the shared +`ZodValidationProblemDetails`/`ZodValidationIssue` components, merged into a declared +`ProducesValidationProblem()`. Types ZodSharp does not know are left as the platform produced them. + +## Manual results and exceptions ```csharp -builder.Services.AddZodSharpProblemDetails(); +app.MapPost("/users", Results, ValidationProblem> (UserDto dto, HttpContext ctx) => +{ + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value!) : ZodTypedResults.ValidationProblem(result, ctx); +}); +``` -var app = builder.Build(); -app.UseExceptionHandler(); +| Member | Produces | +| --- | --- | +| `ZodTypedResults.ValidationProblem(result \| exception, ctx)` | ASP.NET's `ValidationProblem` (always 400) | +| `ZodTypedResults.Problem(result \| exception, ctx)` | `ZodProblemDetailsResult` with the computed status | +| `result.ToValidationProblem(ctx)` / `exception.ToValidationProblem(ctx)` | the same, as `IResult` | +| `ToHttpValidationProblemDetails(ctx)` / `(ZodProblemDetailsOptions, ctx?)` | `HttpValidationProblemDetails` | +| `ToHttpValidationProblemDetails(statusCode = 400)` / `(registry, …)` / `(lookup, …)`, `ToValidationProblem()` | **obsolete** (CS0618) 2.x overloads: they ignore the registered `ZodProblemDetailsOptions`. Move to the `ctx` overload, or pass options explicitly (`{ Registry = new(), FormatMessages = false }` reproduces the status-code form) | +| `ToValidationProblemDetails(…)` | MVC `ValidationProblemDetails`, every extension copied | + +The result-based members **throw `InvalidOperationException` for a successful result**. A `ZodException` +thrown from `Parse` inside a handler is mapped by `ZodExceptionHandler` to the identical body. + +A body rejected by a strict value object converter while it is deserialized (`JsonException(inner: ZodException)`, +wrapped in a `BadHttpRequestException` by binding) gets the same body too: errors under the member that held the value +(`CustomerEmail.Value`, mapped from the endpoint's body contract), named after it. On .NET 11 this needs nothing more +than `AddZodSharpProblemDetails()`. On .NET 10, Production binding answers a bare `400` unless +`options.HandleBindingFailures = true`, which sets `RouteHandlerOptions.ThrowOnBadRequest` app-wide (every binding +failure then goes through `UseExceptionHandler()`; non-ZodSharp ones get a standard ProblemDetails with their own +status). + +## The writer and its options + +```csharp +var writer = ZodProblemDetailsWriter.GetOrCreate(httpContext.RequestServices); +HttpValidationProblemDetails problem = writer.Create(errors, httpContext); +writer.Apply(existingProblem, errors, httpContext); // keeps non-ZodSharp `errors` entries; span or sequence +await writer.WriteAsync(httpContext, problem); ``` -`AddZodSharpProblemDetails(configure?, problemDetails?)` registers `ZodExceptionHandler`, calls -`services.AddProblemDetails(problemDetails)`, and configures `ZodProblemDetailsOptions`: +`ZodProblemDetailsOptions`: -- `ErrorTypeRegistry Registry` — resolves codes to `ErrorType`s; defaults to `ErrorTypeRegistry.Default`. -- `bool FormatMessages` — format messages from the matched `ErrorType.MessageFormat`. -- `Func, int>? StatusCodeSelector` — total control of the status code. +- `Registry` (`ErrorTypeRegistry.Default`) or `ErrorTypeResolver` - code to `ErrorType`. +- `FormatMessages` (`true`) - use the matched `ErrorType.MessageFormat`, **only when every placeholder + resolves** (from parameters or built-ins `path`, `code`, `origin`, `minimum`, `maximum`, `displayName`). +- `PathNaming`, `IncludeIssues`, `DefaultStatusCode` (400). +- `MergeParametersIntoExtensions` (`false`) - also copy parameters to the top level (the 2.4 behaviour). Reserved names + (`type`, `title`, `status`, `detail`, `instance`, `errors`, `issues`, `traceId`) become `param_{name}`. +- `IncludeDiagnosticParameters` (`false`) - `declaringType` (a full CLR type name) and `member` are kept off + `issues[].parameters` and the top level; they stay on `ValidationError.Parameters` for localization. +- `ProjectDataAnnotationsIssues` (`true`) - `Apply` gives foreign entries a `dataannotations` issue. +- `StatusCodeSelector(errors)` then `StatusCodeOverride(errors, computed)` - run last and win. -> It does **not** register `IZodSchemaFactory`. Pair it with `AddZodSharp` (or the core -> `AddZodSharpFactory`) when you also want DI validator resolution. +Messages render through a registered `IZodMessageFormatter` (core) in `CultureInfo.CurrentUICulture`; +without one they are used as produced. `DefaultZodMessageFormatter` re-formats `MessageFormat` in a culture. ## Error codes to HTTP statuses -`ErrorType`, the `[ErrorType]` attribute, the generator that emits `Create`/`Throw` helpers and the -`ZODSASP001`–`ZODSASP003` analyzers all ship in the **core** package and need no ASP.NET Core -dependency. The AspNetCore package adds `ErrorTypeRegistry` and the ProblemDetails mapping on top. +`ErrorType`, `[ErrorType]` and the `ZODSASP*` analyzers ship in the **core** package; this package adds +`ErrorTypeRegistry` and the mapping. ```csharp public static partial class ConcurrentErrorType { [ErrorType] public static readonly ErrorType SaveFailed = new( - Code: "aggregate_save_failed", - Category: "invalid_value", + Code: "aggregate_save_failed", Category: "invalid_value", HttpStatus: 409, Description: "The aggregate could not be saved.", - HttpStatus: 409, - MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", - Parameters: - [ - new("AggregateId", typeof(string)), - ErrorType.Param("AggregateType"), - ]); + MessageFormat: "Aggregate '{AggregateId}' failed to save", + Parameters: [ErrorType.Param("AggregateId")]); } -ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +builder.Services.AddZodSharpProblemDetails(o => o.MapErrorType(ConcurrentErrorType.SaveFailed)); +var error = ConcurrentErrorType.CreateSaveFailed("agg-123"); // or ThrowSaveFailed(...) ``` -`HttpStatus` lives on `ErrorType` purely for convenience — the core library never reads it; only the -ASP.NET Core mapping does. `Category` is a broad grouping that can span many specific codes (code -`invalid_tenant_id`, category `invalid_value`); the generated helpers copy it onto each -`ValidationError` and it surfaces on the serialized `ValidationIssue`. - -Marking the field `[ErrorType]` and the containing class `partial` gets you strongly typed helpers, -one parameter per declared entry: - -```csharp -var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); -ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); - -var detailed = ConcurrentErrorType.CreateSaveFailed( - "agg-123", "Invoice", - path: ["order", "items", "[0]"], - origin: "collection", minimum: 1, maximum: 10, inclusive: true); -``` - -`Create` builds a typed `ErrorTypeParameters` (validated against the declared types, exposed through -`ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so -`error.Message` already reads the formatted text. - -Mapping rules: - -- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to `400`. - `StatusCodeSelector` overrides everything. -- **Title / Type / Detail** — from the highest-status matched `ErrorType`, otherwise defaulted. -- **Message** — named placeholders are substituted from `ValidationError.Parameters`. A placeholder - with no matching value is **left as-is**, so a templating gap stays visible rather than silently - disappearing. `ZODSASP001` warns at compile time when a placeholder is not declared in `Parameters`. -- **Parameters** — surfaced per-issue in `issues`, and merged (camel-cased) into the top-level - extensions for client correlation. - -`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` also accept an `ErrorTypeRegistry` or -a `Func` for on-demand mapping. +- **Status** - the highest matched `HttpStatus`, else 400; the selector/override run after and win. +- **Title / Type / Detail** - from that `ErrorType`, else the RFC 9110 defaults. +- **Parameters** - per issue in `issues[].parameters`; values leave the process, so never put internal + context in them. ## Registering the factory -`IZodSchemaFactory` (`ZodSharp.Core`) resolves validators by type: - -| Member | Behaviour | -| --- | --- | -| `Resolve()` | the validator, or `null` | -| `ResolveRequired()` | the validator, or `InvalidOperationException` | -| `Validate(value)` | validate through the registered validator | -| `Register(…)` / `Register(Type, …)` / `TryRegister(…)` / `IsRegistered()` | registration | - -```csharp -builder.Services.AddZodSharp(options => -{ - options.ScanAssemblies.Add(typeof(UserDto).Assembly); - options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); - options.ScanLoadedAssemblies = true; -}); - -// Additive, for modular apps: -builder.Services.AddZodSharpAssembly(typeof(UserDto).Assembly); -builder.Services.AddZodSharpAssemblyGraph(typeof(Program).Assembly); -builder.Services.AddZodSharpLoadedAssemblies(); -``` - -Scanning finds the generator's `[assembly: ZodSchemaGenerated(typeof(…))]` markers and registers each -`{Type}SchemaValidator`. A hand-built schema registers by wrapping: -`factory.Register(new ZodSchemaValidator>(mySchema))`. +| Method | Package | Registers | +| --- | --- | --- | +| `AddZodSharp(options)` | AspNetCore | factory + generated validators from `ScanAssemblies`, `ScanAssemblyGraphs`, `ScanLoadedAssemblies` | +| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | +| `AddZodSharpFactory(configure)` | core | the factory only | +| `AddZodSharpProblemDetails(…)` | AspNetCore | writer, exception handler, ProblemDetails - not the factory | -> **`TryAdd` semantics.** The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls — -> including their options and `ConfigureFactory` callbacks — are ignored. State is never overwritten, -> so calling it more than once is safe, and the additive helpers still contribute. - -| Method | Package | Registers | Use when | -| --- | --- | --- | --- | -| `AddZodSharp(options)` | AspNetCore | factory + auto-registered generated validators | an ASP.NET Core app | -| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | modular apps where deeper layers contribute their own schemas | -| `AddZodSharpFactory(configure)` | core | the factory only | any host; you call `RegisterFromAssembly` yourself | -| `AddZodSharpProblemDetails(…)` | AspNetCore | exception handler + ProblemDetails, **not** the factory | mapping thrown `ZodException`s | -| `AddZodSchemaOptionsValidator(…)` | core | `IValidateOptions` | options validation; needs a factory first | +The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls are ignored. A hand-built schema +registers with `factory.Register(new ZodSchemaValidator(schema))`. Native AOT apps register validators +explicitly rather than scanning assembly graphs. ## Options validation ```csharp -builder.Services.AddZodSharpFactory(f => f.RegisterFromAssembly(typeof(UserOptions).Assembly)); -builder.Services.AddZodSchemaOptionsValidator(); - -// or chained: -builder.Services - .AddOptions() - .Bind(configuration.GetSection("User")) - .AddZodSchemaValidator() - .ValidateOnStart(); // fail at startup, not first access -``` +builder.Services.AddOptions().BindAndValidateWithZod("Smtp"); // binds + ValidateOnStart -`MissingValidatorBehavior` decides what happens with no validator registered for `T`: -`Throw` (default — `ResolveRequired()`, so a missing schema is loud rather than a silent skip) or -`Ignore` (passes through as success). +// Native AOT: bind yourself so the configuration binder generator intercepts it. +builder.Services.AddOptions() + .BindConfiguration("Smtp") + .AddZodSchemaValidator(MissingValidatorBehavior.Throw, validateOnStart: true, configurationSection: "Smtp"); +``` -The generator also auto-generates `IValidateOptions` for types whose names end in configurable -suffixes — see the `purview-zodsharp-source-generator` skill. +- Failures read `{name}: {location}: {message} ({code})` - `Smtp:Servers:0:Host: Host is required + (missing_field)` with a section, `Servers[0].Host: …` without; the name is omitted for the default options. +- `ZodValidateOptionsResult` carries the full `Errors`. +- **.NET 11:** `ZodSchemaOptionsValidator` also implements `IAsyncValidateOptions` and registers via + `OptionsBuilder.Validate()`. `ValidateOnStart()` runs async and service-aware rules (in a + scope); synchronous access (`IOptions.Value`, reloads) checks only the synchronous rules. +- `OptionsBuilder.AddZodSchemaValidator()` validates only that builder's options name; + `services.AddZodSchemaOptionsValidator()` validates every name. Both run the async rules under + `ValidateOnStart()` on .NET 11 (the platform honours an `IAsyncValidateOptions` registered as + `IValidateOptions`). +- `MissingValidatorBehavior`: `Throw` (default) or `Ignore`. ## Related skills -`purview-zodsharp-core` for the runtime API, `purview-zodsharp-source-generator` for the compile-time -validators this resolves, and `purview-zodsharp-diagnostics` for `ZODSASP*`. +`purview-zodsharp-core` for the runtime API and error model, `purview-zodsharp-source-generator` for the +validators this resolves, `purview-zodsharp-json` for JSON Schema export, and `purview-zodsharp-diagnostics` +for `ZODSASP*`. diff --git a/skills/purview-zodsharp-blazor/SKILL.md b/skills/purview-zodsharp-blazor/SKILL.md new file mode 100644 index 00000000..8392598e --- /dev/null +++ b/skills/purview-zodsharp-blazor/SKILL.md @@ -0,0 +1,149 @@ +--- +name: purview-zodsharp-blazor +description: >- + Use when validating Blazor EditForm models with Purview.ZodSharp on Blazor Server, Blazor WebAssembly or Auto + render mode - AddZodSharpBlazor, the standard DataAnnotationsValidator route versus ZodValidator, nested and + collection field placement, debounced or per-field validation, GetZodErrors, ZodValidationMessage and + ZodValidationSummary, mapping API ProblemDetails onto fields with TryReadZodValidationProblemAsync and + AddZodProblemDetails, and static-SSR client-side validation with AddZodSharpClientValidation. +license: MIT +metadata: + product: Purview.ZodSharp.Blazor + repository: purview-dev/zodsharp +--- + +# Blazor forms + +`Purview.ZodSharp.Blazor` (`net11.0`) validates `EditForm` models with ZodSharp in every render mode. It references +the component packages, never the ASP.NET Core shared framework, so it runs in WebAssembly. + +```bash +dotnet add package Purview.ZodSharp.Blazor +``` + +## Setup - both Program.cs files + +```csharp +builder.Services.AddZodSharpMyAppSharedSchemas(); // generated: AddZodSharp{AssemblyName}Schemas() +builder.Services.AddZodSharpBlazor(); +``` + +- Call both in the **server and the client** project of a Blazor Web App. Put the models in a shared library; + the ZodSharp generator runs there. Models declared in `.razor` files are invisible to generators. +- Prefer the generated registration. Assembly scanning (`AddZodSharpAssembly*`, `RegisterFromAssembly`) is not + trim-safe and warns in a WebAssembly publish. +- `AddZodSharpBlazor` validates **form models only**: it does not claim minimal-API parameters, so a server's + endpoints are untouched. The .NET 11 endpoint filter validates whatever a resolver claims, so to validate + ZodSharp-typed endpoint parameters too, also call `AddZodSharpValidation()` (either order) or set + `ValidateParameters = true` in `configureValidation`; opt single endpoints out with `DisableValidation()`. +- `AddZodSharpBlazor` does **not** register an `IZodValidationReportSink`; never register a scoped one yourself (a + Blazor Server scope is the whole circuit). The ASP.NET Core sink is request-bound and safe alongside Blazor. + +## Choosing a route + +| Need | Use | +| --- | --- | +| Standard forms over `[ZodSchema]` models, minimal setup | `` | +| Codes, categories, bounds in the UI; custom message rendering | `` | +| A runtime `Z.Object(...)` schema for a CLR model | `` | +| Debounced per-keystroke validation | `` | +| Cross-field re-check when a field changes | `` | +| API validation errors on fields | either, plus `AddZodProblemDetails` | + +Use one validator per form. With both, `ZodValidator` records full errors but leaves messages to +`DataAnnotationsValidator` and logs a warning. + +## Route 1 - DataAnnotationsValidator + +Nothing else is needed. Lead with this guarantee: `[ZodSchema]` models get **nested, collection and dictionary +validation with no `[ValidatableType]`, no `AddValidation()` and no Microsoft.Extensions.Validation generator** - +which is the only way to get nested validation in WebAssembly, where that generator does not flow. Messages land +on the right `FieldIdentifier` (`order.Lines[1].Quantity`). A field change runs only that member's rules. Async +rules drive `IsValidationPending`/`IsValidationFaulted` and the `pending`/`faulted` CSS classes. + +**Struct members (value objects).** An error inside a struct member - `Email.Value`, where `Email` is a value +object on the model - is placed on the nearest reference-type owner's member, `FieldIdentifier(model, "Email")`, +on both routes and on server errors: a struct cannot be a `FieldIdentifier` model. So bind +`` next to the value object's input, not `model.Email.Value`. A +scalar value object's messages use the owning member's display name (`"Email is required"`, not +`"Value is required"`); a non-scalar struct keeps its own member names in the text. + +## Route 2 - ZodValidator + +```razor + + + + + + +``` + +- `ValidateOn`: `Submit` (field changes don't validate), `Change` (default), `Input` (+ `DebounceMilliseconds`; + a newer change cancels the pending one). Submit-time validation always runs. +- Full errors: `editContext.GetZodErrors(() => order.Customer.Email)`, `GetZodErrors(field)`, `GetZodErrors()`. +- Runtime schema: `Schema="ZodModelSchema.For(Z.Object().Field("Reference", Z.String().Min(3)).Build())"` + (field names are property names; nested objects are read too). A field change then validates the whole model + and keeps that field's errors. +- `TimeProvider` is resolved from DI, so tests can fake debounce time. +- A validator that throws is logged through `ILogger` and the field is marked faulted. +- `ZodValidationMessage`/`ZodValidationSummary` match the built-in markup and add `data-code`/`data-category`; + `Template` renders a `RenderFragment`; `ShowUnionDetails` lists each union option's issues. + +## Server errors onto fields + +```csharp +var response = await http.PostAsJsonAsync("/orders", order); +if (await response.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); +``` + +Places `issues[].segments` (or parses the dotted `errors` keys of a plain ValidationProblemDetails), matches +camel-cased names case-insensitively, replaces the previous server errors, and clears a field's server error +when it changes (`clearOnFieldChange: false` keeps them). `ClearZodErrors()` removes every ZodSharp error. +Reading uses source-generated JSON metadata, so it is trim- and AOT-safe. `TryReadZodValidationProblemAsync` and +`ZodValidationProblemDetails` (namespace `ZodSharp.Http`) come from `Purview.ZodSharp.Http`, which this package +references; that package also sends validated bodies (`PostAsJsonValidatedAsync`) and reads validated responses +(`ReadFromJsonAndValidateAsync`) - see the `purview-zodsharp-json` skill. + +`AddZodErrors(errors)` places errors produced anywhere else by their typed paths, with the same store and clearing +rules (each call replaces the previous `AddZodErrors`/`AddZodProblemDetails` errors): + +```csharp +@inject IServiceProvider Services +... +var sent = await Http.PostAsJsonValidatedAsync("/orders", order, + new OrderSchemaValidator(), AppJsonContext.Default.Order, Services); // services: the uniqueness rule runs +if (!sent.IsSuccess) + editContext.AddZodErrors(sent.Errors); // rejected before sending +else if (await sent.Value!.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); // rejected by the server +``` + +For one component in every render mode, put the calls in a typed client: `AddScoped()` in the WebAssembly +client's `Program.cs`, `AddHttpClient(...)` on the server (the server-rendered component then calls the API +service-to-service). The repository's `Examples.Blazor` sample does exactly this: `RegisterCustomerForm` with +`CustomerAPIClient`, on `/register` (WebAssembly) and `/register-server` (Interactive Server). + +## Static SSR client-side validation + +```csharp +builder.Services.AddZodSharpClientValidation(); // Purview.ZodSharp.AspNetCore, server only +``` + +Static-SSR forms then carry client rules for ZodSharp rule attributes beside ASP.NET Core's DataAnnotations +rules; `zodsharp-validation.js` registers the browser validators automatically (a Blazor JavaScript initializer). +Order relative to `AddRazorComponents` does not matter; a provider registered **after** it replaces ZodSharp's, +which a Development startup warning reports. Runtime schemas need `o.AddSchema(schema)`. + +Say plainly in any guidance: client rules are UX only; async, service-aware and culture-sensitive rules are +server-only; the server validates every post. + +## Pitfalls + +- **Circuit scope.** On Blazor Server, validators get the circuit's services for the whole connection. In async + rules use `IDbContextFactory` or create a scope; never resolve a scoped `DbContext` directly. +- **Prerendering.** Don't validate in `OnInitialized`; it runs once per render phase. +- **Synchronous `Validate()`** is obsolete and throws for models with async-only rules; use `ValidateAsync`. +- **WebAssembly culture.** Codes and default messages are invariant; localized messages need `AddLocalization()` + and satellite assemblies (the formatter switches on automatically when an `IStringLocalizerFactory` exists). diff --git a/skills/purview-zodsharp-core/SKILL.md b/skills/purview-zodsharp-core/SKILL.md index 98280dd6..da002e69 100644 --- a/skills/purview-zodsharp-core/SKILL.md +++ b/skills/purview-zodsharp-core/SKILL.md @@ -2,7 +2,8 @@ name: purview-zodsharp-core description: >- Use when validating data with Purview.ZodSharp at runtime - picking the right package, building - schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError, + schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError + (codes, categories, typed ZodPath paths, bounds, message formats), localizing messages, composing with Transform/Refine/Pipe/Catch/Default, and how null handling and type coercion work. license: MIT metadata: @@ -21,7 +22,11 @@ exceptions, reporting machine-readable issues. dotnet add package Purview.ZodSharp # core, source generator, JSON Schema export dotnet add package Purview.ZodSharp.SystemTextJson # STJ deserialize-and-validate + schema import dotnet add package Purview.ZodSharp.NewtonsoftJson # Newtonsoft equivalent +dotnet add package Purview.ZodSharp.Http # HttpClient: validated bodies + server problems (WASM-safe) dotnet add package Purview.ZodSharp.AspNetCore # ProblemDetails + DI +dotnet add package Purview.ZodSharp.Extensions.Validation # net11: Microsoft.Extensions.Validation bridge +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi # net11: OpenAPI transformers +dotnet add package Purview.ZodSharp.Blazor # net11: Blazor components ``` JSON Schema **export** (`Z.ToJsonSchema`) is in the core package; **import** (`Z.FromJsonSchema`) comes @@ -47,11 +52,15 @@ if (result.IsSuccess) | `Validate(value)` | returns `ValidationResult`; **never throws** | | `SafeParse(value)` | alias of `Validate` | | `Parse(value)` | returns the validated value, or throws `ZodException` (via `GetValueOrThrow()`) | -| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in the source generator's custom async validation. | +| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in generated validators (`{Type}Schema.ValidateAsync(value, services, ct)`). | Reach for `Validate` by default and `Parse` only where a throw is the right control flow. Exceptions for expected invalid input are expensive and lose the structured issue list unless you catch. +`Validate` is also the right call inside an async method: the analyzer package suppresses CA1849 for it +(`ZODSUP001`), because the pipeline is synchronous and never blocks. `ValidateAsync` is for a type whose graph has +asynchronous rules. + ## `ValidationResult` A `readonly record struct` in `ZodSharp.Core`: @@ -67,22 +76,60 @@ concatenates the errors otherwise. ## `ValidationError` -A `readonly record struct` carrying machine-readable metadata, not just a message: +A `readonly record struct` in `ZodSharp.Core` carrying machine-readable metadata, not just a message: | Member | Meaning | | --- | --- | -| `Code` | `invalid_type`, `too_small`, `too_big`, `invalid_union`, `missing_field`, `unrecognized_key`, `validation_failed`, `refinement_failed`, `transform_error`, … | -| `Message` | human-readable | -| `Path` | `ImmutableArray` — `["user", "email"]`; array indexes are string segments, `"0"` | -| `Origin` | category for size issues: `"string"`, `"array"`, `"collection"` | -| `Minimum` / `Maximum` / `Inclusive` | the bound, for size issues | -| `Parameters` | `IReadOnlyDictionary`; a union failure carries every option error under `"errors"` | - -Build one with -`ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. - -`ZodException` carries `ImmutableArray Errors`; its `ToString()` renders one line per -error as `"{joinedPath}: {Message} ({Code})"`. +| `Code` | ZodSharp's specific identity: a rule's `ErrorCode`, or a `ZodIssueCodes` constant (`invalid_type`, `missing_field`, `refinement_failed`, `invalid_union`, `unrecognized_keys`, …). The same failure has the same code whichever producer reports it | +| `Category` | the Zod v4 issue code (`ZodIssueCategories`: `invalid_type`, `too_small`, `too_big`, `invalid_format`, `not_multiple_of`, `unrecognized_keys`, `invalid_union`, `invalid_value`, `custom`, …) | +| `Message` | human-readable; built-in messages never contain the input | +| `MessageFormat` | the named-placeholder template that produced `Message` (`"{displayName} is required"`), or `null` for a literal message | +| `FullPath` | the authoritative typed path (`ZodPath`) | +| `Path` | legacy `ImmutableArray` projection of `FullPath`; indexes are `"0"` | +| `Origin` | `"string"`, `"number"`, `"array"`, `"date"`, `"enum"`, … | +| `Lower` / `Upper` | `ErrorBound?` (`Value`, `Inclusive`) of any comparable type; `Minimum`/`Maximum`/`Inclusive` stay for integer bounds | +| `Parameters` | `ErrorTypeParameters` (also `IReadOnlyDictionary`); names in `ZodIssueParameters` (`expected`, `received`, `options`, `count`, `format`, `pattern`, `displayName`, `member`, `declaringType`, …) | + +Build one with `ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, +inclusive, category)` (path as `ZodPath` or `ImmutableArray`), then `with { Lower = ..., MessageFormat += ... }`. Re-path with `WithPathPrefix(...)` only - it keeps every field. Union failures carry per-option +issues: `error.GetUnionErrors()`. `ZodIssues` has factories for the shared failures (null, invalid type, +missing field, refinement, union, collection size). + +`ZodException` carries `ImmutableArray Errors`; its message lists up to five issues as +`"{path}: {Message} ({Code})"` (root renders as `(root)`), and `ToString()` lists every one. + +## Paths + +`ZodPath` is a sequence of typed `ZodPathSegment`s (`Member`, `At(index)`, `Key(key)`). Render it with +`ToDottedString()` - the canonical key every host uses, matching Microsoft.Extensions.Validation: + +| Path | Dotted | +| --- | --- | +| member, index, member | `Items[0].Name` | +| key `en` | `Map[en]` | +| key `"0"` (all digits) or `"a.b"` | `Map["0"]`, `Map["a.b"]` | +| root | `""` | + +Also `ToJsonPointer()`, `ToJsonPath()`, `ToConfigurationKey()` and `ToZodPath()`; parse with +`ParseDotted`/`ParseJsonPath`. Keys are rendered with the invariant culture. A `ZodPathNamer.FromFunc(...)` +renames members (for example to camelCase) when a host opts into `ZodPathNaming.Json`. + +## Messages, culture and PII + +- Validation is culture-independent: codes, categories, paths, bounds and parameters are invariant, and + messages are produced invariantly. +- `ZodMessageFormat.Format(format, error, provider)` resolves named placeholders (`{displayName}`, + `{minimum}`, `{path}`, …) and never throws; unknown placeholders render literally. +- Hosts re-render messages in `CultureInfo.CurrentUICulture` through an `IZodMessageFormatter` + (`DefaultZodMessageFormatter` in core; `StringLocalizerZodMessageFormatter` in + `Purview.ZodSharp.Extensions.Validation` for `IStringLocalizer` resources). +- `ZodErrorOptions.IncludeReceivedValues` (default `false`) puts the raw input in the `received` + parameter. Development only - never in production. +- `ZodValidationDepth.MaxDepth` (default 32) stops generated validators on cyclic graphs with an + `InvalidOperationException`. + +See [references/error-model.md](references/error-model.md) for the error contract in detail. ## The `Z` factory @@ -168,9 +215,25 @@ Object fields and union options wrap typed schemas, so boxed values out of a satisfies a `Z.Number()` field. - A non-nullable value type rejects `null`; anything else falls through to failure. -## Related skills +## Trimming and Native AOT +Every package is `IsTrimmable`/`IsAotCompatible`. Runtime schemas, generated validators and generated +registration are safe as they are. Anything that is not carries `[RequiresUnreferencedCode]` with the +alternative in its message, so read the IL2026/IL3050 text rather than suppressing it: + +- JSON: the `JsonTypeInfo` overloads with a source-generated `JsonSerializerContext`; + Newtonsoft.Json is never trim-safe. +- Options: `BindConfiguration(section).ValidateWithZod(section)`, not `BindAndValidateWithZod`. +- Registration: the generated `AddZodSharp{AssemblyId}Schemas()`, not `AddZodSharpAssemblyGraph`. +- Discriminated unions over CLR types: add each option with `Option`. + +`PublishTrimmed`/`PublishAot` turn off the `ZodSharp.Reflection.IsSupported` fallbacks (an unregistered +POCO's discriminator, field walkers for non-generated validators, graph scanning). See the wiki page +Trimming and Native AOT. + +## Related skills `purview-zodsharp-source-generator` for compile-time `[ZodSchema]` validators (which is what you want for a DTO), `purview-zodsharp-custom-rules` for writing rules and rule attributes, -`purview-zodsharp-aspnetcore` for ProblemDetails and DI, and `purview-zodsharp-diagnostics` for the +`purview-zodsharp-aspnetcore` for ProblemDetails, DI, the .NET 11 validation pipeline and OpenAPI, +`purview-zodsharp-json` for JSON integration, and `purview-zodsharp-diagnostics` for the `ZODSGEN`/`ZODSASP` catalogue. diff --git a/skills/purview-zodsharp-core/references/error-model.md b/skills/purview-zodsharp-core/references/error-model.md new file mode 100644 index 00000000..13c3412a --- /dev/null +++ b/skills/purview-zodsharp-core/references/error-model.md @@ -0,0 +1,112 @@ +# The ZodSharp error model in detail + +Everything here is in `ZodSharp.Core`. + +## Code versus Category + +`Code` is ZodSharp's own identity and is what tests and clients branch on when they need precision. +`Category` is the Zod v4 issue code, so a client that already handles TypeScript Zod issues can treat +ZodSharp issues the same way. + +| Failure | `Code` | `Category` | +| --- | --- | --- | +| null where a value is required | `invalid_type` | `invalid_type` | +| missing object field / generated `[Required]` | `missing_field` | `invalid_type` | +| `Min(3)` on a string | the rule's `ErrorCode` | `too_small` | +| `[StringLength]`/`[Length]` | `invalid_length` | `too_small` or `too_big` by direction | +| email, uuid, regex, prefix, … | the rule's `ErrorCode` | `invalid_format` | +| `MultipleOf` | the rule's `ErrorCode` | `not_multiple_of` | +| strict object with extra keys | `unrecognized_keys` (one issue, `keys` parameter) | `unrecognized_keys` | +| union with no matching option | `invalid_union` | `invalid_union` | +| enum / literal / allowed values | `invalid_enum_value`, `invalid_literal`, rule code | `invalid_value` | +| `Refine`, `SuperRefine`, `ApplyRefine`, custom method | `refinement_failed` (or your code) | `custom` | + +Every rule declares `public const string ErrorCode` and `MessageFormat` (diagnostic `ZODSGEN042`), so +assert against `MinLengthRule.ErrorCode` rather than a literal. + +## Bounds + +`Lower` and `Upper` are `ErrorBound?` - `record struct ErrorBound(object Value, bool Inclusive)` with +`ErrorBound.Including(value)` / `ErrorBound.Excluding(value)`. They hold numbers, decimals and dates. +`GreaterThan`/`LessThan` and `[Range(MinimumIsExclusive = true)]` report `Inclusive = false`. +`Minimum`/`Maximum`/`Inclusive` (`int?`) are still populated for integer size bounds; `Lower`/`Upper` +fall back to them. + +## Message templates + +Built-in messages come from named templates (`ZodMessages`, each rule's `MessageFormat`), kept on the +error as `MessageFormat`. `ZodMessageFormat` resolves `{name}` and `{name:format}` from the error's +parameters, then from the built-ins `displayName`, `member`, `path`, `code`, `category`, `origin`, +`minimum`, `maximum` and `inclusive`. `{{`/`}}` escape braces; an unknown placeholder or an unbalanced +brace is written literally. It never throws - a stray brace in a custom message cannot turn a failure +into an exception. `TryFormatComplete` succeeds only when every placeholder resolved. + +Every built-in rule reports its template through `IZodRule.MessageFormat`, so runtime-schema errors carry +one too (`Z.String().Min(3)`: `"String must be at least {minimum} characters long, but got {count}"`). A +custom rule message is reported with its positional placeholders renamed (`{0}` → `{minimum}`); the +value's position becomes `{received}`. + +```csharp +using ZodSharp.Core; + +var error = ValidationError.Create( + "too_short", + "Name is too short", + ZodPath.Member("Name"), + new Dictionary { [ZodIssueParameters.DisplayName] = "Name" }, + category: ZodIssueCategories.TooSmall +) with +{ + Lower = ErrorBound.Including(3), + MessageFormat = "{displayName} must have at least {minimum} characters", +}; + +var text = ZodMessageFormat.Format(error.MessageFormat!, error); // "Name must have at least 3 characters" +``` + +## Localization hook + +```csharp +public interface IZodMessageFormatter +{ + string Format(in ValidationError validationError, CultureInfo culture); +} +``` + +Hosts (ProblemDetails writer, the Microsoft.Extensions.Validation bridge, Blazor) call it with +`CultureInfo.CurrentUICulture`. `DefaultZodMessageFormatter.Instance` re-formats `MessageFormat` when +every placeholder resolves and otherwise returns `Message`. `StringLocalizerZodMessageFormatter` +(`Purview.ZodSharp.Extensions.Validation`) looks keys up in this order: the `MessageFormat` (or literal +`Message`), `{DeclaringType}_{Member}_{Code}`, `{DeclaringType}_{Code}`, `{Code}`; otherwise the message +is kept. `{displayName}` is localized from `{DeclaringType}_{Member}_DisplayName`. Implementations must +never throw. + +## Paths + +- Members join with `.`; indexes are `[n]`; keys are `[key]`, quoted as `["key"]` when the key is all + digits or contains anything other than ASCII letters, digits, `_` or `-`. +- `ZodPath.FromLegacy(strings)` classifies `"0"` and `"[0]"` as indexes, so string paths passed to + `RefineCtx.AddIssue(code, message, string[])` keep working. +- `RefineCtx.FullPath` is the refinement's base path; `AddIssue(code, message, ZodPath, parameters, + category)` and `AddIssue(ValidationError)` add typed issues relative to it. +- Generated `{Type}Schema.Paths.{Member}` constants give typed member paths for refinement hooks. + +## Unions + +A union failure is one `invalid_union` issue at the union's path. Its per-option issues are in the +`unionErrors` parameter as `ImmutableArray>`; read them with +`error.GetUnionErrors()`. The ProblemDetails `issues[]` projects them as nested `unionErrors`. + +## PII + +`ZodErrorOptions.IncludeReceivedValues` is process-wide and defaults to `false`. When off, a rule failure +carries no `received` parameter and an invalid-type failure's `received` holds only the type name +(`"string"`). Built-in default messages never contain the input either way - numeric rules included +(`"Value must be between 1 and 10"`); a custom message can still opt in with the rule's positional value +placeholder (`{0}`, or `{1}`/`{2}` after the bounds). + +## Depth + +`ZodValidationDepth.MaxDepth` (default `ZodValidationDepth.DefaultMaxDepth`, 32) is process-wide. The +root is depth 0; a generated validator throws `InvalidOperationException` when it reaches the limit, +matching Microsoft.Extensions.Validation's `ValidationOptions.MaxDepth`. diff --git a/skills/purview-zodsharp-custom-rules/SKILL.md b/skills/purview-zodsharp-custom-rules/SKILL.md index 06215ccb..f2151be6 100644 --- a/skills/purview-zodsharp-custom-rules/SKILL.md +++ b/skills/purview-zodsharp-custom-rules/SKILL.md @@ -52,20 +52,26 @@ the rule rather than re-typing literals: public readonly record struct EmailRule : IValidationRule { public const string ErrorCode = "invalid_string"; - public const string MessageFormat = "Invalid email format: {0}"; + public const string MessageFormat = "Invalid email format"; public string Code => ErrorCode; - public string GetErrorMessage(in string value) => - string.Format(CultureInfo.CurrentCulture, MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(MessageFormat, value); } ``` - **`ErrorCode`** is the canonical code. `IValidationRule.Code` defaults to `"validation_failed"`; a rule accepting a per-usage `code` override returns it (falling back to `ErrorCode`) from both `Code` and `IZodRule.Code`, so every route reports the same effective value. -- **`MessageFormat`** is a `string.Format` template. `{0}` is the offending value; `{1}`, … are - rule-specific arguments. Format with `CultureInfo.CurrentCulture`. +- **`MessageFormat`** is a composite template. `{0}` is the offending value; `{1}`, … are + rule-specific arguments. Format it with `ZodSharp.Rules.RuleMessage.Format`: invariant culture (hosts + localize at the boundary through `IZodMessageFormatter`) and it never throws on a stray brace. +- **Keep the input out of the default message.** Errors reach ProblemDetails, logs and UI, so describe + what was expected rather than quoting the value. Pass the value anyway (after the rule's own + arguments), so a custom message can still use it. +- To make the message localizable by template, implement `IZodRule.MessageFormat` with a + named-placeholder form (`"Must be at least {minimum}"`) and `IZodRule.Parameters` with any value it + needs beyond the built-ins; the built-in rules all do. - The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. @@ -74,7 +80,7 @@ A source-declared rule missing either constant is reported as **`ZODSGEN042`**. ```csharp await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); await Assert.That(error.Message).IsEqualTo( - string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); + RuleMessage.Format(EmailRule.MessageFormat, value)); ``` ### Runtime identity: `IZodRule` @@ -113,8 +119,8 @@ Code and origin resolve first-match-wins: ## Writing one ```csharp -using System.Globalization; using ZodSharp.Core; +using ZodSharp.Rules; namespace MyRules; @@ -122,7 +128,7 @@ namespace MyRules; public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule { public const string ErrorCode = "invalid_string"; - public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + public const string MessageFormat = "Whitespace is not allowed."; public bool IsValid(in string value) { @@ -141,7 +147,7 @@ public readonly record struct NoWhitespaceRule(string? Message = null) : IValida public string Code => ErrorCode; public string GetErrorMessage(in string value) => - Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); + Message ?? RuleMessage.Format(MessageFormat, value); } ``` @@ -228,6 +234,11 @@ with an empty path, and the wrapped rule owns the reported `Code`. Closing such scalar type instead (`NonSentinelRule`) compiles but **always passes**, because the check only knows the primitive. +Such a scalar works under every publish mode, including Blazor WebAssembly with `RunAOTCompilation=true`: +the generated code passes the wrapped rule's identity with a direct `(IZodRule)` cast (or `null` when the +rule has none), never a runtime type test on the boxed rule. 3.0.0-prerelease.1 builds before that fix +crashed the Mono AOT compiler (exit code -1073741819); upgrading is the fix, so do not drop the rule to avoid it. + ## Diagnostics you will meet `ZODSGEN030` (mapped rule does not implement `IValidationRule` for the property type), diff --git a/skills/purview-zodsharp-diagnostics/SKILL.md b/skills/purview-zodsharp-diagnostics/SKILL.md index 101e872d..eb584b5c 100644 --- a/skills/purview-zodsharp-diagnostics/SKILL.md +++ b/skills/purview-zodsharp-diagnostics/SKILL.md @@ -2,7 +2,8 @@ name: purview-zodsharp-diagnostics description: >- Use when a Purview.ZodSharp build reports a ZODSGEN or ZODSASP diagnostic - the [ZodSchema] - generator's configuration and usage rules, the custom-rule mapping rules, and the ErrorType + generator's configuration and usage rules, the custom-rule mapping rules, async-only rules, + Microsoft.Extensions.Validation and Razor interplay, error-message placeholders, and the ErrorType analyzers - to find what triggered it and how to fix it. license: MIT metadata: @@ -18,8 +19,9 @@ Two families, both enabled by default: - **`ZODSASP*`** — the `ErrorType` factory's analyzer and generator, which ship in the **core** package and need no ASP.NET Core dependency. -`ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` and `ZODSGEN042` are warnings; `ZODSGEN041` and -`ZODSGEN043` are informational; the rest are errors. +`ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, `ZODSGEN042`, `ZODSGEN045`, `ZODSGEN046`, `ZODSGEN048`, `ZODSGEN049`, +`ZODSGEN051`, `ZODSGEN052` and `ZODSGEN053` are warnings; `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are +informational; the rest are errors. IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused. Rule identifiers are never renumbered or reused, so a sighting of one means stale tooling or a stale suppression. @@ -35,6 +37,9 @@ renumbered or reused, so a sighting of one means stale tooling or a stale suppre | `ZODSGEN006` | Unsupported DataAnnotations usage: a string-only attribute on a non-string, `[AllowedValues]`/`[DeniedValues]` on an unsupported type, `[RegularExpression]` on a non-string, `[Range]` on an unsupported type. | | `ZODSGEN020` | `[Compare]` references an unknown property. | | `ZODSGEN021` | `System.ComponentModel.DataAnnotations` reference is missing. | +| `ZODSGEN048` | *(warning)* The type has rules that only run asynchronously (an `AsyncValidationAttribute` member or `IAsyncValidatableObject`), so the generated synchronous `Validate` throws `InvalidOperationException`. Call `ValidateAsync`. | +| `ZODSGEN052` | *(warning)* An attribute's `ErrorMessage` uses a positional placeholder the attribute does not supply (for example `{3}` on `[Required]`) or has an unbalanced brace. Messages are formatted with a never-throw engine, so the text is shown literally; fix the placeholder. | +| `ZODSGEN053` | *(warning)* `[MinLength]`, `[MaxLength]` or `[Length]` on a collection member of a `[ZodSchema]` type in a trimmed or AOT project (`PublishTrimmed`, `PublishAot`, `IsTrimmable`, `IsAotCompatible`). The BCL constructors are `[RequiresUnreferencedCode]`; use `[MinCount]`, `[MaxCount]` or `[CountRange]` from `ZodSharp.Rules` (the code fix swaps it). | ## Custom async validation method @@ -44,9 +49,10 @@ All of these mean the method the generator was told to call cannot be called: | --- | --- | | `ZODSGEN007` | Configured but not found. | | `ZODSGEN008` | Return type is not `ValueTask>`. | -| `ZODSGEN009` | Parameter count is not 2. | +| `ZODSGEN009` | The method takes neither two parameters `(T value, CancellationToken ct)` nor three, the service-aware `(T value, IServiceProvider services, CancellationToken ct)`. | | `ZODSGEN010` | First parameter is not the model type. | -| `ZODSGEN011` | Second parameter is not `CancellationToken`. | +| `ZODSGEN011` | The last parameter is not `CancellationToken`. | +| `ZODSGEN054` | A three-parameter (service-aware) method's second parameter is not `IServiceProvider`. | | `ZODSGEN012` | The method is generic. | | `ZODSGEN013` | Declared on the model type but not `static`. | | `ZODSGEN014` | Inaccessible from the generated validator (private/protected). | @@ -62,6 +68,7 @@ All of these mean the method the generator was told to call cannot be called: | --- | --- | | `ZODSGEN027` | Requested, but the `Microsoft.Extensions.Options` reference is missing. | | `ZODSGEN028` | Requested on a struct — it requires a class. | +| `ZODSGEN050` | *(error)* The generated `IValidateOptions` validator would conflict with a hand-authored `[OptionsValidator]` class (`SYSLIB1205`), so it is not generated. Remove `[OptionsValidator]` or set `[ZodSchema(SuppressIValidateOptions = true)]`. | ## The refinement hook @@ -93,6 +100,13 @@ All of these mean the method the generator was told to call cannot be called: | --- | --- | | `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements or values (a scalar, a collection whose element cannot be `null`, or a dictionary whose value cannot be `null`), so the setting can never take effect. | +## Microsoft.Extensions.Validation and Razor + +| ID | Meaning | +| --- | --- | +| `ZODSGEN047` | *(info)* `[ValidatableType]` on a `[ZodSchema]` type is redundant: the ZodSharp bridge (`AddZodSharpValidation()`) resolves the generated validator first, and the attribute only makes the Microsoft.Extensions.Validation generator emit a second, DataAnnotations-only validator. A code fix removes the attribute. | +| `ZODSGEN051` | *(warning)* A `[ZodSchema]` type is declared in a `.razor` file. Razor compiles components with its own source generator, and generators cannot see each other's output, so no schema is generated. Move the type to a `.cs` file. | + ## Unions, value objects, JSON Schema | ID | Meaning | @@ -100,6 +114,7 @@ All of these mean the method the generator was told to call cannot be called: | `ZODSGEN041` | *(info)* A typed `Z.Union` whose option types are all reference types can use the allocation-free native C# 15 union from `Z.NativeUnion` on .NET 11+. A code fix is offered. | | `ZODSGEN044` | A `[ZodSchema]` type using an automatic `[Scalar]` form (`[Scalar]` / `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `GenerateValidateMethod = false` suppresses the `Validate` it calls, or the declared underlying type cannot be represented. | | `ZODSGEN045` | A type referenced by an exported type has `GenerateJsonSchema = false`, so the cross-platform contract surface is incomplete. | +| `ZODSGEN049` | *(warning)* A member is typed as a C# 15 closed class whose derived type has no `[ZodSchema]`, so a value of that derived type is not validated. Add `[ZodSchema]` to the derived type. | ## `ErrorType` — `ZODSASP*` @@ -123,3 +138,10 @@ Standard mechanisms: `#pragma warning disable ZODSGEN006`, a `NoWarn` entry, or severity override. The canonical catalogue is the analyzer's `AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project — a new or changed rule must be recorded there in the same change. + +The package also ships `ZODSUP001`, a **suppression** rather than a reportable rule: it removes CA1849 +("call async methods when in an async method") for ZodSharp's `Validate` and `ValidateMember` calls — generated +schemas, validator adapters and fluent schemas — because the synchronous pipeline is the cheap path and never blocks. +`ValidateAsync` is needed only when the graph has asynchronous rules, which `ZODSGEN048` reports. It is scoped to +those call targets: `Task.Wait`, `Thread.Sleep` and other libraries' `Validate`/`ValidateAsync` pairs still report +CA1849. Do not add `NoWarn;CA1849` for ZodSharp's own calls. diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index adaedda0..0b33e215 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -3,8 +3,9 @@ name: purview-zodsharp-json description: >- Use when combining Purview.ZodSharp with JSON - deserialize-and-validate and validating converters for System.Text.Json or Newtonsoft.Json, choosing between the two packages, exporting a schema to - JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, and sharing a contract with - TypeScript Zod. + JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, the invalid_json error and + converter exceptions, x-zod vendor extensions for OpenAPI, sharing a contract with TypeScript Zod, and + HttpClient bodies and server validation problems with Purview.ZodSharp.Http (WebAssembly-safe). license: MIT metadata: product: Purview.ZodSharp @@ -26,8 +27,52 @@ Each adds deserialize-and-validate, a validating converter, and JSON Schema **im Both can be referenced from one project without `extern alias` — the JSON Schema types live in `ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` — but the -deserialize/serialize extension overloads share names, so **import exactly one package namespace per -file**. +deserialize/serialize extensions (`SystemTextJsonExtensions`, `NewtonsoftJsonExtensions`) are both in the +`ZodSharp` namespace with the same names. With both referenced, a call that omits the options argument +(`schema.DeserializeAndValidate(json)`, `DeserializeAndValidateAsync(stream[, ct])`, `ValidateAndSerialize(value)`, +`ValidateAndSerializeAsync(value, output[, ct])`) resolves to System.Text.Json; pass a `JsonSerializerSettings` +for Newtonsoft. `CreateValidatingConverter(schema)` stays ambiguous (CS0121): call the class directly, +`NewtonsoftJsonExtensions.CreateValidatingConverter(schema)`. + +`Purview.ZodSharp.SystemTextJson` is browser-safe (Blazor WebAssembly). For `HttpClient` calls, add +`Purview.ZodSharp.Http` (also browser-safe, `net10.0`+), which builds on it: + +```csharp +// Validate before sending: an invalid value returns the errors and no request is made. +var sent = await http.PostAsJsonValidatedAsync("api/orders", order, new OrderSchemaValidator(), AppJsonContext.Default.Order); + +// Validate while reading: unreadable JSON is an invalid_json error, never an exception. +var read = await response.Content.ReadFromJsonAndValidateAsync(new OrderSchemaValidator(), AppJsonContext.Default.Order); + +// Read a ZodSharp ProblemDetails response back into ZodSharp errors. +var errors = await response.TryReadZodValidationProblemAsync() is { } problem + ? problem.ToValidationErrors() // or problem.ToValidationResult() + : []; +``` + +Every `Purview.ZodSharp.Http` API takes a `JsonTypeInfo`; there are no `JsonSerializerOptions` overloads. +`ZodJsonContent.Create`/`CreateAsync` build a validated `JsonContent` for any other request. `Put`/`PatchAsJsonValidatedAsync` +mirror `Post`, each with `string?` and `Uri?` URIs. + +**Service-aware rules** (`[ZodAsyncMember]` methods that resolve services) need the caller's `IServiceProvider`. Pass a +generated `{TypeName}SchemaValidator` plus the services - a Blazor component's injected `IServiceProvider` (the +browser's or the circuit's), `HttpContext.RequestServices` on a server: + +```csharp +var sent = await http.PostAsJsonValidatedAsync("api/customers", customer, + new RegisterCustomerRequestSchemaValidator(), AppJsonContext.Default.RegisterCustomerRequest, services, ct); +var read = await response.Content.ReadFromJsonAndValidateAsync( + new CustomerSchemaValidator(), AppJsonContext.Default.Customer, services, ct); +``` + +A generated validator with such a rule throws `InvalidOperationException` through an overload without services, so the +rule is never skipped silently. `SystemTextJsonExtensions` has the same overload for `DeserializeAndValidateAsync` and +`ValidateAndSerializeAsync`. + +Wrap the calls in one typed client and register it per host: `AddScoped()` in a WebAssembly client, +`AddHttpClient(...)` on a server (created in the requesting scope, so its injected `IServiceProvider` is the +circuit's or request's). In Blazor, `editContext.AddZodErrors(sent.Errors)` puts a rejected-before-sending result on the +form's fields, and `AddZodProblemDetails(problem)` a server rejection. ## Deserialize and validate @@ -52,7 +97,23 @@ var json = userSchema.ValidateAndSerialize(user); // ValidationResu await userSchema.ValidateAndSerializeAsync(user, outputStream); ``` -Failures add `deserialization_failed` and `json_error` codes alongside the schema's own. +A failure to read the JSON - malformed text, a `null` document, a value the serializer cannot convert, and on +System.Text.Json a `NotSupportedException`/`InvalidOperationException` - is one error with code `invalid_json` +(`SystemTextJsonExtensions.InvalidJsonCode` / `NewtonsoftJsonExtensions.InvalidJsonCode`), category +`invalid_type` and the fixed message `"The JSON value could not be converted to {expected}."` (`expected` is a +Zod type name). The serializer's path becomes the error's `FullPath` (`$.items[1].age` and Newtonsoft's +`items[1].age` both become `items[1].age`); line and position become the `line` and `position` parameters. A +System.Text.Json syntax error between members is located at the element (`items[1]`), and one inside a value read +by a validating converter at that value, because it is buffered first. The +serializer's own message is never surfaced, because it names CLR types and can quote the input. Schema failures +keep the schema's own codes, and the Newtonsoft `DeserializeAndValidateAsync` awaits the schema's +`ValidateAsync`. + +A converter that rejects its value with `new JsonException(message, zodException)` (a Purview.ValueObjects strict +converter) is not `invalid_json`: System.Text.Json's `DeserializeAndValidate*` and `ReadFromJsonAndValidateAsync` +return the `ZodException`'s errors under the member that held the value (`CustomerEmail.Value`, JSON names mapped +back to member names through the `JsonTypeInfo`), named after that member (`Field 'CustomerEmail' ...`). +`ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo, out errors)` does the same for any exception chain. ## Validating converters @@ -65,8 +126,11 @@ var value = JsonSerializer.Deserialize(json, options); var value2 = JsonConvert.DeserializeObject(json, userSchema.CreateValidatingConverter()); ``` -Invalid JSON **throws** — `JsonException` (STJ) or `JsonSerializationException` (Newtonsoft), with a -`"Validation failed: …"` message. Each converter removes itself from the options/serializer it uses +Invalid data **throws** `ZodJsonValidationException : JsonException` (STJ) or +`ZodNewtonsoftJsonValidationException : JsonSerializationException` (Newtonsoft), both in `ZodSharp.Json`, with a +`"Validation failed: …"` message (existing `catch (JsonException)` blocks keep working) and the full errors in +`ImmutableArray Errors`, located under the converted value's JSON path (`Owner.Name` for a +failing nested `Owner`, including nested converters). `DeserializeAndValidate` unwraps them into the result. Each converter removes itself from the options/serializer it uses internally, to avoid recursing into itself. ## Which package differs how @@ -78,7 +142,7 @@ internally, to avoid recursing into itself. | Converter return | generic `JsonConverter` | non-generic `JsonConverter` | | `JToken` overload | no | yes | | Formatting | via options | explicit `Formatting` argument | -| Invalid-data exception | `JsonException` | `JsonSerializationException` | +| Invalid-data exception | `ZodJsonValidationException` (a `JsonException`) | `ZodNewtonsoftJsonValidationException` (a `JsonSerializationException`) | | Plumbing type | `JsonElement` | `JToken`/`JObject`/`JArray` | ## JSON Schema export @@ -95,14 +159,23 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ `ToJsonSchemaOptions`: `IncludeSchema` (default `true`, emits `$schema`), `Id`, `Title`. +`ToJsonSchemaConverter.ConvertUntyped(object schema, options)` (`ZodSharp.JsonSchema`) converts a schema or +validator known only at runtime, unwrapping the `ZodSchemaValidator`/`ZodUntypedValidator` adapters. +Rules with no JSON Schema keyword (`CidrRule`, `JWTRule`, an any-family `IPAddressRule`) are listed as +unsupported and recorded as `x-zod-*` vendor extensions (`x-zod-cidr`, `x-zod-jwt`, `x-zod-ipVersion`) through +`JsonSchemaDefinition.GetVendorExtensions()`/`SetVendorExtension(name, value)`. They are **not** serialized into +the JSON Schema; hosts that carry them (the OpenAPI transformers) read them. + `JsonSchemaDefinition` (`ZodSharp.JsonSchema`) is a mutable POCO mirroring the keywords — identity (`$schema`/`$id`/`$ref`), type and title, string/number/array/object constraints, composition (`anyOf`/`oneOf`/`allOf`), `enum`/`const`, `$defs` and draft-07 `definitions`, and the metadata keywords including OpenAPI 3.0 `nullable`. Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, -`ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodLazy`. Notable -behaviours: +`ZodUnion`, `ZodDiscriminatedUnion` (as `oneOf`), `ZodArray`, `ZodLiteral`, `ZodNullable`, +`ZodNativeEnum`/`ZodEnum`, `ZodLazy`. String formats: `IPAddress` exports `format: ipv4`/`ipv6`, `Duration` +`format: duration`, `DateString` `format: date`, `DatetimeString` `format: date-time` plus its UTC pattern, +`TimeString` a local-time pattern, and `Base64String` `contentEncoding: base64`. Notable behaviours: - objects emit `additionalProperties: false`; - literals emit `const` plus `type`; @@ -183,6 +256,10 @@ Mapped keywords: - Objects — `required` and optional fields. Arrays — `items`, `minItems`, `maxItems` (a nullable item schema — `anyOf` with a `{"type":"null"}` branch — imports as a nullable element). +Imported schemas report JSON kinds, never CLR types (`"Expected string, but got number"`); `minItems`/`maxItems` +failures read like `ZodArray`'s (`"Array must have at least 2 elements, but got 1"`, origin `array`, both bounds); +element and record-value errors use typed index and key segments. + ### Limits `$ref` resolves **local** references only (`#/…`, for example `#/$defs/Name`). An external target @@ -217,7 +294,14 @@ re-checks the fixtures under Zod and parses the C# output — closing the loop i If the C# output directory is empty, the vitest suite tells you to run the C# cross-platform tests first. +## OpenAPI + +On ASP.NET Core 11, the `Purview.ZodSharp.AspNetCore.OpenApi` package feeds the same JSON Schema - including +the `x-zod-*` vendor extensions - into the OpenAPI document (`options.AddZodSharp()` on `OpenApiOptions`), so +the document describes exactly what ZodSharp enforces. See the `purview-zodsharp-aspnetcore` skill. + ## Related skills -`purview-zodsharp-core` for the schema API, and `purview-zodsharp-source-generator` for the generated -validators that export through `BuildJsonSchema`. +`purview-zodsharp-core` for the schema API and the error model, `purview-zodsharp-source-generator` for the +generated validators that export through `BuildJsonSchema`, and `purview-zodsharp-aspnetcore` for +ProblemDetails and OpenAPI. diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index 31d140ec..9d359faf 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -3,8 +3,9 @@ name: purview-zodsharp-source-generator description: >- Use when generating a compile-time validator with Purview.ZodSharp's [ZodSchema] attribute - which types it emits, the attribute options, DataAnnotations coverage, the OnZodValidate refinement hook, - async custom validation, automatic enum validation, IValidateOptions support, and the MSBuild - switches that control it. + async and service-aware validation, per-member validation and path resolution, typed Paths and + JsonNames, trim-safe registration, IValidatableObject, automatic enum validation, IValidateOptions + support, and the MSBuild switches that control it. license: MIT metadata: product: Purview.ZodSharp @@ -14,8 +15,9 @@ metadata: # The `[ZodSchema]` source generator Mark a class, struct or record with `[ZodSchema]` and a static, zero-allocation validator is emitted -at compile time — no reflection at runtime. The attribute itself is generated into the `ZodSharp` -namespace by `Purview.ZodSharp.SourceGenerators`, so nothing beyond `Purview.ZodSharp` is needed. +at compile time — no reflection at runtime. The attribute itself is `ZodSharp.ZodSchemaAttribute` in the` +`Purview.ZodSharp` assembly (as is `[ErrorType]`), so nothing beyond `Purview.ZodSharp` is needed and the` +generator emits no marker types into your project. ```csharp using System.ComponentModel.DataAnnotations; @@ -43,14 +45,25 @@ var validated = UserSchema.Parse(user); // throws ZodException on failure | Artifact | Shape | | --- | --- | -| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | -| `{TypeName}SchemaValidator` | `partial class : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — the DI adapter, with `Validate`/`ValidateAsync`/`BuildJsonSchema`. Primary schema only; `{SchemaName}Validator` when renamed. | -| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` — only when `IValidateOptions` support is on, and the target is a class. | +| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `ValidateAsync(value, services, ct)`, `RequiresAsyncValidation`, the member API below, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | +| `{TypeName}Schema.Paths` | one `ZodPath` constant per member (`OrderSchema.Paths.Lines`) for refinements and tests | +| `{TypeName}Schema.JsonNames` | `Get(memberName)` returns the `[JsonPropertyName]` name, or `null`; `ToJsonNamedPath(path, namingPolicy)` renames a whole path without reflection | +| `{TypeName}SchemaValidator` | `partial class` implementing `IZodSchemaValidator`, `IZodMemberValidator`, `IZodMemberResolver`, `IZodUntypedValidator`, `IZodJsonPathMapper` and `IJsonSchemaProvider` — the adapter hosts use. Primary schema only; `{SchemaName}Validator` when renamed. | +| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` (and, on .NET 11, `IAsyncValidateOptions` when the type has async rules) — only when `IValidateOptions` support is on, and the target is a class. | +| `ZodSharp.Generated.{Assembly}ZodSharpSchemas.Register(factory)` | registers every generated validator in the assembly by name — trim- and AOT-safe | +| `services.AddZodSharp{Assembly}Schemas()` | the DI form of `Register`, emitted when Microsoft.Extensions.DependencyInjection is referenced. `{Assembly}` is the assembly name in PascalCase (`Contoso.Orders.Api` → `AddZodSharpContosoOrdersApiSchemas()`) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | the marker `IZodSchemaFactory` assembly scanning consumes. Primary, non-nested schemas only. | `BuildJsonSchema` is built from the same metadata as `Validate`, so a generated schema exports through `Z.ToJsonSchema` without rebuilding its rules by hand. +No marker type is emitted: `[ZodSchema]` and `[ErrorType]` are ordinary types in `Purview.ZodSharp` (3.x). ZodSharp +2 generated `[Embedded]` copies of both into every compilation, so a **library** packed against 2.x and rebuilt +against 3.x fails package validation with `CP0001` for `T:ZodSharp.ZodSchemaAttribute` and +`T:ZodSharp.Core.ErrorTypeAttribute`. The removal is safe (no compiler binds to an `[Embedded]` type in a reference): +run `dotnet pack -p:ApiCompatGenerateSuppressionFile=true` and check in the `CompatibilitySuppressions.xml` it writes, +until the baseline is a 3.x build. The changelog's upgrade notes give the entries by hand. + ```csharp var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); @@ -90,6 +103,12 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]`, then the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins; an unset level defers to the next. An explicit `AllowNulls = true` exports a `null` branch on the collection's `items` schema. +- **Struct collections:** nullable wrappers are unwrapped only when present. A default `ImmutableArray` + follows missing-value rules (`[Required]` => `missing_field`, optional nullable => accepted, non-nullable + => `invalid_type`). To validate it as empty, set `DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty` + on `[CollectionBehavior]` or `[assembly: ZodSharpSchemaDefaults]`, or set the + `ZodSharpDefaultImmutableArrayBehavior` MSBuild property. Precedence: property, assembly, MSBuild, + built-in `ValidationError`. An absent nullable wrapper remains missing; the model is never mutated. - **Dictionaries are maps, not arrays.** A property whose type implements `IDictionary` or `IReadOnlyDictionary` (for example `Dictionary`, `SortedDictionary`, `ConcurrentDictionary`, `ImmutableDictionary`) validates every **value** against the value type's @@ -98,11 +117,42 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes `[CollectionBehavior(AllowNulls = true)]` accepts it). A dictionary exports as `{ "type": "object", "additionalProperties": … }` and `[Length]`/`[MinLength]`/`[MaxLength]` map to `minProperties`/`maxProperties` — never `z.array`/`items`. +- **Collection counts in trimmed apps:** `[MinLength]`/`[MaxLength]`/`[Length]` constructors are + `[RequiresUnreferencedCode]` (IL2026). Use `[MinCount(n)]`, `[MaxCount(n)]` and `[CountRange(min, max)]` + from `ZodSharp.Rules`: same generated check, error identity and JSON Schema keywords. `ZODSGEN053` + (with a code fix) flags the BCL attributes on a collection when `PublishTrimmed`, `PublishAot`, + `IsTrimmable` or `IsAotCompatible` is set; on a scalar the count attributes are `ZODSGEN030`. - Classes, structs and records are supported. Structs get no `IValidateOptions` (`ZODSGEN028` if requested). -DataAnnotations coverage, positional-record attribute targets, structured size issues and the -`[Range]` shapes are in [references/dataannotations.md](references/dataannotations.md). +- `IValidatableObject.Validate` runs after the members and type rules, as in DataAnnotations; its + results report code `validation_failed`, category `custom`, at the member names they list. On .NET 11 + `IAsyncValidatableObject` and `AsyncValidationAttribute` members run in `ValidateAsync`; a + synchronous `Validate` of such a type throws `InvalidOperationException` (`ZODSGEN048`). +- **C# 15:** a member typed as a union dispatches to the active case's schema, and a member typed as a + closed class to the derived type's schema (`ZODSGEN049` when a derived type has no `[ZodSchema]`). + Paths never include the union's `Value`. +- Nested validation is depth-guarded: `ZodValidationDepth.MaxDepth` (32) throws + `InvalidOperationException` instead of overflowing the stack on a cycle. +- Paths are typed: `Items[0].Name`, `Map[en].Value`, `Map["0"].Value` from `FullPath.ToDottedString()`. + +DataAnnotations coverage, codes and categories, positional-record attribute targets, structured size +issues, error messages and the `[Range]` shapes are in +[references/dataannotations.md](references/dataannotations.md). The member API, the async graph and +registration are in [references/generated-api.md](references/generated-api.md). + +## Member validation and resolution + +```csharp +OrderSchema.IsValidatableMember("CustomerName"); // has rules of its own? +var field = OrderSchema.ValidateMember(order, "CustomerName"); +OrderSchema.TryResolveMember(order, error.FullPath, out var container, out var memberName); +``` + +`ValidateMember`/`ValidateMemberAsync` run **one member's own rules** - no nested recursion, type +rules, `IValidatableObject` or refinement - which is what field-level validation (a Blazor field +change) needs. `TryResolveMember` walks an error path to the object that owns the failing member, +without reflection. ## The `OnZodValidate` refinement hook @@ -119,7 +169,7 @@ public partial class Order partial void OnZodValidate(RefineCtx context) { if (context.Value.Total < 0) - context.AddIssue("invalid_range", "Total cannot be negative", [nameof(Total)]); + context.AddIssue("invalid_total", "Total cannot be negative", OrderSchema.Paths.Total); } } ``` @@ -146,21 +196,45 @@ the hook while keeping the hook optional. **It is not part of your API; never im Mutually exclusive with `OnZodValidate` — declaring both is `ZODSGEN029`. ```csharp -public partial class UserSchemaValidator +[ZodSchema] +public partial class SignUp { - public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) + [Required, EmailAddress] + public string Email { get; set; } = ""; + + [ZodAsyncMember(nameof(Email))] // also run by ValidateMemberAsync(value, "Email", ...) + internal static async ValueTask> CustomValidationAsync( + SignUp value, IServiceProvider services, CancellationToken cancellationToken) { - await Task.Delay(1, ct); - return ValidationResult.Success(value); + var directory = services.GetRequiredService(); + return await directory.IsEmailTakenAsync(value.Email, cancellationToken) + ? ValidationResult.Failure( + ValidationError.Create("email_taken", "Email is already registered", SignUpSchema.Paths.Email)) + : ValidationResult.Success(value); } } + +var result = await SignUpSchema.ValidateAsync(model, serviceProvider, cancellationToken); ``` -Signature `ValueTask> Name(T value, CancellationToken ct)`. The default lookup -name is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the -**model type** must be `static`; one on the generated `{TypeName}SchemaValidator` partial may be an -instance method. The generated `ValidateAsync` runs the synchronous `Validate`, awaits the custom -method, and merges the error sets. +Signature `ValueTask> Name(T value, CancellationToken ct)`, or the service-aware +`Name(T value, IServiceProvider services, CancellationToken ct)`, which receives the services passed +to `ValidateAsync` (the bridge, ProblemDetails and options hosts pass theirs). The default lookup name +is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the +**model type** must be `static` and not `private` (`ZODSGEN013`, `ZODSGEN014`); one on the generated +`{TypeName}SchemaValidator` partial may be an instance method. + +`ValidateAsync` runs the synchronous rules, awaits nested schemas' async validation (re-pathed under +the member), then the custom method, and merges the errors. It completes synchronously when nothing in +the graph is asynchronous; `{Type}Schema.RequiresAsyncValidation` is that compile-time answer. +Without `[ZodAsyncMember]`, the custom method runs only on full validation. + +CA1849, when enabled, would report every `Validate` and `ValidateMember` call inside async code because +`ValidateAsync` exists. The analyzer package suppresses it (`ZODSUP001`), so call the synchronous path freely: it is +cheaper (no `ValueTask`, no state machine) and never blocks. Use `ValidateAsync` when the graph has asynchronous +rules — `RequiresAsyncValidation` is the compile-time answer, and `ZODSGEN048` says so at build time. The suppression +is by call target: CA1849 still reports for `Task.Wait`, `Thread.Sleep` and other libraries' `Validate`/`ValidateAsync` +pairs. ## Automatic enum validation @@ -194,6 +268,8 @@ when the property declares an explicit `[AllowedValues]` allow-list (which gover Enabled by `GenerateIValidateOptions = true`, by auto-detection (unset, target is not a value type, and the type name ends with a configured suffix — `Options` or `Settings` by default), or by MSBuild. +Each failure reads `"{path}: {message} ({code})"`. A hand-authored `[OptionsValidator]` for the same +type conflicts (`ZODSGEN050`). | MSBuild property | Default | Behaviour | | --- | --- | --- | @@ -204,6 +280,7 @@ and the type name ends with a configured suffix — `Options` or `Settings` by d | `ZodSharpEnumSerialization` | `Number` | `String` exports enums as member names, matching a global `JsonStringEnumConverter` | | `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | | `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collections may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDefaultImmutableArrayBehavior` | `ValidationError` | default immutable arrays follow missing-value rules; `TreatAsEmpty` runs collection rules against empty arrays | | `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; pair with the integration package's `UtcDateTimeOffsetConverter`. Both modes export an exact sentinel pattern | `DateTimeOffset` precedence: `[DateTimeOffsetBehavior]` on the property/parameter, then on the type, then diff --git a/skills/purview-zodsharp-source-generator/references/dataannotations.md b/skills/purview-zodsharp-source-generator/references/dataannotations.md index 30e92ec3..6a848c1b 100644 --- a/skills/purview-zodsharp-source-generator/references/dataannotations.md +++ b/skills/purview-zodsharp-source-generator/references/dataannotations.md @@ -5,22 +5,21 @@ direct, typed codegen. Nothing is reflected at runtime. ## Supported attributes -| Attribute | Generated behaviour | Failure code | +| Attribute | Generated behaviour | `Code` / `Category` | | --- | --- | --- | -| `[Required]` | nullable property must not be null; a string with `AllowEmptyStrings=false` must be non-empty | `missing_field` | -| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `too_small` / `too_big` | -| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `too_small` / `too_big` | -| `[MinLength(n)]` | checked only when `n > 0` | `too_small` | -| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` | -| `[Range(…)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | -| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | -| `[AllowedValues(…)]` | typed equality against the allowed set | `invalid_value` | -| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | -| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | -| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` | -| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | -| `[Display(Name=…)]` | not validated; `Name` is the display name in messages and `{0}` placeholders | — | - +| `[Required]` | `RequiredAttribute` semantics: a string is missing when `null`, empty or whitespace-only; `AllowEmptyStrings = true` rejects only `null` | `missing_field` / `invalid_type` | +| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `invalid_length` / `too_small`, `too_big` (both bounds) | +| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `invalid_length` / `too_small`, `too_big` (both bounds) | +| `[MinLength(n)]` | checked only when `n > 0` | `too_small` / `too_small` | +| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` / `too_big` | +| `[Range(…)]` | inclusive or exclusive (`MinimumIsExclusive`/`MaximumIsExclusive`) bounds in `Lower`/`Upper`; origin from the value's Zod type | `invalid_range` / `too_small`, `too_big` | +| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings; `pattern` parameter | `invalid_string` / `invalid_format` | +| `[AllowedValues(…)]` | typed equality against the allowed set; enums listed by name in `options` | `invalid_value` / `invalid_value` | +| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` / `invalid_value` | +| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` / `invalid_format` | +| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` / `invalid_format` | +| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` / `invalid_value` | +| `[Display(Name=…)]`, `[Display(Name=…, ResourceType=…)]` | not validated; the display name in messages (`{displayName}`, `{0}`); `ResourceType` is read at runtime | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. @@ -62,10 +61,11 @@ Size attributes generate direct `Length`/`Count` access where possible: - `IEnumerable` / `IEnumerable` → one counted pass via `CollectionCountHelper.GetCount`, with fast paths for `ICollection`, `IReadOnlyCollection` and non-generic `ICollection` -A structured size failure carries the same metadata as the runtime API: `Code` (`too_small` / -`too_big`), `Origin` (`string` for strings, `array` for arrays, `collection` for countable and -`IEnumerable` collections), `Minimum`/`Maximum` (the inclusive bound), `Inclusive` (`true`) and -`Path`. +A structured size failure carries the same metadata as the runtime API: `Code` (`invalid_length` for +`[Length]`/`[StringLength]`, `too_small`/`too_big` for `[MinLength]`/`[MaxLength]`), `Category` +(`too_small`/`too_big` by direction), `Origin` (`string` for strings, `record` for dictionaries, `array` +for arrays and every other countable or `IEnumerable` collection), `Lower`/`Upper` (both bounds for `[Length]` and +`[StringLength]`) and `FullPath`. ```csharp [ZodSchema] @@ -77,7 +77,7 @@ public sealed class Basket } var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); -// Code == "too_small", Minimum == 2, Origin == "array", Inclusive == true +// Code == "invalid_length", Category == "too_small", Lower == ErrorBound(2, Inclusive: true) ``` ## `[Range]` @@ -94,11 +94,17 @@ Bounds are emitted as static typed fields and compared without running the attri ## Error messages -`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, are honoured, with `{0}` -(display name), `{1}` and `{2}` (bound) formatted through -`string.Format(CultureInfo.CurrentCulture, …)`. Supplying only one half of the resource name/type -pair is `ZODSGEN005`. - +Messages are formatted **at validation time** by `ZodMessageFormat` from a named template kept in +`ValidationError.MessageFormat`, with `displayName`, `member`, `declaringType` and the rule's values +(`minimum`, `maximum`, `pattern`, `options`, …) in `Parameters`. Formatting never throws: a stray brace +in a custom message renders literally. + +`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, replace the text only (the +code, category and bounds are kept). Positional placeholders keep their DataAnnotations meaning: `{0}` is +the display name and `{1}`/`{2}` the attribute's values - for `[Length]` `{1}` is the minimum and `{2}` +the maximum, as `LengthAttribute` formats them; for `[StringLength]` `{1}` is the maximum and `{2}` the +minimum. Supplying only one half of the resource name/type pair is `ZODSGEN005`; a placeholder the +attribute does not supply is `ZODSGEN052`. ## Applicability is a compile error, not a silent skip - `[Length]` with `min > max` → `ZODSGEN003`. diff --git a/skills/purview-zodsharp-source-generator/references/generated-api.md b/skills/purview-zodsharp-source-generator/references/generated-api.md new file mode 100644 index 00000000..7ef1675a --- /dev/null +++ b/skills/purview-zodsharp-source-generator/references/generated-api.md @@ -0,0 +1,95 @@ +# The generated validator API + +For a `[ZodSchema] partial class Order`, the generator emits these public members on `OrderSchema` +(all `static`): + +| Member | Purpose | +| --- | --- | +| `Validate(Order value)` | full synchronous validation; one generated method per member, then type rules, `IValidatableObject`, then the `OnZodValidate` hook | +| `ValidateAsync(Order value, IServiceProvider? services = null, CancellationToken cancellationToken = default)` | full validation including nested async schemas, `IAsyncValidatableObject`, `AsyncValidationAttribute` members and the custom async method | +| `const bool RequiresAsyncValidation` | `true` when anything in the type's graph is asynchronous | +| `IsValidatableMember(string memberName)` | whether the member has rules of its own | +| `ValidateMember(Order value, string memberName)` | the member's own rules only; an unknown member succeeds | +| `ValidateMemberAsync(value, memberName, services, cancellationToken)` | as above plus the member's async rules and any `[ZodAsyncMember]` custom method for it | +| `TryResolveMember(Order value, ZodPath path, out object? container, out string memberName)` | follows an error path to the object that owns the failing member | +| `Paths.{Member}` | `ZodPath` constants | +| `JsonNames.Get(string memberName)` | the member's `[JsonPropertyName]`, or `null` | +| `ToJsonNamedPath(ZodPath path, Func? namingPolicy = null)` | renames member segments to JSON names; members without `[JsonPropertyName]` use `namingPolicy` | +| `Parse`, `BuildJsonSchema`, `ApplyAnd`, `ApplyOr`, `ApplyRefine` | as before | + +`ValidateCore`/`ValidateCoreAsync`/`AppendJsonNamedPath` overloads with a `depth` or `offset` parameter +are public only so nested schemas can call them; do not call them directly. + +## Field-level semantics + +`ValidateMember` never recurses into a nested schema and never runs type-level rules, +`IValidatableObject` or refinements, so every error it returns belongs to the member that changed. +Collection members still check for `null` elements. Hosts reach it through `IZodMemberValidator` +or the untyped `IZodUntypedValidator.ValidateMemberAsync`. + +`TryResolveMember` returns `true` when it followed the full path. For a path ending in an index or key +it resolves to the owner of the collection member; for a path it cannot follow it resolves to the +nearest object reached. `memberName` is empty for a model-level error. + +```csharp +var result = OrderSchema.Validate(order); +foreach (var error in result.Errors) +{ + OrderSchema.TryResolveMember(order, error.FullPath, out var container, out var memberName); + // error.FullPath = Lines[0].Sku -> container = order.Lines[0], memberName = "Sku" +} +``` + +## JSON names + +```csharp +var jsonPath = OrderSchema.ToJsonNamedPath(error.FullPath, JsonNamingPolicy.CamelCase.ConvertName); +var key = jsonPath.ToDottedString(); // customer_name (from [JsonPropertyName]), lines[0].sku +``` + +The adapter implements `IZodJsonPathMapper`, which is what a host uses when it renders +`ZodPathNaming.Json` keys under trimming. + +## Registration + +Each assembly gets one registration class naming every generated validator: + +```csharp +// Without DI +IZodSchemaFactory factory = new ZodSchemaFactory(); +ZodSharp.Generated.ContosoOrdersApiZodSharpSchemas.Register(factory); + +// With DI (Microsoft.Extensions.DependencyInjection referenced) +services.AddZodSharpContosoOrdersApiSchemas(); +``` + +`AddZodSharp{Assembly}Schemas()` calls `services.AddZodSchemaRegistration(...)`, which also registers +the factory (`AddZodSharpFactory`) when it is missing; the factory applies every `ZodSchemaRegistration` +when it is first resolved. Unlike `RegisterFromAssembly` and `AddZodSharp(o => o.ScanAssemblies...)`, +nothing is discovered through metadata, so it is the form to use for trimmed and Native AOT apps, +including Blazor WebAssembly. + +## Async graph + +- Nested schemas' `ValidateAsync` is awaited and its errors are re-pathed under the member, so a nested + type's custom async method reports its errors (it used to be dropped). +- A custom async method may take `(T value, CancellationToken ct)` or + `(T value, IServiceProvider services, CancellationToken ct)`. The services are the ones passed to + `ValidateAsync`; calling `ValidateAsync` without services on a type with a service-aware method + throws `InvalidOperationException`. +- `[ZodAsyncMember(params string[] members)]` on the custom method makes `ValidateMemberAsync` run it + when one of the listed members is validated, keeping only the errors it reports for that member. +- The method must be reachable from the generated code: on the model type it must be `static` and + `internal` or `public` (`ZODSGEN014` for `private`). + +## Options validators + +The generated `{Type}Validator : IValidateOptions` reports each failure as +`"{path}: {message} ({code})"`. On .NET 11 it also implements `IAsyncValidateOptions` when the type +has asynchronous rules, so `ValidateOnStart()` runs them. + +## C# 15 unions and closed hierarchies + +A member typed as a C# union validates the active case with that case's schema; a member typed as a +closed class validates with the runtime derived type's schema. Error paths are those of the case (no +`Value` segment). Each derived type of a closed class needs `[ZodSchema]` (`ZODSGEN049`). diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d7712bef..81a05d78 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,8 +1,9 @@ - - All + + Supported preview ZodSharp @@ -10,15 +11,22 @@ true https://github.com/purview-dev/zodsharp $(NoWarn);CA1031;CA1307;CA1510; + + universal + + true + + - $(PurviewTargetFrameworksAll) + $(PurviewTargetFrameworksSupported) diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ea8040de..04befd39 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -7,14 +7,17 @@ version ships. This lives in the repo because the baseline is package-specific; Purview.BuildSdk has no default for it. - 2.4.0 validates against 2.3.0 with no suppressions: the dictionary validation, record JSON Schema and - minProperties/maxProperties additions are additive public API. An intended change from here needs a - CompatibilitySuppressions.xml next to the project that owns it (generate one with + 3.0.0 validates against 2.4.2. It drops net8.0 and net9.0, so every project that shipped in 2.4.2 carries a + CompatibilitySuppressions.xml for the removed target frameworks (and any intended API change). An intended + change needs a CompatibilitySuppressions.xml next to the project that owns it (generate one with `dotnet pack /p:GenerateCompatibilitySuppressionFile=true`), and that file is then the evidence behind the matching upgrade note — a change that is not listed in both is a defect. --> true - 2.4.0 + + 2.4.2 + diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 39f6e3cd..4644f7a9 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -10,20 +10,35 @@ + + + + + + + + + + + + + + + diff --git a/src/src/AspNetCore.OpenApi/.editorconfig b/src/src/AspNetCore.OpenApi/.editorconfig new file mode 100644 index 00000000..64a0a497 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/.editorconfig @@ -0,0 +1,6 @@ +# Layered over the Purview.BuildSdk-managed root .editorconfig (which must not be edited). + +[*.{cs,csx,cake}] +# "OpenApi" is the spelling ASP.NET Core and Microsoft.OpenApi use (OpenApiOptions, IOpenApiSchemaTransformer), +# so ZodSharp's OpenAPI integration follows it rather than PDS0004's "OpenAPI". Scoped to this project. +dotnet_analyzer_configuration.pds0004.allowed_words = Api diff --git a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj new file mode 100644 index 00000000..7ef2c1b2 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj @@ -0,0 +1,33 @@ + + + + net11.0 + + true + true + true + + true + + $(NoWarn);NU5104 + ZodSharp.AspNetCore.OpenApi + Purview.ZodSharp.AspNetCore.OpenApi + Purview.ZodSharp.AspNetCore.OpenApi + OpenAPI transformers for ASP.NET Core that describe the constraints, formats, unions and error contract ZodSharp enforces. + zod;validation;aspnetcore;openapi;jsonschema + + + + + + + + + + + diff --git a/src/src/AspNetCore.OpenApi/Extensions/Microsoft/AspNetCore/OpenApi/ZodSharpOpenApiOptionsExtensions.cs b/src/src/AspNetCore.OpenApi/Extensions/Microsoft/AspNetCore/OpenApi/ZodSharpOpenApiOptionsExtensions.cs new file mode 100644 index 00000000..c2d47d9d --- /dev/null +++ b/src/src/AspNetCore.OpenApi/Extensions/Microsoft/AspNetCore/OpenApi/ZodSharpOpenApiOptionsExtensions.cs @@ -0,0 +1,39 @@ +using ZodSharp.AspNetCore.OpenApi; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Registers the ZodSharp OpenAPI transformers. +/// +public static class ZodSharpOpenApiOptionsExtensions +{ + extension(OpenApiOptions options) + { + /// + /// Describes what ZodSharp enforces in the OpenAPI document: the constraints and formats of + /// ZodSharp-validated types (), the validation problem responses + /// of the operations that bind them (), and the shared error + /// contract schemas (). + /// + /// Configures the transformers. + /// The options, for chaining. + /// + /// + /// builder.Services.AddOpenApi(options => options.AddZodSharp()); + /// + /// + public OpenApiOptions AddZodSharp(Action? configure = null) + { + ArgumentNullException.ThrowIfNull(options); + + ZodOpenApiOptions zodOptions = new(); + configure?.Invoke(zodOptions); + + options.AddSchemaTransformer(new ZodOpenApiSchemaTransformer(zodOptions)); + options.AddOperationTransformer(new ZodOpenApiOperationTransformer(zodOptions)); + options.AddDocumentTransformer(new ZodOpenApiDocumentTransformer(zodOptions)); + + return options; + } + } +} diff --git a/src/src/AspNetCore.OpenApi/MapperContext.cs b/src/src/AspNetCore.OpenApi/MapperContext.cs new file mode 100644 index 00000000..88b5c67f --- /dev/null +++ b/src/src/AspNetCore.OpenApi/MapperContext.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// The per-schema state the mapper needs: the options, and where to report a keyword ZodSharp overrode. +/// +/// The transformer options. +/// The logger conflicts are reported to; outside Development. +/// A description of the schema being transformed, for the log line. +sealed class MapperContext(ZodOpenApiOptions options, ILogger? logger, string location) +{ + public ZodOpenApiOptions Options { get; } = options; + + /// + /// Gets or sets a format ZodSharp's export assigns to the member's CLR type that does not describe its + /// System.Text.Json wire form, and so is not written. + /// + public string? IgnoredFormat { get; set; } + + /// + /// Reports that ZodSharp replaced a keyword value the platform had already set (for example from a + /// DataAnnotation). Only a replaced value is reported: a keyword the platform left unset is not a conflict. + /// + public void Conflict(string keyword, object? current, object? value) + { + if (current is null || logger is null) + return; + + ZodOpenApiLog.KeywordOverridden(logger, keyword, location, current, value); + } +} + +static partial class ZodOpenApiLog +{ + [LoggerMessage( + EventId = 1, + Level = LogLevel.Debug, + Message = "ZodSharp overrode OpenAPI keyword '{Keyword}' on {Location}: '{Current}' -> '{Value}'. ZodSharp is the enforcing validator." + )] + public static partial void KeywordOverridden( + ILogger logger, + string keyword, + string location, + object current, + object? value + ); +} diff --git a/src/src/AspNetCore.OpenApi/Sdk/README.md b/src/src/AspNetCore.OpenApi/Sdk/README.md new file mode 100644 index 00000000..164d4e0c --- /dev/null +++ b/src/src/AspNetCore.OpenApi/Sdk/README.md @@ -0,0 +1,95 @@ +# Purview.ZodSharp.AspNetCore.OpenApi + +[![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.AspNetCore.OpenApi.svg)](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore.OpenApi) +[![Release](https://github.com/purview-dev/zodsharp/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/zodsharp/actions/workflows/release.yml) + +OpenAPI integration for [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp): ASP.NET Core 11 +OpenAPI transformers that make the document describe exactly what ZodSharp enforces — constraints, formats, +unions, maps and the validation error contract — with one line of setup. + +`net11.0` only: ASP.NET Core 11 builds documents on the Microsoft.OpenApi 3.x object model. + +## Installation + +```bash +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi +``` + +## Usage + +```csharp +using Microsoft.AspNetCore.OpenApi; // AddZodSharp(this OpenApiOptions) lives here; it is not an implicit using + +builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(Program).Assembly)); +builder.Services.AddOpenApi(options => options.AddZodSharp()); + +var app = builder.Build(); +app.MapOpenApi(); +``` + +Options: + +```csharp +builder.Services.AddOpenApi(options => options.AddZodSharp(zod => +{ + zod.EmitVendorExtensions = true; // x-zod-* for rules without a JSON Schema keyword + zod.DiscriminatedUnionsAsOneOf = true; // oneOf + discriminator for polymorphic types + zod.DescribeValidationProblems = true; // 400 ZodValidationProblemDetails on validated endpoints +})); +``` + +All three default to `true` (`ZodOpenApiOptions`). Trimmed and Native AOT apps can register their validators with the generated `AddZodSharp{Assembly}Schemas()` instead of assembly scanning. + +## What it describes + +The source of truth is ZodSharp's own JSON Schema for a type: the generated `BuildJsonSchema()` of a +`[ZodSchema]` type, or a runtime schema registered in `IZodSchemaFactory`. A type ZodSharp does not know is left +exactly as ASP.NET Core produced it. + +| ZodSharp | OpenAPI keyword | +| --- | --- | +| `MinLength`/`MaxLength`/`Length`/`StringLength` | `minLength`/`maxLength` (strings), `minItems`/`maxItems` (collections), `minProperties`/`maxProperties` (maps) | +| `MinValue`/`MaxValue`/`GreaterThanOrEqual`/`LessThanOrEqual` | `minimum`/`maximum` | +| `GreaterThan`/`LessThan` | `exclusiveMinimum`/`exclusiveMaximum` | +| `MultipleOf` | `multipleOf` | +| `Int` | `type: integer` | +| `SafeInteger` | `type: integer` with ZodSharp's safe range | +| `Regex`/`RegularExpression` | `pattern` (several become `allOf`) | +| `StartsWith`/`EndsWith`/`Includes` | `pattern`, plus `x-zod-startsWith`/`x-zod-endsWith`/`x-zod-includes` | +| `Email`/`EmailAddress` | `format: email` | +| `Url` | `format: uri` | +| `UUID(version)` | `format: uuid`, a version pattern and `x-zod-uuidVersion` | +| `IPAddress(IPv4/IPv6)` | `format: ipv4`/`ipv6` | +| `Cidr`, `JWT` | `x-zod-cidr`, `x-zod-jwt` (listed under `x-zodsharp-unsupported`) | +| `DatetimeString`/`DateString` | `format: date-time` (with the UTC pattern)/`format: date` | +| `TimeString` | a local-time `pattern` and `x-zod-format: time` | +| `Duration` | `format: duration` | +| `Base64String` | `contentEncoding: base64` | +| `AllowedValues`/enum members | `enum` | +| `DeniedValues` | `not: { enum: [...] }` | +| `[Display(Description)]` | `description` | + +Members ZodSharp rejects when absent (non-nullable references, `[Required]`, `[RequiredZod]`) are listed in +`required`, and nullability uses `type: [x, "null"]`. When a DataAnnotation the platform mapped disagrees with +ZodSharp, ZodSharp's value is written — it is the validator that enforces the request — and in Development the +override is logged at `Debug`. + +Polymorphic types (`[JsonPolymorphic]`) are described with `oneOf` and the platform's `discriminator`. A C# union +keeps the platform's `anyOf`; ZodSharp only constrains each case. + +## Validation problem responses + +Every operation that binds a ZodSharp-validated parameter (a body, an `[AsParameters]` container, or a union of +ZodSharp cases) lists `400` — and `422` when an `ErrorType` registered in the `ErrorTypeRegistry` uses it — as +`application/problem+json` with the shared `ZodValidationProblemDetails` and `ZodValidationIssue` component +schemas. A `400` the endpoint already declares with `ProducesValidationProblem()` is merged into rather than +duplicated. + +## Documentation + +- [Homepage](https://purview.dev/projects/zodsharp/) +- [Documentation](https://purview.dev/docs/zodsharp/) + +## License + +MIT — see the package metadata in `Purview.ZodSharp.AspNetCore.OpenApi` on NuGet. diff --git a/src/src/AspNetCore.OpenApi/Sdk/buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets b/src/src/AspNetCore.OpenApi/Sdk/buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets new file mode 100644 index 00000000..739c3c18 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/Sdk/buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets @@ -0,0 +1,15 @@ + + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + diff --git a/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs b/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs new file mode 100644 index 00000000..34910782 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs @@ -0,0 +1,109 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Core; +using ZodSharp.JsonSchema; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Resolves ZodSharp's own JSON Schema view of a CLR type: the source of truth the transformers describe. +/// +/// +/// +/// A validator registered in wins, because it is what the host actually runs. +/// Otherwise the generated validator recorded by on the type's +/// assembly is used; that path is trim- and AOT-safe for the same reasons as +/// . +/// +/// +/// A type neither source knows, or whose schema exports as the empty (any) schema, resolves to +/// and is left untouched. Results are cached per type. +/// +/// +sealed class ZodJsonSchemaSource +{ + readonly ConcurrentDictionary _definitions = new(); + readonly ConcurrentDictionary> _generated = new(); + + public bool TryGet(Type type, IServiceProvider services, out JsonSchemaDefinition definition) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(services); + + var resolved = _definitions.GetOrAdd(type, Resolve, services); + definition = resolved!; + return resolved is not null; + } + + public bool IsKnown(Type type, IServiceProvider services) => TryGet(type, services, out _); + + JsonSchemaDefinition? Resolve(Type type, IServiceProvider services) + { + object? source = null; + + if ( + services.GetService() is { } factory + && factory.TryGetUntypedValidator(type, out var validator) + && validator is not null + ) + { + source = validator; + } + + source ??= CreateGeneratedValidator(type); + if (source is null) + return null; + + JsonSchemaDefinition definition; + try + { + definition = ToJsonSchemaConverter.ConvertUntyped( + source, + new ToJsonSchemaOptions { IncludeSchema = false } + ); + } + catch (NotSupportedException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + + return IsEmpty(definition) ? null : definition; + } + + object? CreateGeneratedValidator(Type type) + { + var generated = _generated.GetOrAdd(type.Assembly, ReadGeneratedValidators); + return generated.TryGetValue(type, out var attribute) + ? Activator.CreateInstance(attribute.ValidatorType) + : null; + } + + // Activator.CreateInstance over ValidatorType is trim- and AOT-safe: the property is annotated + // PublicParameterlessConstructor and the generator records the type as a typeof, which roots it. + static Dictionary ReadGeneratedValidators(Assembly assembly) + { + Dictionary validators = []; + foreach (var attribute in assembly.GetCustomAttributes()) + validators.TryAdd(attribute.TargetType, attribute); + + return validators; + } + + static bool IsEmpty(JsonSchemaDefinition definition) => + definition.Type is null + && definition.Ref is null + && definition.Properties is null + && definition.Items is null + && definition.AnyOf is null + && definition.OneOf is null + && definition.AllOf is null + && definition.Enum is null + && definition.Const is null + && definition.Format is null + && definition.Pattern is null; +} diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs new file mode 100644 index 00000000..a0d05935 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Adds the shared ZodValidationProblemDetails and ZodValidationIssue component schemas to a +/// document whose operations reference them. +/// +/// +/// A document with no ZodSharp-validated operation is left unchanged, so adding the integration to an API +/// that does not use ZodSharp validation does not add unused components. +/// +/// The transformer options. +public sealed class ZodOpenApiDocumentTransformer(ZodOpenApiOptions options) : IOpenApiDocumentTransformer +{ + readonly ZodOpenApiOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + public Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(context); + + if (_options.DescribeValidationProblems && ReferencesValidationProblem(document)) + ZodValidationProblemSchemas.Register(document); + + if (_options.DiscriminatedUnionsAsOneOf) + ApplyOneOf(document); + + return Task.CompletedTask; + } + + /// + /// Rewrites the unions the schema transformer marked from anyOf to oneOf, once the platform has + /// resolved their case references. + /// + static void ApplyOneOf(OpenApiDocument document) + { + if (document.Components?.Schemas is not { } schemas) + return; + + foreach (var schema in schemas.Values) + { + if ( + schema is OpenApiSchema { AnyOf: { Count: > 0 } cases, Metadata: { } metadata } union + && metadata.ContainsKey(ZodOpenApiSchemaMapper.OneOfMetadataKey) + ) + { + union.OneOf = cases; + union.AnyOf = null; + } + } + } + + static bool ReferencesValidationProblem(OpenApiDocument document) + { + if (document.Paths is null) + return false; + + foreach (var path in document.Paths.Values) + { + if (path.Operations is null) + continue; + + foreach (var operation in path.Operations.Values) + { + if (operation.Responses is null) + continue; + + foreach (var response in operation.Responses.Values) + { + if (response.Content is null) + continue; + + foreach (var mediaType in response.Content.Values) + { + if ( + mediaType.Schema is OpenApiSchemaReference { Reference.Id: { } id } + && string.Equals( + id, + ZodOpenApiOptions.ValidationProblemDetailsSchemaId, + StringComparison.Ordinal + ) + ) + { + return true; + } + } + } + } + } + + return false; + } +} diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs new file mode 100644 index 00000000..c155ef9b --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Lists the validation problem responses of every operation that binds a ZodSharp-validated parameter: +/// 400, plus 422 when an registered in the +/// uses it, each as application/problem+json with the +/// ZodValidationProblemDetails schema. +/// +/// +/// +/// A parameter is ZodSharp-validated when its type (or, for an [AsParameters] member, its container +/// type) has a validator registered in or a generated +/// [ZodSchema] validator. Services and special parameters (HttpContext, +/// CancellationToken, …) are skipped. +/// +/// +/// A response the endpoint already declares — typically ProducesValidationProblem() — is merged into +/// rather than duplicated: its description is kept and its application/problem+json schema is replaced +/// with the ZodSharp contract. +/// +/// +/// The transformer options. +public sealed class ZodOpenApiOperationTransformer(ZodOpenApiOptions options) : IOpenApiOperationTransformer +{ + readonly ZodOpenApiOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + readonly ZodJsonSchemaSource _source = new(); + + /// + public Task TransformAsync( + OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(context); + + if (!_options.DescribeValidationProblems || !IsValidated(context.Description, context.ApplicationServices)) + return Task.CompletedTask; + + var document = context.Document; + operation.Responses ??= []; + + foreach (var status in GetStatusCodes(context.ApplicationServices)) + DescribeResponse(operation.Responses, status, document); + + if (document is not null) + ZodValidationProblemSchemas.Register(document); + + return Task.CompletedTask; + } + + static void DescribeResponse(OpenApiResponses responses, int status, OpenApiDocument? document) + { + var key = status.ToString(CultureInfo.InvariantCulture); + OpenApiResponse response; + + if (responses.TryGetValue(key, out var existing)) + { + // A response component reference is the developer's own shared definition; leave it alone. + if (existing is not OpenApiResponse declared) + return; + + response = declared; + } + else + { + response = new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(status) }; + responses[key] = response; + } + + response.Content ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + OpenApiSchemaReference schema = new(ZodOpenApiOptions.ValidationProblemDetailsSchemaId, document); + + if ( + response.Content.TryGetValue(ZodOpenApiOptions.ProblemJsonContentType, out var mediaType) + && mediaType is OpenApiMediaType declaredMediaType + ) + { + declaredMediaType.Schema = schema; + } + else + { + response.Content[ZodOpenApiOptions.ProblemJsonContentType] = new OpenApiMediaType { Schema = schema }; + } + } + + static IEnumerable GetStatusCodes(IServiceProvider services) + { + yield return StatusCodes.Status400BadRequest; + + // Which error types an endpoint's rules can raise is not known statically, so 422 is listed only when + // the registry the ProblemDetails mapping uses contains an error type that maps to it. + var registry = + services.GetService>()?.Value.Registry ?? ErrorTypeRegistry.Default; + if (registry.All.Any(static errorType => errorType.HttpStatus == StatusCodes.Status422UnprocessableEntity)) + yield return StatusCodes.Status422UnprocessableEntity; + } + + bool IsValidated(ApiDescription description, IServiceProvider services) + { + foreach (var parameter in description.ParameterDescriptions) + { + if (parameter.Source == BindingSource.Services || parameter.Source == BindingSource.Special) + continue; + + if ( + parameter.ModelMetadata is { MetadataKind: ModelMetadataKind.Property, ContainerType: { } container } + && _source.IsKnown(container, services) + ) + { + return true; + } + + var type = parameter.Type; + if (type is null) + continue; + + type = Nullable.GetUnderlyingType(type) ?? type; + if (_source.IsKnown(type, services) || IsUnionOfKnownCases(type, services)) + return true; + } + + return false; + } + + /// + /// A C# union is validated through its cases, so a union body whose cases ZodSharp knows is validated. + /// + bool IsUnionOfKnownCases(Type type, IServiceProvider services) + { + if (services.GetService>()?.Value.SerializerOptions is not { } serializerOptions) + return false; + + JsonTypeInfo typeInfo; + try + { + typeInfo = serializerOptions.GetTypeInfo(type); + } + catch (NotSupportedException) + { + return false; + } + + return typeInfo.Kind == JsonTypeInfoKind.Union + && typeInfo.UnionCases.Any(unionCase => _source.IsKnown(unionCase.CaseType, services)); + } +} diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiOptions.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiOptions.cs new file mode 100644 index 00000000..3785f5b3 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiOptions.cs @@ -0,0 +1,45 @@ +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Options for the ZodSharp OpenAPI transformers registered by +/// OpenApiOptions.AddZodSharp(Action<ZodOpenApiOptions>?). +/// +public sealed class ZodOpenApiOptions +{ + /// + /// The component name of the ZodSharp validation ProblemDetails schema. + /// + public const string ValidationProblemDetailsSchemaId = "ZodValidationProblemDetails"; + + /// + /// The component name of the ZodSharp validation issue schema. + /// + public const string ValidationIssueSchemaId = "ZodValidationIssue"; + + /// + /// The media type validation problem responses are described with. + /// + public const string ProblemJsonContentType = "application/problem+json"; + + /// + /// Gets or sets whether rules that have no JSON Schema keyword, or that a keyword only approximates, are + /// described with x-zod-* vendor extensions (and unrepresentable rules listed under + /// x-zodsharp-unsupported). Defaults to . + /// + public bool EmitVendorExtensions { get; set; } = true; + + /// + /// Gets or sets whether discriminated unions are described with oneOf and a discriminator + /// rather than anyOf. A discriminated union matches exactly one case, so oneOf is the more + /// precise description and client generators produce better code from it. Defaults to + /// . + /// + public bool DiscriminatedUnionsAsOneOf { get; set; } = true; + + /// + /// Gets or sets whether operations whose parameters ZodSharp validates list the validation problem + /// responses (400, and 422 when a registered ErrorType uses it) with the + /// schema. Defaults to . + /// + public bool DescribeValidationProblems { get; set; } = true; +} diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs new file mode 100644 index 00000000..cd6c544e --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs @@ -0,0 +1,419 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using ZodSharp.JsonSchema; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Applies ZodSharp's JSON Schema view of a value onto the schema ASP.NET Core generated for it. +/// +/// +/// +/// ZodSharp's exporter already turns every rule into JSON Schema keywords, so this is a keyword-by-keyword +/// merge rather than a second rule interpreter. The conflict policy is the same for every keyword: a keyword +/// ZodSharp has no opinion on is left alone, an identical value is left alone, and a different value is +/// replaced, because ZodSharp is the validator that enforces it. +/// +/// +/// The output is the OpenAPI 3.1/3.2 (JSON Schema 2020-12) dialect: numeric exclusive bounds and +/// type: [x, "null"] for nullability. ZodSharp's OpenAPI 3.0 nullable flag is ignored. +/// +/// +static class ZodOpenApiSchemaMapper +{ + public const string UnsupportedExtensionName = "x-zodsharp-unsupported"; + public const string DiscriminatorExtensionName = "x-zod-discriminator"; + public const string DiscriminatorValueExtensionName = "x-zod-discriminatorValue"; + public const string OneOfMetadataKey = "zodsharp:oneOf"; + + /// + /// Applies a value schema (a property, parameter, item or scalar type), including its nullability. + /// + public static void ApplyValue(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + var inner = UnwrapNullable(source, out var nullable); + + if (target.Type is { } type && HasOwnType(inner)) + { + var updated = nullable ? type | JsonSchemaType.Null : type & ~JsonSchemaType.Null; + if (updated != type && updated != 0) + { + context.Conflict("type", type, updated); + target.Type = updated; + } + } + + ApplyKeywords(target, inner, context); + } + + /// + /// Applies the keywords of that describe a value (not the object members, which + /// are applied by their own property schemas). + /// + static void ApplyKeywords(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + ApplyType(target, source, context); + ApplyFormat(target, source, context); + ApplyLengths(target, source, context); + ApplyBounds(target, source, context); + ApplyPatterns(target, source, context); + ApplyValues(target, source, context); + + if (source.ContentEncoding is { } encoding) + target.ContentEncoding = Set("contentEncoding", target.ContentEncoding, encoding, context); + + if (source.UniqueItems is { } unique) + target.UniqueItems = Set("uniqueItems", target.UniqueItems, unique, context); + + if (source.Description is { } description) + target.Description = Set("description", target.Description, description, context); + + if (source.Items is { } items && target.Items is OpenApiSchema targetItems && !IsObject(items)) + ApplyValue(targetItems, items, context); + + if ( + source.AdditionalProperties is JsonSchemaDefinition values + && target.AdditionalProperties is OpenApiSchema targetValues + && !IsObject(values) + ) + { + ApplyValue(targetValues, values, context); + } + + ApplyVendorExtensions(target, source, context); + } + + static void ApplyType(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + // The only type change ZodSharp makes is narrowing a number to an integer (the Int and SafeInteger + // rules on a double). Every other type is the CLR type's wire form, which the platform already knows. + if ( + string.Equals(source.Type, "integer", StringComparison.Ordinal) + && target.Type is { } type + && type.HasFlag(JsonSchemaType.Number) + ) + { + var updated = (type & ~JsonSchemaType.Number) | JsonSchemaType.Integer; + context.Conflict("type", type, updated); + target.Type = updated; + } + } + + static void ApplyFormat(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + if (source.Format is { } format && !string.Equals(format, context.IgnoredFormat, StringComparison.Ordinal)) + target.Format = Set("format", target.Format, format, context); + } + + static void ApplyLengths(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + if (source.MinLength is { } minLength) + target.MinLength = Set("minLength", target.MinLength, minLength, context); + + if (source.MaxLength is { } maxLength) + target.MaxLength = Set("maxLength", target.MaxLength, maxLength, context); + + if (source.MinItems is { } minItems) + target.MinItems = Set("minItems", target.MinItems, minItems, context); + + if (source.MaxItems is { } maxItems) + target.MaxItems = Set("maxItems", target.MaxItems, maxItems, context); + + if (source.MinProperties is { } minProperties) + target.MinProperties = Set("minProperties", target.MinProperties, minProperties, context); + + if (source.MaxProperties is { } maxProperties) + target.MaxProperties = Set("maxProperties", target.MaxProperties, maxProperties, context); + } + + static void ApplyBounds(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + // A bound is applied as a pair: when ZodSharp states a lower bound it states the whole lower bound, so + // an inclusive minimum the platform derived from [Range] is cleared when ZodSharp's bound is exclusive + // (and the other way round). + if (source.Minimum is not null || source.ExclusiveMinimum is not null) + { + target.Minimum = SetNumber("minimum", target.Minimum, source.Minimum, context); + target.ExclusiveMinimum = SetNumber( + "exclusiveMinimum", + target.ExclusiveMinimum, + source.ExclusiveMinimum, + context + ); + } + + if (source.Maximum is not null || source.ExclusiveMaximum is not null) + { + target.Maximum = SetNumber("maximum", target.Maximum, source.Maximum, context); + target.ExclusiveMaximum = SetNumber( + "exclusiveMaximum", + target.ExclusiveMaximum, + source.ExclusiveMaximum, + context + ); + } + + if (source.MultipleOf is { } multipleOf) + { + var value = (decimal)multipleOf; + if (target.MultipleOf != value) + { + context.Conflict("multipleOf", target.MultipleOf, value); + target.MultipleOf = value; + } + } + } + + static void ApplyPatterns(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + if (source.Pattern is { } pattern) + { + // A Guid's wire form is already described by the platform's format: uuid, and ZodSharp's base Guid + // pattern is the same constraint spelled differently. Keep the platform's description. + if ( + !( + string.Equals(target.Format, "uuid", StringComparison.Ordinal) + && string.Equals(pattern, Rules.GuidRule.JsonSchemaPattern, StringComparison.Ordinal) + ) + ) + { + target.Pattern = Set("pattern", target.Pattern, pattern, context); + } + } + + // Several patterns cannot share the single pattern keyword, so ZodSharp promotes them to allOf + // branches. The branches are written without a type, so a nullable value still accepts null. + var branches = source.AllOf?.Where(IsPatternBranch).Select(static branch => branch.Pattern).ToList(); + if (branches is not { Count: > 0 }) + return; + + if (target.Pattern is { } existing) + { + context.Conflict("pattern", existing, null); + target.Pattern = null; + } + + target.AllOf ??= []; + foreach (var branch in branches) + { + if ( + target.AllOf.Any(schema => + schema is OpenApiSchema { } current + && string.Equals(current.Pattern, branch, StringComparison.Ordinal) + ) + ) + { + continue; + } + + target.AllOf.Add(new OpenApiSchema { Pattern = branch }); + } + } + + /// The number of values in the (unwrapped) source's enum, or 0 when it has none. + public static int EnumValueCount(JsonSchemaDefinition source) => UnwrapNullable(source, out _).Enum?.Count ?? 0; + + /// Applies only the enum keyword of the (unwrapped) source. + /// The platform's schema. + /// ZodSharp's schema. + /// The mapping context. + /// + /// Converts each value to the form written on the wire (for example an enum member's name when System.Text.Json + /// writes enums as strings); converts the value as it is. + /// + public static void ApplyEnum( + OpenApiSchema target, + JsonSchemaDefinition source, + MapperContext context, + Func? toNode = null + ) + { + if (UnwrapNullable(source, out _).Enum is not { Count: > 0 } values) + return; + + var nodes = toNode is null ? ToNodes(values) : [.. values.Select(toNode).OfType()]; + if (!SameNodes(target.Enum, nodes)) + { + context.Conflict("enum", Render(target.Enum), Render(nodes)); + target.Enum = nodes; + } + } + + static void ApplyValues(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + ApplyEnum(target, source, context); + + if (source.Not is { Enum: { Count: > 0 } denied }) + { + var nodes = ToNodes(denied); + if (target.Not is not OpenApiSchema { } current || !SameNodes(current.Enum, nodes)) + target.Not = new OpenApiSchema { Enum = nodes }; + } + + if (source.Const is { } constant && ToNode(constant) is { } node) + { + // Microsoft.OpenApi models const as a string and writes it as a JSON string, so only a string + // literal can be expressed faithfully; any other literal is expressed as a single-value enum. + if (node.GetValueKind() == JsonValueKind.String) + target.Const = Set("const", target.Const, node.GetValue(), context); + else if (!SameNodes(target.Enum, [node])) + target.Enum = [node]; + } + } + + static void ApplyVendorExtensions(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + if (!context.Options.EmitVendorExtensions) + return; + + foreach (var (name, value) in source.GetVendorExtensions()) + { + // Structural markers the transformer itself consumes; they are not descriptions of a value. + if (name is DiscriminatorExtensionName or DiscriminatorValueExtensionName) + continue; + + if (ToNode(value) is { } node) + { + target.Extensions ??= new Dictionary(StringComparer.Ordinal); + target.Extensions[name] = new JsonNodeExtension(node); + } + } + + if (source.UnsupportedRules is { Count: > 0 } unsupported) + { + JsonArray names = []; + foreach (var name in unsupported) + names.Add((JsonNode?)JsonValue.Create(name)); + + target.Extensions ??= new Dictionary(StringComparer.Ordinal); + target.Extensions[UnsupportedExtensionName] = new JsonNodeExtension(names); + } + } + + /// + /// Removes ZodSharp's nullable wrapper (anyOf: [inner, {type: null}]). + /// + public static JsonSchemaDefinition UnwrapNullable(JsonSchemaDefinition source, out bool nullable) + { + if ( + source.AnyOf is { Count: 2 } options + && source.Type is null + && options.FindIndex(IsNullSchema) is var nullIndex and >= 0 + ) + { + nullable = true; + return options[1 - nullIndex]; + } + + nullable = false; + return source; + } + + public static bool IsObject(JsonSchemaDefinition source) => + UnwrapNullable(source, out _) is { Properties: not null } or { Type: "object", AdditionalProperties: bool }; + + static bool HasOwnType(JsonSchemaDefinition source) => source.Type is not null || source.Enum is not null; + + static bool IsNullSchema(JsonSchemaDefinition schema) => + string.Equals(schema.Type, "null", StringComparison.Ordinal) && schema.Properties is null; + + static bool IsPatternBranch(JsonSchemaDefinition branch) => + branch.Pattern is not null && branch.Properties is null && branch.Items is null && branch.AllOf is null; + + static T Set(string keyword, T current, T value, MapperContext context) + { + if (!EqualityComparer.Default.Equals(current, value)) + context.Conflict(keyword, current, value); + + return value; + } + + static string? SetNumber(string keyword, string? current, double? value, MapperContext context) + { + if (value is not { } number) + { + if (current is not null) + context.Conflict(keyword, current, null); + + return null; + } + + if ( + current is not null + && double.TryParse(current, NumberStyles.Float, CultureInfo.InvariantCulture, out var existing) + && existing.Equals(number) + ) + { + return current; + } + + var formatted = FormatNumber(number); + if (current is not null) + context.Conflict(keyword, current, formatted); + + return formatted; + } + + static string FormatNumber(double value) => + value == Math.Floor(value) && Math.Abs(value) < 1e15 + ? ((long)value).ToString(CultureInfo.InvariantCulture) + : value.ToString("R", CultureInfo.InvariantCulture); + + static List ToNodes(IEnumerable values) + { + List nodes = []; + foreach (var value in values) + { + if (ToNode(value) is { } node) + nodes.Add(node); + } + + return nodes; + } + + /// + /// Converts a boxed JSON Schema value to a without reflection-based serialization, + /// so the conversion is trim- and AOT-safe. A value with no JSON primitive form is skipped. + /// + public static JsonNode? ToNode(object? value) => + value switch + { + null => null, + JsonNode node => node.DeepClone(), + string text => JsonValue.Create(text), + bool boolean => JsonValue.Create(boolean), + char character => JsonValue.Create(character.ToString()), + byte number => JsonValue.Create(number), + sbyte number => JsonValue.Create(number), + short number => JsonValue.Create(number), + ushort number => JsonValue.Create(number), + int number => JsonValue.Create(number), + uint number => JsonValue.Create(number), + long number => JsonValue.Create(number), + ulong number => JsonValue.Create(number), + float number => JsonValue.Create(number), + double number => JsonValue.Create(number), + decimal number => JsonValue.Create(number), + Enum member => JsonValue.Create(member.ToString()), + _ => null, + }; + + static bool SameNodes(IList? current, List values) + { + if (current is null || current.Count != values.Count) + return false; + + for (var i = 0; i < values.Count; i++) + { + if (!JsonNode.DeepEquals(current[i], values[i])) + return false; + } + + return true; + } + + static string? Render(IEnumerable? nodes) => + nodes is null ? null : "[" + string.Join(", ", nodes.Select(static node => node.ToJsonString())) + "]"; +} diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs new file mode 100644 index 00000000..4de24a6d --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs @@ -0,0 +1,368 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using ZodSharp.JsonSchema; +using DisplayAttribute = System.ComponentModel.DataAnnotations.DisplayAttribute; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Describes the constraints ZodSharp enforces on the schemas ASP.NET Core generates: lengths, bounds, +/// patterns, formats, allowed and denied values, required members, nullability and discriminated unions. +/// +/// +/// +/// The source of truth is ZodSharp's own JSON Schema export for the type — the generated +/// BuildJsonSchema(), or a runtime schema registered in IZodSchemaFactory. A type ZodSharp does +/// not know is left exactly as the platform produced it. +/// +/// +/// When ZodSharp and the platform (DataAnnotations) disagree on a keyword, ZodSharp's value is written, +/// because it is the validator that enforces the request. In Development each override is logged at +/// . +/// +/// +/// The transformer options. +public sealed class ZodOpenApiSchemaTransformer(ZodOpenApiOptions options) : IOpenApiSchemaTransformer +{ + readonly ZodOpenApiOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + readonly ZodJsonSchemaSource _source = new(); + + /// + public Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(context); + + var services = context.ApplicationServices; + + if (context.JsonPropertyInfo is { } property) + { + if (property.DeclaringType is { } declaringType && _source.TryGet(declaringType, services, out var owner)) + TransformProperty(schema, property, context.JsonTypeInfo, owner, CreateContext(services, property)); + } + else if (TryGetParameterMember(context, services, out var member, out var location)) + { + ZodOpenApiSchemaMapper.ApplyValue(schema, member, CreateContext(services, location)); + } + else if (_source.TryGet(context.JsonTypeInfo.Type, services, out var definition)) + { + TransformType(schema, context.JsonTypeInfo, definition, CreateContext(services, context.JsonTypeInfo.Type)); + } + else if (context.JsonTypeInfo.Kind == JsonTypeInfoKind.Union) + { + TransformUnionCases(schema, context.JsonTypeInfo, services); + } + + return Task.CompletedTask; + } + + static void TransformProperty( + OpenApiSchema schema, + JsonPropertyInfo property, + JsonTypeInfo propertyType, + JsonSchemaDefinition owner, + MapperContext context + ) + { + if (!TryGetMember(owner, property.Name, out var definition)) + return; + + // An object-typed member is described by its own type schema (and usually becomes a component + // reference), so only value members are constrained here. + if (propertyType.Kind == JsonTypeInfoKind.Object || ZodOpenApiSchemaMapper.IsObject(definition)) + return; + + var clrType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + + // An enum member's schema becomes the enum's shared component, so anything written here would constrain + // every member of that enum type. Only the enum's own value set — a fact about the type — is written. + if (clrType.IsEnum) + { + if (Enum.GetValuesAsUnderlyingType(clrType).Length == ZodOpenApiSchemaMapper.EnumValueCount(definition)) + { + var wire = TryGetTypeInfo(propertyType.Options, clrType); + ZodOpenApiSchemaMapper.ApplyEnum( + schema, + definition, + context, + wire is null ? null : value => ToWireValue(value, clrType, wire) + ); + } + + return; + } + + // System.Text.Json writes a TimeSpan in its constant ("c") format, which the platform already describes + // with a pattern; it is not an ISO 8601 duration, so ZodSharp's base format for the type is not applied. + if (clrType == typeof(TimeSpan)) + context.IgnoredFormat = "duration"; + + ZodOpenApiSchemaMapper.ApplyValue(schema, definition, context); + + // [Display(Description)] is the one description attribute the platform does not map. + if ( + schema.Description is null + && property.AttributeProvider?.GetCustomAttributes(typeof(DisplayAttribute), inherit: true) + is [DisplayAttribute { Description: { Length: > 0 } description }, ..] + ) + { + schema.Description = description; + } + } + + static JsonTypeInfo? TryGetTypeInfo(JsonSerializerOptions options, Type type) + { + try + { + return options.GetTypeInfo(type); + } + catch (NotSupportedException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + /// + /// Converts one of ZodSharp's enum values (the member's number, or its name when the generator was told enums + /// are strings) to what System.Text.Json writes for that member with the application's options: a number by + /// default, or the member name — after the converter's naming policy and any [JsonStringEnumMemberName] + /// — when a JsonStringEnumConverter applies globally or to the type. The generator cannot see the + /// application's options, so the document is corrected here. A value that is not a member is left as it is. + /// + static JsonNode? ToWireValue(object? value, Type enumType, JsonTypeInfo typeInfo) + { + var member = value switch + { + string name when Enum.TryParse(enumType, name, ignoreCase: true, out var parsed) => parsed, + byte or sbyte or short or ushort or int or uint or long or ulong => Enum.ToObject(enumType, value), + double number when number == Math.Floor(number) => Enum.ToObject(enumType, (long)number), + _ => null, + }; + + if (member is null) + return ZodOpenApiSchemaMapper.ToNode(value); + + try + { + return JsonSerializer.SerializeToNode(member, typeInfo) ?? ZodOpenApiSchemaMapper.ToNode(value); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException) + { + return ZodOpenApiSchemaMapper.ToNode(value); + } + } + + /// + /// A C# union keeps the platform's anyOf over its case schemas; ZodSharp only constrains each case + /// it knows, and never restructures the union. The platform builds the case schemas inline with the union, + /// so they are constrained here rather than through their own transformer invocations. + /// + void TransformUnionCases(OpenApiSchema schema, JsonTypeInfo union, IServiceProvider services) + { + if (schema.AnyOf is not { } cases || cases.Count != union.UnionCases.Count) + return; + + for (var i = 0; i < cases.Count; i++) + { + var caseType = union.UnionCases[i].CaseType; + if (cases[i] is not OpenApiSchema caseSchema || !_source.TryGet(caseType, services, out var definition)) + continue; + + var caseInfo = union.Options.GetTypeInfo(caseType); + TransformType(caseSchema, caseInfo, definition, CreateContext(services, caseType)); + + if (caseSchema.Properties is null) + continue; + + foreach (var property in caseInfo.Properties) + { + if ( + caseSchema.Properties.TryGetValue(property.Name, out var member) + && member is OpenApiSchema memberSchema + ) + { + TransformProperty( + memberSchema, + property, + union.Options.GetTypeInfo(property.PropertyType), + definition, + CreateContext(services, property) + ); + } + } + } + } + + void TransformType( + OpenApiSchema schema, + JsonTypeInfo typeInfo, + JsonSchemaDefinition definition, + MapperContext context + ) + { + var inner = ZodOpenApiSchemaMapper.UnwrapNullable(definition, out _); + + if (inner.Properties is null) + { + // A scalar (a value object, or a registered schema for a primitive-shaped type) or a union + // described by ZodSharp at the type level. + if (inner.OneOf is { Count: > 0 } && schema.AnyOf is { Count: > 0 }) + ApplyDiscriminatedUnion(schema, inner); + else if (inner.OneOf is null && inner.AnyOf is null) + ZodOpenApiSchemaMapper.ApplyValue(schema, definition, context); + + return; + } + + ApplyRequired(schema, typeInfo, inner); + + // System.Text.Json polymorphism ([JsonPolymorphic]/[JsonDerivedType], or a closed hierarchy) already + // dispatches on a discriminator, so exactly one case matches. + if (schema.AnyOf is { Count: > 0 } && schema.Discriminator is not null) + MarkOneOf(schema); + } + + void ApplyDiscriminatedUnion(OpenApiSchema schema, JsonSchemaDefinition definition) + { + MarkOneOf(schema); + + if ( + _options.DiscriminatedUnionsAsOneOf + && schema.Discriminator is null + && definition + .GetVendorExtensions() + .TryGetValue(ZodOpenApiSchemaMapper.DiscriminatorExtensionName, out var name) + && name is string propertyName + ) + { + schema.Discriminator = new OpenApiDiscriminator { PropertyName = propertyName }; + } + } + + /// + /// Marks a union for oneOf. The swap itself happens in : + /// the platform resolves the case references of a polymorphic anyOf after the schema transformers + /// run, and a case moved out of anyOf here would lose its polymorphic component name. + /// + void MarkOneOf(OpenApiSchema schema) + { + if (!_options.DiscriminatedUnionsAsOneOf) + return; + + schema.Metadata ??= new Dictionary(StringComparer.Ordinal); + schema.Metadata[ZodOpenApiSchemaMapper.OneOfMetadataKey] = true; + } + + /// + /// Adds the members ZodSharp rejects when absent: an absent member deserializes to , + /// so a reference (or ) member ZodSharp does not allow to be null — a non-nullable + /// reference, [Required], or a required rule such as [RequiredZod] — is required. + /// + /// + /// A non-nullable value-type member is excluded: an absent value deserializes to its default, which ZodSharp + /// sees and may accept, so listing it would claim more than is enforced. Members the platform already lists + /// (C# required, [JsonRequired]) are kept, because System.Text.Json enforces them. + /// + static void ApplyRequired(OpenApiSchema schema, JsonTypeInfo typeInfo, JsonSchemaDefinition definition) + { + if (schema.Properties is null) + return; + + foreach (var property in typeInfo.Properties) + { + if ( + !schema.Properties.ContainsKey(property.Name) + || !TryGetMember(definition, property.Name, out var member) + ) + continue; + + var type = property.PropertyType; + if (type.IsValueType && Nullable.GetUnderlyingType(type) is null) + continue; + + ZodOpenApiSchemaMapper.UnwrapNullable(member, out var allowsNull); + if (allowsNull) + continue; + + schema.Required ??= new HashSet(StringComparer.Ordinal); + schema.Required.Add(property.Name); + } + } + + /// + /// Finds ZodSharp's schema for a member by its wire name. The generator records the camelCase name (the + /// ASP.NET Core default) unless [JsonPropertyName] says otherwise, so a host with a different naming + /// policy is matched through the camelCase form as well. + /// + static bool TryGetMember(JsonSchemaDefinition owner, string name, out JsonSchemaDefinition definition) + { + definition = null!; + return owner.Properties is { } properties + && ( + properties.TryGetValue(name, out definition!) + || properties.TryGetValue(JsonNamingPolicy.CamelCase.ConvertName(name), out definition!) + ); + } + + /// + /// Resolves the ZodSharp schema for an [AsParameters] member bound from the route, query or a header: + /// the member's container is the ZodSharp-known type. + /// + bool TryGetParameterMember( + OpenApiSchemaTransformerContext context, + IServiceProvider services, + out JsonSchemaDefinition member, + out string location + ) + { + member = null!; + location = string.Empty; + + if ( + context.ParameterDescription?.ModelMetadata is not { ContainerType: { } container, PropertyName: { } name } + || !_source.TryGet(container, services, out var owner) + || owner.Properties is null + ) + { + return false; + } + + if (!TryGetMember(owner, name, out var definition)) + return false; + + if (ZodOpenApiSchemaMapper.IsObject(definition)) + return false; + + member = definition; + location = $"parameter '{context.ParameterDescription.Name}' ({container.Name}.{name})"; + return true; + } + + MapperContext CreateContext(IServiceProvider services, JsonPropertyInfo property) => + CreateContext(services, $"property '{property.DeclaringType.Name}.{property.Name}'"); + + MapperContext CreateContext(IServiceProvider services, Type type) => CreateContext(services, $"type '{type.Name}'"); + + MapperContext CreateContext(IServiceProvider services, string location) + { + ILogger? logger = null; + if (services.GetService() is { } environment && environment.IsDevelopment()) + logger = services.GetService>(); + + return new MapperContext(_options, logger, location); + } +} diff --git a/src/src/AspNetCore.OpenApi/ZodValidationProblemSchemas.cs b/src/src/AspNetCore.OpenApi/ZodValidationProblemSchemas.cs new file mode 100644 index 00000000..0d848327 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodValidationProblemSchemas.cs @@ -0,0 +1,134 @@ +using Microsoft.OpenApi; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Builds the component schemas of the ZodSharp validation error contract. +/// +/// +/// ZodValidationProblemDetails is RFC 9457 HttpValidationProblemDetails (type, title, +/// status, detail, instance, errors) plus the structured issues and the +/// traceId. ZodValidationIssue is one structured issue: the dotted path, its +/// segments (member names and indexes), the ZodSharp code, the Zod issue code as its category +/// and the origin, the +/// bounds of a size issue, the message, per-issue parameters and the case errors of a union. +/// +static class ZodValidationProblemSchemas +{ + public static void Register(OpenApiDocument document) + { + document.Components ??= new OpenApiComponents(); + document.Components.Schemas ??= new Dictionary(StringComparer.Ordinal); + + document.Components.Schemas.TryAdd(ZodOpenApiOptions.ValidationIssueSchemaId, CreateIssue(document)); + document.Components.Schemas.TryAdd( + ZodOpenApiOptions.ValidationProblemDetailsSchemaId, + CreateProblemDetails(document) + ); + } + + public static OpenApiSchemaReference ProblemDetailsReference(OpenApiDocument document) => + new(ZodOpenApiOptions.ValidationProblemDetailsSchemaId, document); + + static OpenApiSchema CreateProblemDetails(OpenApiDocument document) => + new() + { + Type = JsonSchemaType.Object, + Description = + "An RFC 9457 validation problem (HttpValidationProblemDetails) with ZodSharp's structured issues.", + Properties = new Dictionary(StringComparer.Ordinal) + { + ["type"] = Nullable(JsonSchemaType.String), + ["title"] = Nullable(JsonSchemaType.String), + ["status"] = Nullable(JsonSchemaType.Integer, "int32"), + ["detail"] = Nullable(JsonSchemaType.String), + ["instance"] = Nullable(JsonSchemaType.String), + ["errors"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Description = "The messages for each failing path, keyed by the dotted path.", + AdditionalProperties = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = new OpenApiSchema { Type = JsonSchemaType.String }, + }, + }, + ["issues"] = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Description = "The structured validation issues.", + Items = new OpenApiSchemaReference(ZodOpenApiOptions.ValidationIssueSchemaId, document), + }, + ["traceId"] = Nullable(JsonSchemaType.String), + }, + }; + + static OpenApiSchema CreateIssue(OpenApiDocument document) => + new() + { + Type = JsonSchemaType.Object, + Description = "A structured ZodSharp validation issue.", + Required = new HashSet(StringComparer.Ordinal) { "path", "code", "message" }, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["path"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "The dotted path of the failing value, for example 'Items[0].Quantity'.", + }, + ["segments"] = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Description = "The path as segments: member names (strings) and collection indexes (integers).", + Items = new OpenApiSchema { Type = JsonSchemaType.String | JsonSchemaType.Integer }, + }, + ["code"] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = + "The ZodSharp error code: the rule's or error type's own identity, for example 'invalid_email', 'missing_field' or 'too_small'.", + }, + ["category"] = new OpenApiSchema + { + Type = JsonSchemaType.String | JsonSchemaType.Null, + Description = + "The Zod issue code (Zod v4) the error maps to, for example 'too_small', 'invalid_format' or 'invalid_type'; handle ZodSharp and Zod issues alike by this.", + }, + ["origin"] = Nullable(JsonSchemaType.String), + ["minimum"] = Bound("The lower bound of a size or range issue."), + ["maximum"] = Bound("The upper bound of a size or range issue."), + ["inclusive"] = Nullable(JsonSchemaType.Boolean), + ["message"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["parameters"] = new OpenApiSchema + { + Type = JsonSchemaType.Object | JsonSchemaType.Null, + Description = "The error type's parameters for this issue.", + }, + ["unionErrors"] = new OpenApiSchema + { + Type = JsonSchemaType.Array | JsonSchemaType.Null, + Description = "For a union, the issues each case reported.", + Items = new OpenApiSchema + { + Type = JsonSchemaType.Array, + Items = new OpenApiSchemaReference(ZodOpenApiOptions.ValidationIssueSchemaId, document), + }, + }, + }, + }; + + static OpenApiSchema Nullable(JsonSchemaType type, string? format = null) => + new() { Type = type | JsonSchemaType.Null, Format = format }; + + static OpenApiSchema Bound(string description) => + new() + { + Description = description, + AnyOf = + [ + new OpenApiSchema { Type = JsonSchemaType.Number }, + new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" }, + new OpenApiSchema { Type = JsonSchemaType.Null }, + ], + }; +} diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 640de6cf..4a16ea5b 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -1,6 +1,6 @@  - $(PurviewTargetFrameworksAll) + $(PurviewTargetFrameworksSupported) true + + diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationOptions.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationOptions.cs new file mode 100644 index 00000000..7d237bd7 --- /dev/null +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationOptions.cs @@ -0,0 +1,48 @@ +#if NET11_0_OR_GREATER +using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.Schemas; + +namespace ZodSharp.AspNetCore.ClientValidation; + +/// +/// Configures the ZodSharp client-side validation rules emitted for statically rendered (static SSR) Blazor forms. +/// +public sealed class ZodClientValidationOptions +{ + readonly Dictionary _schemas = []; + + /// + /// Gets the JSON Schemas client rules are derived from for model types validated by a runtime schema (which has no + /// attributes to read), keyed by model type. + /// + public IReadOnlyDictionary Schemas => _schemas; + + /// + /// Derives client rules for from a runtime object schema whose field names are the + /// model's property names (see ZodModelSchema.For<TModel>(ZodObject) in Purview.ZodSharp.Blazor). + /// + /// The form model type. + /// The schema. + /// The options, for chaining. + public ZodClientValidationOptions AddSchema(ZodObject schema) + { + ArgumentNullException.ThrowIfNull(schema); + _schemas[typeof(TModel)] = Z.ToJsonSchema(schema); + return this; + } + + /// + /// Derives client rules for from a schema of the model's own type. + /// + /// The form model type. + /// The schema. + /// The options, for chaining. + public ZodClientValidationOptions AddSchema(IZodSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + _schemas[typeof(TModel)] = Z.ToJsonSchema(schema); + return this; + } +} +#endif diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs new file mode 100644 index 00000000..840f5ff6 --- /dev/null +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs @@ -0,0 +1,281 @@ +#if NET11_0_OR_GREATER +using System.Buffers; +using System.Collections.Concurrent; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore.ClientValidation; + +/// +/// The registered by AddZodSharpClientValidation: renders the rules of +/// the provider it decorates (by default ASP.NET Core's DataAnnotations provider) plus client rules for ZodSharp rule +/// attributes and registered runtime schemas, in the same <blazor-client-validation-data data-rules> +/// payload. +/// +/// +/// +/// ASP.NET Core has no public API to add rules to another provider's payload, so this provider renders the decorated +/// provider's fragment, reads its data-rules attribute and merges its own rules into the same JSON +/// ({"fields":[{"name":…,"rules":[{"name":…,"message":…,"params":{…}}]}]}). That format is pinned by contract +/// tests; a change to it in ASP.NET Core fails them rather than silently dropping rules. +/// +/// +/// Client rules only improve the user experience: the server always validates the posted form. +/// +/// +[RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] +public sealed class ZodClientValidationProvider : ClientValidationProvider +{ + const string ElementName = "blazor-client-validation-data"; + const string RulesAttribute = "data-rules"; + + static readonly ConcurrentDictionary<(Type Container, string Field), List> AttributeRules = new(); + readonly ZodClientValidationOptions _options; + readonly IZodSchemaFactory? _factory; + + /// + /// Creates the provider. + /// + /// The provider whose rules are kept; for ZodSharp rules only. + /// The options. + /// The schema factory, used to tell which nested model types ZodSharp validates. + public ZodClientValidationProvider( + ClientValidationProvider? inner, + ZodClientValidationOptions options, + IZodSchemaFactory? factory = null + ) + { + ArgumentNullException.ThrowIfNull(options); + + Inner = inner is ZodClientValidationProvider { Inner: var unwrapped } ? unwrapped : inner; + _options = options; + _factory = factory; + } + + /// + /// Gets the decorated provider. + /// + public ClientValidationProvider? Inner { get; } + + /// + public override RenderFragment? RenderClientValidationRules( + EditContext editContext, + IReadOnlyDictionary renderedFields + ) + { + ArgumentNullException.ThrowIfNull(editContext); + ArgumentNullException.ThrowIfNull(renderedFields); + + var inner = Inner?.RenderClientValidationRules(editContext, renderedFields); + var own = CollectRules(editContext, renderedFields); + if (own.Count == 0) + return inner; + + var innerJson = inner is null ? null : ReadRules(inner); + if (inner is not null && innerJson is null) + return inner; // The decorated provider rendered something this provider does not understand: keep it. + + var json = Merge(innerJson, own); + return builder => + { + builder.OpenElement(0, ElementName); + builder.AddAttribute(1, RulesAttribute, json); + builder.CloseElement(); + }; + } + + List<(string Name, IReadOnlyList Rules)> CollectRules( + EditContext editContext, + IReadOnlyDictionary renderedFields + ) + { + List<(string, IReadOnlyList)> fields = []; + var formType = editContext.Model.GetType(); + foreach (var (field, renderedName) in renderedFields) + { + if (string.IsNullOrEmpty(field.FieldName) || string.IsNullOrEmpty(renderedName)) + continue; + + var containerType = field.Model.GetType(); + List rules = []; + + if (_options.Schemas.TryGetValue(containerType, out var schema)) + { + if (schema.Properties?.TryGetValue(field.FieldName, out var property) == true) + rules.AddRange( + ZodClientValidationRules.FromJsonSchema( + property, + schema.Required?.Contains(field.FieldName, StringComparer.Ordinal) == true + ) + ); + } + else if (containerType == formType || IsZodValidated(containerType)) + { + rules.AddRange( + AttributeRules.GetOrAdd((containerType, field.FieldName), static key => + BuildRules(key.Container, key.Field) + ) + ); + } + + if (rules.Count > 0) + fields.Add((renderedName, rules)); + } + + return fields; + } + + bool IsZodValidated(Type type) => + _factory is not null && _factory.TryGetUntypedValidator(type, out var validator) && validator is not null; + + [RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] + static List BuildRules(Type containerType, string fieldName) + { + var property = containerType.GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance); + if (property is null) + return []; + + List rules = []; + foreach (var attribute in property.GetCustomAttributes(inherit: true)) + { + // DataAnnotations attributes and attributes that describe their own client rules belong to the decorated + // provider; emitting them here would duplicate them. + if (attribute is IClientValidationRuleProvider || IsDataAnnotationsAttribute(attribute)) + continue; + + rules.AddRange(ZodClientValidationRules.FromAttribute(attribute)); + } + + return rules; + } + + static bool IsDataAnnotationsAttribute(ValidationAttribute attribute) => + attribute.GetType().Assembly == typeof(ValidationAttribute).Assembly; + + /// + /// Reads the data-rules JSON from a provider's fragment by rendering it into a scratch builder. + /// + [SuppressMessage( + "Usage", + "BL0006:Do not use RenderTree types", + Justification = "The only public way to read another provider's rendered payload." + )] + static string? ReadRules(RenderFragment fragment) + { + using RenderTreeBuilder builder = new(); + fragment(builder); + var frames = builder.GetFrames(); + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if ( + frame.FrameType == RenderTreeFrameType.Attribute + && string.Equals(frame.AttributeName, RulesAttribute, StringComparison.Ordinal) + ) + return frame.AttributeValue as string; + } + + return null; + } + + static string Merge(string? innerJson, List<(string Name, IReadOnlyList Rules)> own) + { + ArrayBufferWriter buffer = new(512); + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WritePropertyName("fields"); + writer.WriteStartArray(); + + HashSet written = [with(StringComparer.Ordinal)]; + if (innerJson is not null) + { + using var document = JsonDocument.Parse(innerJson); + if ( + document.RootElement.TryGetProperty("fields", out var fields) + && fields.ValueKind == JsonValueKind.Array + ) + { + foreach (var field in fields.EnumerateArray()) + { + var name = field.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null; + if (name is null) + { + field.WriteTo(writer); + continue; + } + + written.Add(name); + writer.WriteStartObject(); + writer.WriteString("name", name); + writer.WritePropertyName("rules"); + writer.WriteStartArray(); + if (field.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array) + { + foreach (var rule in rules.EnumerateArray()) + rule.WriteTo(writer); + } + + foreach (var (ownName, ownRules) in own) + { + if (string.Equals(ownName, name, StringComparison.Ordinal)) + WriteRules(writer, ownRules); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + } + } + + foreach (var (name, rules) in own) + { + if (!written.Add(name)) + continue; + + writer.WriteStartObject(); + writer.WriteString("name", name); + writer.WritePropertyName("rules"); + writer.WriteStartArray(); + WriteRules(writer, rules); + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + static void WriteRules(Utf8JsonWriter writer, IReadOnlyList rules) + { + foreach (var rule in rules) + { + writer.WriteStartObject(); + writer.WriteString("name", rule.Name); + writer.WriteString("message", rule.Message); + if (rule.Parameters.Count > 0) + { + writer.WritePropertyName("params"); + writer.WriteStartObject(); + foreach (var (key, value) in rule.Parameters) + writer.WriteString(key, value); + + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + } +} +#endif diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs new file mode 100644 index 00000000..7c1318fd --- /dev/null +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs @@ -0,0 +1,537 @@ +#if NET11_0_OR_GREATER +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using Microsoft.AspNetCore.Components.Forms; +using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.Rules; + +namespace ZodSharp.AspNetCore.ClientValidation; + +/// +/// One client-side rule: the name the JavaScript validator is registered under +/// (Blazor.formValidation.addValidator), the message shown when it fails, and its string parameters. +/// +/// The validator name. +/// The message. +/// The parameters. +public sealed record ZodClientRule(string Name, string Message, IReadOnlyDictionary Parameters); + +/// +/// Maps ZodSharp rules to the client-side validators in zodsharp-validation.js (Purview.ZodSharp.Blazor). +/// +/// +/// +/// The mapping reads the rule's metadata - its format parameters (prefix, +/// suffix, includes, version, pattern, divisor, expected) and its +/// / bounds - so a custom rule that reports the same metadata +/// gets the same client rule. A rule with no client equivalent (async, service-aware, culture-sensitive comparisons, +/// formats without a JavaScript validator) produces no client rule and is enforced on the server only. +/// +/// +/// Messages carry a {value} placeholder where the rule's message includes the validated value; the JavaScript +/// validator substitutes the input's value. +/// +/// +public static class ZodClientValidationRules +{ + /// + /// The placeholder the JavaScript validators replace with the input's value. + /// + public const string ValuePlaceholder = "{value}"; + + /// + /// The parameter carrying a message template that contains . + /// + public const string MessageTemplateParameter = "template"; + + /// The validator name for a required prefix. + public const string StartsWith = "zod-startswith"; + + /// The validator name for a required suffix. + public const string EndsWith = "zod-endswith"; + + /// The validator name for a required substring. + public const string Includes = "zod-includes"; + + /// The validator name for a UUID (optionally of one version). + public const string Uuid = "zod-uuid"; + + /// The validator name for a CUID2. + public const string Cuid2 = "zod-cuid2"; + + /// The validator name for a ULID. + public const string Ulid = "zod-ulid"; + + /// The validator name for an IP address. + public const string IPAddress = "zod-ip"; + + /// The validator name for a CIDR block. + public const string Cidr = "zod-cidr"; + + /// The validator name for an integer. + public const string WholeNumber = "zod-int"; + + /// The validator name for a multiple of a divisor. + public const string MultipleOf = "zod-multipleof"; + + /// The validator name for an inclusive lower bound (number or date). + public const string Min = "zod-min"; + + /// The validator name for an inclusive upper bound (number or date). + public const string Max = "zod-max"; + + /// The validator name for an exclusive lower bound (number or date). + public const string GreaterThan = "zod-gt"; + + /// The validator name for an exclusive upper bound (number or date). + public const string LessThan = "zod-lt"; + + /// The validator name for a regular expression that must match somewhere in the value. + public const string Regex = "zod-regex"; + + /// + /// Gets the client rules for , without messages - for a custom validation attribute that + /// implements IClientValidationRuleProvider (whose message comes from the attribute). + /// + /// The rule. + /// The client rules; empty when the rule has no client equivalent. + public static IReadOnlyList For(IZodRule rule) + { + ArgumentNullException.ThrowIfNull(rule); + return [.. FromRule(rule, string.Empty).Select(static r => new ClientValidationRule(r.Name, r.Parameters))]; + } + + /// + /// Gets the client rules for . + /// + /// The rule. + /// The message shown when a rule fails; may contain . + /// For prefix and suffix rules, the comparison the rule uses. + /// The client rules; empty when the rule has no client equivalent. + public static IReadOnlyList FromRule( + IZodRule rule, + string message, + StringComparison comparison = StringComparison.Ordinal + ) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentNullException.ThrowIfNull(message); + + List rules = []; + var parameters = rule.Parameters; + var format = Get(parameters, ZodIssueParameters.Format); + + switch (format) + { + case "starts_with" when TryGetComparison(comparison, out var mode): + Add( + rules, + StartsWith, + message, + ("prefix", Get(parameters, ZodIssueParameters.Prefix)), + ("comparison", mode) + ); + return rules; + case "ends_with" when TryGetComparison(comparison, out var mode): + Add( + rules, + EndsWith, + message, + ("suffix", Get(parameters, ZodIssueParameters.Suffix)), + ("comparison", mode) + ); + return rules; + case "starts_with" or "ends_with": + return rules; // Culture-sensitive comparisons have no faithful JavaScript equivalent. + case "includes": + Add(rules, Includes, message, ("includes", Get(parameters, ZodIssueParameters.Includes))); + return rules; + case "uuid": + Add(rules, Uuid, message, ("version", Get(parameters, ZodIssueParameters.Version))); + return rules; + case "cuid2": + Add(rules, Cuid2, message); + return rules; + case "ulid": + Add(rules, Ulid, message); + return rules; + case "ip" or "ipv4" or "ipv6": + Add(rules, IPAddress, message, ("version", Version(format))); + return rules; + case "cidr" or "cidrv4" or "cidrv6": + Add(rules, Cidr, message, ("version", Version(format))); + return rules; + case "regex": + Add(rules, Regex, message, ("pattern", Get(parameters, ZodIssueParameters.Pattern))); + return rules; + default: + break; + } + + if (string.Equals(Get(parameters, ZodIssueParameters.Expected), "int", StringComparison.Ordinal)) + { + Add(rules, WholeNumber, message); + return rules; + } + + if (Get(parameters, ZodIssueParameters.Divisor) is { } divisor) + { + Add(rules, MultipleOf, message, ("divisor", divisor)); + return rules; + } + + // Bounds of values (not lengths or counts, whose origin is string, array or collection). + var origin = rule.Origin; + if (origin is ZodTypeNames.Number or ZodTypeNames.BigInt or ZodTypeNames.Date) + { + if (rule.Lower is { } lower && TryFormatBound(lower.Value, out var lowerValue, out var lowerKind)) + Add(rules, lower.Inclusive ? Min : GreaterThan, message, ("value", lowerValue), ("kind", lowerKind)); + + if (rule.Upper is { } upper && TryFormatBound(upper.Value, out var upperValue, out var upperKind)) + Add(rules, upper.Inclusive ? Max : LessThan, message, ("value", upperValue), ("kind", upperKind)); + } + + return rules; + } + + /// + /// Gets the client rules for a ZodSharp rule attribute (a mapped to a rule with + /// ). + /// + /// The attribute. + /// The client rules; empty when the attribute is not a ZodSharp rule attribute or has no client equivalent. + /// + /// The built-in rule attributes are mapped directly; any other -mapped attribute is read by + /// reflection over its properties and its rule's constructors, so this is annotated + /// . and + /// are trim-safe. + /// + [RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] + public static IReadOnlyList FromAttribute(ValidationAttribute attribute) + { + ArgumentNullException.ThrowIfNull(attribute); + + try + { + return attribute switch + { + StartsWithAttribute { Prefix: { Length: > 0 } prefix } a => FromStringRule( + new StartsWithRule(prefix, a.Comparison, a.Message), + a.Comparison + ), + EndsWithAttribute { Suffix: { Length: > 0 } suffix } a => FromStringRule( + new EndsWithRule(suffix, a.Comparison, a.Message), + a.Comparison + ), + IncludesAttribute { Substring: { } substring } a => FromStringRule( + new IncludesRule(substring, a.Message) + ), + UUIDAttribute { Version: UuidVersion.None } a => FromStringRule(new UUIDRule(a.Message)), + UUIDAttribute a => FromStringRule(new UUIDRule(a.Version, a.Message)), + Cuid2Attribute a => FromStringRule(new Cuid2Rule(a.Message)), + ULIDAttribute a => FromStringRule(new ULIDRule(a.Message)), + IPAddressAttribute a => FromStringRule(new IPAddressRule(a.RuleType, a.Message)), + CidrAttribute a => FromStringRule(new CidrRule(a.RuleType, a.Message)), + RegexAttribute { Pattern: { Length: > 0 } pattern } a => FromStringRule( + new RegexRule(pattern, a.Message) + ), + IntAttribute a => FromRule( + new IntRule(a.Message), + ValueMessage(a.Message ?? IntRule.MessageFormat) + ), + MultipleOfAttribute a when a.Divisor != 0 => FromRule( + new MultipleOfRule(a.Divisor, a.Message), + BoundMessage(a.Message ?? MultipleOfRule.MessageFormat, a.Divisor) + ), + MinValueAttribute a => FromRule( + new MinValueRule(a.MinValue, a.Message), + BoundMessage(a.Message ?? MinValueRule.MessageFormat, a.MinValue) + ), + MaxValueAttribute a => FromRule( + new MaxValueRule(a.MaxValue, a.Message), + BoundMessage(a.Message ?? MaxValueRule.MessageFormat, a.MaxValue) + ), + GreaterThanAttribute a => FromRule( + new GreaterThanRule(a.ExclusiveMinimum, a.Message), + BoundMessage(a.Message ?? GreaterThanRule.MessageFormat, a.ExclusiveMinimum) + ), + GreaterThanOrEqualAttribute a => FromRule( + new GreaterThanOrEqualRule(a.MinValue, a.Message), + BoundMessage(a.Message ?? GreaterThanOrEqualRule.MessageFormat, a.MinValue) + ), + LessThanAttribute a => FromRule( + new LessThanRule(a.ExclusiveMaximum, a.Message), + BoundMessage(a.Message ?? LessThanRule.MessageFormat, a.ExclusiveMaximum) + ), + LessThanOrEqualAttribute a => FromRule( + new LessThanOrEqualRule(a.MaxValue, a.Message), + BoundMessage(a.Message ?? LessThanOrEqualRule.MessageFormat, a.MaxValue) + ), + _ => FromCustomAttribute(attribute), + }; + } + catch (ArgumentException) + { + // An attribute whose values the rule rejects is reported by the server; it has no client rule. + return []; + } + } + + /// + /// Gets the client rules a JSON Schema property describes (minLength, maxLength, pattern, + /// minimum, maximum, exclusive bounds, multipleOf, type: integer and the + /// uuid/ipv4/ipv6 formats) - how a runtime schema, which has no attributes, gets client rules. + /// + /// The property schema. + /// Whether the property is required. + /// The client rules. + public static IReadOnlyList FromJsonSchema(JsonSchemaDefinition property, bool required) + { + ArgumentNullException.ThrowIfNull(property); + + List rules = []; + if (required) + Add(rules, "required", "Required"); + + if (property.MinLength is { } minLength && property.MaxLength is { } maxLength) + Add( + rules, + "length", + Invariant($"Must be between {minLength} and {maxLength} characters"), + ("min", Invariant(minLength)), + ("max", Invariant(maxLength)) + ); + else if (property.MinLength is { } min) + Add(rules, "minlength", Invariant($"Must be at least {min} characters"), ("min", Invariant(min))); + else if (property.MaxLength is { } max) + Add(rules, "maxlength", Invariant($"Must be at most {max} characters"), ("max", Invariant(max))); + + if (property.Pattern is { Length: > 0 } pattern) + Add(rules, Regex, "Invalid format", ("pattern", pattern)); + + if (string.Equals(property.Type, "integer", StringComparison.Ordinal)) + Add(rules, WholeNumber, "Expected integer"); + + if (property.Minimum is { } minimum) + Add( + rules, + Min, + Invariant($"Must be at least {minimum}"), + ("value", Invariant(minimum)), + ("kind", "number") + ); + + if (property.ExclusiveMinimum is { } exclusiveMinimum) + Add( + rules, + GreaterThan, + Invariant($"Must be greater than {exclusiveMinimum}"), + ("value", Invariant(exclusiveMinimum)), + ("kind", "number") + ); + + if (property.Maximum is { } maximum) + Add(rules, Max, Invariant($"Must be at most {maximum}"), ("value", Invariant(maximum)), ("kind", "number")); + + if (property.ExclusiveMaximum is { } exclusiveMaximum) + Add( + rules, + LessThan, + Invariant($"Must be less than {exclusiveMaximum}"), + ("value", Invariant(exclusiveMaximum)), + ("kind", "number") + ); + + if (property.MultipleOf is { } multipleOf) + Add( + rules, + MultipleOf, + Invariant($"Must be a multiple of {multipleOf}"), + ("divisor", Invariant(multipleOf)) + ); + + switch (property.Format) + { + case "uuid": + Add(rules, Uuid, "Invalid UUID format"); + break; + case "ipv4": + Add(rules, IPAddress, "Invalid IPv4 address", ("version", "v4")); + break; + case "ipv6": + Add(rules, IPAddress, "Invalid IPv6 address", ("version", "v6")); + break; + default: + break; + } + + return rules; + } + + static IReadOnlyList FromStringRule( + TRule rule, + StringComparison comparison = StringComparison.Ordinal + ) + where TRule : IZodRule, IStringValidationRule => + FromRule(rule, rule.GetErrorMessage(ValuePlaceholder), comparison); + + /// + /// A custom attribute mapped with [ZodRule(typeof(TRule))] to a non-generic rule that implements + /// : the rule is constructed from the attribute's properties, matched to the constructor's + /// parameters by name. + /// + [RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] + static IReadOnlyList FromCustomAttribute(ValidationAttribute attribute) + { + var attributeType = attribute.GetType(); + if ( + attributeType.GetCustomAttribute() is not { RuleType: { } ruleType } + || ruleType.IsGenericTypeDefinition + || !typeof(IZodRule).IsAssignableFrom(ruleType) + ) + return []; + + var properties = attributeType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var constructor in ruleType.GetConstructors().OrderByDescending(static c => c.GetParameters().Length)) + { + var parameters = constructor.GetParameters(); + var arguments = new object?[parameters.Length]; + var bound = true; + for (var i = 0; i < parameters.Length && bound; i++) + { + var parameter = parameters[i]; + var property = properties.FirstOrDefault(p => + string.Equals(p.Name, parameter.Name, StringComparison.OrdinalIgnoreCase) + && parameter.ParameterType.IsAssignableFrom(p.PropertyType) + ); + if (property is not null) + arguments[i] = property.GetValue(attribute); + else if (parameter.HasDefaultValue) + arguments[i] = parameter.DefaultValue; + else + bound = false; + } + + if (!bound) + continue; + + object rule; + try + { + rule = constructor.Invoke(arguments); + } + catch (TargetInvocationException) + { + return []; + } + + var message = rule switch + { + IStringValidationRule text => text.GetErrorMessage(ValuePlaceholder), + _ => attribute.FormatErrorMessage(string.Empty), + }; + return FromRule((IZodRule)rule, message); + } + + return []; + } + + static string ValueMessage(string format) => SafeFormat(format, ValuePlaceholder, null); + + static string BoundMessage(string format, double bound) => + SafeFormat(format, bound.ToString(CultureInfo.InvariantCulture), ValuePlaceholder); + + static string SafeFormat(string format, string arg0, string? arg1) + { + try + { + return arg1 is null ? RuleMessage.Format(format, arg0) : RuleMessage.Format(format, arg0, arg1); + } + catch (FormatException) + { + return format; + } + } + + static bool TryGetComparison(StringComparison comparison, [NotNullWhen(true)] out string? mode) + { + mode = comparison switch + { + StringComparison.Ordinal => "ordinal", + StringComparison.OrdinalIgnoreCase => "ignorecase", + _ => null, + }; + return mode is not null; + } + + static string Version(string format) => + format.EndsWith("v4", StringComparison.Ordinal) ? "v4" + : format.EndsWith("v6", StringComparison.Ordinal) ? "v6" + : "any"; + + static bool TryFormatBound(object value, [NotNullWhen(true)] out string? text, out string kind) + { + kind = "number"; + switch (value) + { + case DateTime dateTime: + kind = "date"; + text = dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + return true; + case DateTimeOffset offset: + kind = "date"; + text = offset.DateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + return true; + case DateOnly date: + kind = "date"; + text = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + return true; + case double number when double.IsFinite(number): + text = number.ToString("R", CultureInfo.InvariantCulture); + return true; + case float number when float.IsFinite(number): + text = number.ToString("R", CultureInfo.InvariantCulture); + return true; + case IConvertible convertible + and (int or long or short or byte or sbyte or uint or ulong or ushort or decimal): + text = convertible.ToString(CultureInfo.InvariantCulture); + return true; + default: + text = null; + return false; + } + } + + static string? Get(IReadOnlyDictionary? parameters, string name) => + parameters is not null && parameters.TryGetValue(name, out var value) && value is not null + ? Convert.ToString(value, CultureInfo.InvariantCulture) + : null; + + static string Invariant(FormattableString value) => FormattableString.Invariant(value); + + static string Invariant(IFormattable value) => value.ToString(null, CultureInfo.InvariantCulture); + + static void Add( + List rules, + string name, + string message, + params (string Key, string? Value)[] parameters + ) + { + Dictionary values = [with(StringComparer.Ordinal)]; + foreach (var (key, value) in parameters) + { + if (value is not null) + values[key] = value; + } + + if (message.Contains(ValuePlaceholder, StringComparison.Ordinal)) + values[MessageTemplateParameter] = message; + + rules.Add(new(name, message, values)); + } +} +#endif diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationStartupFilter.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationStartupFilter.cs new file mode 100644 index 00000000..f6731355 --- /dev/null +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationStartupFilter.cs @@ -0,0 +1,60 @@ +#if NET11_0_OR_GREATER +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ZodSharp.AspNetCore.ClientValidation; + +/// +/// In Development, warns at startup when the resolved is not the ZodSharp +/// provider - something registered after AddZodSharpClientValidation replaced it, so ZodSharp rules are +/// silently missing from static-SSR forms. +/// +sealed partial class ZodClientValidationStartupFilter( + IServiceProvider services, + IHostEnvironment environment, + ILogger logger +) : IStartupFilter +{ + public Action Configure(Action next) + { + ArgumentNullException.ThrowIfNull(next); + + if (environment.IsDevelopment()) + { + try + { + using var scope = services.CreateScope(); + var provider = scope.ServiceProvider.GetService(); + if (provider is not ZodClientValidationProvider) + LogProviderReplaced(logger, provider?.GetType().FullName ?? "(none)"); + } + catch (InvalidOperationException ex) + { + LogProviderUnresolvable(logger, ex); + } + } + + return next; + } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Warning, + Message = "AddZodSharpClientValidation was called, but the resolved ClientValidationProvider is '{ProviderType}'. " + + "A registration made after AddZodSharpClientValidation replaced the ZodSharp provider, so static-SSR forms " + + "carry no ZodSharp client rules. Call AddZodSharpClientValidation after that registration." + )] + static partial void LogProviderReplaced(ILogger logger, string providerType); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Warning, + Message = "The ClientValidationProvider could not be resolved to check the ZodSharp client validation registration." + )] + static partial void LogProviderUnresolvable(ILogger logger, Exception exception); +} +#endif diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationTrimming.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationTrimming.cs new file mode 100644 index 00000000..6836829d --- /dev/null +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationTrimming.cs @@ -0,0 +1,21 @@ +#if NET11_0_OR_GREATER +namespace ZodSharp.AspNetCore.ClientValidation; + +/// +/// The trimming and Native AOT requirement of static-SSR client validation. +/// +/// +/// Static SSR client validation needs Razor components (AddRazorComponents), which ASP.NET Core annotates +/// [RequiresUnreferencedCode]/[RequiresDynamicCode], and it reads the rule attributes on form model +/// properties by reflection, which a trimmer cannot follow. The requirement is declared rather than suppressed, so a +/// trimmed or AOT application is warned at the call site. +/// +static class ZodClientValidationTrimming +{ + public const string Message = + "Static-SSR client validation reflects over form model properties and [ZodRule] attributes, and requires " + + "Razor components (AddRazorComponents), which are not trim- or Native AOT-compatible. Client rules only " + + "improve the user experience and the server still validates; in a trimmed or AOT application omit it, or " + + "implement IClientValidationRuleProvider on the attribute using ZodClientValidationRules.For(IZodRule)."; +} +#endif diff --git a/src/src/AspNetCore/CompatibilitySuppressions.xml b/src/src/AspNetCore/CompatibilitySuppressions.xml new file mode 100644 index 00000000..3301894e --- /dev/null +++ b/src/src/AspNetCore/CompatibilitySuppressions.xml @@ -0,0 +1,12 @@ + + + + + PKV006 + net8.0 + + + PKV006 + net9.0 + + diff --git a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs index 0f3dd812..02c856ed 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using ZodSharp.AspNetCore; +using ZodSharp.Core; namespace Microsoft.AspNetCore.Builder; @@ -9,17 +10,28 @@ namespace Microsoft.AspNetCore.Builder; public static class ZodSharpRouteHandlerBuilderExtensions { /// - /// Adds an endpoint filter that validates the first bound argument of type against - /// the registered , returning a validation-problem response when - /// validation fails. + /// Validates every bound argument assignable to against the registered + /// , returning the ZodSharp ProblemDetails contract when validation fails. /// + /// + /// The validator is resolved once, when the endpoint is built; building the endpoint fails with a message + /// naming the handler and parameter when no or no validator for + /// is registered. Use WithZodSharpValidation() to validate every parameter that + /// has a validator, on an endpoint or a whole route group. + /// /// The request type to validate. /// The route handler builder. /// The builder for chaining. public static RouteHandlerBuilder WithZodSharpValidation(this RouteHandlerBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - builder.AddEndpointFilter>(); + + ZodEndpointValidationOptions options = new(); + ZodEndpointValidationFilterFactory.AddTo(builder, options, typeof(T), ResolveTyped); + ZodSharpEndpointConventionBuilderExtensions.AddValidationMetadata(builder, options); return builder; } + + static ZodUntypedValidator? ResolveTyped(IZodSchemaFactory factory) => + factory.Resolve() is { } validator ? new ZodUntypedValidator(validator) : null; } diff --git a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs new file mode 100644 index 00000000..c1ea4fa4 --- /dev/null +++ b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Http; +using ZodSharp.AspNetCore; +using ZodSharp.Core; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// ZodSharp validation for any endpoint convention builder: a single endpoint, a route group, or a controller +/// action convention. +/// +public static class ZodSharpEndpointConventionBuilderExtensions +{ + /// + /// Validates every bound parameter that has a validator registered with the , + /// returning the ZodSharp ProblemDetails contract when validation fails. Applies to every endpoint of a route + /// group. + /// + /// + /// + /// Parameters are selected when the endpoint is built. Services ([FromServices], keyed services, and + /// anything the container can resolve), HttpContext, HttpRequest, HttpResponse, + /// ClaimsPrincipal, CancellationToken, form files, streams and parameters marked + /// [SkipValidation] are skipped; [FromBody], [FromRoute], [FromQuery], + /// [FromHeader] and [FromForm] force a parameter to be considered. + /// + /// + /// Error keys follow Microsoft.Extensions.Validation: a complex parameter's errors are not prefixed + /// (Name, Address.Street), a sequence parameter's are (items[0].Name), and an error about + /// the value itself is keyed by the parameter name. + /// + /// + /// The endpoint also advertises a 400 application/problem+json response, so OpenAPI documents + /// show the error contract without extra annotations. + /// + /// + /// On .NET 11, when AddZodSharpValidation() registers ZodSharp with the built-in validation pipeline and + /// the endpoint has not opted out with DisableValidation(), parameters the pipeline already validates + /// with ZodSharp are skipped (logged once, at Debug, when the endpoint is built), so nothing is validated + /// twice. + /// + /// + /// The builder type. + /// The endpoint convention builder. + /// Optional configuration. + /// The builder for chaining. + public static TBuilder WithZodSharpValidation( + this TBuilder builder, + Action? configure = null + ) + where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + ZodEndpointValidationOptions options = new(); + configure?.Invoke(options); + + ZodEndpointValidationFilterFactory.AddTo(builder, options, requiredType: null, requiredTypeResolver: null); + AddValidationMetadata(builder, options); + return builder; + } + + internal static void AddValidationMetadata(TBuilder builder, ZodEndpointValidationOptions options) + where TBuilder : IEndpointConventionBuilder => + builder.WithMetadata( + new ProducesResponseTypeMetadata( + options.ProducesStatusCode, + typeof(HttpValidationProblemDetails), + ["application/problem+json"] + ) + ); +} diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index 7f2e7833..a95b0d89 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,12 +1,20 @@ using System.Reflection; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZodSharp.AspNetCore; using ZodSharp.Core; namespace Microsoft.Extensions.DependencyInjection; public static class ServiceCollectionExtensions { + const string GraphScanRequirement = + "Walks the assembly reference graph, which a trimmer can remove (Assembly.GetReferencedAssemblies). Register " + + "validators with the generated AddZodSharp{AssemblyId}Schemas() extension (or AddZodSharpAssembly) instead."; + extension(IServiceCollection services) { /// @@ -27,11 +35,12 @@ public IServiceCollection AddZodSharp(Action? configure { ConfigureFactory = options.ConfigureFactory, ScanAssemblies = [.. options.ScanAssemblies], - ScanAssemblyGraphs = [.. options.ScanAssemblyGraphs], + ScanAssemblyGraphs = ReadGraphRoots(options), ScanLoadedAssemblies = options.ScanLoadedAssemblies, } ); services.TryAddSingleton(CreateFactory); + AddProblemDetailsWriter(services); return services; } @@ -76,6 +85,7 @@ public IServiceCollection AddZodSharpAssembly(Assembly assembly) /// validators. /// The type whose assembly graph is scanned for generated validators. /// The instance for chaining. + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(GraphScanRequirement)] public IServiceCollection AddZodSharpAssemblyGraph() => AddZodSharpAssemblyGraph(services, typeof(T).Assembly); @@ -84,6 +94,7 @@ public IServiceCollection AddZodSharpAssemblyGraph() => /// /// The type whose assembly contains the ZodSharp definitions to register. /// The service collection for chaining additional registrations. + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(GraphScanRequirement)] public IServiceCollection AddZodSharpAssemblyGraph(Type type) { ArgumentNullException.ThrowIfNull(type); @@ -94,6 +105,7 @@ public IServiceCollection AddZodSharpAssemblyGraph(Type type) /// /// Registers a root assembly whose referenced assembly graph should be scanned for generated validators. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(GraphScanRequirement)] public IServiceCollection AddZodSharpAssemblyGraph(Assembly rootAssembly) { ArgumentNullException.ThrowIfNull(rootAssembly); @@ -118,13 +130,26 @@ public IServiceCollection AddZodSharpLoadedAssemblies() } /// - /// Registers as an - /// and configures . + /// Registers as an , + /// the shared and ASP.NET Core's ProblemDetails service, and configures + /// . /// /// /// /// Requires app.UseExceptionHandler() in the request pipeline for the handler to be invoked. /// + /// + /// Also adds the ZodSharp ProblemDetails JSON metadata to the minimal-API JsonOptions resolver chain, + /// so the payload serializes under Native AOT. + /// + /// + /// On .NET 11 it also enriches the built-in Microsoft.Extensions.Validation pipeline (AddZodSharpValidation()): + /// it registers a report sink that collects the bridge's full errors on the current request (ZodRequestValidationReport, in HttpContext.Features) and composes a + /// callback that rewrites the pipeline's + /// with and mirrors the + /// status to the response. The ZodSharp callback runs first and any callback the application configures - before + /// or after this call - runs after it, so the application keeps the last word. + /// /// public IServiceCollection AddZodSharpProblemDetails( Action? configure = null, @@ -136,24 +161,84 @@ public IServiceCollection AddZodSharpProblemDetails( if (configure is not null) services.Configure(configure); + AddProblemDetailsWriter(services); services.AddExceptionHandler(); + AddBindingFailureHandling(services); +#if NET11_0_OR_GREATER + AddValidationPipelineEnrichment(services); +#endif return services; } } - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( - "Trimming", - "IL2026:RequiresUnreferencedCode", - Justification = "Assembly scanning for generated validators cannot be followed by a trimmer. The " - + "requirement is surfaced on the public AddZodSharp* registration methods, which is where a " - + "host opts into scanning; a trimmed or Native AOT host registers validators explicitly." - )] +#if NET11_0_OR_GREATER + /// + /// Enriches the built-in Microsoft.Extensions.Validation pipeline's ProblemDetails with the ZodSharp contract: a + /// per-request (in HttpContext.Features) collects the bridge's full errors, and a + /// composed callback rewrites the response from it. + /// + static void AddValidationPipelineEnrichment(IServiceCollection services) + { + // The sink stores errors on the current HTTP request, not in the DI scope: a Blazor Server circuit is one + // long-lived scope, and a scoped collector there would only ever grow. + services.AddHttpContextAccessor(); + services.TryAddSingleton< + ZodSharp.Extensions.Validation.IZodValidationReportSink, + HttpRequestValidationReportSink + >(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton< + IPostConfigureOptions, + ZodValidationProblemDetailsCustomizer + >() + ); + } +#endif + + /// + /// Honours : a post-configuration, so it wins over the + /// host's environment-based default whichever order the registrations were made in. Registering it more than once + /// only sets the same value again. + /// + static void AddBindingFailureHandling(IServiceCollection services) => + services + .AddOptions() + .PostConfigure>( + static (routeHandler, zod) => + { + if (zod.Value.HandleBindingFailures) + routeHandler.ThrowOnBadRequest = true; + } + ); + + static void AddProblemDetailsWriter(IServiceCollection services) + { + services.AddOptions(); + services.TryAddSingleton(static sp => new ZodProblemDetailsWriter( + sp.GetRequiredService>(), + sp.GetService(), + sp.GetService>() + )); + + if (!services.Any(static descriptor => descriptor.ServiceType == typeof(ZodProblemDetailsJsonMarker))) + { + services.AddSingleton(); + services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Add(ZodProblemDetailsJsonContext.Default) + ); + } + } + static ZodSchemaFactory CreateFactory(IServiceProvider serviceProvider) { ZodSchemaFactory factory = new(); var configuration = serviceProvider.GetService(); + // Trim-safe registrations added by the generated AddZodSharp{Assembly}Schemas() extensions. + foreach (var registration in serviceProvider.GetServices()) + registration.Register(factory); + configuration?.ConfigureFactory?.Invoke(factory); foreach (var assembly in GetAssembliesToScan(serviceProvider, configuration)) @@ -180,18 +265,7 @@ static IEnumerable GetAssembliesToScan( ) yield return registration.Assembly; - foreach (var assembly in GetAssembliesFromGraphs(configuration?.ScanAssemblyGraphs ?? [], seenAssemblyNames)) - yield return assembly; - - foreach ( - var assembly in GetAssembliesFromGraphs( - serviceProvider - .GetServices() - .Where(static registration => registration.Kind == ZodSchemaAssemblyRegistrationKind.Graph) - .Select(static registration => registration.Assembly), - seenAssemblyNames - ) - ) + foreach (var assembly in GetGraphAssemblies(serviceProvider, configuration, seenAssemblyNames)) yield return assembly; if ( @@ -203,19 +277,37 @@ var assembly in GetAssembliesFromGraphs( yield return assembly; } + // Graph scanning needs Assembly.GetReferencedAssemblies, which trimming can invalidate. It is opted into only through + // [RequiresUnreferencedCode] APIs (AddZodSharpAssemblyGraph, ScanAssemblyGraphs), so the requirement is stated where + // the application opts in. The walk is also removed when trimming, because ZodReflectionFeature is a feature guard. + static IReadOnlyList ReadGraphRoots(ZodSchemaFactoryOptions options) => + ZodReflectionFeature.IsSupported ? [.. options.ScanAssemblyGraphs] : []; + + static IEnumerable GetGraphAssemblies( + IServiceProvider serviceProvider, + ZodSchemaFactoryConfiguration? configuration, + HashSet seenAssemblyNames + ) => + ZodReflectionFeature.IsSupported + ? GetAssembliesFromGraphs( + [ + .. configuration?.ScanAssemblyGraphs ?? [], + .. serviceProvider + .GetServices() + .Where(static registration => registration.Kind == ZodSchemaAssemblyRegistrationKind.Graph) + .Select(static registration => registration.Assembly), + ], + seenAssemblyNames + ) + : []; + /// /// Walking an assembly's reference graph cannot be followed by a trimmer, which may also have removed /// the references entirely. Prefer ScanAssemblies with an explicit assembly, or register /// validators directly, in a trimmed or Native AOT host — the graph walk also loads the whole /// transitive closure at container-build time, which is a measurable startup cost in a large solution. /// - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( - "Trimming", - "IL2026:RequiresUnreferencedCode", - Justification = "Only reached when a host opts into graph scanning via ScanAssemblyGraphs. The " - + "alternative — naming assemblies explicitly, or registering validators directly — is " - + "documented on the option and is what a trimmed or Native AOT host should use." - )] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(GraphScanRequirement)] static IEnumerable GetAssembliesFromGraphs( IEnumerable rootAssemblies, HashSet seenAssemblyNames diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs new file mode 100644 index 00000000..6236ed46 --- /dev/null +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs @@ -0,0 +1,114 @@ +#if NET11_0_OR_GREATER +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using ZodSharp.AspNetCore.ClientValidation; +using ZodSharp.Core; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Registers ZodSharp client-side validation rules for statically rendered Blazor forms. +/// +public static class ZodSharpClientValidationServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Emits client-side validation rules for ZodSharp rule attributes ([StartsWith], [UUID], + /// [MinValue], …) and registered runtime schemas in statically rendered (static SSR) Blazor forms, + /// alongside ASP.NET Core's DataAnnotations rules. Reference Purview.ZodSharp.Blazor so the browser has the + /// matching validators (zodsharp-validation.js, loaded automatically as a JavaScript initializer). + /// + /// Optional configuration, for example runtime schemas to derive rules from. + /// The for chaining. + /// + /// + /// Safe to call before or after AddRazorComponents: the registered + /// is replaced by a decorator around it (or around ASP.NET Core's default when none is registered yet, which + /// AddRazorComponents' TryAdd then leaves alone). A provider registered later still replaces it; + /// in Development a startup check logs a warning when that happens. + /// + /// Client rules only improve the user experience; the server always validates the posted form. + /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(ZodClientValidationTrimming.Message)] + public IServiceCollection AddZodSharpClientValidation(Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + if (configure is not null) + services.Configure(configure); + + services.TryAddEnumerable(ServiceDescriptor.Transient()); + + var existing = services.LastOrDefault(static d => + d.ServiceType == typeof(ClientValidationProvider) && !d.IsKeyedService + ); + if (existing?.ImplementationFactory is { } factory && factory.Target is ZodClientValidationRegistration) + return services; // Already decorated. + + var inner = existing ?? FindDefaultProvider(); + services.RemoveAll(); + + ZodClientValidationRegistration registration = new(inner); + services.Add(ServiceDescriptor.Scoped(registration.Create)); + return services; + } + } + + /// + /// Finds the descriptor AddRazorComponents registers for by calling it + /// on a scratch collection, so the decorator wraps the real default even when it is registered first. + /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(ZodClientValidationTrimming.Message)] + static ServiceDescriptor? FindDefaultProvider() + { + ServiceCollection scratch = []; + scratch.AddRazorComponents(); + return scratch.LastOrDefault(static d => + d.ServiceType == typeof(ClientValidationProvider) && !d.IsKeyedService + ); + } + + /// + /// The factory behind the decorated registration; also how repeated calls recognise it. + /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(ZodClientValidationTrimming.Message)] + sealed class ZodClientValidationRegistration(ServiceDescriptor? inner) + { + public ZodClientValidationProvider Create(IServiceProvider provider) => + new( + CreateInner(provider), + provider.GetRequiredService>().Value, + provider.GetService() + ); + + ClientValidationProvider? CreateInner(IServiceProvider provider) + { + if (inner is null) + return null; + + try + { + return inner switch + { + { ImplementationInstance: ClientValidationProvider instance } => instance, + { ImplementationFactory: { } factory } => factory(provider) as ClientValidationProvider, + { ImplementationType: { } type } => ActivatorUtilities.CreateInstance(provider, type) + as ClientValidationProvider, + _ => null, + }; + } + catch (InvalidOperationException) + { + // The default's dependencies are registered by AddRazorComponents; without it there is nothing to keep. + return null; + } + } + } +} +#endif diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpOptionsBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpOptionsBuilderExtensions.cs new file mode 100644 index 00000000..edc89500 --- /dev/null +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpOptionsBuilderExtensions.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using ZodSharp.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Configuration-aware options validation for ZodSharp. +/// +public static class ZodSharpOptionsBuilderExtensions +{ + const string BindRequirement = + "Binds through the reflection-based configuration binder. Call BindConfiguration(section) (intercepted by the configuration binder source generator) and ValidateWithZod(section) instead."; + + /// + /// Binds to the configuration section and + /// validates it with its ZodSharp schema. Failures name the configuration key of the offending setting + /// (Smtp:Servers:0:Host: Host is required (missing_field)), so it can be found in appsettings.json. + /// + /// + /// Binding uses the reflection-based configuration binder. A trimmed or Native AOT application should call + /// BindConfiguration(section) itself (the configuration binder source generator intercepts that call) + /// followed by ValidateWithZod(section). + /// + /// The options type. + /// The options builder. + /// The configuration section path, for example Smtp. + /// When , the options are validated at startup. + /// How to behave when no validator is registered. + /// The builder for chaining. + [RequiresUnreferencedCode(BindRequirement)] + [RequiresDynamicCode(BindRequirement)] + public static OptionsBuilder BindAndValidateWithZod< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions + >( + this OptionsBuilder builder, + string configSectionPath, + bool validateOnStart = true, + MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw + ) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(configSectionPath); + + return builder + .BindConfiguration(configSectionPath) + .AddZodSchemaValidator(missingValidatorBehavior, validateOnStart, configSectionPath); + } + + /// + /// Binds to and validates it with its ZodSharp + /// schema; see . + /// + /// The options type. + /// The options builder. + /// The configuration section; its names failures. + /// When , the options are validated at startup. + /// How to behave when no validator is registered. + /// The builder for chaining. + [RequiresUnreferencedCode(BindRequirement)] + [RequiresDynamicCode(BindRequirement)] + public static OptionsBuilder BindAndValidateWithZod< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions + >( + this OptionsBuilder builder, + IConfigurationSection section, + bool validateOnStart = true, + MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw + ) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(section); + + return builder.Bind(section).AddZodSchemaValidator(missingValidatorBehavior, validateOnStart, section.Path); + } +} diff --git a/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs b/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs new file mode 100644 index 00000000..2cd49530 --- /dev/null +++ b/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs @@ -0,0 +1,32 @@ +namespace ZodSharp; + +/// +/// The deprecation messages of the 2.x ProblemDetails conversion overloads. They take no +/// and no request, so they cannot see the options an application +/// configures (AddZodSharpProblemDetails) and their output can differ from the endpoint filter, the exception +/// handler and . Their behaviour is unchanged; they are kept for source and +/// binary compatibility. +/// +static class LegacyOverloads +{ + const string Ignores = + "Ignores the application's ZodProblemDetailsOptions, so the body can differ from the endpoint filter and the " + + "exception handler. Use the HttpContext overload in a request; outside one, pass "; + + public const string StatusCodeMessage = + Ignores + + "new ZodProblemDetailsOptions { Registry = new(), FormatMessages = false, DefaultStatusCode = statusCode } " + + "for the same output."; + + public const string RegistryMessage = + Ignores + + "new ZodProblemDetailsOptions { Registry = registry, DefaultStatusCode = statusCode } for the same output."; + + public const string LookupMessage = + Ignores + + "new ZodProblemDetailsOptions { ErrorTypeResolver = lookup, DefaultStatusCode = statusCode } for the same output."; + + public const string ResultMessage = + "Ignores the application's ZodProblemDetailsOptions and the status they compute. Use " + + "ToValidationProblem(HttpContext), which applies the registered options."; +} diff --git a/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs index 6ba41b10..17c1b092 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs @@ -1,62 +1,93 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using ZodSharp.AspNetCore; using ZodSharp.Core; namespace ZodSharp; /// -/// Converts ZodSharp validation results into ASP.NET Core ProblemDetails payloads. +/// Converts ZodSharp validation results into ASP.NET Core ProblemDetails payloads. Every overload delegates to +/// , so the body matches the endpoint filter and the exception handler. /// public static class ProblemDetailsExtensions { extension(ValidationResult result) { /// - /// Converts a failed validation result into . + /// Converts a failed validation result into , without resolving + /// s. /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( int statusCode = StatusCodes.Status400BadRequest ) { EnsureFailed(result); - return ProblemDetailsMapper.Build( - result.Errors, - statusCode, - registry: null, - lookup: null, - formatMessages: false - ); + return CreateWriter(registry: null, lookup: null, formatMessages: false, statusCode).Create(result.Errors); } /// /// Converts a failed validation result into , resolving /// s from the supplied registry to derive the status code, title, and messages. /// + [Obsolete(LegacyOverloads.RegistryMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( ErrorTypeRegistry registry, int statusCode = StatusCodes.Status400BadRequest ) { + ArgumentNullException.ThrowIfNull(registry); EnsureFailed(result); - return ProblemDetailsMapper.Build(result.Errors, statusCode, registry, lookup: null, formatMessages: true); + return CreateWriter(registry, lookup: null, formatMessages: true, statusCode).Create(result.Errors); } /// /// Converts a failed validation result into , resolving /// s through the supplied lookup to derive the status code, title, and messages. /// + [Obsolete(LegacyOverloads.LookupMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( Func lookup, int statusCode = StatusCodes.Status400BadRequest ) { + ArgumentNullException.ThrowIfNull(lookup); EnsureFailed(result); - return ProblemDetailsMapper.Build(result.Errors, statusCode, registry: null, lookup, formatMessages: true); + return CreateWriter(registry: null, lookup, formatMessages: true, statusCode).Create(result.Errors); + } + + /// + /// Converts a failed validation result into with the supplied + /// options. + /// + /// The ProblemDetails options. + /// The request, for the trace id and JSON naming; optional. + public HttpValidationProblemDetails ToHttpValidationProblemDetails( + ZodProblemDetailsOptions options, + HttpContext? httpContext = null + ) + { + ArgumentNullException.ThrowIfNull(options); + EnsureFailed(result); + return new ZodProblemDetailsWriter(options).Create(result.Errors, httpContext); + } + + /// + /// Converts a failed validation result into with the writer + /// (and therefore the options) registered in the request services. + /// + /// The request. + public HttpValidationProblemDetails ToHttpValidationProblemDetails(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + EnsureFailed(result); + return ZodProblemDetailsWriter.GetOrCreate(httpContext.RequestServices).Create(result.Errors, httpContext); } /// /// Converts a failed validation result into . /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public ValidationProblemDetails ToValidationProblemDetails(int statusCode = StatusCodes.Status400BadRequest) { var details = result.ToHttpValidationProblemDetails(statusCode); @@ -67,6 +98,7 @@ public ValidationProblemDetails ToValidationProblemDetails(int statusCode = Stat /// Converts a failed validation result into , resolving /// s from the supplied registry. /// + [Obsolete(LegacyOverloads.RegistryMessage)] public ValidationProblemDetails ToValidationProblemDetails( ErrorTypeRegistry registry, int statusCode = StatusCodes.Status400BadRequest @@ -80,6 +112,7 @@ public ValidationProblemDetails ToValidationProblemDetails( /// Converts a failed validation result into , resolving /// s through the supplied lookup. /// + [Obsolete(LegacyOverloads.LookupMessage)] public ValidationProblemDetails ToValidationProblemDetails( Func lookup, int statusCode = StatusCodes.Status400BadRequest @@ -90,23 +123,42 @@ public ValidationProblemDetails ToValidationProblemDetails( } /// - /// Converts a failed validation result into a minimal-API that renders the - /// standard ASP.NET Core validation-problem payload (status 400, errors keyed by path, and the - /// structured issues extension). + /// Converts a failed validation result into MVC with the supplied + /// options. Every extension is copied. + /// + /// The ProblemDetails options. + /// The request, for the trace id and JSON naming; optional. + public ValidationProblemDetails ToValidationProblemDetails( + ZodProblemDetailsOptions options, + HttpContext? httpContext = null + ) + { + var details = result.ToHttpValidationProblemDetails(options, httpContext); + return ToValidationProblemDetails(details); + } + + /// + /// Converts a failed validation result into a minimal-API that renders the ZodSharp + /// ProblemDetails contract, without resolving s. Prefer + /// the ToValidationProblem(HttpContext) overload, which applies the registered options. /// /// An suitable for returning from a minimal-API handler. + [Obsolete(LegacyOverloads.ResultMessage)] public IResult ToValidationProblem() { var details = result.ToHttpValidationProblemDetails(); - return Results.ValidationProblem( - details.Errors, - detail: details.Detail, - instance: details.Instance, - title: details.Title, - type: details.Type, - extensions: details.Extensions - ); + return TypedResults.Problem(details); } + + /// + /// Converts a failed validation result into a minimal-API written by the + /// registered in the request services, so it applies the configured + /// and goes through the ProblemDetails service exactly like the + /// endpoint filter and the exception handler. + /// + /// The request. + /// An suitable for returning from a minimal-API handler. + public IResult ToValidationProblem(HttpContext httpContext) => ZodTypedResults.Problem(result, httpContext); } static void EnsureFailed(ValidationResult result) @@ -115,14 +167,38 @@ static void EnsureFailed(ValidationResult result) throw new InvalidOperationException("Cannot create ProblemDetails from a successful validation result."); } - internal static ValidationProblemDetails ToValidationProblemDetails(HttpValidationProblemDetails details) => - new(details.Errors) + internal static ZodProblemDetailsWriter CreateWriter( + ErrorTypeRegistry? registry, + Func? lookup, + bool formatMessages, + int statusCode + ) => + new( + new ZodProblemDetailsOptions + { + Registry = registry ?? EmptyRegistry, + ErrorTypeResolver = lookup, + FormatMessages = formatMessages, + DefaultStatusCode = statusCode, + } + ); + + static readonly ErrorTypeRegistry EmptyRegistry = new(); + + internal static ValidationProblemDetails ToValidationProblemDetails(HttpValidationProblemDetails details) + { + ValidationProblemDetails copy = new(details.Errors) { Title = details.Title, Status = details.Status, Type = details.Type, Detail = details.Detail, Instance = details.Instance, - Extensions = { ["issues"] = details.Extensions["issues"] }, }; + + foreach (var pair in details.Extensions) + copy.Extensions[pair.Key] = pair.Value; + + return copy; + } } diff --git a/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs b/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs index 7f65d412..ac28ac09 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs @@ -1,74 +1,82 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using ZodSharp.AspNetCore; using ZodSharp.Core; namespace ZodSharp; /// -/// Converts a thrown into ASP.NET Core ProblemDetails payloads. +/// Converts a thrown into ASP.NET Core ProblemDetails payloads. Every overload delegates +/// to , so the body matches the endpoint filter and the exception handler. /// public static class ZodExceptionExtensions { extension([NotNull] ZodException exception) { /// - /// Converts a into . + /// Converts a into , without resolving + /// s. /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( int statusCode = StatusCodes.Status400BadRequest - ) - { - return ProblemDetailsMapper.Build( - exception.Errors, - statusCode, - registry: null, - lookup: null, - formatMessages: false - ); - } + ) => + ProblemDetailsExtensions + .CreateWriter(registry: null, lookup: null, formatMessages: false, statusCode) + .Create(exception.Errors); /// /// Converts a into , resolving /// s from the supplied registry to derive the status code, title, and messages. /// + [Obsolete(LegacyOverloads.RegistryMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( ErrorTypeRegistry registry, int statusCode = StatusCodes.Status400BadRequest ) { ArgumentNullException.ThrowIfNull(registry); - return ProblemDetailsMapper.Build( - exception.Errors, - statusCode, - registry, - lookup: null, - formatMessages: true - ); + return ProblemDetailsExtensions + .CreateWriter(registry, lookup: null, formatMessages: true, statusCode) + .Create(exception.Errors); } /// /// Converts a into , resolving /// s through the supplied lookup to derive the status code, title, and messages. /// + [Obsolete(LegacyOverloads.LookupMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( Func lookup, int statusCode = StatusCodes.Status400BadRequest ) { ArgumentNullException.ThrowIfNull(lookup); - return ProblemDetailsMapper.Build( - exception.Errors, - statusCode, - registry: null, - lookup, - formatMessages: true - ); + return ProblemDetailsExtensions + .CreateWriter(registry: null, lookup, formatMessages: true, statusCode) + .Create(exception.Errors); + } + + /// + /// Converts a into with the supplied + /// options. + /// + /// The ProblemDetails options. + /// The request, for the trace id and JSON naming; optional. + public HttpValidationProblemDetails ToHttpValidationProblemDetails( + ZodProblemDetailsOptions options, + HttpContext? httpContext = null + ) + { + ArgumentNullException.ThrowIfNull(options); + return new ZodProblemDetailsWriter(options).Create(exception.Errors, httpContext); } /// /// Converts a into . /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public ValidationProblemDetails ToValidationProblemDetails(int statusCode = StatusCodes.Status400BadRequest) { var details = exception.ToHttpValidationProblemDetails(statusCode); @@ -79,6 +87,7 @@ public ValidationProblemDetails ToValidationProblemDetails(int statusCode = Stat /// Converts a into , resolving /// s from the supplied registry. /// + [Obsolete(LegacyOverloads.RegistryMessage)] public ValidationProblemDetails ToValidationProblemDetails( ErrorTypeRegistry registry, int statusCode = StatusCodes.Status400BadRequest @@ -92,6 +101,7 @@ public ValidationProblemDetails ToValidationProblemDetails( /// Converts a into , resolving /// s through the supplied lookup. /// + [Obsolete(LegacyOverloads.LookupMessage)] public ValidationProblemDetails ToValidationProblemDetails( Func lookup, int statusCode = StatusCodes.Status400BadRequest @@ -100,5 +110,28 @@ public ValidationProblemDetails ToValidationProblemDetails( var details = exception.ToHttpValidationProblemDetails(lookup, statusCode); return ProblemDetailsExtensions.ToValidationProblemDetails(details); } + + /// + /// Converts a into MVC with the supplied + /// options. Every extension is copied. + /// + /// The ProblemDetails options. + /// The request, for the trace id and JSON naming; optional. + public ValidationProblemDetails ToValidationProblemDetails( + ZodProblemDetailsOptions options, + HttpContext? httpContext = null + ) + { + var details = exception.ToHttpValidationProblemDetails(options, httpContext); + return ProblemDetailsExtensions.ToValidationProblemDetails(details); + } + + /// + /// Converts a into a minimal-API written by the + /// registered in the request services. + /// + /// The request. + /// An . + public IResult ToValidationProblem(HttpContext httpContext) => ZodTypedResults.Problem(exception, httpContext); } } diff --git a/src/src/AspNetCore/ProblemDetailsMapper.cs b/src/src/AspNetCore/ProblemDetailsMapper.cs deleted file mode 100644 index 0d4c3311..00000000 --- a/src/src/AspNetCore/ProblemDetailsMapper.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Http; -using ZodSharp.Core; - -namespace ZodSharp.AspNetCore; - -/// -/// Shared implementation that maps a set of s onto an -/// , honouring registered s. -/// -static class ProblemDetailsMapper -{ - public static HttpValidationProblemDetails Build( - ImmutableArray errors, - int defaultStatusCode, - ErrorTypeRegistry? registry, - Func? lookup, - bool formatMessages - ) - { - var errorCount = errors.IsDefault ? 0 : errors.Length; - - Dictionary> groupedMessages = [with(StringComparer.Ordinal)]; - Dictionary errorDictionary = new(errorCount, StringComparer.Ordinal); - var issues = new ValidationIssue[errorCount]; - - ErrorType? highest = null; - var highestStatus = 0; - var issueIndex = 0; - - foreach (var error in errors.IsDefault ? [] : errors) - { - var errorType = TryResolve(registry, lookup, error.Code); - if (errorType is not null && errorType.HttpStatus > highestStatus) - { - highestStatus = errorType.HttpStatus; - highest = errorType; - } - - var key = ToProblemDetailsKey(error.Path); - if (!groupedMessages.TryGetValue(key, out var messages)) - { - messages = []; - groupedMessages[key] = messages; - } - - var message = - formatMessages && errorType?.MessageFormat is not null - ? errorType.FormatMessage(error.Parameters) - : error.Message; - - messages.Add(message); - - issues[issueIndex++] = new ValidationIssue - { - Code = error.Code, - Category = error.Category, - Origin = error.Origin, - Minimum = error.Minimum, - Maximum = error.Maximum, - Inclusive = error.Inclusive, - Path = error.Path.IsDefault ? [] : [.. error.Path], - Message = message, - Parameters = error.Parameters, - }; - } - - foreach (var pair in groupedMessages) - errorDictionary[pair.Key] = [.. pair.Value]; - - var status = highestStatus != 0 ? highestStatus : defaultStatusCode; - - HttpValidationProblemDetails details = new(errorDictionary) - { - Title = highest?.Title ?? "One or more validation errors occurred.", - Status = status, - }; - - if (highest?.Type is not null) - details.Type = highest.Type; - - if (highest?.Description is not null) - details.Detail = highest.Description; - - details.Extensions["issues"] = issues; - - if (lookup is not null || registry is not null) - MergeParameters(details, errors); - - return details; - } - - static ErrorType? TryResolve(ErrorTypeRegistry? registry, Func? lookup, string code) => - lookup is not null ? lookup(code) - : registry is not null && registry.TryGet(code, out var errorType) ? errorType - : null; - - static string ToProblemDetailsKey(ImmutableArray path) - { - if (path.IsDefaultOrEmpty) - return string.Empty; - - StringBuilder builder = new(); - for (var i = 0; i < path.Length; i++) - { - var segment = path[i]; - if (i > 0 && !segment.StartsWith('[')) - builder = builder.Append('.'); - - builder = builder.Append(segment); - } - - return builder.ToString(); - } - - /// - /// Every declared error parameter is copied into the response. That is deliberate — the parameters exist - /// to be substituted into a client-facing message, so the values are already destined for the caller — - /// but it does mean a rule that stores internal context in a parameter discloses it. The constraint is - /// documented on , where an author populates them. issues and - /// traceId are reserved by this mapper and are never overwritten from parameters. - /// - static void MergeParameters(HttpValidationProblemDetails details, ImmutableArray errors) - { - foreach (var error in errors.IsDefault ? [] : errors) - { - if (error.Parameters is null) - continue; - - foreach (var pair in error.Parameters) - { - if (pair.Key is "issues" or "traceId") - continue; - - details.Extensions[JsonNamingPolicy.CamelCase.ConvertName(pair.Key)] = pair.Value; - } - } - } -} diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 8ecbff14..e17afa76 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -14,31 +14,73 @@ dotnet add package Purview.ZodSharp.AspNetCore ## Usage ```csharp -using ZodSharp.AspNetCore; +using ZodSharp; // the ProblemDetails extension methods + +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); -var result = BasketSchema.Validate(basket); +// Validate every bound parameter that has a ZodSharp validator, for a whole route group. +var orders = app.MapGroup("/orders").WithZodSharpValidation(); +orders.MapPost("/", (Order order) => TypedResults.Created($"/orders/{order.Id}", order)); -if (!result.IsSuccess) +// Or validate manually and return the same contract. +app.MapPost("/baskets", (Basket basket, HttpContext context) => { - var problem = result.ToHttpValidationProblemDetails(); - return Results.ValidationProblem( - problem.Errors, - extensions: new Dictionary - { - ["issues"] = problem.Extensions["issues"], - }); -} + var result = BasketSchema.Validate(basket); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToValidationProblem(context); +}); +``` + +The endpoint filter, the `ZodException` handler, `ZodTypedResults` and the `ToHttpValidationProblemDetails` / +`ToValidationProblemDetails` / `ToValidationProblem` extensions all go through one `ZodProblemDetailsWriter`, so +they produce the same body: `errors` keyed by dotted paths (`Items[0].Quantity`), an `issues` extension of +structured `ValidationIssue`s (`path`, `segments`, `code`, `category`, `origin`, typed `minimum`/`maximum`, +`inclusive`, `message`, safe `parameters`, `unionErrors`) and a `traceId`. Configure it with +`ZodProblemDetailsOptions` (path naming, error-type registry, status selection, parameter merging). Use the overloads that take the request (`HttpContext`) or explicit options: the 2.x overloads that take only a +status code, a registry or a lookup cannot see the configured options and are obsolete. + +### .NET 11: the built-in validation pipeline + +On `net11.0`, pair `AddZodSharpProblemDetails()` with `AddZodSharpValidation()` from +[Purview.ZodSharp.Extensions.Validation](https://www.nuget.org/packages/Purview.ZodSharp.Extensions.Validation). +ASP.NET Core then validates every endpoint and route group itself, and the response is the same ZodSharp +contract. DataAnnotations errors are merged into it. + +```csharp +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpValidation(); +builder.Services.AddValidation(); // when DataAnnotations parameters/types or union bodies are used +builder.Services.AddZodSharpProblemDetails(); + +var orders = app.MapGroup("/orders"); // validated automatically +orders.MapPost("/", (Order order, [Range(1, 100)] int page) => TypedResults.Ok()); +orders.MapPost("/import", (Import i) => TypedResults.Ok()).DisableValidation(); ``` -`ToHttpValidationProblemDetails()` maps each `ValidationError` to a standard `HttpValidationProblemDetails.Errors` entry keyed by its JSON path. The structured `ValidationIssue` metadata (code, origin, minimum/maximum, inclusive bounds, path, message) is preserved in the `issues` extension so clients get more than a flat message list. +`AddZodSharpProblemDetails()` registers `AddProblemDetails()` and a scoped `ZodRequestValidationReport` that +collects the bridge's full errors. It also composes a `CustomizeProblemDetails` callback, which rewrites the +pipeline's response and mirrors the status (an `ErrorType` with 422, `StatusCodeSelector`, +`StatusCodeOverride`). The ZodSharp callback runs first, and any callback of your own runs after it. +`WithZodSharpValidation()` on a pipeline-validated endpoint skips the parameters the pipeline already covers, +so nothing is validated twice. -A `ValidationProblemDetails` overload is also available: +### .NET 11: client-side validation for static-SSR Blazor forms ```csharp -var problem = result.ToValidationProblemDetails(); +builder.Services.AddRazorComponents(); +builder.Services.AddZodSharpClientValidation(); // before or after AddRazorComponents +builder.Services.AddZodSharpBlazor(); // Purview.ZodSharp.Blazor ships the browser validators ``` -For minimal APIs, `ToValidationProblem()` returns a `Results.ValidationProblem` `IResult` directly, and `WithZodSharpValidation()` validates the bound request DTO automatically (see the [ASP.NET Core Integration](https://purview.dev/docs/zodsharp/aspnetcore-integration/) guide). +Statically rendered `EditForm`s then carry client rules for ZodSharp rule attributes (`[StartsWith]`, `[UUID]`, +`[MinValue]`, …) beside ASP.NET Core's DataAnnotations rules, and for runtime schemas registered with +`o.AddSchema(schema)`. ASP.NET Core has no public composition API, so the registered +`ClientValidationProvider` is decorated and its rendered payload merged; a provider registered later replaces it, +which a Development startup check reports. Client rules only improve the user experience - the server still +validates every post. ## Exception handling @@ -160,7 +202,7 @@ var error = ConcurrentErrorType.CreateSaveFailed( "agg-123", "Invoice", path: ["order", "items", "[0]"], - origin: "collection", + origin: "array", minimum: 1, maximum: 10, inclusive: true); diff --git a/src/src/AspNetCore/ValidationIssue.cs b/src/src/AspNetCore/ValidationIssue.cs index 705dc5de..8d4ba803 100644 --- a/src/src/AspNetCore/ValidationIssue.cs +++ b/src/src/AspNetCore/ValidationIssue.cs @@ -1,58 +1,135 @@ +using System.Text.Json.Serialization; using ZodSharp.Core; namespace ZodSharp.AspNetCore; /// -/// Serializable structured validation issue metadata included in ProblemDetails extensions. +/// The serialized shape of one validation issue in the ProblemDetails issues extension. It is a safe +/// projection of a : only invariant scalar values are serialized, never raw +/// objects, so nothing internal leaks through a parameter. /// +/// +/// The JSON shape mirrors Zod's issue format: +/// { "path": "Items[0].Quantity", "segments": ["Items", 0, "Quantity"], "code": "too_small", +/// "category": "too_small", "origin": "number", "minimum": 1, "inclusive": true, "message": "…" }. +/// The CLR-only members (, , and +/// ) keep their 2.x meaning for code that reads the issues in-process, and are not +/// serialized. +/// public readonly record struct ValidationIssue { + /// + /// The issue path rendered as the errors key (Items[0].Quantity); empty for the root. + /// + [JsonPropertyName("path")] + [JsonPropertyOrder(0)] + public string DottedPath { get; init; } + + /// + /// The issue path in Zod's shape: strings for members and keys, integers for indexes. + /// + [JsonPropertyName("segments")] + [JsonPropertyOrder(1)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays")] + public object[]? Segments { get; init; } + /// /// The issue code. /// + [JsonPropertyName("code")] + [JsonPropertyOrder(2)] public required string Code { get; init; } /// - /// The optional category the issue belongs to. + /// The optional category the issue belongs to (the Zod issue code for built-in errors). /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("category")] + [JsonPropertyOrder(3)] public string? Category { get; init; } /// /// The issue origin category. /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("origin")] + [JsonPropertyOrder(4)] public string? Origin { get; init; } /// - /// The inclusive minimum bound when present. + /// The lower bound, serialized as minimum: a JSON number for numeric bounds, an ISO-8601 string for + /// dates and times. /// - public int? Minimum { get; init; } + [JsonPropertyName("minimum")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(5)] + public object? Lower { get; init; } /// - /// The inclusive maximum bound when present. + /// The upper bound, serialized as maximum: a JSON number for numeric bounds, an ISO-8601 string for + /// dates and times. /// - public int? Maximum { get; init; } + [JsonPropertyName("maximum")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(6)] + public object? Upper { get; init; } /// - /// Whether the bound is inclusive. + /// Whether the reported bound is inclusive. /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("inclusive")] + [JsonPropertyOrder(7)] public bool? Inclusive { get; init; } /// - /// The issue path segments. + /// The human-readable issue message. + /// + [JsonPropertyName("message")] + [JsonPropertyOrder(8)] + public required string Message { get; init; } + + /// + /// The issue's parameters, camel-cased and restricted to invariant scalar values (strings, numbers, booleans, + /// ISO-8601 dates) and arrays of them. /// + [JsonPropertyName("parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(9)] + public IReadOnlyDictionary? SafeParameters { get; init; } + + /// + /// For a failed union, the issues reported by each option, with paths relative to the union. + /// + [JsonPropertyName("unionErrors")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(10)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays")] + public ValidationIssue[][]? UnionErrors { get; init; } + + /// + /// The legacy issue path segments (indexes as digits). Not serialized; see and + /// . + /// + [JsonIgnore] [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays")] public required string[] Path { get; init; } /// - /// The human-readable issue message. + /// The inclusive integer minimum bound when present. Not serialized; see . /// - public required string Message { get; init; } + [JsonIgnore] + public int? Minimum { get; init; } + + /// + /// The inclusive integer maximum bound when present. Not serialized; see . + /// + [JsonIgnore] + public int? Maximum { get; init; } /// - /// The additional error parameters carried by the validation error (for example aggregate ids). + /// The raw error parameters carried by the validation error. Not serialized; see . /// - [System.Text.Json.Serialization.JsonIgnore( - Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - )] + [JsonIgnore] public ErrorTypeParameters? Parameters { get; init; } } diff --git a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs new file mode 100644 index 00000000..8a75cb71 --- /dev/null +++ b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs @@ -0,0 +1,416 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// Builds the ZodSharp endpoint filter. All per-endpoint work - choosing the parameters to validate and +/// resolving their validators - happens once, when the endpoint is built; the per-request delegate only +/// validates the selected arguments. +/// +static class ZodEndpointValidationFilterFactory +{ + internal const string InvalidJsonCode = "invalid_json"; + + /// + /// Adds the filter factory to every endpoint builds. The factory sees the endpoint's + /// final metadata (it runs after every convention), which the double-validation guard needs. + /// + public static void AddTo( + TBuilder builder, + ZodEndpointValidationOptions options, + Type? requiredType, + Func? requiredTypeResolver + ) + where TBuilder : IEndpointConventionBuilder => + builder.Add(endpoint => + endpoint.FilterFactories.Add( + (context, next) => Build(context, next, options, requiredType, requiredTypeResolver, endpoint.Metadata) + ) + ); + + static EndpointFilterDelegate Build( + EndpointFilterFactoryContext context, + EndpointFilterDelegate next, + ZodEndpointValidationOptions options, + Type? requiredType, + Func? requiredTypeResolver, + IList endpointMetadata + ) + { + var services = context.ApplicationServices; + var factory = services.GetService(); + var isService = services.GetService(); + var method = context.MethodInfo; + var parameters = method.GetParameters(); +#if NET11_0_OR_GREATER + var pipeline = ZodValidationPipelineGuard.Create(services, endpointMetadata); + List? coveredByPipeline = null; +#else + _ = endpointMetadata; +#endif + + if (factory is null && (requiredType is not null || options.ThrowIfNoValidator)) + { + throw new InvalidOperationException( + $"No IZodSchemaFactory is registered, so endpoint handler '{DescribeHandler(method)}' cannot be " + + "validated. Call AddZodSharp (or AddZodSharpFactory) before applying WithZodSharpValidation." + ); + } + + List targets = []; + for (var index = 0; index < parameters.Length; index++) + { + var parameter = parameters[index]; + var parameterType = parameter.ParameterType; + var validatedType = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + +#if NET11_0_OR_GREATER + // The built-in pipeline already validates this parameter with ZodSharp (outermost filter, before this one). + if (pipeline?.Covers(parameter) == true) + { + (coveredByPipeline ??= []).Add( + parameter.Name ?? index.ToString(System.Globalization.CultureInfo.InvariantCulture) + ); + continue; + } +#endif + + IZodUntypedValidator? validator = null; + if (requiredType is not null) + { + if (!requiredType.IsAssignableFrom(validatedType)) + continue; + + if (!factory!.TryGetUntypedValidator(requiredType, out validator) || validator is null) + validator = requiredTypeResolver?.Invoke(factory); + + if (validator is null) + throw MissingValidator(method, parameter, requiredType); + } + else + { + if (IsSkipped(parameter, isService)) + continue; + + validator = factory is null ? null : ResolveUntyped(factory, parameterType, validatedType); + if (validator is null) + { + if (options.ThrowIfNoValidator && !IsScalar(validatedType)) + throw MissingValidator(method, parameter, validatedType); + + continue; + } + } + + targets.Add( + new ValidationTarget( + index, + parameter.Name ?? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"arg{index}"), + validator, + PrefixAlways: IsSequence(validatedType), + IsBody: IsBody(parameter, validatedType, isService) + ) + ); + } + +#if NET11_0_OR_GREATER + if (coveredByPipeline is not null) + pipeline!.LogCovered(DescribeHandler(method), coveredByPipeline); +#endif + + if (targets.Count == 0) + return next; + + var writer = ZodProblemDetailsWriter.GetOrCreate(services); + ValidationTarget[] selected = [.. targets]; + + return invocation => InvokeAsync(invocation, next, selected, writer, options); + } + + static async ValueTask InvokeAsync( + EndpointFilterInvocationContext invocation, + EndpointFilterDelegate next, + ValidationTarget[] targets, + ZodProblemDetailsWriter writer, + ZodEndpointValidationOptions options + ) + { + var httpContext = invocation.HttpContext; + +#if NET11_0_OR_GREATER + // .NET 11 runs endpoint filters after a binding failure, with the 400 already set. Validating the + // default/null argument would overwrite the binder's response with a misleading one. + if (httpContext.Response.StatusCode == StatusCodes.Status400BadRequest) + { + return options.OnBindingFailure == ZodBindingFailureBehavior.PassThrough + ? await next(invocation).ConfigureAwait(false) + : CreateBindingFailureResult(invocation, targets, writer); + } +#else + _ = options; +#endif + + List? errors = null; + foreach (var target in targets) + { + if (target.Index >= invocation.Arguments.Count || invocation.Arguments[target.Index] is not { } argument) + continue; + + var pending = target.Validator.ValidateAsync( + argument, + httpContext.RequestServices, + httpContext.RequestAborted + ); + var result = pending.IsCompletedSuccessfully ? pending.Result : await pending.ConfigureAwait(false); + if (result.IsDefaultOrEmpty) + continue; + + errors ??= []; + foreach (var error in result) + errors.Add(target.Rebase(error)); + } + + return errors is not null + ? new ZodProblemDetailsResult(writer, writer.Create(CollectionsMarshal.AsSpan(errors), httpContext)) + : await next(invocation).ConfigureAwait(false); + } + + internal static ZodProblemDetailsResult CreateBindingFailureResult( + EndpointFilterInvocationContext invocation, + ValidationTarget[] targets, + ZodProblemDetailsWriter writer + ) + { + List errors = []; + foreach (var target in targets) + { + var argument = target.Index < invocation.Arguments.Count ? invocation.Arguments[target.Index] : null; + if (argument is not null) + continue; + + errors.Add(CreateBindingError(target.Name, target.IsBody)); + } + + if (errors.Count == 0) + { + errors.Add( + ValidationError.Create( + ZodIssueCategories.InvalidType, + "One or more request values could not be bound.", + ZodPath.Root, + category: ZodIssueCategories.InvalidType + ) + ); + } + + return new ZodProblemDetailsResult( + writer, + writer.Create(CollectionsMarshal.AsSpan(errors), invocation.HttpContext) + ); + } + + static ValidationError CreateBindingError(string? name, bool isBody) + { + var path = name is null ? ZodPath.Root : ZodPath.Member(name); + return isBody + ? ValidationError.Create( + InvalidJsonCode, + "The request body could not be read.", + path, + category: ZodIssueCategories.InvalidType + ) + : ValidationError.Create( + ZodIssueCategories.InvalidType, + "The value could not be bound.", + path, + category: ZodIssueCategories.InvalidType + ); + } + + static IZodUntypedValidator? ResolveUntyped(IZodSchemaFactory factory, Type parameterType, Type validatedType) + { + if (factory.TryGetUntypedValidator(parameterType, out var validator) && validator is not null) + return validator; + + // A conditional expression here would nest two lookups into one unreadable expression; the two ifs + // keep the fallback order explicit. +#pragma warning disable IDE0046 // Convert to conditional expression + if (factory.TryGetUntypedValidator(validatedType, out validator) && validator is not null) + return validator; +#pragma warning restore IDE0046 + + return AdaptRegisteredValidator(factory, validatedType); + } + + /// + /// Adapts a validator registered without an untyped view ( + /// with a validator that does not implement , as assembly scanning does for + /// validators generated before untyped support, or a custom factory that does not implement + /// ). Needs dynamic code, so it is skipped under Native AOT, + /// where such a parameter is not validated (or fails the endpoint build with ThrowIfNoValidator). Every + /// validator the current generator emits implements , and + /// adds the untyped view itself, so neither path + /// reaches this. + /// + /// + /// The reflection is limited to closing , a private method with no + /// [DynamicallyAccessedMembers] requirement on T, which is why the IL2060 suppression is safe. + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2060:MakeGenericMethod", + Justification = "Adapt has no DynamicallyAccessedMembers requirements on T." + )] + static IZodUntypedValidator? AdaptRegisteredValidator(IZodSchemaFactory factory, Type validatedType) + { + if (!RuntimeFeature.IsDynamicCodeSupported || validatedType.ContainsGenericParameters) + return null; + + var adapt = typeof(ZodEndpointValidationFilterFactory) + .GetMethod(nameof(Adapt), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(validatedType); + + return adapt.Invoke(null, [factory]) as IZodUntypedValidator; + } + + static ZodUntypedValidator? Adapt(IZodSchemaFactory factory) => + factory.Resolve() is { } typed ? new ZodUntypedValidator(typed) : null; + + static InvalidOperationException MissingValidator(MethodInfo method, ParameterInfo parameter, Type type) => + new( + $"Endpoint handler '{DescribeHandler(method)}' parameter '{parameter.Name}' requires a ZodSharp validator " + + $"for '{type.FullName}', but none is registered with the IZodSchemaFactory. Register the type's " + + "generated validator (AddZodSharpAssembly, or IZodSchemaFactory.Register) or remove the " + + "requirement." + ); + + static string DescribeHandler(MethodInfo method) => + method.DeclaringType is { } declaringType ? $"{declaringType.FullName}.{method.Name}" : method.Name; + + static bool IsSkipped(ParameterInfo parameter, IServiceProviderIsService? isService) + { + var type = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + if ( + type == typeof(HttpContext) + || type == typeof(HttpRequest) + || type == typeof(HttpResponse) + || type == typeof(ClaimsPrincipal) + || type == typeof(CancellationToken) + || type == typeof(IFormFile) + || type == typeof(IFormFileCollection) + || type == typeof(IFormCollection) + || type == typeof(Stream) + || type == typeof(PipeReader) + ) + { + return true; + } + + var forced = false; + foreach (var attribute in parameter.GetCustomAttributes(inherit: true)) + { + switch (attribute) + { + case IFromServiceMetadata: + case FromKeyedServicesAttribute: + return true; + case IFromBodyMetadata: + case IFromRouteMetadata: + case IFromQueryMetadata: + case IFromHeaderMetadata: + case IFromFormMetadata: + forced = true; + break; + default: + // Microsoft.Extensions.Validation's [SkipValidation], or an application's own, matched by name + // so no package reference is needed. + if (string.Equals(attribute.GetType().Name, "SkipValidationAttribute", StringComparison.Ordinal)) + return true; + break; + } + } + + return !forced && isService?.IsService(parameter.ParameterType) == true; + } + + static bool IsBody(ParameterInfo parameter, Type validatedType, IServiceProviderIsService? isService) + { + foreach (var attribute in parameter.GetCustomAttributes(inherit: true)) + { + switch (attribute) + { + case IFromBodyMetadata: + return true; + case IFromRouteMetadata: + case IFromQueryMetadata: + case IFromHeaderMetadata: + case IFromFormMetadata: + return false; + default: + break; + } + } + + return !IsScalar(validatedType) && isService?.IsService(parameter.ParameterType) != true; + } + + static bool IsSequence(Type type) => + type != typeof(string) && typeof(IEnumerable).IsAssignableFrom(type) && !IsDictionary(type); + + static bool IsDictionary(Type type) => + typeof(IDictionary).IsAssignableFrom(type) + || ( + type.IsGenericType + && ( + type.GetGenericTypeDefinition() == typeof(IDictionary<,>) + || type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) + ) + ); + + static bool IsScalar(Type type) => + type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(Guid) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(DateOnly) + || type == typeof(TimeOnly) + || type == typeof(TimeSpan) + || type == typeof(Uri); + + /// + /// A parameter selected for validation at build time. + /// + /// The argument index. + /// The parameter name. + /// The resolved validator. + /// + /// Whether every error is prefixed with the parameter name (sequence parameters: items[0].Name). + /// Otherwise only errors about the value itself (an empty path) are, matching Microsoft.Extensions.Validation. + /// + /// Whether the parameter is (or is inferred to be) bound from the body. + internal sealed record ValidationTarget( + int Index, + string Name, + IZodUntypedValidator Validator, + bool PrefixAlways, + bool IsBody + ) + { + public ValidationError Rebase(in ValidationError error) => + PrefixAlways || error.FullPath.IsRoot ? error.WithPathPrefix(ZodPathSegment.Member(Name)) : error; + } +} diff --git a/src/src/AspNetCore/ZodEndpointValidationOptions.cs b/src/src/AspNetCore/ZodEndpointValidationOptions.cs new file mode 100644 index 00000000..5fd1144f --- /dev/null +++ b/src/src/AspNetCore/ZodEndpointValidationOptions.cs @@ -0,0 +1,46 @@ +namespace ZodSharp.AspNetCore; + +/// +/// Options for WithZodSharpValidation(). +/// +public sealed class ZodEndpointValidationOptions +{ + /// + /// When true, building the endpoint fails if a complex (non-scalar) bound parameter has no registered + /// validator. Defaults to false: parameters without a validator are not validated. + /// + public bool ThrowIfNoValidator { get; set; } + + /// + /// What to do when parameter binding has already failed. .NET 11 runs endpoint filters after a route, query, + /// header or form binding failure, with the response status already set to 400 and the failed argument left at + /// its default; a malformed JSON body is answered by ASP.NET Core itself and never reaches a filter. Defaults to + /// . Has no effect before .NET 11, where filters do not run + /// after a binding failure. + /// + public ZodBindingFailureBehavior OnBindingFailure { get; set; } = ZodBindingFailureBehavior.PassThrough; + + /// + /// The status code advertised in the endpoint's response metadata (and therefore in OpenAPI) for a validation + /// failure. Defaults to 400; set it when a mapped ErrorType changes the status. + /// + public int ProducesStatusCode { get; set; } = 400; +} + +/// +/// How the ZodSharp endpoint filter behaves when parameter binding has already failed. +/// +public enum ZodBindingFailureBehavior +{ + /// + /// Do not validate; let ASP.NET Core's own binding-failure response stand. + /// + PassThrough, + + /// + /// Write the ZodSharp ProblemDetails contract instead of ASP.NET Core's empty 400: an invalid_type issue + /// (invalid_json for a body) for each validated parameter left unbound, or a single root + /// invalid_type issue when the failed parameter cannot be identified. + /// + ConvertToProblemDetails, +} diff --git a/src/src/AspNetCore/ZodExceptionHandler.cs b/src/src/AspNetCore/ZodExceptionHandler.cs index f26d3d6e..178503f9 100644 --- a/src/src/AspNetCore/ZodExceptionHandler.cs +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -1,35 +1,62 @@ +using System.Collections.Immutable; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZodSharp.Core; +using HttpJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; namespace ZodSharp.AspNetCore; /// -/// An that converts a thrown -/// into a standard response, -/// resolving s from the configured . +/// An that converts a thrown into the ZodSharp +/// ProblemDetails contract, written by - the same body the endpoint filter +/// and produce. /// /// /// /// Register with services.AddZodSharpProblemDetails() and ensure /// app.UseExceptionHandler() is present in the pipeline, otherwise the handler is never invoked. /// +/// +/// A or whose inner exceptions carry a +/// - a request body rejected by a strict value object converter while it was deserialized - +/// is handled too: the errors are located under the member that held the value, with member names mapped from the +/// endpoint's body contract (see ), so the body matches what validating the bound model +/// reports. Minimal APIs only throw on a bad request in Development unless +/// is set (on .NET 11 binding writes a validation problem +/// instead, which AddZodSharpProblemDetails rewrites the same way). Any other bad request is left to the next +/// handler, unless is set, when it is answered with its +/// own status code and a standard ProblemDetails body. +/// /// public sealed class ZodExceptionHandler : IExceptionHandler { - static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - readonly ZodProblemDetailsOptions _options; + readonly ZodProblemDetailsWriter _writer; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with a writer built from + /// . /// public ZodExceptionHandler(IOptions options) { ArgumentNullException.ThrowIfNull(options); - _options = options.Value; + _writer = new ZodProblemDetailsWriter(options); + } + + /// + /// Initializes a new instance of the class with the registered writer. + /// + /// The options; unused when is supplied. + /// The shared ProblemDetails writer. + public ZodExceptionHandler(IOptions options, ZodProblemDetailsWriter writer) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(writer); + _writer = writer; } /// @@ -41,38 +68,54 @@ CancellationToken cancellationToken { ArgumentNullException.ThrowIfNull(httpContext); - if (exception is not ZodException zodException) + ImmutableArray errors; + if (exception is ZodException zodException) + errors = zodException.Errors; + else if ( + exception is BadHttpRequestException or JsonException + && ZodJsonExceptions.TryGetErrors(exception, ResolveBodyTypeInfo(httpContext), out var rerooted) + ) + errors = rerooted; + else if (exception is BadHttpRequestException badRequest && _writer.Options.HandleBindingFailures) + { + // HandleBindingFailures made binding throw instead of answering; answer as binding would have, plus a body. + await ZodProblemDetailsWriter + .WriteStatusAsync(httpContext, badRequest.StatusCode, exception) + .ConfigureAwait(false); + return true; + } + else return false; - var defaultStatusCode = - _options.StatusCodeSelector?.Invoke(zodException.Errors) ?? StatusCodes.Status400BadRequest; - - var problem = zodException.ToHttpValidationProblemDetails(_options.Registry, defaultStatusCode); - problem.Extensions["traceId"] = httpContext.TraceIdentifier; - - httpContext.Response.StatusCode = problem.Status!.Value; - await WriteProblemAsync(httpContext, problem, cancellationToken); + var problem = _writer.Create(errors, httpContext); + await _writer.WriteAsync(httpContext, problem, exception).ConfigureAwait(false); return true; } - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( - "Trimming", - "IL2026:RequiresUnreferencedCode", - Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and is " - + "the same type ASP.NET Core's own problem-details writer emits, so a trimmed or Native AOT host " - + "already roots it. WriteAsJsonAsync carries the requirement unconditionally regardless of T." - )] - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( - "AOT", - "IL3050:RequiresDynamicCode", - Justification = "The serialized type is the concrete HttpValidationProblemDetails, not object, and is " - + "the same type ASP.NET Core's own problem-details writer emits, so a trimmed or Native AOT host " - + "already roots it. WriteAsJsonAsync carries the requirement unconditionally regardless of T." - )] - static Task WriteProblemAsync( - HttpContext httpContext, - HttpValidationProblemDetails problem, - CancellationToken cancellationToken - ) => httpContext.Response.WriteAsJsonAsync(problem, JsonOptions, "application/problem+json", cancellationToken); + /// + /// The contract of the endpoint's body, from its and the minimal-API JSON options, + /// so the JSON names in a deserialization failure's path map back to member names. The exception handler middleware + /// clears the endpoint before handlers run, so it is read from first. + /// + internal static JsonTypeInfo? ResolveBodyTypeInfo(HttpContext httpContext) + { + var endpoint = httpContext.Features.Get()?.Endpoint ?? httpContext.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata()?.RequestType is not { } requestType) + return null; + + var options = httpContext.RequestServices?.GetService>()?.Value.SerializerOptions; + if (options is null) + return null; + + try + { + return options.TryGetTypeInfo(requestType, out var typeInfo) ? typeInfo : null; + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or ArgumentException) + { + // The errors are still reported, under the JSON names. + return null; + } + } } diff --git a/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs b/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs new file mode 100644 index 00000000..d4bfcde3 --- /dev/null +++ b/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; + +namespace ZodSharp.AspNetCore; + +/// +/// Source-generated JSON metadata for every type ZodSharp places in a ProblemDetails payload, so the payload +/// serializes under Native AOT (where reflection-based metadata is unavailable). Added to the minimal-API +/// JsonOptions resolver chain by AddZodSharpProblemDetails, and used directly when no +/// is registered. +/// +/// +/// normalizes every value it writes into one of the scalar types listed +/// here, which is what makes object-typed members (bounds, parameters, segments) serializable without +/// reflection. +/// +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(HttpValidationProblemDetails))] +[JsonSerializable(typeof(Microsoft.AspNetCore.Mvc.ProblemDetails))] +[JsonSerializable(typeof(ValidationIssue[]))] +[JsonSerializable(typeof(ValidationIssue[][]))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(object[]))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(bool))] +sealed partial class ZodProblemDetailsJsonContext : JsonSerializerContext; + +/// +/// Marks that the ZodSharp JSON metadata has been added to the minimal-API JSON options, so repeated registration +/// calls add it once. +/// +sealed class ZodProblemDetailsJsonMarker; diff --git a/src/src/AspNetCore/ZodProblemDetailsOptions.cs b/src/src/AspNetCore/ZodProblemDetailsOptions.cs index 4de4ea8b..d2d55acb 100644 --- a/src/src/AspNetCore/ZodProblemDetailsOptions.cs +++ b/src/src/AspNetCore/ZodProblemDetailsOptions.cs @@ -4,7 +4,9 @@ namespace ZodSharp.AspNetCore; /// -/// Configuration for and the ProblemDetails mapping extensions. +/// Configuration for , and therefore for every path that turns ZodSharp +/// errors into ProblemDetails: the endpoint filter, , +/// and the ToHttpValidationProblemDetails/ToValidationProblem extensions. /// public sealed class ZodProblemDetailsOptions { @@ -15,17 +17,106 @@ public sealed class ZodProblemDetailsOptions public ErrorTypeRegistry Registry { get; set; } = ErrorTypeRegistry.Default; /// - /// When true, an error's message is formatted from the matched - /// using the error's . Defaults to true. + /// An optional resolver used instead of to map an error code to an + /// . + /// + public Func? ErrorTypeResolver { get; set; } + + /// + /// When true, an error's message is formatted from the matched . + /// The template only replaces the error's message when every placeholder resolves, from the error's + /// or its built-in fields (path, origin, + /// minimum, maximum, displayName, …). Defaults to true. /// public bool FormatMessages { get; set; } = true; /// - /// An optional escape hatch that takes complete control of the response status code. When set, its - /// result overrides the highest matched . + /// How member names are rendered in errors keys and issues[].path. Defaults to + /// , which matches Microsoft.Extensions.Validation and MVC model state. + /// applies the naming policy from the minimal-API JsonOptions. + /// + public ZodPathNaming PathNaming { get; set; } = ZodPathNaming.MemberName; + + /// + /// When true, the issues extension (structured s) is included. + /// Defaults to true. + /// + public bool IncludeIssues { get; set; } = true; + + /// + /// When true, every error's parameters are also merged (camel-cased) into the top-level extensions, + /// where the last error wins. Defaults to false (it was the 2.4 behaviour): the parameters are always + /// present per issue in issues[].parameters, which is the reliable place to read them. Enable only for + /// clients that still read the top-level copy. Reserved member names (type, title, + /// status, detail, instance, errors, issues, traceId) are never + /// overwritten; a colliding parameter is written as param_{name}. + /// + public bool MergeParametersIntoExtensions { get; set; } + + /// + /// When true, the diagnostic parameters generated validators attach to every error - declaringType + /// (the full CLR name of the type that declares the failing member) and member (its C# name) - are written + /// to issues[].parameters (and, with , the top-level extensions). + /// Defaults to false: they describe the server's implementation, not the request, so they are kept on + /// for localization and logging but not sent to API clients. The error's + /// path already names the member in the request's terms. + /// + public bool IncludeDiagnosticParameters { get; set; } + + /// + /// When true, errors entries that did not come from ZodSharp (for example DataAnnotations errors + /// produced by Microsoft.Extensions.Validation and merged by ) also get an + /// issues entry, with code . Defaults to + /// true. + /// + public bool ProjectDataAnnotationsIssues { get; set; } = true; + + /// + /// The status code used when no matched supplies one. Defaults to 400. + /// + public int DefaultStatusCode { get; set; } = 400; + + /// + /// An optional escape hatch that takes complete control of the response status code. It runs after the + /// highest matched has been computed, and its result wins. /// public Func, int>? StatusCodeSelector { get; set; } + /// + /// Like , but also receives the computed status (the highest matched + /// , or ). Runs last and wins. + /// + public Func, int, int>? StatusCodeOverride { get; set; } + + /// + /// When true, AddZodSharpProblemDetails sets , + /// so a minimal-API request body that fails validation while it is deserialized - a strict value object converter + /// throwing JsonException(inner: ZodException) - reaches and is answered + /// with the ZodSharp ProblemDetails contract in every environment. Every other bad request that binding throws + /// (malformed JSON, a missing body, ...) is then answered by the handler too, with its own status code + /// () and a standard ProblemDetails body. + /// Defaults to false. + /// + /// + /// + /// Needed on .NET 10 only. There, without it, binding answers a body it cannot deserialize itself, with a bare + /// 400 and no body, unless the application is running in the Development environment (where ASP.NET Core + /// already throws); the handler still answers whatever does reach it. On .NET 11, binding writes its own validation + /// problem through the ProblemDetails service with the exception attached, and AddZodSharpProblemDetails + /// rewrites it with the ZodSharp errors whether or not this is set. An unsupported content type (415) is + /// always answered by binding itself. + /// + /// + /// The trade-off: is + /// application-wide, so every minimal-API binding failure becomes an exception that travels through the exception + /// handler middleware (app.UseExceptionHandler() is required) instead of a status code set in place. That + /// costs an exception per bad request, and the middleware's diagnostics follow + /// ExceptionHandlerOptions.SuppressDiagnosticsCallback. The handler never writes the exception's message, + /// which can quote the request. + /// + /// + public bool HandleBindingFailures { get; set; } + /// /// Registers an with the configured . /// @@ -34,4 +125,20 @@ public ZodProblemDetailsOptions MapErrorType(ErrorType errorType) Registry.Register(errorType); return this; } + + internal ZodProblemDetailsOptions Clone() => + new() + { + Registry = Registry, + ErrorTypeResolver = ErrorTypeResolver, + FormatMessages = FormatMessages, + PathNaming = PathNaming, + IncludeIssues = IncludeIssues, + MergeParametersIntoExtensions = MergeParametersIntoExtensions, + ProjectDataAnnotationsIssues = ProjectDataAnnotationsIssues, + DefaultStatusCode = DefaultStatusCode, + StatusCodeSelector = StatusCodeSelector, + StatusCodeOverride = StatusCodeOverride, + HandleBindingFailures = HandleBindingFailures, + }; } diff --git a/src/src/AspNetCore/ZodProblemDetailsResult.cs b/src/src/AspNetCore/ZodProblemDetailsResult.cs new file mode 100644 index 00000000..1b2dfd9a --- /dev/null +++ b/src/src/AspNetCore/ZodProblemDetailsResult.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Http; + +namespace ZodSharp.AspNetCore; + +/// +/// An that writes a ZodSharp ProblemDetails through , +/// so it goes through the registered like every other path. +/// +public sealed class ZodProblemDetailsResult + : IResult, + IStatusCodeHttpResult, + IValueHttpResult, + IValueHttpResult, + IContentTypeHttpResult +{ + readonly ZodProblemDetailsWriter _writer; + + internal ZodProblemDetailsResult(ZodProblemDetailsWriter writer, HttpValidationProblemDetails problemDetails) + { + _writer = writer; + ProblemDetails = problemDetails; + } + + /// + /// Gets the ProblemDetails that will be written. + /// + public HttpValidationProblemDetails ProblemDetails { get; } + + /// + public int? StatusCode => ProblemDetails.Status; + + /// + public string ContentType => "application/problem+json"; + + object? IValueHttpResult.Value => ProblemDetails; + + HttpValidationProblemDetails? IValueHttpResult.Value => ProblemDetails; + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + return _writer.WriteAsync(httpContext, ProblemDetails).AsTask(); + } +} diff --git a/src/src/AspNetCore/ZodProblemDetailsWriter.cs b/src/src/AspNetCore/ZodProblemDetailsWriter.cs new file mode 100644 index 00000000..b9884175 --- /dev/null +++ b/src/src/AspNetCore/ZodProblemDetailsWriter.cs @@ -0,0 +1,815 @@ +using System.Collections; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZodSharp.Core; +using HttpJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; + +namespace ZodSharp.AspNetCore; + +/// +/// The one implementation that turns ZodSharp s into an +/// . Every path uses it - the endpoint filter, , +/// and the ToHttpValidationProblemDetails/ToValidationProblem extensions - +/// so they all produce the same body. +/// +/// +/// +/// Keys are of +/// (Items[0].Name), named by . +/// Messages come from the registered in the request culture (or +/// when none is registered). A matched +/// replaces the message only when is on and every placeholder +/// resolves. +/// Status is the highest matched , then +/// and , +/// which run last and win. +/// Extensions: issues (safe s), optionally the merged parameters, +/// and traceId. Reserved ProblemDetails member names are never overwritten. +/// +/// +public sealed class ZodProblemDetailsWriter +{ + /// + /// The issue code given to errors entries that did not come from ZodSharp (see + /// ). + /// + public const string DataAnnotationsCode = "dataannotations"; + + /// + /// The title used when no matched supplies one. + /// + public const string DefaultTitle = "One or more validation errors occurred."; + + /// + /// The prefix given to a parameter merged into the extensions whose name collides with a reserved member. + /// + public const string ReservedParameterPrefix = "param_"; + + const int MaxUnionDepth = 8; + + static readonly HashSet ReservedNames = + [ + with(StringComparer.OrdinalIgnoreCase), + "type", + "title", + "status", + "detail", + "instance", + "errors", + "issues", + "traceId", + ]; + + static readonly Action LogReservedParameter = LoggerMessage.Define< + string, + string + >( + LogLevel.Warning, + new EventId(1, "ZodReservedParameter"), + "The validation error parameter '{Parameter}' collides with a reserved ProblemDetails member and was written as '{Renamed}'." + ); + readonly IZodMessageFormatter? _formatter; + readonly ILogger? _logger; + JsonNamingPolicy? _cachedPolicy; + IZodPathNamer? _cachedNamer; + int _collisionLogged; + + /// + /// Initializes a writer from the configured options. + /// + /// The options. + /// The message formatter; when , messages are used as produced. + /// An optional logger, used once to report a reserved-name parameter collision. + public ZodProblemDetailsWriter( + IOptions options, + IZodMessageFormatter? formatter = null, + ILogger? logger = null + ) + : this(options?.Value ?? throw new ArgumentNullException(nameof(options)), formatter, logger) { } + + /// + /// Initializes a writer from an options instance. + /// + /// The options. + /// The message formatter; when , messages are used as produced. + public ZodProblemDetailsWriter(ZodProblemDetailsOptions options, IZodMessageFormatter? formatter = null) + : this(options, formatter, logger: null) { } + + ZodProblemDetailsWriter(ZodProblemDetailsOptions options, IZodMessageFormatter? formatter, ILogger? logger) + { + ArgumentNullException.ThrowIfNull(options); + + Options = options; + _formatter = formatter; + _logger = logger; + } + + /// + /// Gets the options this writer applies. + /// + public ZodProblemDetailsOptions Options { get; } + + /// + /// Returns the writer registered in , or creates one from the registered + /// and (or the defaults). + /// + /// The services, normally . + /// The writer. + public static ZodProblemDetailsWriter GetOrCreate(IServiceProvider? services) => + services?.GetService() + ?? new( + services?.GetService>()?.Value ?? new ZodProblemDetailsOptions(), + services?.GetService(), + services?.GetService>() + ); + + /// + /// Creates the ProblemDetails for . + /// + /// The errors. + /// The request, used for the trace id, JSON naming and services; optional. + /// The ProblemDetails. + public HttpValidationProblemDetails Create(ReadOnlySpan errors, HttpContext? httpContext = null) + { + HttpValidationProblemDetails details = new(); + Populate(details, errors, httpContext, mergeExistingErrors: false); + return details; + } + + /// + public HttpValidationProblemDetails Create(IEnumerable errors, HttpContext? httpContext = null) + { + ArgumentNullException.ThrowIfNull(errors); + + if (errors is ImmutableArray immutable) + return Create(immutable.IsDefault ? [] : immutable.AsSpan(), httpContext); + + ValidationError[] materialized = [.. errors]; + return Create(materialized.AsSpan(), httpContext); + } + + /// + /// Rewrites an existing (for example one produced by + /// Microsoft.Extensions.Validation) with the ZodSharp contract for . Existing + /// errors entries whose key ZodSharp also produces are replaced; the others are kept and, when + /// is on, get an issues entry with + /// code . Status, title, type and detail are recomputed. + /// + /// The ProblemDetails to rewrite in place. + /// The ZodSharp errors. + /// The request; optional. + public void Apply( + HttpValidationProblemDetails problemDetails, + ReadOnlySpan errors, + HttpContext? httpContext = null + ) + { + ArgumentNullException.ThrowIfNull(problemDetails); + Populate(problemDetails, errors, httpContext, mergeExistingErrors: true); + } + + /// + /// + /// Accepts any sequence, such as a or a + /// ; an is read without copying. + /// + public void Apply( + HttpValidationProblemDetails problemDetails, + IEnumerable errors, + HttpContext? httpContext = null + ) + { + ArgumentNullException.ThrowIfNull(problemDetails); + ArgumentNullException.ThrowIfNull(errors); + + if (errors is ImmutableArray immutable) + { + Apply(problemDetails, immutable.IsDefault ? [] : immutable.AsSpan(), httpContext); + return; + } + + ValidationError[] materialized = [.. errors]; + Apply(problemDetails, materialized.AsSpan(), httpContext); + } + + /// + /// Adds an issues entry (code ) for every existing errors message, + /// leaving the rest of untouched. Does nothing when + /// or + /// is off, when there are no errors, or when the payload already has an issues extension. + /// + internal void ProjectExternalIssues(HttpValidationProblemDetails problemDetails) + { + if ( + !Options.IncludeIssues + || !Options.ProjectDataAnnotationsIssues + || problemDetails.Errors.Count == 0 + || problemDetails.Extensions.ContainsKey("issues") + ) + { + return; + } + + List issues = []; + foreach (var pair in problemDetails.Errors) + { + foreach (var message in pair.Value ?? []) + issues.Add(CreateExternalIssue(pair.Key, message)); + } + + // Ahead of traceId, matching the order every ZodSharp payload uses. + var hasTraceId = problemDetails.Extensions.TryGetValue("traceId", out var traceId); + problemDetails.Extensions.Remove("traceId"); + problemDetails.Extensions["issues"] = issues.ToArray(); + if (hasTraceId) + problemDetails.Extensions["traceId"] = traceId; + } + + /// + /// Creates the ProblemDetails for and writes it to the response. + /// + /// The request. + /// The errors. + /// A task that completes when the response is written. + public ValueTask WriteAsync(HttpContext httpContext, ReadOnlySpan errors) + { + ArgumentNullException.ThrowIfNull(httpContext); + return WriteAsync(httpContext, Create(errors, httpContext)); + } + + /// + /// Writes to the response: sets the status code, then writes through the + /// registered (so CustomizeProblemDetails runs), falling back to + /// application/problem+json with the minimal-API JSON options. + /// + /// The request. + /// The ProblemDetails. + /// The exception being handled, if any; passed to the ProblemDetails service. + /// A task that completes when the response is written. + public async ValueTask WriteAsync( + HttpContext httpContext, + HttpValidationProblemDetails problemDetails, + Exception? exception = null + ) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(problemDetails); + + httpContext.Response.StatusCode = problemDetails.Status ?? Options.DefaultStatusCode; + + if (httpContext.RequestServices?.GetService() is { } service) + { + ProblemDetailsContext context = new() + { + HttpContext = httpContext, + ProblemDetails = problemDetails, + Exception = exception, + }; + + if (await service.TryWriteAsync(context).ConfigureAwait(false)) + return; + } + + await httpContext + .Response.WriteAsJsonAsync( + problemDetails, + ResolveTypeInfo(httpContext), + "application/problem+json", + httpContext.RequestAborted + ) + .ConfigureAwait(false); + } + + /// + /// Writes a standard ProblemDetails for - no errors, no issues, nothing from the + /// exception's message - through the registered , falling back to + /// application/problem+json. Used for the bad requests that carry no ZodSharp errors. + /// + internal static async ValueTask WriteStatusAsync(HttpContext httpContext, int status, Exception exception) + { + httpContext.Response.StatusCode = status; + Microsoft.AspNetCore.Mvc.ProblemDetails problemDetails = new() + { + Status = status, + Type = GetDefaultType(status), + }; + + if (httpContext.RequestServices?.GetService() is { } service) + { + ProblemDetailsContext context = new() + { + HttpContext = httpContext, + ProblemDetails = problemDetails, + Exception = exception, + }; + + if (await service.TryWriteAsync(context).ConfigureAwait(false)) + return; + } + + var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; + if (traceId is not null) + problemDetails.Extensions["traceId"] = traceId; + + await httpContext + .Response.WriteAsJsonAsync( + problemDetails, + ZodProblemDetailsJsonContext.Default.ProblemDetails, + "application/problem+json", + httpContext.RequestAborted + ) + .ConfigureAwait(false); + } + + void Populate( + HttpValidationProblemDetails target, + ReadOnlySpan errors, + HttpContext? httpContext, + bool mergeExistingErrors + ) + { + Dictionary> grouped = [with(StringComparer.Ordinal)]; + var issues = Options.IncludeIssues ? new List(errors.Length) : null; + + var highest = CollectErrors(errors, httpContext, grouped, issues); + + if (mergeExistingErrors) + MergeExistingErrors(target, grouped, issues); + + target.Errors.Clear(); + foreach (var pair in grouped) + target.Errors[pair.Key] = [.. pair.Value]; + + var status = ComputeStatus(errors, highest); + target.Status = status; + target.Title = highest?.Title ?? DefaultTitle; + target.Type = highest?.Type ?? GetDefaultType(status); + target.Detail = highest?.Description ?? (mergeExistingErrors ? target.Detail : null); + + // Re-added below in a fixed order (issues, parameters, traceId), so a payload rewritten by Apply - which may + // already carry a traceId from the ProblemDetails defaults - serializes exactly like one built by Create. + target.Extensions.Remove("issues"); + target.Extensions.Remove("traceId"); + + if (issues is not null) + target.Extensions["issues"] = issues.ToArray(); + + if (Options.MergeParametersIntoExtensions) + MergeParameters(target, errors); + + var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier; + if (traceId is not null) + target.Extensions["traceId"] = traceId; + } + + ErrorType? CollectErrors( + ReadOnlySpan errors, + HttpContext? httpContext, + Dictionary> grouped, + List? issues + ) + { + var namer = ResolveNamer(httpContext); + var culture = CultureInfo.CurrentUICulture; + + ErrorType? highest = null; + foreach (ref readonly var error in errors) + { + var errorType = ResolveErrorType(error.Code); + if (errorType is not null && errorType.HttpStatus > (highest?.HttpStatus ?? 0)) + highest = errorType; + + var key = error.FullPath.ToDottedString(namer); + var message = ResolveMessage(error, errorType, culture); + AddMessage(grouped, key, message); + issues?.Add(CreateIssue(error, key, message, namer, culture, depth: 0)); + } + + return highest; + } + + void MergeExistingErrors( + HttpValidationProblemDetails target, + Dictionary> grouped, + List? issues + ) + { + var project = issues is not null && Options.ProjectDataAnnotationsIssues; + foreach (var pair in target.Errors) + { + if (grouped.ContainsKey(pair.Key)) + continue; + + List messages = [.. pair.Value ?? []]; + grouped[pair.Key] = messages; + + if (!project) + continue; + + foreach (var message in messages) + issues!.Add(CreateExternalIssue(pair.Key, message)); + } + } + + int ComputeStatus(ReadOnlySpan errors, ErrorType? highest) + { + var status = highest?.HttpStatus ?? Options.DefaultStatusCode; + if (Options.StatusCodeSelector is null && Options.StatusCodeOverride is null) + return status; + + var snapshot = ImmutableArray.Create(errors); + if (Options.StatusCodeSelector is { } selector) + status = selector(snapshot); + if (Options.StatusCodeOverride is { } statusOverride) + status = statusOverride(snapshot, status); + + return status; + } + + ErrorType? ResolveErrorType(string code) + { + if (string.IsNullOrEmpty(code)) + return null; + + // The resolver deliberately does not fall through to the registry when it returns null, so this is + // not a ?? and the conditional expression would be a nested ternary. +#pragma warning disable IDE0046 // Convert to conditional expression + if (Options.ErrorTypeResolver is { } resolver) + return resolver(code); +#pragma warning restore IDE0046 + + return Options.Registry is { } registry && registry.TryGet(code, out var errorType) ? errorType : null; + } + + string ResolveMessage(in ValidationError error, ErrorType? errorType, CultureInfo culture) + { + try + { + if (Options.FormatMessages && errorType?.MessageFormat is { Length: > 0 } template) + { + var templated = error with { MessageFormat = template }; + return _formatter is null + ? DefaultZodMessageFormatter.Instance.Format(templated, CultureInfo.InvariantCulture) + : _formatter.Format(templated, culture); + } + + return _formatter is null ? error.Message ?? string.Empty : _formatter.Format(error, culture); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // A formatter must never turn a validation failure into a server error. + return error.Message ?? string.Empty; + } + } + + ValidationIssue CreateIssue( + in ValidationError error, + string key, + string message, + IZodPathNamer? namer, + CultureInfo culture, + int depth + ) + { + var lower = error.Lower; + var upper = error.Upper; + var inclusive = error.Category switch + { + ZodIssueCategories.TooBig => upper?.Inclusive ?? error.Inclusive, + ZodIssueCategories.TooSmall => lower?.Inclusive ?? error.Inclusive, + _ => lower?.Inclusive ?? upper?.Inclusive ?? error.Inclusive, + }; + + return new ValidationIssue + { + DottedPath = key, + Segments = ToSegments(error.FullPath, namer), + Code = error.Code ?? string.Empty, + Category = error.Category, + Origin = error.Origin, + Lower = lower is { } l ? NormalizeBound(l.Value) : null, + Upper = upper is { } u ? NormalizeBound(u.Value) : null, + Inclusive = inclusive, + Message = message, + SafeParameters = ProjectParameters(error.Parameters), + UnionErrors = depth < MaxUnionDepth ? ProjectUnionErrors(error, namer, culture, depth) : null, + Path = [.. error.Path], + Minimum = error.Minimum, + Maximum = error.Maximum, + Parameters = error.Parameters, + }; + } + + static ValidationIssue CreateExternalIssue(string key, string message) + { + var path = ZodPath.ParseDotted(key); + return new ValidationIssue + { + DottedPath = key, + Segments = [.. path.ToZodPath()], + Code = DataAnnotationsCode, + Category = ZodIssueCategories.InvalidValue, + Message = message, + Path = [.. path.LegacySegments], + }; + } + + ValidationIssue[][]? ProjectUnionErrors( + in ValidationError error, + IZodPathNamer? namer, + CultureInfo culture, + int depth + ) + { + if (error.Parameters is not { } parameters) + return null; + + List>? groups = null; + if (parameters.TryGetValue(ZodIssueParameters.UnionErrors, out var raw)) + { + switch (raw) + { + case ImmutableArray> immutable when !immutable.IsDefault: + groups = []; + foreach (var group in immutable) + groups.Add(group.IsDefault ? [] : group); + break; + case IEnumerable> nested: + groups = [.. nested]; + break; + case IEnumerable flat: + groups = [flat]; + break; + default: + break; + } + } + + if (groups is null || groups.Count == 0) + return null; + + var result = new ValidationIssue[groups.Count][]; + for (var i = 0; i < groups.Count; i++) + { + List projected = []; + foreach (var inner in groups[i]) + { + var errorType = ResolveErrorType(inner.Code); + var message = ResolveMessage(inner, errorType, culture); + projected.Add( + CreateIssue(inner, inner.FullPath.ToDottedString(namer), message, namer, culture, depth + 1) + ); + } + + result[i] = [.. projected]; + } + + return result; + } + + void MergeParameters(HttpValidationProblemDetails target, ReadOnlySpan errors) + { + foreach (ref readonly var error in errors) + { + if (error.Parameters is not { } parameters) + continue; + + foreach (var pair in parameters) + { + if (!IsClientParameter(pair.Key) || !TryNormalize(pair.Value, allowCollections: true, out var safe)) + continue; + + var name = JsonNamingPolicy.CamelCase.ConvertName(pair.Key); + if (ReservedNames.Contains(name)) + { + var renamed = ReservedParameterPrefix + name; + if (_logger is not null && Interlocked.Exchange(ref _collisionLogged, 1) == 0) + LogReservedParameter(_logger, pair.Key, renamed, null); + name = renamed; + } + + target.Extensions[name] = safe; + } + } + } + + Dictionary? ProjectParameters(ErrorTypeParameters? parameters) + { + if (parameters is null || parameters.Count == 0) + return null; + + Dictionary? result = null; + foreach (var pair in parameters) + { + if (!IsClientParameter(pair.Key) || !TryNormalize(pair.Value, allowCollections: true, out var safe)) + continue; + + result ??= [with(StringComparer.Ordinal)]; + result[JsonNamingPolicy.CamelCase.ConvertName(pair.Key)] = safe; + } + + return result; + } + + // The union's nested errors are projected separately (unionErrors); the diagnostic parameters describe the + // server's types, not the request, so they are written only when IncludeDiagnosticParameters asks for them. + bool IsClientParameter(string key) => + !IsUnionParameter(key) + && ( + Options.IncludeDiagnosticParameters + || !( + string.Equals(key, ZodIssueParameters.DeclaringType, StringComparison.Ordinal) + || string.Equals(key, ZodIssueParameters.Member, StringComparison.Ordinal) + ) + ); + + static bool IsUnionParameter(string key) => + string.Equals(key, ZodIssueParameters.UnionErrors, StringComparison.Ordinal); + + static object[] ToSegments(ZodPath path, IZodPathNamer? namer) + { + if (path.IsRoot) + return []; + + var segments = new object[path.Length]; + for (var i = 0; i < segments.Length; i++) + { + var segment = path[i]; + segments[i] = segment.Kind switch + { + ZodPathSegmentKind.Index => segment.Index, + ZodPathSegmentKind.Member => namer?.GetName(segment.Name ?? string.Empty) + ?? segment.Name + ?? string.Empty, + ZodPathSegmentKind.Key or _ => segment.Name ?? string.Empty, + }; + } + + return segments; + } + + static object? NormalizeBound(object? value) => + TryNormalize(value, allowCollections: false, out var safe) + ? safe + : Convert.ToString(value, CultureInfo.InvariantCulture); + + /// + /// Projects a value onto the JSON-safe scalar set (string, long, double, decimal, bool, ISO-8601 strings and + /// arrays of them). Anything else - objects, nested errors - is rejected, so it is never serialized. + /// + static bool TryNormalize(object? value, bool allowCollections, out object? safe) + { + switch (value) + { + case null: + safe = null; + return true; + case string text: + safe = text; + return true; + case bool flag: + safe = flag; + return true; + case char character: + safe = character.ToString(); + return true; + case Enum enumValue: + safe = enumValue.ToString(); + return true; + case int or short or byte or sbyte or ushort or long or uint: + safe = Convert.ToInt64(value, CultureInfo.InvariantCulture); + return true; + case ulong unsigned: + safe = unsigned <= long.MaxValue ? (long)unsigned : (decimal)unsigned; + return true; + case float single: + safe = float.IsFinite(single) ? (double)single : single.ToString("R", CultureInfo.InvariantCulture); + return true; + case double number: + safe = double.IsFinite(number) ? number : number.ToString("R", CultureInfo.InvariantCulture); + return true; + case decimal money: + safe = money; + return true; + case IEnumerable sequence when allowCollections: + List items = []; + foreach (var item in sequence) + { + if (!TryNormalize(item, allowCollections: false, out var safeItem)) + { + safe = null; + return false; + } + + items.Add(safeItem); + } + + safe = items.ToArray(); + return true; + default: + safe = TryFormatInvariant(value); + return safe is not null; + } + } + + /// + /// Renders dates, times, identifiers and other invariant-formattable scalars as strings (ISO-8601 for dates). + /// + static string? TryFormatInvariant(object value) => + value switch + { + DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture), + DateOnly date => date.ToString("O", CultureInfo.InvariantCulture), + TimeOnly time => time.ToString("O", CultureInfo.InvariantCulture), + TimeSpan span => span.ToString("c", CultureInfo.InvariantCulture), + Guid guid => guid.ToString("D"), + Uri uri => uri.OriginalString, + IFormattable formattable when value.GetType().IsPrimitive || value is Half or Int128 or UInt128 => + formattable.ToString(null, CultureInfo.InvariantCulture), + _ => null, + }; + + IZodPathNamer? ResolveNamer(HttpContext? httpContext) + { + if (Options.PathNaming != ZodPathNaming.Json) + return null; + + var jsonOptions = httpContext?.RequestServices?.GetService>(); + var policy = jsonOptions is null + ? JsonNamingPolicy.CamelCase + : jsonOptions.Value.SerializerOptions.PropertyNamingPolicy; + if (policy is null) + return null; + + if (!ReferenceEquals(policy, _cachedPolicy) || _cachedNamer is null) + { + _cachedNamer = ZodPathNamer.FromFunc(policy.ConvertName); + _cachedPolicy = policy; + } + + return _cachedNamer; + } + + static JsonTypeInfo ResolveTypeInfo(HttpContext httpContext) + { + var options = httpContext.RequestServices?.GetService>()?.Value.SerializerOptions; + // The three-part pattern is clearer as a guard than as a conditional expression. +#pragma warning disable IDE0046 // Convert to conditional expression + if ( + options is not null + && options.TryGetTypeInfo(typeof(HttpValidationProblemDetails), out var typeInfo) + && typeInfo is JsonTypeInfo typed + ) + { + return typed; + } +#pragma warning restore IDE0046 + + return ZodProblemDetailsJsonContext.Default.HttpValidationProblemDetails; + } + + static void AddMessage(Dictionary> grouped, string key, string message) + { + if (!grouped.TryGetValue(key, out var messages)) + { + messages = []; + grouped[key] = messages; + } + + messages.Add(message); + } + + /// + /// The RFC 9110 type URI ASP.NET Core assigns by default for , so every path + /// sets the same value whether or not it goes through the ProblemDetails service. + /// + static string? GetDefaultType(int status) => + status switch + { + 400 => "https://tools.ietf.org/html/rfc9110#section-15.5.1", + 401 => "https://tools.ietf.org/html/rfc9110#section-15.5.2", + 403 => "https://tools.ietf.org/html/rfc9110#section-15.5.4", + 404 => "https://tools.ietf.org/html/rfc9110#section-15.5.5", + 405 => "https://tools.ietf.org/html/rfc9110#section-15.5.6", + 406 => "https://tools.ietf.org/html/rfc9110#section-15.5.7", + 408 => "https://tools.ietf.org/html/rfc9110#section-15.5.9", + 409 => "https://tools.ietf.org/html/rfc9110#section-15.5.10", + 412 => "https://tools.ietf.org/html/rfc9110#section-15.5.13", + 415 => "https://tools.ietf.org/html/rfc9110#section-15.5.16", + 422 => "https://tools.ietf.org/html/rfc9110#section-15.5.21", + 426 => "https://tools.ietf.org/html/rfc9110#section-15.5.22", + 500 => "https://tools.ietf.org/html/rfc9110#section-15.6.1", + 502 => "https://tools.ietf.org/html/rfc9110#section-15.6.3", + 503 => "https://tools.ietf.org/html/rfc9110#section-15.6.4", + 504 => "https://tools.ietf.org/html/rfc9110#section-15.6.5", + _ => null, + }; +} diff --git a/src/src/AspNetCore/ZodReflectionFeature.cs b/src/src/AspNetCore/ZodReflectionFeature.cs new file mode 100644 index 00000000..d2c6c58f --- /dev/null +++ b/src/src/AspNetCore/ZodReflectionFeature.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ZodSharp.AspNetCore; + +/// +/// The ZodSharp.Reflection.IsSupported feature switch, shared by every ZodSharp package, which guards the +/// reflection fallbacks that walk a model the library was not told about (here: walking an assembly reference graph for generated validators). +/// +/// +/// The property is a feature guard for : the trim analyzer accepts the +/// reflection it guards, and ILLink and ILC treat it as when trimming or compiling ahead of time, +/// so the fallback is removed rather than failing unpredictably on trimmed metadata. An untrimmed application keeps it +/// unless it sets the switch to . +/// +static class ZodReflectionFeature +{ + /// The feature switch name. + public const string SwitchName = "ZodSharp.Reflection.IsSupported"; + + /// Gets whether the reflection fallbacks may run. + [FeatureSwitchDefinition(SwitchName)] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + [UnconditionalSuppressMessage( + "Trimming", + "IL4000:FeatureGuard", + Justification = "A RequiresUnreferencedCode feature guard cannot be derived from another guard: none exists. ILLink and ILC " + + "treat this guard as false when trimming, which the trim verification harness proves (no IL2026 from the " + + "guarded calls)." + )] + public static bool IsSupported => !AppContext.TryGetSwitch(SwitchName, out var enabled) || enabled; +} diff --git a/src/src/AspNetCore/ZodRequestValidationReport.cs b/src/src/AspNetCore/ZodRequestValidationReport.cs new file mode 100644 index 00000000..0ff51e70 --- /dev/null +++ b/src/src/AspNetCore/ZodRequestValidationReport.cs @@ -0,0 +1,88 @@ +#if NET11_0_OR_GREATER +using System.Collections.Immutable; +using Microsoft.AspNetCore.Http; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; + +namespace ZodSharp.AspNetCore; + +/// +/// The full-fidelity ZodSharp errors reported during one HTTP request by the Microsoft.Extensions.Validation +/// bridge. It lives in the request's , so it is created and discarded with the +/// request, and the ProblemDetails customization uses it to rebuild the built-in pipeline's response with the +/// ZodSharp contract. +/// +public sealed class ZodRequestValidationReport +{ + readonly Lock _lock = new(); + + /// + /// Gets the errors reported so far, in the order they were reported. Paths are canonical: they already + /// include the validation path prefix they were reported under. + /// + public ImmutableArray Errors { get; private set; } = []; + + /// + /// Gets whether any error was reported. + /// + public bool HasErrors => !Errors.IsEmpty; + + /// + /// Gets the report for , or when nothing was reported. + /// + /// The request. + /// The report, if any. + public static ZodRequestValidationReport? Get(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + return httpContext.Features.Get(); + } + + /// + /// Appends errors to the report. + /// + /// The errors. + public void Add(ImmutableArray errors) + { + if (errors.IsDefaultOrEmpty) + return; + + lock (_lock) + Errors = Errors.AddRange(errors); + } + + internal static ZodRequestValidationReport GetOrCreate(HttpContext httpContext) + { + var report = httpContext.Features.Get(); + if (report is null) + { + report = new(); + httpContext.Features.Set(report); + } + + return report; + } +} + +/// +/// The bridge's report sink for HTTP requests. Errors are stored on the current request rather than in the DI +/// scope: on Blazor Server a DI scope is the whole circuit, so a scoped collector would grow for as long as the +/// user stays connected. Outside an HTTP request that is still being handled - an interactive Blazor circuit (a +/// WebSocket or other long-lived connection whose response has started), WebAssembly, a background service - the +/// write is discarded, because nothing will turn it into a response. +/// +sealed class HttpRequestValidationReportSink(IHttpContextAccessor httpContextAccessor) : IZodValidationReportSink +{ + public void Add(Type validatedType, string pathPrefix, ImmutableArray errors) + { + if (errors.IsDefaultOrEmpty) + return; + + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is null || httpContext.Response.HasStarted || httpContext.WebSockets.IsWebSocketRequest) + return; + + ZodRequestValidationReport.GetOrCreate(httpContext).Add(errors); + } +} +#endif diff --git a/src/src/AspNetCore/ZodSchemaFactoryOptions.cs b/src/src/AspNetCore/ZodSchemaFactoryOptions.cs index 4c296940..3bd0adb8 100644 --- a/src/src/AspNetCore/ZodSchemaFactoryOptions.cs +++ b/src/src/AspNetCore/ZodSchemaFactoryOptions.cs @@ -18,8 +18,21 @@ public sealed class ZodSchemaFactoryOptions /// Root assemblies whose referenced assembly graphs should be scanned for /// and auto-register generated validators. /// + /// + /// The reference-graph walk needs , which trimming can invalidate, so it + /// is removed when trimming and this property is annotated [RequiresUnreferencedCode]. Register validators with + /// the generated AddZodSharp{AssemblyId}Schemas() extension, or list assemblies in + /// , instead. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists")] - public List ScanAssemblyGraphs { get; } = []; + public List ScanAssemblyGraphs + { + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( + "Walks the assembly reference graph, which a trimmer can remove. Register validators with the generated " + + "AddZodSharp{AssemblyId}Schemas() extension, or list assemblies in ScanAssemblies, instead." + )] + get; + } = []; /// /// Whether to scan all currently loaded assemblies for diff --git a/src/src/AspNetCore/ZodTypedResults.cs b/src/src/AspNetCore/ZodTypedResults.cs new file mode 100644 index 00000000..de21a67f --- /dev/null +++ b/src/src/AspNetCore/ZodTypedResults.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// Typed results for handlers that validate manually (an injected , or a generated +/// XxxSchema.Validate). They produce the same ProblemDetails body as the endpoint filter and +/// , using the from the request services. +/// +public static class ZodTypedResults +{ + /// + /// Creates a for a failed result, for union return types such as + /// Results<Ok<T>, ValidationProblem>. + /// + /// + /// ASP.NET Core's is always 400. When a mapped (or a + /// status selector) yields another status, the body is still the full contract but the status is 400; + /// use to keep the computed status. + /// + /// The validated type. + /// The failed result. + /// The request. + /// The result. + /// The result is successful. + public static ValidationProblem ValidationProblem(in ValidationResult result, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + EnsureFailed(result); + + var details = ZodProblemDetailsWriter + .GetOrCreate(httpContext.RequestServices) + .Create(result.Errors, httpContext); + + return ToValidationProblem(details); + } + + /// + /// Creates a for a thrown ; see + /// . + /// + /// The exception. + /// The request. + /// The result. + public static ValidationProblem ValidationProblem(ZodException exception, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(httpContext); + + var details = ZodProblemDetailsWriter + .GetOrCreate(httpContext.RequestServices) + .Create(exception.Errors, httpContext); + + return ToValidationProblem(details); + } + + /// + /// Creates a result that writes the ZodSharp ProblemDetails with its computed status (for example a mapped + /// of 409) through the registered . + /// + /// The validated type. + /// The failed result. + /// The request. + /// The result. + /// The result is successful. + public static ZodProblemDetailsResult Problem(in ValidationResult result, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + EnsureFailed(result); + + var writer = ZodProblemDetailsWriter.GetOrCreate(httpContext.RequestServices); + return new ZodProblemDetailsResult(writer, writer.Create(result.Errors, httpContext)); + } + + /// + /// Creates a result that writes the ZodSharp ProblemDetails for a thrown with its + /// computed status. + /// + /// The exception. + /// The request. + /// The result. + public static ZodProblemDetailsResult Problem(ZodException exception, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(httpContext); + + var writer = ZodProblemDetailsWriter.GetOrCreate(httpContext.RequestServices); + return new ZodProblemDetailsResult(writer, writer.Create(exception.Errors, httpContext)); + } + + static ValidationProblem ToValidationProblem(HttpValidationProblemDetails details) => + TypedResults.ValidationProblem( + details.Errors, + detail: details.Detail, + instance: details.Instance, + title: details.Title, + type: details.Status == StatusCodes.Status400BadRequest + ? details.Type + : "https://tools.ietf.org/html/rfc9110#section-15.5.1", + extensions: details.Extensions + ); + + static void EnsureFailed(in ValidationResult result) + { + if (result.IsSuccess) + throw new InvalidOperationException("Cannot create ProblemDetails from a successful validation result."); + } +} diff --git a/src/src/AspNetCore/ZodValidationFilter.cs b/src/src/AspNetCore/ZodValidationFilter.cs index d6bbf23b..64e8500b 100644 --- a/src/src/AspNetCore/ZodValidationFilter.cs +++ b/src/src/AspNetCore/ZodValidationFilter.cs @@ -5,11 +5,16 @@ namespace ZodSharp.AspNetCore; /// -/// An endpoint filter that validates the first bound argument of type against the -/// registered . When validation fails the request is short-circuited with a -/// standard validation-problem response. +/// An endpoint filter that validates every bound argument of type against the +/// registered . When validation fails the request is short-circuited with the +/// ZodSharp ProblemDetails contract, written by . /// /// The request type to validate. +/// +/// WithZodSharpValidation<T>() no longer uses this class: it installs a filter factory that resolves +/// the validator once, when the endpoint is built, and fails then if none is registered. This class remains for +/// code that adds it directly with AddEndpointFilter<ZodValidationFilter<T>>(). +/// public sealed class ZodValidationFilter : IEndpointFilter { /// @@ -18,30 +23,48 @@ public sealed class ZodValidationFilter : IEndpointFilter ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(next); + var httpContext = context.HttpContext; + +#if NET11_0_OR_GREATER + // A binding failure already set the 400; let ASP.NET Core's response stand. + if (httpContext.Response.StatusCode == StatusCodes.Status400BadRequest) + return await next(context).ConfigureAwait(false); +#endif + var factory = - context.HttpContext.RequestServices.GetService() + httpContext.RequestServices.GetService() ?? throw new InvalidOperationException( "No IZodSchemaFactory is registered. Call AddZodSharp (or AddZodSharpFactory) before applying WithZodSharpValidation." ); + IZodSchemaValidator? validator = null; + List? errors = null; + foreach (var argument in context.Arguments) { if (argument is not T value) continue; - var validator = + validator ??= factory.Resolve() ?? throw new InvalidOperationException( $"No Zod schema validator is registered for type '{typeof(T).FullName}'." ); - var result = await validator.ValidateAsync(value, context.HttpContext.RequestAborted); - if (!result.IsSuccess) - return result.ToValidationProblem(); + var result = await validator + .ValidateAsync(value, httpContext.RequestServices, httpContext.RequestAborted) + .ConfigureAwait(false); + if (result.IsSuccess) + continue; - break; + errors ??= []; + errors.AddRange(result.Errors); } - return await next(context); + if (errors is null) + return await next(context).ConfigureAwait(false); + + var writer = ZodProblemDetailsWriter.GetOrCreate(httpContext.RequestServices); + return new ZodProblemDetailsResult(writer, writer.Create(errors, httpContext)); } } diff --git a/src/src/AspNetCore/ZodValidationPipelineGuard.cs b/src/src/AspNetCore/ZodValidationPipelineGuard.cs new file mode 100644 index 00000000..e5c748be --- /dev/null +++ b/src/src/AspNetCore/ZodValidationPipelineGuard.cs @@ -0,0 +1,96 @@ +#if NET11_0_OR_GREATER +using System.Reflection; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Extensions.Validation; + +namespace ZodSharp.AspNetCore; + +/// +/// The double-validation guard: tells the ZodSharp endpoint filter which parameters the built-in +/// Microsoft.Extensions.Validation pipeline already validates with ZodSharp, so WithZodSharpValidation() on +/// an endpoint the pipeline also covers does not validate them twice. +/// +/// +/// The pipeline's filter is outermost and short-circuits on failure, so a parameter it covers has already passed +/// ZodSharp validation by the time the ZodSharp filter would run. +/// +sealed class ZodValidationPipelineGuard +{ + static readonly Action LogCoveredParameters = LoggerMessage.Define< + string, + string + >( + LogLevel.Debug, + new EventId(2, "ZodValidationCoveredByPipeline"), + "WithZodSharpValidation on endpoint handler '{Handler}' skips parameter(s) {Parameters}: the " + + "Microsoft.Extensions.Validation pipeline already validates them with ZodSharp (AddZodSharpValidation)." + ); + + readonly ValidationOptions _options; + readonly Assembly _bridge; + readonly IServiceProviderIsService? _isService; + readonly ILogger? _logger; + + ZodValidationPipelineGuard(ValidationOptions options, IServiceProviderIsService? isService, ILogger? logger) + { + _options = options; + _bridge = typeof(ZodValidatableInfoResolver).Assembly; + _isService = isService; + _logger = logger; + } + + /// + /// Returns the guard for an endpoint, or when the pipeline does not validate it with + /// ZodSharp: the bridge resolver is not registered, or the endpoint opted out with DisableValidation(). + /// + public static ZodValidationPipelineGuard? Create(IServiceProvider services, IList endpointMetadata) + { + if (services.GetService() is not { } resolver) + return null; + + foreach (var item in endpointMetadata) + { + if (item is IDisableValidationMetadata) + return null; + } + + var options = services.GetService>()?.Value; + // The guard keeps the option lookup and the construction separate; a conditional expression would + // bury the three services the guard object needs. +#pragma warning disable IDE0046 // Convert to conditional expression + if (options is null || !options.Resolvers.Contains(resolver)) + return null; +#pragma warning restore IDE0046 + + return new( + options, + services.GetService(), + services.GetService()?.CreateLogger("ZodSharp.AspNetCore.ZodEndpointValidationFilter") + ); + } + + /// + /// Whether the pipeline validates with ZodSharp. + /// + public bool Covers(ParameterInfo parameter) + { + // The pipeline never validates a parameter the container resolves. + return _isService?.IsService(parameter.ParameterType) != true + && _options.TryGetValidatableParameterInfo(parameter, out var info) + && info.GetType().Assembly == _bridge; + } + + /// + /// Logs, once per endpoint (when it is built), the parameters left to the pipeline. + /// + public void LogCovered(string handler, List parameters) + { + if (_logger is not null && _logger.IsEnabled(LogLevel.Debug)) + LogCoveredParameters(_logger, handler, "'" + string.Join("', '", parameters) + "'", null); + } +} +#endif diff --git a/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs b/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs new file mode 100644 index 00000000..1c41df9e --- /dev/null +++ b/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs @@ -0,0 +1,82 @@ +#if NET11_0_OR_GREATER +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// Composes the ZodSharp enrichment into , so a +/// validation response written by the built-in Microsoft.Extensions.Validation pipeline carries the full ZodSharp +/// contract. +/// +/// +/// A post-configure step, so it runs after every Configure of - including +/// AddProblemDetails(o => o.CustomizeProblemDetails = ...) calls made before or after +/// AddZodSharpProblemDetails(). The callback already present is kept and runs after the ZodSharp one, so the +/// application can still change the result. +/// +sealed class ZodValidationProblemDetailsCustomizer : IPostConfigureOptions +{ + public void PostConfigure(string? name, ProblemDetailsOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var existing = options.CustomizeProblemDetails; + options.CustomizeProblemDetails = existing is null + ? Customize + : context => + { + Customize(context); + existing(context); + }; + } + + internal static void Customize(ProblemDetailsContext context) + { + if (context.ProblemDetails is not HttpValidationProblemDetails problem) + return; + + var httpContext = context.HttpContext; + var services = httpContext.RequestServices; + if (services is null) + return; + + var writer = ZodProblemDetailsWriter.GetOrCreate(services); + + // Minimal-API binding writes a body it could not deserialize as a validation problem, keyed by the JSON path and + // quoting the exception's message. When a converter rejected a value with ZodSharp errors (a strict value object + // converter throwing JsonException(inner: ZodException)), those errors replace it, under the member that held + // the value - the body validating the bound model would produce. + if ( + context.Exception is { } exception + && ZodJsonExceptions.TryGetErrors( + exception, + ZodExceptionHandler.ResolveBodyTypeInfo(httpContext), + out var rejected + ) + ) + { + problem.Errors.Clear(); + writer.Apply(problem, rejected.AsSpan(), httpContext); + if (problem.Status is { } rejectedStatus) + httpContext.Response.StatusCode = rejectedStatus; + + return; + } + + if (ZodRequestValidationReport.Get(httpContext) is { HasErrors: true } report) + { + writer.Apply(problem, report.Errors.AsSpan(), httpContext); + if (problem.Status is { } status) + httpContext.Response.StatusCode = status; + + return; + } + + // No ZodSharp errors (a DataAnnotations-only failure, or a payload ZodSharp already wrote): leave it alone + // apart from the optional issues projection, which ZodSharp's own payloads already carry. + writer.ProjectExternalIssues(problem); + } +} +#endif diff --git a/src/src/Benchmarks/Benchmarks.csproj b/src/src/Benchmarks/Benchmarks.csproj index 9afa0448..8bad621c 100644 --- a/src/src/Benchmarks/Benchmarks.csproj +++ b/src/src/Benchmarks/Benchmarks.csproj @@ -14,8 +14,33 @@ + + + + + + + + + + + + diff --git a/src/src/Benchmarks/GeneratedValidationPerformanceTests.cs b/src/src/Benchmarks/GeneratedValidationPerformanceTests.cs new file mode 100644 index 00000000..821e3e14 --- /dev/null +++ b/src/src/Benchmarks/GeneratedValidationPerformanceTests.cs @@ -0,0 +1,94 @@ +using System.ComponentModel.DataAnnotations; +using BenchmarkDotNet.Attributes; +using ZodSharp.Core; + +namespace ZodSharp; + +/// +/// Performance tests for source-generated ([ZodSchema]) validation: the success path, the failure path and +/// a nested graph. +/// +[MemoryDiagnoser] +[SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] +public class GeneratedValidationPerformanceTests +{ + readonly BenchmarkCustomer _valid = new() + { + Name = "Ada Lovelace", + Email = "ada@example.com", + Age = 36, + Tags = ["math", "engines"], + Address = new() { Street = "12 St James's Square", City = "London" }, + }; + + readonly BenchmarkCustomer _invalid = new() + { + Name = "A", + Email = "not-an-email", + Age = 250, + Tags = [], + Address = new() { Street = "", City = "L" }, + }; + + [Benchmark(Baseline = true)] + public ValidationResult Validate_Valid() => BenchmarkCustomerSchema.Validate(_valid); + + [Benchmark] + public ValidationResult Validate_Invalid() => BenchmarkCustomerSchema.Validate(_invalid); + + [Benchmark] + public ValidationResult ValidateMember_Valid() => + BenchmarkCustomerSchema.ValidateMember(_valid, nameof(BenchmarkCustomer.Email)); + + [Benchmark] + public ValidationResult ValidateMember_Invalid() => + BenchmarkCustomerSchema.ValidateMember(_invalid, nameof(BenchmarkCustomer.Email)); + + [Benchmark] + public bool TryResolveMember_NestedPath() => + BenchmarkCustomerSchema.TryResolveMember(_invalid, NestedPath, out _, out _); + + static readonly ZodPath NestedPath = BenchmarkCustomerSchema.Paths.Address.Append( + ZodPathSegment.Member(nameof(BenchmarkAddress.City)) + ); +} + +/// A generated-schema benchmark model. +[ZodSchema] +public sealed class BenchmarkCustomer +{ + /// The name. + [Required] + [StringLength(100, MinimumLength = 2)] + public string Name { get; set; } = string.Empty; + + /// The email. + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + /// The age. + [Range(0, 150)] + public int Age { get; set; } + + /// The tags. + [MinLength(1)] + public IReadOnlyList Tags { get; init; } = []; + + /// The address. + public BenchmarkAddress Address { get; set; } = new(); +} + +/// A nested generated-schema benchmark model. +[ZodSchema] +public sealed class BenchmarkAddress +{ + /// The street. + [Required] + public string Street { get; set; } = string.Empty; + + /// The city. + [Required] + [MinLength(2)] + public string City { get; set; } = string.Empty; +} diff --git a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs index 9dad91af..4c47b123 100644 --- a/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs +++ b/src/src/Benchmarks/ProblemDetailsMappingPerformanceTests.cs @@ -74,13 +74,16 @@ public ProblemDetailsMappingPerformanceTests() } [Benchmark] - public HttpValidationProblemDetails MapWithoutRegistry() => _unmappedException.ToHttpValidationProblemDetails(); + public HttpValidationProblemDetails MapWithoutRegistry() => + _unmappedException.ToHttpValidationProblemDetails( + new ZodProblemDetailsOptions { Registry = new(), FormatMessages = false } + ); [Benchmark] public HttpValidationProblemDetails MapWithRegistryLookup() => - _mappedException.ToHttpValidationProblemDetails(_lookup); + _mappedException.ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { ErrorTypeResolver = _lookup }); [Benchmark] public HttpValidationProblemDetails MapWithRegistryAndFormatting() => - _mappedException.ToHttpValidationProblemDetails(_registry); + _mappedException.ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = _registry }); } diff --git a/src/src/Blazor/Blazor.csproj b/src/src/Blazor/Blazor.csproj new file mode 100644 index 00000000..b849fb3c --- /dev/null +++ b/src/src/Blazor/Blazor.csproj @@ -0,0 +1,73 @@ + + + + true + + net11.0 + + ZodSharp.Blazor + + Purview.ZodSharp.Blazor + Purview.ZodSharp.Blazor + true + true + true + true + + Blazor integration for ZodSharp: validates EditForm models with ZodSharp on Blazor Server, Blazor + WebAssembly and Auto render mode, through the standard DataAnnotationsValidator or the full-fidelity + ZodValidator, and maps API validation problems back onto form fields. + + zod;validation;schema;blazor;webassembly;forms + + true + + $(NoWarn);NU5104 + + 11.0.0-rc.3.26522.148 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs b/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs new file mode 100644 index 00000000..13fef33f --- /dev/null +++ b/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs @@ -0,0 +1,180 @@ +using System.Linq.Expressions; +using ZodSharp.Blazor; +using ZodSharp.Core; +using ZodSharp.Http; + +namespace Microsoft.AspNetCore.Components.Forms; + +/// +/// ZodSharp extensions for : the full-fidelity errors behind the messages, and server +/// validation problems mapped onto fields. +/// +public static class ZodEditContextExtensions +{ + extension(EditContext editContext) + { + /// + /// Gets the full ZodSharp errors shown on : those placed and + /// those mapped by AddZodProblemDetails. Empty when the form only uses + /// DataAnnotationsValidator, whose store holds strings only. + /// + /// The field. + /// The errors. + public IReadOnlyList GetZodErrors(in FieldIdentifier field) + { + ArgumentNullException.ThrowIfNull(editContext); + return ZodFormErrors.Get(editContext) is { } errors + ? [.. errors.GetEntries(field).Select(static e => e.Error)] + : []; + } + + /// + /// Gets the full ZodSharp errors shown on the field identifies. + /// + /// The field type. + /// The field accessor, for example () => order.Customer.Email. + /// The errors. + public IReadOnlyList GetZodErrors(Expression> accessor) + { + ArgumentNullException.ThrowIfNull(accessor); + return editContext.GetZodErrors(FieldIdentifier.Create(accessor)); + } + + /// + /// Gets every full ZodSharp error on the form. + /// + /// The errors. + public IReadOnlyList GetZodErrors() + { + ArgumentNullException.ThrowIfNull(editContext); + return ZodFormErrors.Get(editContext) is { } errors + ? [.. errors.GetAllEntries().Select(static e => e.Entry.Error)] + : []; + } + + /// + /// Maps a server validation problem (an API's ProblemDetails response) onto the form's fields. Issues are placed + /// by their typed segments, so Lines[0].Quantity lands on order.Lines[0].Quantity; when the + /// response has no issues (a plain Microsoft.Extensions.Validation or DataAnnotations response) the dotted + /// errors keys are parsed instead. Member names are matched case-insensitively when no exact match + /// exists, so camel-cased server paths place too. + /// + /// The problem. + /// + /// The store to add the messages to; to use one the form keeps for server errors. + /// + /// + /// Whether a field's server errors are cleared when the field changes ("server error until edited"). Defaults to + /// . + /// + /// Previously mapped server errors are replaced. + public void AddZodProblemDetails( + ZodValidationProblemDetails problem, + ValidationMessageStore? store = null, + bool clearOnFieldChange = true + ) + { + ArgumentNullException.ThrowIfNull(problem); + editContext.AddZodErrors(problem.ToValidationErrors(), store, clearOnFieldChange); + } + + /// + /// Places ZodSharp errors produced outside the form onto its fields, exactly as AddZodProblemDetails + /// places a server's: for example the errors PostAsJsonValidatedAsync returns when it refuses to send an + /// invalid value, or ZodValidationProblemDetails.ToValidationErrors() after post-processing. Errors are + /// placed by their typed , matching member names case-insensitively when no + /// exact match exists. + /// + /// The errors. + /// + /// The store to add the messages to; to use one the form keeps for these errors. + /// + /// + /// Whether a field's errors are cleared when the field changes. Defaults to . + /// + /// + /// Previously added errors (from this method or AddZodProblemDetails) are replaced; they are read back by + /// GetZodErrors and removed by ClearZodErrors. + /// + public void AddZodErrors( + IEnumerable errors, + ValidationMessageStore? store = null, + bool clearOnFieldChange = true + ) + { + ArgumentNullException.ThrowIfNull(editContext); + ArgumentNullException.ThrowIfNull(errors); + + var formErrors = ZodFormErrors.GetOrCreate(editContext); + AddExternalErrors(editContext, formErrors, errors, store, clearOnFieldChange); + } + + /// + /// Clears every ZodSharp error on the form: the messages and errors placed and the + /// errors added by AddZodProblemDetails and AddZodErrors. + /// + public void ClearZodErrors() + { + ArgumentNullException.ThrowIfNull(editContext); + + if (ZodFormErrors.Get(editContext) is not { } errors) + return; + + ClearServerErrors(errors); + errors.Validator?.ClearOwnErrors(); + errors.ClearValidator(); + editContext.NotifyValidationStateChanged(); + } + } + + static void AddExternalErrors( + EditContext editContext, + ZodFormErrors errors, + IEnumerable source, + ValidationMessageStore? store, + bool clearOnFieldChange + ) + { + ClearServerErrors(errors); + + var target = store ?? (errors.ServerStore ??= new ValidationMessageStore(editContext)); + var validator = errors.Validator?.RootValidator; + foreach (var error in source) + { + var field = ZodFieldLocator.Locate(editContext.Model, error.FullPath, validator, ignoreCase: true); + var message = error.Message ?? string.Empty; + target.Add(field, message); + errors.AddServerError(field, error, message); + errors.ServerStores[field] = target; + } + + if (clearOnFieldChange && !errors.ClearsServerErrorsOnFieldChange) + { + errors.ClearsServerErrorsOnFieldChange = true; + editContext.OnFieldChanged += static (sender, e) => + { + if (sender is not EditContext context || ZodFormErrors.Get(context) is not { } state) + return; + + var field = e.FieldIdentifier; + if (state.ServerStores.Remove(field, out var fieldStore)) + { + fieldStore.Clear(field); + state.ClearServer(field); + context.NotifyValidationStateChanged(); + } + }; + } + + editContext.NotifyValidationStateChanged(); + } + + static void ClearServerErrors(ZodFormErrors errors) + { + foreach (var (field, store) in errors.ServerStores) + store.Clear(field); + + errors.ServerStore?.Clear(); + errors.ClearServer(); + } +} diff --git a/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs b/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs new file mode 100644 index 00000000..8fb122ad --- /dev/null +++ b/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using ZodSharp.Blazor; +using ZodSharp.Extensions.Validation; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Registers ZodSharp for Blazor forms. +/// +public static class ZodSharpBlazorServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Registers ZodSharp for Blazor forms on Blazor Server, Blazor WebAssembly and Auto render mode. Call it in + /// the server and the client Program.cs. + /// + /// + /// Optional configuration of the Microsoft.Extensions.Validation bridge - for example registering generated, + /// trim-safe validator sources. + /// + /// Optional configuration of the Blazor components. + /// The for chaining. + /// + /// + /// Registers the Microsoft.Extensions.Validation bridge, so the standard <DataAnnotationsValidator /> validates every + /// type that has a ZodSharp validator with ZodSharp - nested members, collections and dictionaries included - + /// with no [ValidatableType] and no Microsoft.Extensions.Validation generator. Also registers the + /// options reads and when no + /// is registered. + /// + /// + /// The bridge is registered for type validation only: it does not claim minimal-API parameters, so a host + /// that also maps endpoints keeps its endpoints exactly as they were (no ZodSharp validation runs before their + /// handlers). To validate ZodSharp-typed endpoint parameters as well, also call AddZodSharpValidation() + /// (in either order), or set through + /// . + /// + /// + /// It deliberately does not register an : on Blazor Server the scope + /// is the circuit, so a scoped sink would accumulate the errors of every validation on the page. + /// + /// + public IServiceCollection AddZodSharpBlazor( + Action? configureValidation = null, + Action? configure = null + ) + { + ArgumentNullException.ThrowIfNull(services); + + // Type validation only: forms need it, the host's minimal APIs should not change because forms are used. + services.AddZodSharpValidation(configureValidation, validateParameters: false); + services.AddOptions(); + if (configure is not null) + services.Configure(configure); + + services.TryAddSingleton(TimeProvider.System); + return services; + } + } +} diff --git a/src/src/Blazor/Sdk/README.md b/src/src/Blazor/Sdk/README.md new file mode 100644 index 00000000..59097017 --- /dev/null +++ b/src/src/Blazor/Sdk/README.md @@ -0,0 +1,112 @@ +# Purview.ZodSharp.Blazor + +Blazor forms validated by [ZodSharp](https://github.com/purview-dev/zodsharp) on **Blazor Server**, **Blazor +WebAssembly** and **Auto** render mode (`net11.0`). + +```bash +dotnet add package Purview.ZodSharp.Blazor +``` + +```csharp +// Program.cs - the server AND the client project of a Blazor Web App. +builder.Services.AddZodSharpMyAppSharedSchemas(); // generated, trim-safe registration of your [ZodSchema] models +builder.Services.AddZodSharpBlazor(); +``` + +> **`AddZodSharpBlazor()` does not validate your minimal APIs.** It registers the validation bridge for form models +> only, so a server that also maps endpoints keeps them as they were. To have the .NET 11 pipeline validate +> ZodSharp-typed endpoint parameters too (a 400 before the handler runs), also call +> `builder.Services.AddZodSharpValidation()` (either order), or pass +> `AddZodSharpBlazor(validation => validation.ValidateParameters = true)`; opt endpoints out with `DisableValidation()`. + +## Route 1: the standard `` + +```razor + + + + + + +``` + +`[ZodSchema]` models validate with ZodSharp, **including nested objects, collections and dictionaries, with no +`[ValidatableType]` and no Microsoft.Extensions.Validation generator** - the generator does not flow to a +WebAssembly client, so this is what makes nested validation work there. A field change runs only that member's +own rules; async rules show the platform's `pending` and `faulted` states. + +## Route 2: `` (full fidelity) + +```razor + + + + + +``` + +- Keeps the full `ValidationError` (code, category, origin, bounds, parameters) behind every message: + `editContext.GetZodErrors(() => order.Customer.Email)`, `GetZodErrors(field)`, `GetZodErrors()`. +- `ValidateOn`: `Submit`, `Change` (default) or `Input` with `DebounceMilliseconds` (each change supersedes and + cancels the pending check). Submit-time validation always runs. +- `Schema`: an explicit validator for models without a registered one - + `ZodModelSchema.For(Z.Object().Field("Reference", Z.String().Min(3)).Build())`. +- `ValidateNestedOnFieldChange`: a field change re-validates the whole owning object (cross-field rules). +- With a `DataAnnotationsValidator` on the same form it records the full errors but leaves the messages to + `DataAnnotationsValidator`, so nothing is shown twice, and logs one warning. Use one validator. + +`ZodValidationMessage` and `ZodValidationSummary` render the same markup and CSS classes as the built-in +components, plus `data-code`/`data-category`. + +## Server errors onto the form + +```csharp +var response = await http.PostAsJsonAsync("/orders", order); +if (await response.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); // cleared per field when the field changes +``` + +Reads Purview.ZodSharp.AspNetCore's `issues[]` (typed `segments`), or a plain `errors` dictionary, with +source-generated JSON metadata (trim- and AOT-safe). `TryReadZodValidationProblemAsync` and `ZodValidationProblemDetails` +come from [Purview.ZodSharp.Http](https://www.nuget.org/packages/Purview.ZodSharp.Http), which this package references. + +Errors produced anywhere else go on the form the same way, by their typed paths: + +```csharp +var sent = await http.PostAsJsonValidatedAsync("/orders", order, + new OrderSchemaValidator(), AppJsonContext.Default.Order, services); +if (!sent.IsSuccess) + editContext.AddZodErrors(sent.Errors); // rejected before sending; cleared per field like server errors +``` + +`AddZodErrors` and `AddZodProblemDetails` replace each other's errors, `GetZodErrors` reads them back, and +`ClearZodErrors` removes them. + +## Static SSR client-side validation + +`zodsharp-validation.js` ships as a static web asset and registers itself through a Blazor JavaScript initializer. +On the server, `AddZodSharpClientValidation()` (Purview.ZodSharp.AspNetCore) adds the matching rules - for +`[StartsWith]`, `[EndsWith]`, `[Includes]`, `[UUID]`, `[Cuid2]`, `[ULID]`, `[IPAddress]`, `[Cidr]`, `[Regex]`, +`[Int]`, `[MultipleOf]` and the bound attributes - to the form's `data-rules` payload, beside ASP.NET Core's +DataAnnotations rules. + +Limitations: + +- ASP.NET Core has no public API to add rules to its DataAnnotations provider, so ZodSharp decorates it and merges + into its rendered payload. The payload format is pinned by contract tests. +- Async, service-aware and culture-sensitive rules, and custom rules whose metadata has no JavaScript equivalent, + are enforced on the server only. .NET and JavaScript regular expressions differ at the edges. +- Client rules only improve the user experience; **the server always validates the posted form**. + +## Notes + +- Blazor Server: validation services resolve from the **circuit** scope. In async validators use + `IDbContextFactory` or an explicitly created scope rather than a scoped `DbContext`. +- Don't validate in `OnInitialized` of prerendered components; it would run once per render phase. +- WebAssembly: prefer the generated `AddZodSharp{Assembly}Schemas()` registration; assembly scanning is not + trim-safe. +- Value objects and other struct members: an error inside one (`Email.Value`) is shown on the owning member's + field (`For="() => model.Email"`), because a struct cannot be a `FieldIdentifier` model; a scalar value object's + messages use the owning member's display name. + +Documentation: diff --git a/src/src/Blazor/ZodBlazorOptions.cs b/src/src/Blazor/ZodBlazorOptions.cs new file mode 100644 index 00000000..7fbee742 --- /dev/null +++ b/src/src/Blazor/ZodBlazorOptions.cs @@ -0,0 +1,24 @@ +using ZodSharp.Core; + +namespace ZodSharp.Blazor; + +/// +/// Configures the Blazor-specific behaviour of ZodSharp: and the +/// EditContext extensions. +/// +public sealed class ZodBlazorOptions +{ + /// + /// Gets or sets the formatter that renders the messages places on fields. When + /// (the default), the bridge's ZodValidationOptions.MessageFormatter is used, then a + /// StringLocalizerZodMessageFormatter when an IStringLocalizerFactory is registered + /// (AddLocalization()), and otherwise each error's own message. + /// + public IZodMessageFormatter? MessageFormatter { get; set; } + + /// + /// Gets or sets whether logs a one-time warning when it shares a form with + /// DataAnnotationsValidator. Defaults to . + /// + public bool WarnOnValidatorCoexistence { get; set; } = true; +} diff --git a/src/src/Blazor/ZodFieldLocator.cs b/src/src/Blazor/ZodFieldLocator.cs new file mode 100644 index 00000000..171e8a1b --- /dev/null +++ b/src/src/Blazor/ZodFieldLocator.cs @@ -0,0 +1,253 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Components.Forms; +using ZodSharp.Core; + +namespace ZodSharp.Blazor; + +/// +/// Turns a ZodSharp error path into the Blazor shows the message on. Validators that +/// resolve members themselves (generated ones do) are asked first; anything else falls back to a property walker. +/// Never throws. +/// +static class ZodFieldLocator +{ + const string WalkRequirement = + "Walks public properties of the form model's runtime types, which a trimmer cannot follow. Guarded by " + + ZodReflectionFeature.SwitchName + + "; generated validators resolve members without it."; + + static readonly ConcurrentDictionary<(Type Type, string Name, bool IgnoreCase), PropertyInfo?> Properties = new(); + + /// + /// Locates the field addresses below . + /// + /// The object the path is relative to. + /// The error path. + /// The validator of , when known. + /// + /// Whether member names may differ in case from the CLR property (server paths rendered with a JSON naming policy). + /// + /// The field; a model-level field (empty name) for a root error. + public static FieldIdentifier Locate(object root, ZodPath path, IZodUntypedValidator? validator, bool ignoreCase) + { + if (path.IsRoot) + return new(root, string.Empty); + + if (IsDictionaryBacked(root)) + return new(root, path.ToDottedString()); + + object? resolvedContainer = null; + var resolvedMember = string.Empty; + if (validator is not null) + { + try + { + if ( + validator.ValidatedType.IsInstanceOfType(root) + && validator.TryResolveMember(root, path, out resolvedContainer, out resolvedMember) + && IsFieldOwner(resolvedContainer) + ) + return new(resolvedContainer, resolvedMember ?? string.Empty); + } + catch (Exception) + { + resolvedContainer = null; + resolvedMember = string.Empty; + } + } + + var container = (object?)root; + var member = string.Empty; + + // The reflection walk is the fallback for a validator that does not resolve members itself; it is removed when + // trimming, where the message stays on the nearest object the validator reached (or the model). + if (ZodReflectionFeature.IsSupported && Walk(root, path, ignoreCase, out container, out member)) + return new(container ?? root, member); + + // The validator followed part of the path itself: its nearest ancestor is better informed than ours. + // A conditional expression would hide which of the three fallbacks produced the result. +#pragma warning disable IDE0046 // Convert to conditional expression + if (IsFieldOwner(resolvedContainer) && !string.IsNullOrEmpty(resolvedMember)) + return new(resolvedContainer, resolvedMember); +#pragma warning restore IDE0046 + + return new(container ?? root, member); + } + + // A FieldIdentifier's model must be a reference type, and a boxed copy of a struct is not the instance the form binds, + // so a struct is never an owner: a schema generated by an earlier 3.0 prerelease resolves a struct member to it. + static bool IsFieldOwner([NotNullWhen(true)] object? container) => + container is not null && !container.GetType().IsValueType; + + static bool IsDictionaryBacked(object value) => + value is IDictionary or IReadOnlyDictionary or JsonElement or JsonNode; + + /// + /// Walks public instance properties along . Indexes step into sequences; keys - and member + /// segments over a dictionary, which is how a server path renders a key - step into dictionaries. A path that + /// ends in an index or a key resolves to the owner of the collection member; a path that cannot be followed stops + /// at the nearest object reached. + /// + [RequiresUnreferencedCode(WalkRequirement)] + static bool Walk(object root, ZodPath path, bool ignoreCase, out object? container, out string memberName) + { + container = root; + memberName = string.Empty; + + var current = (object?)root; + foreach (var segment in path.Segments) + { + switch (segment.Kind) + { + case ZodPathSegmentKind.Member when current is IDictionary: + case ZodPathSegmentKind.Key: + if (!TryGetEntry(current, segment.Name ?? string.Empty, ignoreCase, out current)) + return false; + + break; + + case ZodPathSegmentKind.Index: + if (!TryGetElement(current, segment.Index, out current)) + return false; + + break; + + case ZodPathSegmentKind.Member: + default: + if (current is null) + return false; + + var property = FindCachedProperty(current.GetType(), segment.Name ?? string.Empty, ignoreCase); + + // A member of a struct stays attributed to the nearest reference-type owner and its member: the struct + // cannot be a FieldIdentifier model. + if (!current.GetType().IsValueType) + { + container = current; + memberName = property?.Name ?? segment.Name ?? string.Empty; + } + if (property is null || !TryGetValue(property, current, out current)) + return false; + + break; + } + } + + return true; + } + + [RequiresUnreferencedCode(WalkRequirement)] + static PropertyInfo? FindCachedProperty(Type type, string name, bool ignoreCase) + { + var exact = Properties.GetOrAdd((type, name, false), static key => FindProperty(key.Type, key.Name, false)); + return exact is not null || !ignoreCase + ? exact + : Properties.GetOrAdd((type, name, true), static key => FindProperty(key.Type, key.Name, true)); + } + + [RequiresUnreferencedCode(WalkRequirement)] + static PropertyInfo? FindProperty(Type type, string name, bool ignoreCase) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + PropertyInfo? best = null; + var bestDepth = -1; + foreach (var candidate in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if ( + !string.Equals(candidate.Name, name, comparison) + || !candidate.CanRead + || candidate.GetIndexParameters().Length != 0 + ) + continue; + + // Most derived wins, so a property hidden with 'new' resolves to its newest declaration. + var depth = Depth(candidate.DeclaringType); + if (depth > bestDepth) + { + best = candidate; + bestDepth = depth; + } + } + + return best; + } + + static int Depth(Type? type) + { + var depth = 0; + for (var current = type; current is not null; current = current.BaseType) + depth++; + + return depth; + } + + static bool TryGetValue(PropertyInfo property, object instance, out object? value) + { + try + { + value = property.GetValue(instance); + return true; + } + catch (Exception) + { + value = null; + return false; + } + } + + static bool TryGetElement(object? sequence, int index, out object? element) + { + element = null; + switch (sequence) + { + case null: + case string: + return false; + case IList list: + if (index < 0 || index >= list.Count) + return false; + + element = list[index]; + return true; + case IEnumerable enumerable: + var position = 0; + foreach (var item in enumerable) + { + if (position++ == index) + { + element = item; + return true; + } + } + + return false; + default: + return false; + } + } + + static bool TryGetEntry(object? map, string key, bool ignoreCase, out object? value) + { + value = null; + if (map is not IDictionary dictionary) + return false; + + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + foreach (DictionaryEntry entry in dictionary) + { + if (string.Equals(ZodPathSegment.Key(entry.Key).Name, key, comparison)) + { + value = entry.Value; + return true; + } + } + + return false; + } +} diff --git a/src/src/Blazor/ZodFieldNameFormatter.cs b/src/src/Blazor/ZodFieldNameFormatter.cs new file mode 100644 index 00000000..fe8ea654 --- /dev/null +++ b/src/src/Blazor/ZodFieldNameFormatter.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Text; + +namespace ZodSharp.Blazor; + +/// +/// Renders a field accessor the way Blazor names statically rendered inputs (Model.Lines[0].Quantity), for +/// the data-valmsg-for attribute that ties a message to its input in static SSR. +/// +static class ZodFieldNameFormatter +{ + public static string Format(LambdaExpression accessor) + { + List parts = []; + var current = accessor.Body; + while (current is not null) + { + switch (current) + { + case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary: + current = unary.Operand; + continue; + + case MemberExpression { Expression: ConstantExpression or null } captured: + // A captured variable or a member of the component (Model): the root of the name. + parts.Add(captured.Member.Name); + current = null; + continue; + + case MemberExpression member: + parts.Add("." + member.Member.Name); + current = member.Expression; + continue; + + case MethodCallExpression { Method.Name: "get_Item", Arguments.Count: 1, Object: not null } indexer: + parts.Add("[" + FormatIndex(indexer.Arguments[0]) + "]"); + current = indexer.Object; + continue; + + case BinaryExpression { NodeType: ExpressionType.ArrayIndex } arrayIndex: + parts.Add("[" + FormatIndex(arrayIndex.Right) + "]"); + current = arrayIndex.Left; + continue; + + default: + current = null; + continue; + } + } + + parts.Reverse(); + StringBuilder builder = new(); + foreach (var part in parts) + builder.Append(part); + + var name = builder.ToString(); + return name.StartsWith('.') ? name[1..] : name; + } + + static string FormatIndex(Expression index) + { + var value = index switch + { + ConstantExpression constant => constant.Value, + MemberExpression { Expression: ConstantExpression closure, Member: FieldInfo field } => field.GetValue( + closure.Value + ), + MemberExpression { Expression: ConstantExpression closure, Member: PropertyInfo property } => + property.GetValue(closure.Value), + _ => null, + }; + + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } +} diff --git a/src/src/Blazor/ZodFormErrors.cs b/src/src/Blazor/ZodFormErrors.cs new file mode 100644 index 00000000..f50ff3df --- /dev/null +++ b/src/src/Blazor/ZodFormErrors.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Components.Forms; +using ZodSharp.Core; + +namespace ZodSharp.Blazor; + +/// +/// One full-fidelity error placed on a field, with the message that was shown for it, so the message components can +/// pair a rendered message with its error. +/// +readonly record struct ZodFieldError(ValidationError Error, string Message); + +/// +/// The full-fidelity errors of one , held in +/// EditContext.Properties[ZodValidator.ErrorsKey]: the errors placed, and the +/// server errors mapped by AddZodProblemDetails. +/// +/// +/// Only touched on the renderer's synchronization context (inside the validator delegates the platform invokes, +/// after marshalling through InvokeAsync), like itself. +/// +sealed class ZodFormErrors +{ + readonly Dictionary> _validator = []; + readonly Dictionary> _server = []; + + /// + /// Gets the validator component attached to the form, if any; used to resolve server error paths with the same + /// validator that validates the form. + /// + public ZodValidator? Validator { get; set; } + + /// + /// Gets or sets the store server errors were added to when the caller supplied none. + /// + public ValidationMessageStore? ServerStore { get; set; } + + /// + /// Gets the store server errors were added to, per field, so clear-on-change clears the right one. + /// + public Dictionary ServerStores { get; } = []; + + /// + /// Gets or sets whether the clear-on-field-change handler is attached. + /// + public bool ClearsServerErrorsOnFieldChange { get; set; } + + public static ZodFormErrors GetOrCreate(EditContext editContext) + { + if ( + editContext.Properties.TryGetValue(ZodValidator.ErrorsKey, out var existing) + && existing is ZodFormErrors errors + ) + return errors; + + ZodFormErrors created = new(); + editContext.Properties[ZodValidator.ErrorsKey] = created; + return created; + } + + public static ZodFormErrors? Get(EditContext editContext) => + editContext.Properties.TryGetValue(ZodValidator.ErrorsKey, out var existing) ? existing as ZodFormErrors : null; + + public IEnumerable ValidatorFields => _validator.Keys; + + public void AddValidatorError(in FieldIdentifier field, in ValidationError error, string message) => + Add(_validator, field, new(error, message)); + + public void ClearValidator(in FieldIdentifier field) => _validator.Remove(field); + + public void ClearValidator() => _validator.Clear(); + + public void AddServerError(in FieldIdentifier field, in ValidationError error, string message) => + Add(_server, field, new(error, message)); + + public bool ClearServer(in FieldIdentifier field) => _server.Remove(field); + + public void ClearServer() + { + _server.Clear(); + ServerStores.Clear(); + } + + public List GetEntries(in FieldIdentifier field) + { + List entries = []; + if (_validator.TryGetValue(field, out var validator)) + entries.AddRange(validator); + + if (_server.TryGetValue(field, out var server)) + entries.AddRange(server); + + return entries; + } + + public List<(FieldIdentifier Field, ZodFieldError Entry)> GetAllEntries() + { + List<(FieldIdentifier, ZodFieldError)> entries = []; + foreach (var (field, errors) in _validator) + { + foreach (var error in errors) + entries.Add((field, error)); + } + + foreach (var (field, errors) in _server) + { + foreach (var error in errors) + entries.Add((field, error)); + } + + return entries; + } + + static void Add( + Dictionary> target, + in FieldIdentifier field, + ZodFieldError entry + ) + { + if (!target.TryGetValue(field, out var list)) + { + list = []; + target[field] = list; + } + + list.Add(entry); + } +} diff --git a/src/src/Blazor/ZodMessageRenderer.cs b/src/src/Blazor/ZodMessageRenderer.cs new file mode 100644 index 00000000..33670fad --- /dev/null +++ b/src/src/Blazor/ZodMessageRenderer.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; +using ValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Blazor; + +/// +/// Renders the message shown for a ZodSharp error, through the same formatter chain the Microsoft.Extensions.Validation +/// bridge uses, so DataAnnotationsValidator and show identical text. +/// +sealed class ZodMessageRenderer +{ + readonly IZodMessageFormatter? _formatter; + + ZodMessageRenderer(IZodMessageFormatter? formatter) => _formatter = formatter; + + public static ZodMessageRenderer Create(IServiceProvider services, ZodBlazorOptions options) + { + if (options.MessageFormatter is { } configured) + return new(configured); + + if (services.GetService>()?.Value.MessageFormatter is { } bridge) + return new(bridge); + + if (services.GetService() is { } factory) + { + var localizerProvider = services.GetService>()?.Value.LocalizerProvider; + return new(new StringLocalizerZodMessageFormatter(factory, localizerProvider)); + } + + return new(null); + } + + public string Render(in ValidationError error, Type? declaringType) + { + var culture = CultureInfo.CurrentUICulture; + try + { + return _formatter switch + { + null => error.Message ?? string.Empty, + StringLocalizerZodMessageFormatter localizer => localizer.Format(error, declaringType, culture), + _ => _formatter.Format(error, culture), + }; + } + catch (Exception) + { + // A formatter must never turn a validation failure into an exception. + return error.Message ?? string.Empty; + } + } +} diff --git a/src/src/Blazor/ZodModelSchema.cs b/src/src/Blazor/ZodModelSchema.cs new file mode 100644 index 00000000..5f065ffa --- /dev/null +++ b/src/src/Blazor/ZodModelSchema.cs @@ -0,0 +1,148 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using ZodSharp.Core; +using ZodSharp.Schemas; + +namespace ZodSharp.Blazor; + +/// +/// Adapts runtime schemas to for . +/// +public static class ZodModelSchema +{ + const string ObjectModelRequirement = + "Reads the form model and its nested models by reflection, which a trimmer cannot follow. Validate with a " + + "[ZodSchema] generated validator, or ZodModelSchema.For(IZodSchema), instead."; + + /// + /// Validates a CLR form model with a schema of the model's own type. + /// + /// The form model type. + /// The schema. + /// The validator to pass as . + public static IZodUntypedValidator For(IZodSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + return new ZodUntypedValidator(new ZodSchemaValidator(schema)); + } + + /// + /// Validates a CLR form model with a runtime Z.Object() schema whose field names are the model's property + /// names. The model's public properties are read into the key/value shape the object schema validates; nested + /// objects are read the same way, so a nested Z.Object() field validates a nested model. Errors are placed on + /// the CLR fields they name. + /// + /// The form model type. + /// The object schema. + /// The validator to pass as . + /// + /// + /// Collections are passed to the schema as they are (their elements are not converted), and there is no member + /// validation, so a field change validates the whole model and keeps the changed field's errors. + /// + /// + /// The model is read by reflection, including nested model types a trimmer cannot see, so this is annotated + /// . In a trimmed or WebAssembly-AOT application validate with a + /// [ZodSchema] generated validator (or ) instead. + /// + /// + [RequiresUnreferencedCode(ObjectModelRequirement)] + public static IZodUntypedValidator For< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TModel + >(ZodObject schema) + where TModel : class + { + ArgumentNullException.ThrowIfNull(schema); + return new ObjectModelValidator(typeof(TModel), schema); + } + + [RequiresUnreferencedCode(ObjectModelRequirement)] + sealed class ObjectModelValidator(Type modelType, ZodObject schema) : IZodUntypedValidator + { + static readonly ConcurrentDictionary PropertiesByType = new(); + + public Type ValidatedType => modelType; + + public bool RequiresAsyncValidation => false; + + public ImmutableArray Validate(object? value) + { + if (value is null) + return [ZodIssues.NullValue(ZodTypeNames.Object)]; + + var result = schema.Validate(Project(value, depth: 0)); + return result.IsSuccess ? [] : result.Errors; + } + + public async ValueTask> ValidateAsync( + object? value, + IServiceProvider? services, + CancellationToken cancellationToken + ) + { + if (value is null) + return [ZodIssues.NullValue(ZodTypeNames.Object)]; + + var result = await schema.ValidateAsync(Project(value, depth: 0), cancellationToken).ConfigureAwait(false); + return result.IsSuccess ? [] : result.Errors; + } + + public bool IsValidatableMember(string memberName) => false; + + public ValueTask> ValidateMemberAsync( + object value, + string memberName, + IServiceProvider? services, + CancellationToken cancellationToken + ) => new([]); + + public bool TryResolveMember(object value, ZodPath path, out object? container, out string memberName) + { + container = value; + memberName = string.Empty; + return false; + } + + static Dictionary Project(object value, int depth) + { + var properties = PropertiesByType.GetOrAdd(value.GetType(), GetReadableProperties); + Dictionary projected = new(properties.Length, StringComparer.Ordinal); + foreach (var property in properties) + { + object? propertyValue; + try + { + propertyValue = property.GetValue(value); + } + catch (Exception) + { + continue; + } + + projected[property.Name] = + depth < 16 && propertyValue is not null && IsComplex(propertyValue.GetType()) + ? Project(propertyValue, depth + 1) + : propertyValue; + } + + return projected; + } + + static bool IsComplex(Type type) => + !type.IsPrimitive + && !type.IsEnum + && !type.IsValueType + && type != typeof(string) + && type != typeof(Uri) + && !typeof(IEnumerable).IsAssignableFrom(type); + + static PropertyInfo[] GetReadableProperties(Type type) => + [ + .. type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(static property => property.CanRead && property.GetIndexParameters().Length == 0), + ]; + } +} diff --git a/src/src/Blazor/ZodReflectionFeature.cs b/src/src/Blazor/ZodReflectionFeature.cs new file mode 100644 index 00000000..7e671aca --- /dev/null +++ b/src/src/Blazor/ZodReflectionFeature.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ZodSharp.Blazor; + +/// +/// The ZodSharp.Reflection.IsSupported feature switch, shared by every ZodSharp package, which guards the +/// reflection fallbacks that walk a model the library was not told about (here: placing an error on a field when the +/// validator does not resolve members itself, as generated validators do). +/// +/// +/// The property is a feature guard for : the trim analyzer accepts the +/// reflection it guards, and ILLink and ILC treat it as when trimming or compiling ahead of time, +/// so the fallback is removed rather than failing unpredictably on trimmed metadata. An untrimmed application keeps it +/// unless it sets the switch to . +/// +static class ZodReflectionFeature +{ + /// The feature switch name. + public const string SwitchName = "ZodSharp.Reflection.IsSupported"; + + /// Gets whether the reflection fallbacks may run. + [FeatureSwitchDefinition(SwitchName)] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + [UnconditionalSuppressMessage( + "Trimming", + "IL4000:FeatureGuard", + Justification = "A RequiresUnreferencedCode feature guard cannot be derived from another guard: none exists. ILLink and ILC " + + "treat this guard as false when trimming, which the trim verification harness proves (no IL2026 from the " + + "guarded calls)." + )] + public static bool IsSupported => !AppContext.TryGetSwitch(SwitchName, out var enabled) || enabled; +} diff --git a/src/src/Blazor/ZodValidationMessage.cs b/src/src/Blazor/ZodValidationMessage.cs new file mode 100644 index 00000000..c6ee4e70 --- /dev/null +++ b/src/src/Blazor/ZodValidationMessage.cs @@ -0,0 +1,190 @@ +using System.Linq.Expressions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using ZodSharp.Core; + +namespace ZodSharp.Blazor; + +/// +/// Displays the validation messages of one field - a drop-in replacement for ValidationMessage<TValue> +/// with the same markup and CSS classes, plus data-code and data-category attributes when the full +/// ZodSharp error is known (, or server errors mapped with AddZodProblemDetails). +/// +/// The field type. +public class ZodValidationMessage : ComponentBase, IDisposable +{ + readonly EventHandler _validationStateChangedHandler; + EditContext? _previousEditContext; + Expression>? _previousFieldAccessor; + FieldIdentifier _fieldIdentifier; + string? _fieldName; + + /// + /// Creates the component. + /// + public ZodValidationMessage() => _validationStateChangedHandler = (_, _) => StateHasChanged(); + + [CascadingParameter] + EditContext CurrentEditContext { get; set; } = default!; + + /// + /// Gets or sets the attributes applied to each message element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + /// Gets or sets the field to display messages for. + /// + [Parameter] + public Expression>? For { get; set; } + + /// + /// Gets or sets whether the error code is shown after the message, in a span.validation-code. + /// + [Parameter] + public bool ShowCode { get; set; } + + /// + /// Gets or sets the content rendered inside the message element for a message whose full error is known. Messages + /// without one (DataAnnotationsValidator) render as text. + /// + [Parameter] + public RenderFragment? Template { get; set; } + + /// + protected override void OnParametersSet() + { + if (CurrentEditContext is null) + throw new InvalidOperationException( + $"{GetType()} requires a cascading parameter of type {nameof(EditContext)}. For example, you can use " + + $"{GetType()} inside an {nameof(EditForm)}." + ); + + if (For is null) + throw new InvalidOperationException($"{GetType()} requires a value for the {nameof(For)} parameter."); + + if (For != _previousFieldAccessor) + { + _fieldIdentifier = FieldIdentifier.Create(For); + _fieldName = null; + _previousFieldAccessor = For; + } + + if (CurrentEditContext != _previousEditContext) + { + Detach(); + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + _previousEditContext = CurrentEditContext; + } + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var entries = ZodFormErrors.Get(CurrentEditContext)?.GetEntries(_fieldIdentifier) ?? []; + var isStatic = AssignedRenderMode is null; + var first = true; + foreach (var message in CurrentEditContext.GetValidationMessages(_fieldIdentifier)) + { + var error = ZodMessageMatcher.Take(entries, message); + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "validation-message"); + if (isStatic && first) + { + builder.AddAttribute(2, "data-valmsg-for", GetFieldName()); + builder.AddAttribute(3, "data-valmsg-replace", "true"); + } + + first = false; + if (error is { } known) + { + builder.AddAttribute(4, "data-code", known.Code); + if (known.Category is { Length: > 0 } category) + builder.AddAttribute(5, "data-category", category); + } + + builder.AddMultipleAttributes(6, AdditionalAttributes); + if (error is { } templated && Template is not null) + { + builder.AddContent(7, Template(templated)); + } + else + { + builder.AddContent(8, message); + if (ShowCode && error is { } coded) + { + builder.OpenElement(9, "span"); + builder.AddAttribute(10, "class", "validation-code"); + builder.AddContent(11, coded.Code); + builder.CloseElement(); + } + } + + builder.CloseElement(); + } + + // Static SSR: an empty placeholder the client-side validator fills in, exactly as ValidationMessage renders. + if (isStatic && first) + { + builder.OpenElement(12, "div"); + builder.AddAttribute(13, "class", "validation-message"); + builder.AddAttribute(14, "data-valmsg-for", GetFieldName()); + builder.AddAttribute(15, "data-valmsg-replace", "true"); + builder.AddAttribute(16, "hidden", true); + builder.AddMultipleAttributes(17, AdditionalAttributes); + builder.CloseElement(); + } + } + + string GetFieldName() => _fieldName ??= ZodFieldNameFormatter.Format(For!); + + /// + /// Releases resources. + /// + /// Whether managed resources are released. + protected virtual void Dispose(bool disposing) + { + if (disposing) + Detach(); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + void Detach() + { + _previousEditContext?.OnValidationStateChanged -= _validationStateChangedHandler; + } +} + +/// +/// Pairs rendered messages with the full errors behind them. +/// +static class ZodMessageMatcher +{ + /// + /// Takes the first entry shown with , so duplicate messages pair one-to-one. + /// + public static ValidationError? Take(List entries, string message) + { + for (var i = 0; i < entries.Count; i++) + { + if (string.Equals(entries[i].Message, message, StringComparison.Ordinal)) + { + var error = entries[i].Error; + entries.RemoveAt(i); + return error; + } + } + + return null; + } +} diff --git a/src/src/Blazor/ZodValidationSummary.cs b/src/src/Blazor/ZodValidationSummary.cs new file mode 100644 index 00000000..f437271d --- /dev/null +++ b/src/src/Blazor/ZodValidationSummary.cs @@ -0,0 +1,233 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using ZodSharp.Core; + +namespace ZodSharp.Blazor; + +/// +/// Displays a summary of the form's validation messages - a drop-in replacement for ValidationSummary with the +/// same markup and CSS classes, plus data-code/data-category attributes, optional grouping by path and +/// optional union details when the full ZodSharp errors are known. +/// +public class ZodValidationSummary : ComponentBase, IDisposable +{ + readonly EventHandler _validationStateChangedHandler; + EditContext? _previousEditContext; + + /// + /// Creates the component. + /// + public ZodValidationSummary() => _validationStateChangedHandler = (_, _) => StateHasChanged(); + + [CascadingParameter] + EditContext CurrentEditContext { get; set; } = default!; + + /// + /// Gets or sets the model whose model-level messages (those not attached to a field) are shown. When + /// , every message on the form is shown. Same as ValidationSummary.Model. + /// + [Parameter] + public object? Model { get; set; } + + /// + /// Gets or sets whether only the form model's model-level messages are shown (as when is the + /// form model). + /// + [Parameter] + public bool ShowModelOnly { get; set; } + + /// + /// Gets or sets whether messages are grouped by their dotted path (Lines[0].Quantity). Messages without a + /// known path are listed after the groups. + /// + [Parameter] + public bool GroupByPath { get; set; } + + /// + /// Gets or sets whether a failed union lists the issues of each option below its message. + /// + [Parameter] + public bool ShowUnionDetails { get; set; } + + /// + /// Gets or sets the attributes applied to the list element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + protected override void OnParametersSet() + { + if (CurrentEditContext is null) + throw new InvalidOperationException( + $"{nameof(ZodValidationSummary)} requires a cascading parameter of type {nameof(EditContext)}. For " + + $"example, you can use {nameof(ZodValidationSummary)} inside an {nameof(EditForm)}." + ); + + if (CurrentEditContext != _previousEditContext) + { + Detach(); + CurrentEditContext.OnValidationStateChanged += _validationStateChangedHandler; + _previousEditContext = CurrentEditContext; + } + } + + /// + /// One summary line. + /// + readonly record struct Line(string Message, ValidationError? Error, string? Path); + + List GetLines() + { + var errors = ZodFormErrors.Get(CurrentEditContext); + List lines = []; + + var model = ShowModelOnly ? CurrentEditContext.Model : Model; + if (model is not null) + { + FieldIdentifier field = new(model, string.Empty); + var entries = errors?.GetEntries(field) ?? []; + foreach (var message in CurrentEditContext.GetValidationMessages(field)) + lines.Add(ToLine(message, ZodMessageMatcher.Take(entries, message))); + + return lines; + } + + // Pair every message with its error by message text. + var all = errors?.GetAllEntries() ?? []; + List pool = [.. all.Select(static e => e.Entry)]; + foreach (var message in CurrentEditContext.GetValidationMessages()) + lines.Add(ToLine(message, ZodMessageMatcher.Take(pool, message))); + + return lines; + } + + static Line ToLine(string message, ValidationError? error) => + new(message, error, error is { } known ? known.FullPath.ToDottedString() : null); + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var lines = GetLines(); + var isStatic = AssignedRenderMode is null; + var hasMessages = lines.Count > 0; + + if (!isStatic && !hasMessages) + return; + + builder.OpenElement(0, "ul"); + builder.AddAttribute( + 1, + "class", + isStatic + ? hasMessages + ? "validation-errors validation-summary-errors" + : "validation-errors validation-summary-valid" + : "validation-errors" + ); + builder.AddMultipleAttributes(2, AdditionalAttributes); + if (isStatic) + { + builder.AddAttribute(3, "data-valmsg-summary", "true"); + if (!hasMessages) + builder.AddAttribute(4, "hidden", true); + } + + if (GroupByPath) + { + foreach (var group in lines.Where(static l => l.Path is not null).GroupBy(static l => l.Path)) + { + builder.OpenElement(5, "li"); + builder.AddAttribute(6, "class", "validation-group"); + builder.AddAttribute(7, "data-path", group.Key); + builder.OpenElement(8, "span"); + builder.AddAttribute(9, "class", "validation-path"); + builder.AddContent(10, group.Key); + builder.CloseElement(); + builder.OpenElement(11, "ul"); + foreach (var line in group) + RenderLine(builder, line); + + builder.CloseElement(); + builder.CloseElement(); + } + + foreach (var line in lines.Where(static l => l.Path is null)) + RenderLine(builder, line); + } + else + { + foreach (var line in lines) + RenderLine(builder, line); + } + + builder.CloseElement(); + } + + void RenderLine(RenderTreeBuilder builder, in Line line) + { + builder.OpenElement(20, "li"); + builder.AddAttribute(21, "class", "validation-message"); + if (line.Error is { } error) + { + builder.AddAttribute(22, "data-code", error.Code); + if (error.Category is { Length: > 0 } category) + builder.AddAttribute(23, "data-category", category); + } + + builder.AddContent(24, line.Message); + + if (ShowUnionDetails && line.Error is { } union && union.GetUnionErrors() is { Length: > 0 } options) + { + builder.OpenElement(25, "ol"); + builder.AddAttribute(26, "class", "validation-union"); + foreach (var option in options) + { + builder.OpenElement(27, "li"); + builder.AddAttribute(28, "class", "validation-union-option"); + builder.OpenElement(29, "ul"); + foreach (var issue in option) + { + builder.OpenElement(30, "li"); + builder.AddAttribute(31, "class", "validation-message"); + builder.AddAttribute(32, "data-code", issue.Code); + builder.AddAttribute(33, "data-path", issue.FullPath.ToDottedString()); + builder.AddContent(34, issue.Message); + builder.CloseElement(); + } + + builder.CloseElement(); + builder.CloseElement(); + } + + builder.CloseElement(); + } + + builder.CloseElement(); + } + + /// + /// Releases resources. + /// + /// Whether managed resources are released. + protected virtual void Dispose(bool disposing) + { + if (disposing) + Detach(); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + void Detach() + { + _previousEditContext?.OnValidationStateChanged -= _validationStateChangedHandler; + } +} diff --git a/src/src/Blazor/ZodValidationTrigger.cs b/src/src/Blazor/ZodValidationTrigger.cs new file mode 100644 index 00000000..e640a194 --- /dev/null +++ b/src/src/Blazor/ZodValidationTrigger.cs @@ -0,0 +1,28 @@ +namespace ZodSharp.Blazor; + +/// +/// When validates. Form-level validation on submit (and on every +/// EditContext.ValidateAsync call) always runs; these flags choose the field-level triggers. +/// +[Flags] +public enum ZodValidationTrigger +{ + /// + /// Validate only when the form is submitted or EditContext.ValidateAsync is called. Field changes do not + /// validate. + /// + Submit = 1, + + /// + /// Also validate a field as soon as it changes (an input's onchange, the default binding event). + /// + Change = 2, + + /// + /// Also validate a field as it changes, delayed by so a burst of + /// changes validates once. Each change supersedes and cancels the pending check. The input must report a change per + /// keystroke: an InputText subclass whose <input> binds with @bind:event="oninput" + /// (component bindings such as InputText's @bind-Value have no event to choose). + /// + Input = 4, +} diff --git a/src/src/Blazor/ZodValidator.cs b/src/src/Blazor/ZodValidator.cs new file mode 100644 index 00000000..4921d4ce --- /dev/null +++ b/src/src/Blazor/ZodValidator.cs @@ -0,0 +1,454 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; + +namespace ZodSharp.Blazor; + +/// +/// Validates an model with ZodSharp and keeps every error at full fidelity - code, category, +/// origin, bounds and parameters - in addition to the message shown on the field. +/// +/// +/// +/// Use it in place of <DataAnnotationsValidator /> when the UI needs more than the message string, when +/// the model is validated by a runtime schema (), or for debounced, per-keystroke validation. +/// The model's validator comes from , or from the ZodSharp validators registered with +/// AddZodSharpBlazor. +/// +/// +/// Form-level validation runs through ValidationRequestedEventArgs.AddAsyncValidator and field-level validation +/// through EditContext.RegisterAsyncFieldValidator, so the platform's pending and faulted states, +/// cancellation and supersession all apply. A validator that throws is logged and marks the field (or form) faulted. +/// +/// +/// Read the full errors with EditContext.GetZodErrors(...), or render them with +/// and . +/// +/// +public sealed partial class ZodValidator : ComponentBase, IDisposable +{ + /// + /// The EditContext.Properties key under which the full-fidelity errors of the form are kept. Read them with + /// EditContext.GetZodErrors(...). + /// + public static readonly object ErrorsKey = new(); + + /// + /// The value writes under the DataAnnotationsValidator key to enable static-SSR + /// client validation, distinguishable from the DataAnnotationsValidator writes. + /// + static readonly object ClientValidationMarker = new(); + + EditContext? _editContext; + ValidationMessageStore? _store; + ZodFormErrors? _errors; + ZodValidatableInfoResolver? _resolver; + ZodBlazorOptions _options = new(); + ZodMessageRenderer? _renderer; + TimeProvider _timeProvider = TimeProvider.System; + ILogger _logger = NullLogger.Instance; + bool _wroteClientValidationKey; + bool _warnedCoexistence; + + [CascadingParameter] + EditContext? CurrentEditContext { get; set; } + + [Inject] + IServiceProvider Services { get; set; } = default!; + + /// + /// Gets or sets an explicit validator for the form model, for models without a registered validator - for example + /// a runtime Z.Object(...) schema over a CLR type (see ). It also validates + /// nested objects of its own type. When , the registered validator of the model type is used. + /// + [Parameter] + public IZodUntypedValidator? Schema { get; set; } + + /// + /// Gets or sets the field-level triggers. Defaults to . Form-level + /// validation on submit always runs. + /// + [Parameter] + public ZodValidationTrigger ValidateOn { get; set; } = ZodValidationTrigger.Change; + + /// + /// Gets or sets the delay, in milliseconds, before an -triggered field + /// validation runs. Each change supersedes (and cancels) the pending one, so a burst of keystrokes validates once. + /// Ignored unless includes . + /// + [Parameter] + public int DebounceMilliseconds { get; set; } + + /// + /// Gets or sets whether a statically rendered (static SSR) form emits client-side validation rules, exactly as + /// it does for DataAnnotationsValidator. Defaults to . Client rules come from the + /// registered ClientValidationProvider; see AddZodSharpClientValidation in + /// Purview.ZodSharp.AspNetCore for ZodSharp rules. + /// + [Parameter] + public bool EnableClientValidation { get; set; } = true; + + /// + /// Gets or sets whether a field change re-validates the whole object that owns the field (cross-field rules such + /// as [Compare] and type-level rules) and updates every field of that object. When + /// (the default) a field change runs only that member's own rules. + /// + [Parameter] + public bool ValidateNestedOnFieldChange { get; set; } + + /// + protected override void OnInitialized() + { + if (CurrentEditContext is null) + throw new InvalidOperationException( + $"{nameof(ZodValidator)} requires a cascading parameter of type {nameof(EditContext)}. For example, " + + $"you can use {nameof(ZodValidator)} inside an {nameof(EditForm)}." + ); + + _editContext = CurrentEditContext; + _store = new(_editContext); + _errors = ZodFormErrors.GetOrCreate(_editContext); + _errors.Validator = this; + _editContext.Properties[typeof(ZodValidator)] = this; + + _resolver = Services.GetService(); + _options = Services.GetService>()?.Value ?? new(); + _renderer = ZodMessageRenderer.Create(Services, _options); + _timeProvider = Services.GetService() ?? TimeProvider.System; + _logger = (ILogger?)Services.GetService>() ?? NullLogger.Instance; + + if (EnableClientValidation && !_editContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out _)) + { + _editContext.Properties[typeof(DataAnnotationsValidator)] = ClientValidationMarker; + _wroteClientValidationKey = true; + } + + _editContext.OnValidationRequested += OnValidationRequested; + _editContext.OnFieldChanged += OnFieldChanged; + } + + /// + protected override void OnParametersSet() + { + if (CurrentEditContext != _editContext) + throw new InvalidOperationException( + $"{nameof(ZodValidator)} does not support changing the {nameof(EditContext)} dynamically." + ); + } + + /// + public void Dispose() + { + if (_editContext is null) + return; + + _editContext.OnValidationRequested -= OnValidationRequested; + _editContext.OnFieldChanged -= OnFieldChanged; + + _store?.Clear(); + _errors?.ClearValidator(); + if (_errors is not null && ReferenceEquals(_errors.Validator, this)) + _errors.Validator = null; + + if ( + _editContext.Properties.TryGetValue(typeof(ZodValidator), out var attached) + && ReferenceEquals(attached, this) + ) + _editContext.Properties.Remove(typeof(ZodValidator)); + + if ( + _wroteClientValidationKey + && _editContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out var marker) + && ReferenceEquals(marker, ClientValidationMarker) + ) + _editContext.Properties.Remove(typeof(DataAnnotationsValidator)); + + _editContext.NotifyValidationStateChanged(); + _editContext = null; + } + + /// + /// Gets the validator of the form model: , or the registered validator of the model type. + /// + internal IZodUntypedValidator? RootValidator => + _editContext is null ? null : ResolveValidator(_editContext.Model.GetType(), preferSchema: true); + + IZodUntypedValidator? ResolveValidator(Type type, bool preferSchema) + { + if (preferSchema && Schema is { } schema && schema.ValidatedType.IsAssignableFrom(type)) + return schema; + + // A conditional expression would nest the schema and resolver lookups; the ordered ifs keep the + // precedence of the three sources explicit. +#pragma warning disable IDE0046 // Convert to conditional expression + if (_resolver is not null && _resolver.TryGetValidator(type, out var registered)) + return registered; +#pragma warning restore IDE0046 + + return !preferSchema && Schema is { } fallback && fallback.ValidatedType.IsAssignableFrom(type) + ? fallback + : null; + } + + /// + /// Gets whether a DataAnnotationsValidator shares the form: its client-validation key holds the value it + /// writes rather than ours. (A DataAnnotationsValidator with client validation disabled is not detected.) + /// + bool IsDataAnnotationsValidatorPresent => + _editContext is not null + && _editContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out var value) + && !ReferenceEquals(value, ClientValidationMarker); + + /// + /// Gets whether messages for are already shown by a DataAnnotationsValidator + /// on the same form, because the ZodSharp bridge validates that type for it. The full errors are still recorded. + /// + bool ShouldSuppressMessages(Type modelType) + { + if (!IsDataAnnotationsValidatorPresent || _resolver is null || !_resolver.TryGetValidator(modelType, out _)) + return false; + + if (!_warnedCoexistence && _options.WarnOnValidatorCoexistence) + { + _warnedCoexistence = true; + LogValidatorCoexistence(_logger, modelType.FullName ?? modelType.Name); + } + + return true; + } + + void OnValidationRequested(object? sender, ValidationRequestedEventArgs e) + { + if (_editContext is null || RootValidator is not { } validator) + return; + + var model = _editContext.Model; + try + { + e.AddAsyncValidator(cancellationToken => ValidateFormAsync(model, validator, cancellationToken)); + } + catch (InvalidOperationException) + { + // The obsolete synchronous EditContext.Validate(): no async validators can be registered. + ValidateFormSynchronously(model, validator); + } + } + + void ValidateFormSynchronously(object model, IZodUntypedValidator validator) + { + if (validator.RequiresAsyncValidation) + throw new InvalidOperationException( + $"'{validator.ValidatedType.FullName}' has asynchronous validation rules, so it cannot be validated " + + "synchronously. Use EditContext.ValidateAsync." + ); + + ApplyFormErrors(model, validator, validator.Validate(model)); + } + + async Task ValidateFormAsync(object model, IZodUntypedValidator validator, CancellationToken cancellationToken) + { + ImmutableArray errors; + try + { + errors = await validator.ValidateAsync(model, Services, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + LogValidationFaulted(_logger, ex, model.GetType().FullName ?? model.GetType().Name, string.Empty); + throw; + } + + cancellationToken.ThrowIfCancellationRequested(); + await InvokeAsync(() => ApplyFormErrors(model, validator, errors)).ConfigureAwait(false); + } + + void ApplyFormErrors(object model, IZodUntypedValidator validator, ImmutableArray errors) + { + if (_editContext is null || _store is null || _errors is null) + return; + + _store.Clear(); + _errors.ClearValidator(); + + var suppress = ShouldSuppressMessages(model.GetType()); + foreach (var error in errors.IsDefault ? [] : errors) + { + var field = ZodFieldLocator.Locate(model, error.FullPath, validator, ignoreCase: false); + Place(field, error, suppress); + } + + _editContext.NotifyValidationStateChanged(); + } + + void Place(in FieldIdentifier field, in ValidationError error, bool suppressMessage) + { + var message = _renderer!.Render(error, field.Model.GetType()); + _errors!.AddValidatorError(field, error, message); + if (!suppressMessage) + _store!.Add(field, message); + } + + void OnFieldChanged(object? sender, FieldChangedEventArgs e) + { + if (_editContext is null || (ValidateOn & (ZodValidationTrigger.Change | ZodValidationTrigger.Input)) == 0) + return; + + var field = e.FieldIdentifier; + var plan = PlanFieldValidation(field); + if (plan is null) + return; + + var debounce = + (ValidateOn & ZodValidationTrigger.Input) != 0 && DebounceMilliseconds > 0 + ? TimeSpan.FromMilliseconds(DebounceMilliseconds) + : TimeSpan.Zero; + + _editContext.RegisterAsyncFieldValidator(field, cancellationToken => + ValidateFieldAsync(field, plan.Value, debounce, cancellationToken) + ); + } + + /// + /// How a changed field is validated. + /// + /// The validator that runs. + /// The object it validates: the field's owner, or the form model. + /// Whether only the member's own rules run (). + readonly record struct FieldPlan(IZodUntypedValidator Validator, object Target, bool MemberOnly); + + FieldPlan? PlanFieldValidation(in FieldIdentifier field) + { + var owner = field.Model; + var ownerValidator = ResolveValidator( + owner.GetType(), + preferSchema: ReferenceEquals(owner, _editContext!.Model) + ); + if (ownerValidator is not null) + { + if (ValidateNestedOnFieldChange) + return new FieldPlan(ownerValidator, owner, MemberOnly: false); + + if (!string.IsNullOrEmpty(field.FieldName) && ownerValidator.IsValidatableMember(field.FieldName)) + return new FieldPlan(ownerValidator, owner, MemberOnly: true); + } + + // No member validation available (a runtime schema): validate the whole form and keep this field's errors. + return RootValidator is { } root ? new FieldPlan(root, _editContext.Model, MemberOnly: false) : null; + } + + async Task ValidateFieldAsync( + FieldIdentifier field, + FieldPlan plan, + TimeSpan debounce, + CancellationToken cancellationToken + ) + { + if (debounce > TimeSpan.Zero) + await Task.Delay(debounce, _timeProvider, cancellationToken).ConfigureAwait(false); + + ImmutableArray errors; + try + { + errors = plan.MemberOnly + ? await plan + .Validator.ValidateMemberAsync(plan.Target, field.FieldName, Services, cancellationToken) + .ConfigureAwait(false) + : await plan.Validator.ValidateAsync(plan.Target, Services, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + LogValidationFaulted( + _logger, + ex, + field.Model.GetType().FullName ?? field.Model.GetType().Name, + field.FieldName + ); + throw; + } + + // Superseded by a newer change (or a submit) while validating: the newer run owns the field now. + cancellationToken.ThrowIfCancellationRequested(); + await InvokeAsync(() => ApplyFieldErrors(field, plan, errors)).ConfigureAwait(false); + } + + void ApplyFieldErrors(FieldIdentifier field, FieldPlan plan, ImmutableArray errors) + { + if (_editContext is null || _store is null || _errors is null) + return; + + var suppress = ShouldSuppressMessages(plan.Target.GetType()); + var scopeToOwner = ValidateNestedOnFieldChange; + + // Every field this run speaks for is reset; other fields' messages are left exactly as they were. + List reset = [field]; + if (scopeToOwner) + { + foreach (var tracked in _errors.ValidatorFields) + { + if (ReferenceEquals(tracked.Model, field.Model) && !tracked.Equals(field)) + reset.Add(tracked); + } + } + + foreach (var target in reset) + { + _store.Clear(target); + _errors.ClearValidator(target); + } + + foreach (var error in errors.IsDefault ? [] : errors) + { + if (plan.MemberOnly) + { + // Member validation runs only the field's own rules: every error belongs to the field. + Place(field, error, suppress); + continue; + } + + var located = ZodFieldLocator.Locate(plan.Target, error.FullPath, plan.Validator, ignoreCase: false); + var inScope = scopeToOwner ? ReferenceEquals(located.Model, field.Model) : located.Equals(field); + if (inScope) + Place(located, error, suppress); + } + + _editContext.NotifyValidationStateChanged(); + } + + /// + /// Clears every message and error this validator placed. + /// + internal void ClearOwnErrors() + { + _store?.Clear(); + _errors?.ClearValidator(); + } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Warning, + Message = "ZodValidator and DataAnnotationsValidator share a form for '{ModelType}'. ZodValidator records the " + + "full errors but leaves the messages to DataAnnotationsValidator; use a single validator." + )] + static partial void LogValidatorCoexistence(ILogger logger, string modelType); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Error, + Message = "ZodSharp validation of '{ModelType}' field '{FieldName}' threw; the field is marked faulted." + )] + static partial void LogValidationFaulted(ILogger logger, Exception exception, string modelType, string fieldName); +} diff --git a/src/src/Blazor/wwwroot/Purview.ZodSharp.Blazor.lib.module.js b/src/src/Blazor/wwwroot/Purview.ZodSharp.Blazor.lib.module.js new file mode 100644 index 00000000..0d793e2d --- /dev/null +++ b/src/src/Blazor/wwwroot/Purview.ZodSharp.Blazor.lib.module.js @@ -0,0 +1,12 @@ +// Blazor JavaScript initializer for Purview.ZodSharp.Blazor: Blazor loads it automatically and calls +// afterWebStarted once Blazor.formValidation exists, so the ZodSharp client validators are registered with no +// + + + + + + + + diff --git a/src/src/Examples.Blazor/Components/Layout/MainLayout.razor b/src/src/Examples.Blazor/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..d1977eed --- /dev/null +++ b/src/src/Examples.Blazor/Components/Layout/MainLayout.razor @@ -0,0 +1,33 @@ +@inherits LayoutComponentBase +@using System.Globalization +@inject NavigationManager Navigation + +
+ + +
+ +
+ @Body +
+ +@code { + string CultureLink(string culture) => + $"{SampleApp.CulturePath.TrimStart('/')}?culture={culture}&redirectUri={Uri.EscapeDataString("/" + Navigation.ToBaseRelativePath(Navigation.Uri))}"; + + static bool IsCurrent(string culture) => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == culture; +} diff --git a/src/src/Examples.Blazor/Components/Pages/AddressFields.razor b/src/src/Examples.Blazor/Components/Pages/AddressFields.razor new file mode 100644 index 00000000..1755ad1a --- /dev/null +++ b/src/src/Examples.Blazor/Components/Pages/AddressFields.razor @@ -0,0 +1,28 @@ +@* The inputs of one Address. The FieldIdentifiers are (Address, "Street") and so on, which is where the ZodSharp bridge + places a nested error such as ShippingAddress.City or AdditionalAddresses[billing].PostCode. *@ + + + + + + + + + + + + + +@code { + /// + /// Gets or sets the address being edited. + /// + [Parameter, EditorRequired] + public Address Address { get; set; } = default!; + + /// + /// Gets or sets the prefix of the input ids. + /// + [Parameter, EditorRequired] + public string IdPrefix { get; set; } = default!; +} diff --git a/src/src/Examples.Blazor/Components/Pages/Home.razor b/src/src/Examples.Blazor/Components/Pages/Home.razor new file mode 100644 index 00000000..74ab135b --- /dev/null +++ b/src/src/Examples.Blazor/Components/Pages/Home.razor @@ -0,0 +1,29 @@ +@page "/" + +ZodSharp Blazor sample + +

ZodSharp Blazor sample

+

+ The same [ZodSchema] models from Examples.Shared validate in every render mode. Each page + shows one way to use them. +

+
    +
  • + Interactive Server: the standard <DataAnnotationsValidator /> (route 1) + over a nested address, a collection of lines and a dictionary of addresses, with field-level validation. +
  • +
  • + Interactive WebAssembly: <ZodValidator> (route 2) with debounced + validation, error codes, an async uniqueness check and server errors mapped back onto the form. It posts from the + browser with Purview.ZodSharp.Http, which validates before sending and as the response is read. +
  • +
  • + Interactive Server + HttpClient: the same form on the server, whose typed client + (IHttpClientFactory) calls the API service-to-service with Purview.ZodSharp.Http. +
  • +
  • + Static SSR: a posted form with client-side validation of ZodSharp rules + ([StartsWith], [UUID]) before it is submitted. +
  • +
+

Switch the language above to see the validation messages in French; codes and paths never change.

diff --git a/src/src/Examples.Blazor/Components/Pages/Orders.razor b/src/src/Examples.Blazor/Components/Pages/Orders.razor new file mode 100644 index 00000000..9598a27c --- /dev/null +++ b/src/src/Examples.Blazor/Components/Pages/Orders.razor @@ -0,0 +1,84 @@ +@page "/orders" +@rendermode InteractiveServer + +Interactive Server - DataAnnotationsValidator + +

Place an order

+

+ Interactive Server, route 1: the standard <DataAnnotationsValidator /> validates + CreateOrderRequest with its generated ZodSharp validator - no [ValidatableType] needed. + Each message lands next to its input: the nested shipping address, every order line and every extra address. + Changing a field re-validates only that field. +

+ + + + +
+ Customer + + + +
+ +
+ Shipping address + +
+ +
+ Lines + @for (var i = 0; i < _order.Lines.Count; i++) + { + var line = _order.Lines[i]; + var index = i; +
+ + + + + + + +
+ } + + +
+ +
+ Additional addresses + @foreach (var (purpose, address) in _order.AdditionalAddresses) + { +

@purpose

+ + + } + @foreach (var purpose in Purposes.Where(p => !_order.AdditionalAddresses.ContainsKey(p))) + { + + } +
+ + + +
+ +@if (_saved) +{ +

Order accepted with @_order.Lines.Count line(s).

+} + +@code { + static readonly string[] Purposes = ["billing", "gift"]; + + readonly CreateOrderRequest _order = new() + { + ShippingAddress = new(), + Lines = [new()], + }; + + bool _saved; + + void Save() => _saved = true; +} diff --git a/src/src/Examples.Blazor/Components/Pages/RegisterCustomerServer.razor b/src/src/Examples.Blazor/Components/Pages/RegisterCustomerServer.razor new file mode 100644 index 00000000..da14693a --- /dev/null +++ b/src/src/Examples.Blazor/Components/Pages/RegisterCustomerServer.razor @@ -0,0 +1,17 @@ +@page "/register-server" +@rendermode InteractiveServer + +Interactive Server - HttpClient + +

Register a customer (server)

+

+ Interactive Server: the same RegisterCustomerForm as the + Interactive WebAssembly page, running on the server. Its CustomerAPIClient is a + typed client created by IHttpClientFactory and calls the API service-to-service with + Purview.ZodSharp.Http: PostAsJsonValidatedAsync validates with the circuit's services + (the uniqueness rule resolves the server's own registry - try + @InMemoryCustomerEmailRegistry.SeededEmail), ReadFromJsonAndValidateAsync validates the + response, and TryReadZodValidationProblemAsync reads a rejection back into ZodSharp errors. +

+ + diff --git a/src/src/Examples.Blazor/Components/Pages/Returns.razor b/src/src/Examples.Blazor/Components/Pages/Returns.razor new file mode 100644 index 00000000..d65bc9da --- /dev/null +++ b/src/src/Examples.Blazor/Components/Pages/Returns.razor @@ -0,0 +1,76 @@ +@page "/returns" + +Static SSR - client-side validation + +

Return items

+

+ Static server-side rendering: no interactivity. The form posts to the server, which binds and + validates ReturnRequest with its generated ZodSharp validator and re-renders the messages next to the + inputs. Before posting, the browser already checks the DataAnnotations rules and the ZodSharp ones - + [StartsWith("@ReturnRequest.ReferencePrefix")] and [UUID] - because + AddZodSharpClientValidation() adds them to the form's client rules. Client rules only improve the + experience; the server always validates. +

+ + + + + +
+ Return + + + + + + +
+ +
+ Return address + + + + + + + + + +
+ +
+ Items + @for (var i = 0; i < Model!.Items.Count; i++) + { + var index = i; +
+ + + + + + +
+ } +
+ + +
+ +@if (_submitted) +{ +

Return @Model!.Reference requested for @Model.Items.Count item(s).

+} + +@code { + bool _submitted; + + [SupplyParameterFromForm(FormName = "return")] + ReturnRequest? Model { get; set; } + + protected override void OnInitialized() => + Model ??= new() { ReturnAddress = new(), Items = [new(), new()] }; + + void Submit() => _submitted = true; +} diff --git a/src/src/Examples.Blazor/Components/Routes.razor b/src/src/Examples.Blazor/Components/Routes.razor new file mode 100644 index 00000000..550a5a79 --- /dev/null +++ b/src/src/Examples.Blazor/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/src/Examples.Blazor/Components/_Imports.razor b/src/src/Examples.Blazor/Components/_Imports.razor new file mode 100644 index 00000000..30c914ad --- /dev/null +++ b/src/src/Examples.Blazor/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using ZodSharp.Blazor +@using ZodSharp.Examples +@using ZodSharp.Examples.Blazor +@using ZodSharp.Examples.Blazor.Client +@using ZodSharp.Examples.Blazor.Components diff --git a/src/src/Examples.Blazor/Examples.Blazor.csproj b/src/src/Examples.Blazor/Examples.Blazor.csproj new file mode 100644 index 00000000..bbb3f105 --- /dev/null +++ b/src/src/Examples.Blazor/Examples.Blazor.csproj @@ -0,0 +1,33 @@ + + + + net11.0 + + + true + false + + false + false + + $(NoWarn);NU5104 + 11.0.0-rc.3.26522.148 + + + + + + + + + + + + + + + diff --git a/src/src/Examples.Blazor/Program.cs b/src/src/Examples.Blazor/Program.cs new file mode 100644 index 00000000..8abfb811 --- /dev/null +++ b/src/src/Examples.Blazor/Program.cs @@ -0,0 +1,7 @@ +var builder = WebApplication.CreateBuilder(args); +SampleApp.ConfigureServices(builder.Services); + +var app = builder.Build(); +SampleApp.Configure(app); + +await app.RunAsync(); diff --git a/src/src/Examples.Blazor/Properties/launchSettings.json b/src/src/Examples.Blazor/Properties/launchSettings.json new file mode 100644 index 00000000..1e798992 --- /dev/null +++ b/src/src/Examples.Blazor/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Examples.Blazor": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:56733;http://localhost:56735" + } + } +} \ No newline at end of file diff --git a/src/src/Examples.Blazor/SampleApp.cs b/src/src/Examples.Blazor/SampleApp.cs new file mode 100644 index 00000000..25fec8b8 --- /dev/null +++ b/src/src/Examples.Blazor/SampleApp.cs @@ -0,0 +1,152 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Localization; +using ZodSharp.Examples.Blazor.Client; +using ZodSharp.Examples.Blazor.Components; + +namespace ZodSharp.Examples.Blazor; + +/// +/// The Blazor sample's service registration, request pipeline and endpoints. +/// +static class SampleApp +{ + /// + /// The path of the culture switch endpoint. + /// + public const string CulturePath = "/culture/set"; + + /// + /// Registers Razor components in every render mode, ZodSharp for Blazor forms and static-SSR client validation, + /// the ProblemDetails contract for the API and the sample's localization. + /// + /// The services. + public static void ConfigureServices(IServiceCollection services) + { + // A Blazor Web App with Auto render mode available: interactive server, interactive WebAssembly and static SSR. + services.AddRazorComponents().AddInteractiveServerComponents().AddInteractiveWebAssemblyComponents(); + + // Client-side rules for ZodSharp rule attributes ([StartsWith], [UUID], ...) on statically rendered forms, + // beside ASP.NET Core's DataAnnotations rules. Either order with AddRazorComponents works. It is + // [RequiresUnreferencedCode], like AddRazorComponents itself; this server is not trimmed. + services.AddZodSharpClientValidation(); + + // The same two calls as the client Program.cs: the generated, trim-safe registration of the Examples.Shared + // [ZodSchema] models, then ZodSharp for Blazor forms. AddZodSharpBlazor validates form models only; it does not + // change how endpoints behave. + services.AddZodSharpZodSharpExamplesSharedSchemas(); + services.AddZodSharpBlazor(); + + // Opt the minimal API below in to the built-in .NET 11 validation pipeline: ZodSharp-typed parameters are + // validated before their handlers run. + services.AddZodSharpValidation(); + + // One ProblemDetails contract (errors + issues + traceId) for the API, which the WebAssembly form maps back + // onto its fields with AddZodProblemDetails. + services.AddZodSharpProblemDetails(static options => options.MergeParametersIntoExtensions = false); + services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ClientJsonContext.Default) + ); + + // The store the service-aware async email-uniqueness rule on RegisterCustomerRequest resolves on the server. + services.AddSingleton(); + + // The Interactive Server page renders the same RegisterCustomerForm as the WebAssembly page. Its CustomerAPIClient + // is a typed client here: IHttpClientFactory creates it in the circuit's scope (so service-aware rules resolve the + // circuit's services) and it calls this app's API service-to-service, with Purview.ZodSharp.Http validating the + // request before it is sent and the response as it is read. + services + .AddHttpClient() + .ConfigureHttpClient(static (provider, http) => http.BaseAddress ??= SelfBaseAddress(provider)); + + SampleLocalization.AddSampleLocalization(services); + } + + /// + /// Configures the request pipeline, the API and the Razor components. + /// + /// The application. + public static void Configure(WebApplication app) + { + if (!app.Environment.IsDevelopment()) + app.UseExceptionHandler(); + + // The culture comes from the cookie the culture switch writes, then Accept-Language. + app.UseRequestLocalization(static options => + options + .SetDefaultCulture(SampleLocalization.SupportedCultures[0]) + .AddSupportedCultures(SampleLocalization.SupportedCultures) + .AddSupportedUICultures(SampleLocalization.SupportedCultures) + ); + + app.UseAntiforgery(); + app.MapStaticAssets(); + + MapEndpoints(app); + + app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(SampleMessages).Assembly); + } + + static void MapEndpoints(WebApplication app) + { + var customers = app.MapGroup("/api/customers").WithTags("Customers"); + + // Validated by the .NET 11 pipeline with the generated ZodSharp validator, including the async uniqueness + // rule - nothing on the endpoint asks for it. + customers.MapPost("/", static (RegisterCustomerRequest customer, ICustomerEmailRegistry registry) => + { + registry.TryRegister(customer.Email!); + return TypedResults.Created($"/api/customers/{customer.Email}", new CustomerRegistered(customer.Email!)); + }); + + // What the browser's HttpCustomerEmailRegistry asks while the user types. + customers + .MapGet( + "/registered", + static async (string email, ICustomerEmailRegistry registry, CancellationToken cancellationToken) => + TypedResults.Ok(new EmailStatus(await registry.IsRegisteredAsync(email, cancellationToken))) + ) + .DisableValidation(); + + // The culture switch: remembers the choice in the localization cookie and returns to the page. + app.MapGet(CulturePath, static (string culture, string? redirectUri, HttpContext context) => + { + if (Array.IndexOf(SampleLocalization.SupportedCultures, culture) >= 0) + { + context.Response.Cookies.Append( + CookieRequestCultureProvider.DefaultCookieName, + CookieRequestCultureProvider.MakeCookieValue(new(culture)) + ); + } + + return TypedResults.LocalRedirect(IsLocal(redirectUri) ? redirectUri! : "/"); + }); + } + + // The address this app listens on (preferring http, so a self-call needs no certificate), with wildcard hosts + // rewritten to localhost. Null when the server reports none (an in-memory TestServer); tests supply their own. + static Uri? SelfBaseAddress(IServiceProvider provider) + { + var addresses = provider.GetRequiredService().Features.Get()?.Addresses; + var address = + addresses?.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + ?? addresses?.FirstOrDefault(); + if (address is null) + return null; + + foreach (var wildcard in WildcardHosts) + address = address.Replace(wildcard, "://localhost", StringComparison.Ordinal); + + return new Uri(address.TrimEnd('/') + "/"); + } + + static readonly string[] WildcardHosts = ["://+", "://*", "://[::]", "://0.0.0.0"]; + + static bool IsLocal(string? uri) => + uri is ['/', ..] + && !uri.StartsWith("//", StringComparison.Ordinal) + && !uri.StartsWith("/\\", StringComparison.Ordinal); +} diff --git a/src/src/Examples.Blazor/wwwroot/app.css b/src/src/Examples.Blazor/wwwroot/app.css new file mode 100644 index 00000000..ff220649 --- /dev/null +++ b/src/src/Examples.Blazor/wwwroot/app.css @@ -0,0 +1,70 @@ +body { + font-family: system-ui, sans-serif; + margin: 0 auto; + max-width: 60rem; + padding: 1rem; +} + +header { + display: flex; + justify-content: space-between; + border-bottom: 1px solid #ccc; + margin-bottom: 1rem; +} + +nav a { + margin-right: 1rem; +} + +nav a[aria-current="true"] { + font-weight: bold; +} + +fieldset { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 1rem; + margin-bottom: 1rem; +} + +fieldset legend, +fieldset h3, +fieldset .line, +fieldset > button, +fieldset > .validation-message { + grid-column: 1 / -1; +} + +.line { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 1rem; +} + +.field { + margin-bottom: 0.5rem; +} + +.validation-message { + color: #b00020; + grid-column: 2; +} + +.validation-code { + margin-left: 0.5rem; + font-family: monospace; + opacity: 0.7; +} + +.valid.modified:not([type="checkbox"]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #b00020; +} + +.pending, +.pending-indicator { + color: #996f00; +} diff --git a/src/src/Examples.CLI/AdvancedExamples.cs b/src/src/Examples.CLI/AdvancedExamples.cs index 4d11d169..e0e4b4ea 100644 --- a/src/src/Examples.CLI/AdvancedExamples.cs +++ b/src/src/Examples.CLI/AdvancedExamples.cs @@ -277,9 +277,8 @@ static void SchemaCachingExamples() { Console.WriteLine("--- Schema Caching Examples ---"); - var schema = SchemaCache.GetOrCreate( - "user", - static () => Z.Object().Field("name", Z.String().Min(1)).Field("age", Z.Number().Min(0)).Build() + var schema = SchemaCache.GetOrCreate("user", static () => + Z.Object().Field("name", Z.String().Min(1)).Field("age", Z.Number().Min(0)).Build() ); if (SchemaCache.TryGet("user", out ZodObject cachedSchema)) diff --git a/src/src/Examples.MinimalApi/.editorconfig b/src/src/Examples.MinimalApi/.editorconfig new file mode 100644 index 00000000..a6bbe198 --- /dev/null +++ b/src/src/Examples.MinimalApi/.editorconfig @@ -0,0 +1,6 @@ +# Layered over the Purview.BuildSdk-managed root .editorconfig (which must not be edited). + +[*.{cs,csx,cake}] +# "MinimalApi" follows ASP.NET Core's own spelling (the "minimal APIs" docs, OpenApiOptions), as the OpenAPI +# package does, rather than PDS0004's "MinimalAPI". Scoped to this project. +dotnet_analyzer_configuration.pds0004.allowed_words = Api diff --git a/src/src/Examples.MinimalApi/Examples.MinimalApi.csproj b/src/src/Examples.MinimalApi/Examples.MinimalApi.csproj new file mode 100644 index 00000000..6d463206 --- /dev/null +++ b/src/src/Examples.MinimalApi/Examples.MinimalApi.csproj @@ -0,0 +1,45 @@ + + + + net11.0 + + + true + + true + false + + false + + false + + false + true + + + + + + + + + + + + + + + diff --git a/src/src/Examples.MinimalApi/Examples.MinimalApi.http b/src/src/Examples.MinimalApi/Examples.MinimalApi.http new file mode 100644 index 00000000..fb351950 --- /dev/null +++ b/src/src/Examples.MinimalApi/Examples.MinimalApi.http @@ -0,0 +1,86 @@ +@host = http://localhost:5000 + +### Mode A (the .NET 11 pipeline) - FAILS: nested, collection and dictionary errors in one ProblemDetails +POST {{host}}/orders +Content-Type: application/json + +{ + "customerEmail": "not-an-email", + "shippingAddress": { "street": "1 Main Street", "city": "", "postCode": "LS1 1AA" }, + "lines": [{ "sku": "abc", "quantity": 0 }], + "additionalAddresses": { "billing": { "street": "2 High Street", "city": "Leeds", "postCode": "1" } } +} + +### Mode A - FAILS, with the messages in French (codes and paths do not change) +POST {{host}}/orders +Content-Type: application/json +Accept-Language: fr + +{ + "customerEmail": "not-an-email", + "shippingAddress": { "street": "1 Main Street", "city": "", "postCode": "LS1 1AA" }, + "lines": [{ "sku": "abc", "quantity": 0 }] +} + +### Mode A - PASSES (201 Created) +POST {{host}}/orders +Content-Type: application/json + +{ + "customerEmail": "ann@example.com", + "shippingAddress": { "street": "1 Main Street", "city": "Leeds", "postCode": "LS1 1AA" }, + "lines": [{ "sku": "ABC-1234", "quantity": 2 }], + "additionalAddresses": { "billing": { "street": "2 High Street", "city": "Leeds", "postCode": "LS2 2BB" } } +} + +### Mode B (WithZodSharpValidation filter) - FAILS with the same contract +POST {{host}}/filtered/orders +Content-Type: application/json + +{ + "customerEmail": "not-an-email", + "shippingAddress": { "street": "1 Main Street", "city": "", "postCode": "LS1 1AA" }, + "lines": [] +} + +### Manual validation (ZodTypedResults.ValidationProblem) - FAILS with the same contract +POST {{host}}/manual/orders +Content-Type: application/json + +{ + "customerEmail": "not-an-email", + "lines": [{ "sku": "ABC-1234", "quantity": 101 }] +} + +### Manual validation - PASSES (201 Created) +POST {{host}}/manual/orders +Content-Type: application/json + +{ + "customerEmail": "ann@example.com", + "shippingAddress": { "street": "1 Main Street", "city": "Leeds", "postCode": "LS1 1AA" }, + "lines": [{ "sku": "ABC-1234", "quantity": 1 }] +} + +### Service-aware async rule - FAILS: taken@example.com is already in the in-memory registry (email_taken) +POST {{host}}/customers +Content-Type: application/json + +{ + "name": "Ann", + "email": "taken@example.com", + "age": 30 +} + +### Service-aware async rule - PASSES once (201 Created); sending it again FAILS, because it is now registered +POST {{host}}/customers +Content-Type: application/json + +{ + "name": "Ann", + "email": "ann@example.com", + "age": 30 +} + +### The OpenAPI document: ZodSharp constraints, and ZodValidationProblemDetails on every validated operation +GET {{host}}/openapi/v1.json diff --git a/src/src/Examples.MinimalApi/Program.cs b/src/src/Examples.MinimalApi/Program.cs new file mode 100644 index 00000000..fb097b74 --- /dev/null +++ b/src/src/Examples.MinimalApi/Program.cs @@ -0,0 +1,9 @@ +// The slim builder is the Native AOT-friendly host. Everything the sample registers and maps lives in +// SampleApp, so the smoke tests start exactly the same application in memory. +var builder = WebApplication.CreateSlimBuilder(args); +SampleApp.ConfigureServices(builder); + +var app = builder.Build(); +SampleApp.Configure(app); + +await app.RunAsync(); diff --git a/src/src/Examples.MinimalApi/Properties/launchSettings.json b/src/src/Examples.MinimalApi/Properties/launchSettings.json new file mode 100644 index 00000000..59e0507a --- /dev/null +++ b/src/src/Examples.MinimalApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Examples.MinimalApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:56734;http://localhost:56736" + } + } +} \ No newline at end of file diff --git a/src/src/Examples.MinimalApi/Responses.cs b/src/src/Examples.MinimalApi/Responses.cs new file mode 100644 index 00000000..c6fbaed8 --- /dev/null +++ b/src/src/Examples.MinimalApi/Responses.cs @@ -0,0 +1,18 @@ +namespace ZodSharp.Examples.MinimalApi; + +/// +/// The response to an accepted order. +/// +/// The order id. +/// The number of order lines. +sealed record OrderAccepted(string Id, int Lines) +{ + public static OrderAccepted For(CreateOrderRequest order) => + new(Guid.CreateVersion7().ToString("N"), order.Lines.Count); +} + +/// +/// The response to a registered customer. +/// +/// The registered email address. +sealed record CustomerRegistered(string Email); diff --git a/src/src/Examples.MinimalApi/SampleApp.cs b/src/src/Examples.MinimalApi/SampleApp.cs new file mode 100644 index 00000000..08eda89c --- /dev/null +++ b/src/src/Examples.MinimalApi/SampleApp.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.Localization; +using ZodSharp.AspNetCore; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; +using ZodSharp.Options; + +namespace ZodSharp.Examples.MinimalApi; + +/// +/// The sample's service registration and endpoints, shared by Program.cs and the in-memory smoke tests. +/// +static class SampleApp +{ + /// + /// Registers ZodSharp, the .NET 11 validation pipeline, ProblemDetails, OpenAPI, localization and the validated + /// options. + /// + /// The application builder. + public static void ConfigureServices(WebApplicationBuilder builder) + { + var services = builder.Services; + + // Request and response types are serialized through source-generated metadata, as Native AOT requires. + services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, SampleJsonContext.Default) + ); + + // The IZodSchemaFactory, populated without assembly scanning: the generated AddZodSharp{Assembly}Schemas() + // extensions name every validator directly, which is what keeps registration trim- and AOT-safe. + services.AddZodSharp(); + services.AddZodSharpZodSharpExamplesSharedSchemas(); + services.AddZodSharpZodSharpExamplesMinimalApiSchemas(); + + // Mode A: ZodSharp becomes the first Microsoft.Extensions.Validation resolver, so the built-in .NET 11 + // pipeline validates every endpoint parameter of a ZodSharp-known type - no per-endpoint call. + services.AddZodSharpValidation(); + + // One ProblemDetails contract (errors + issues + traceId) on every path: the pipeline, the endpoint + // filter, ZodException and manual results. Each issue carries its own parameters, so they are not also + // merged into the top-level extensions (that merge is on by default only for compatibility). + services.AddZodSharpProblemDetails(static options => options.MergeParametersIntoExtensions = false); + + // The OpenAPI document describes the constraints ZodSharp enforces and the ZodValidationProblemDetails + // error contract of every validated operation. + services.AddOpenApi(static options => options.AddZodSharp()); + + // Validation messages in the request culture (Accept-Language: fr). Codes and paths never change. + services.AddLocalization(); + services.AddSingleton(static sp => new StringLocalizerZodMessageFormatter( + sp.GetRequiredService(), + sharedResourceType: typeof(ValidationMessages) + )); + + // The store the service-aware async email-uniqueness rule on RegisterCustomerRequest resolves. + services.AddSingleton(); + + // Options validated with their generated schema - including the async rule - when the host starts. + // BindAndValidateWithZod(SampleOptions.SectionName) does the same in one call, but it binds through the + // reflection-based configuration binder ([RequiresUnreferencedCode]). Under Native AOT, bind with + // BindConfiguration, which the configuration binder source generator intercepts, and then add the + // ZodSharp validator naming the section, so failures read "Sample:ApiName: ...". + services + .AddOptions() + .BindConfiguration(SampleOptions.SectionName) + .AddZodSchemaValidator( + MissingValidatorBehavior.Throw, + validateOnStart: true, + configurationSection: SampleOptions.SectionName + ); + } + + /// + /// Configures the request pipeline and maps the endpoints. + /// + /// The application. + public static void Configure(WebApplication app) + { + app.UseExceptionHandler(); + app.UseRequestLocalization(static options => + options.SetDefaultCulture("en").AddSupportedCultures("en", "fr").AddSupportedUICultures("en", "fr") + ); + + app.MapOpenApi(); + + MapPipelineValidatedEndpoints(app); + MapFilterValidatedEndpoints(app); + MapManuallyValidatedEndpoints(app); + } + + /// + /// Mode A: route groups validated by the .NET 11 pipeline. Nothing on the group or the endpoints asks for + /// validation; registering ZodSharp with the pipeline is enough. + /// + static void MapPipelineValidatedEndpoints(WebApplication app) + { + var orders = app.MapGroup("/orders").WithTags("Orders"); + orders.MapPost("/", static (CreateOrderRequest order) => + { + var accepted = OrderAccepted.For(order); + return TypedResults.Created($"/orders/{accepted.Id}", accepted); + }); + + var customers = app.MapGroup("/customers").WithTags("Customers"); + customers.MapPost("/", static (RegisterCustomerRequest customer, ICustomerEmailRegistry registry) => + { + // The async uniqueness rule has already run against the same registry. + registry.TryRegister(customer.Email!); + return TypedResults.Created($"/customers/{customer.Email}", new CustomerRegistered(customer.Email!)); + }); + } + + /// + /// Mode B: the ZodSharp endpoint filter, which works on every target framework. DisableValidation() + /// takes the endpoint out of the built-in pipeline, so the filter is the one validating it. + /// + static void MapFilterValidatedEndpoints(WebApplication app) => + app.MapPost("/filtered/orders", static (CreateOrderRequest order) => + { + var accepted = OrderAccepted.For(order); + return TypedResults.Created($"/orders/{accepted.Id}", accepted); + }) + .WithTags("Orders") + .DisableValidation() + .WithZodSharpValidation(); + + /// + /// Manual validation with the generated schema, returning the same contract through + /// . + /// + static void MapManuallyValidatedEndpoints(WebApplication app) => + app.MapPost( + "/manual/orders", + static Results, ValidationProblem> ( + CreateOrderRequest order, + HttpContext context + ) => + { + var result = CreateOrderRequestSchema.Validate(order); + if (!result.IsSuccess) + return ZodTypedResults.ValidationProblem(result, context); + + var accepted = OrderAccepted.For(order); + return TypedResults.Created($"/orders/{accepted.Id}", accepted); + } + ) + .WithTags("Orders") + .DisableValidation(); +} diff --git a/src/src/Examples.MinimalApi/SampleJsonContext.cs b/src/src/Examples.MinimalApi/SampleJsonContext.cs new file mode 100644 index 00000000..ee150535 --- /dev/null +++ b/src/src/Examples.MinimalApi/SampleJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZodSharp.Examples.MinimalApi; + +/// +/// Source-generated JSON metadata for the request and response types, so the endpoints serialize under Native AOT. +/// The ProblemDetails types are supplied by ASP.NET Core and by AddZodSharpProblemDetails(). +/// +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(CreateOrderRequest))] +[JsonSerializable(typeof(RegisterCustomerRequest))] +[JsonSerializable(typeof(OrderAccepted))] +[JsonSerializable(typeof(CustomerRegistered))] +sealed partial class SampleJsonContext : JsonSerializerContext; diff --git a/src/src/Examples.MinimalApi/SampleOptions.cs b/src/src/Examples.MinimalApi/SampleOptions.cs new file mode 100644 index 00000000..16dda31c --- /dev/null +++ b/src/src/Examples.MinimalApi/SampleOptions.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp.Core; + +namespace ZodSharp.Examples.MinimalApi; + +/// +/// The sample's settings, bound from the Sample configuration section and validated with the generated +/// schema when the host starts. A failure names the configuration key (Sample:SupportEmail: ...), so it can +/// be found in appsettings.json. +/// +[ZodSchema] +sealed class SampleOptions +{ + /// + /// The configuration section the options are bound from. + /// + public const string SectionName = "Sample"; + + [Required] + [StringLength(50, MinimumLength = 1)] + public string? ApiName { get; set; } + + [Required] + [EmailAddress] + public string? SupportEmail { get; set; } + + [Range(1, 20)] + public int MaxLinesPerOrder { get; set; } = 20; + + /// + /// An asynchronous, service-aware rule. On .NET 11 the options validator implements + /// IAsyncValidateOptions<T>, so ValidateOnStart() awaits it before the host starts: the support + /// mailbox must not be one of the customer addresses. + /// + internal static async ValueTask> CustomValidationAsync( + SampleOptions value, + IServiceProvider services, + CancellationToken cancellationToken + ) + { + var registry = services.GetRequiredService(); + // The awaited guard and the failure construction would become one long conditional expression. +#pragma warning disable IDE0046 // Convert to conditional expression + if (value.SupportEmail is null || !await registry.IsRegisteredAsync(value.SupportEmail, cancellationToken)) + return ValidationResult.Success(value); +#pragma warning restore IDE0046 + + return ValidationResult.Failure( + ValidationError.Create( + "support_email_is_customer", + "The support mailbox must not be a customer address", + SampleOptionsSchema.Paths.SupportEmail + ) + ); + } +} diff --git a/src/src/Examples.MinimalApi/ValidationMessages.cs b/src/src/Examples.MinimalApi/ValidationMessages.cs new file mode 100644 index 00000000..593188e9 --- /dev/null +++ b/src/src/Examples.MinimalApi/ValidationMessages.cs @@ -0,0 +1,8 @@ +namespace ZodSharp.Examples.MinimalApi; + +/// +/// The marker type for the ValidationMessages resources (ValidationMessages.resx and +/// ValidationMessages.fr.resx). Keys are tried in the order StringLocalizerZodMessageFormatter +/// documents; this sample keys by error code, so one entry localizes a code wherever it is reported. +/// +sealed class ValidationMessages; diff --git a/src/src/Examples.MinimalApi/ValidationMessages.fr.resx b/src/src/Examples.MinimalApi/ValidationMessages.fr.resx new file mode 100644 index 00000000..2125d472 --- /dev/null +++ b/src/src/Examples.MinimalApi/ValidationMessages.fr.resx @@ -0,0 +1,38 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Le champ « {displayName} » est obligatoire + + + Le champ « {displayName} » doit être une adresse e-mail valide. + + + Le champ « {displayName} » doit respecter l'expression régulière « {pattern} ». + + + Le champ « {displayName} » doit être compris entre {minimum} et {maximum}. + + + Le champ « {displayName} » doit contenir entre {minimum} et {maximum} caractères. + + + Cette adresse e-mail est déjà enregistrée + + diff --git a/src/src/Examples.MinimalApi/ValidationMessages.resx b/src/src/Examples.MinimalApi/ValidationMessages.resx new file mode 100644 index 00000000..3bf5236c --- /dev/null +++ b/src/src/Examples.MinimalApi/ValidationMessages.resx @@ -0,0 +1,19 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Email is already registered + + diff --git a/src/src/Examples.MinimalApi/appsettings.json b/src/src/Examples.MinimalApi/appsettings.json new file mode 100644 index 00000000..b3c6fd22 --- /dev/null +++ b/src/src/Examples.MinimalApi/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Sample": { + "ApiName": "ZodSharp minimal API sample", + "SupportEmail": "support@example.com", + "MaxLinesPerOrder": 20 + } +} diff --git a/src/src/Examples.Shared/CustomerEmailRegistry.cs b/src/src/Examples.Shared/CustomerEmailRegistry.cs new file mode 100644 index 00000000..b9af2637 --- /dev/null +++ b/src/src/Examples.Shared/CustomerEmailRegistry.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; + +namespace ZodSharp.Examples; + +/// +/// Looks up whether an email address is already registered. The asynchronous uniqueness rule on +/// resolves it from the caller's services. +/// +public interface ICustomerEmailRegistry +{ + /// + /// Gets whether is already registered. + /// + /// The email address. + /// The cancellation token. + /// when the address is taken. + ValueTask IsRegisteredAsync(string email, CancellationToken cancellationToken); + + /// + /// Registers . + /// + /// The email address. + /// when the address was already registered. + bool TryRegister(string email); +} + +/// +/// An in-memory , case-insensitive, seeded with taken@example.com so the +/// samples can show the uniqueness rule failing. +/// +public sealed class InMemoryCustomerEmailRegistry : ICustomerEmailRegistry +{ + /// + /// The address the registry starts with. + /// + public const string SeededEmail = "taken@example.com"; + + readonly ConcurrentDictionary _emails = new(StringComparer.OrdinalIgnoreCase) { [SeededEmail] = 0 }; + + /// + public async ValueTask IsRegisteredAsync(string email, CancellationToken cancellationToken) + { + // Stands in for a database or directory lookup. + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + + return _emails.ContainsKey(email); + } + + /// + public bool TryRegister(string email) => _emails.TryAdd(email, 0); +} diff --git a/src/src/Examples.Shared/Examples.Shared.csproj b/src/src/Examples.Shared/Examples.Shared.csproj new file mode 100644 index 00000000..ba08d85a --- /dev/null +++ b/src/src/Examples.Shared/Examples.Shared.csproj @@ -0,0 +1,27 @@ + + + + true + + net11.0 + + + true + true + + + + + + + + diff --git a/src/src/Examples.Shared/Orders.cs b/src/src/Examples.Shared/Orders.cs new file mode 100644 index 00000000..8a806e40 --- /dev/null +++ b/src/src/Examples.Shared/Orders.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp.Rules; + +namespace ZodSharp.Examples; + +/// +/// An order: a nested , a collection of items and a dictionary of +/// extra addresses keyed by purpose (billing, gift). Errors are reported at +/// ShippingAddress.City, Lines[0].Quantity and AdditionalAddresses[billing].PostCode. +/// +[ZodSchema] +public sealed class CreateOrderRequest +{ + [Required] + [EmailAddress] + public string? CustomerEmail { get; set; } + + [Required] + public Address? ShippingAddress { get; set; } + + // [MinCount]/[MaxCount] rather than [MinLength]/[MaxLength]: the BCL attributes' constructors are + // [RequiresUnreferencedCode], so they raise IL2026 in a trimmed or Native AOT application (ZODSGEN053). The + // generator validates both spellings identically, with the same error identity. + [MinCount(1)] + [MaxCount(20)] + public IList Lines { get; init; } = []; + + public IDictionary AdditionalAddresses { get; init; } = new Dictionary(); +} + +/// +/// One line of an order. +/// +[ZodSchema] +public sealed class OrderLine +{ + [Required] + [RegularExpression("^[A-Z]{3}-[0-9]{4}$")] + public string? Sku { get; set; } + + [Range(1, 100)] + public int Quantity { get; set; } +} + +/// +/// A postal address. +/// +[ZodSchema] +public sealed class Address +{ + [Required] + [StringLength(100, MinimumLength = 1)] + public string? Street { get; set; } + + [Required] + [StringLength(60, MinimumLength = 1)] + public string? City { get; set; } + + [Required] + [StringLength(10, MinimumLength = 3)] + public string? PostCode { get; set; } +} diff --git a/src/src/Examples.Shared/RegisterCustomerRequest.cs b/src/src/Examples.Shared/RegisterCustomerRequest.cs new file mode 100644 index 00000000..caff833e --- /dev/null +++ b/src/src/Examples.Shared/RegisterCustomerRequest.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Core; + +namespace ZodSharp.Examples; + +/// +/// A customer registration. Besides its member rules it has a service-aware asynchronous rule: the email address +/// must not already be registered, which is checked against resolved from the +/// caller's services (the request services in a minimal API, the circuit's services in Blazor). +/// +[ZodSchema] +public sealed class RegisterCustomerRequest +{ + /// + /// The error code reported when the email address is already registered. + /// + public const string EmailTakenCode = "email_taken"; + + [Required] + [StringLength(100, MinimumLength = 2)] + public string? Name { get; set; } + + [Required] + [EmailAddress] + public string? Email { get; set; } + + [Range(13, 130)] + public int Age { get; set; } + + /// + /// Runs after the member rules pass. ties it to , so + /// field-level validation (a Blazor field change) re-runs it only when the email changes. + /// + [ZodAsyncMember(nameof(Email))] + internal static async ValueTask> CustomValidationAsync( + RegisterCustomerRequest value, + IServiceProvider services, + CancellationToken cancellationToken + ) + { + var registry = services.GetRequiredService(); + // The awaited guard and the failure construction would become one long conditional expression. +#pragma warning disable IDE0046 // Convert to conditional expression + if (value.Email is null || !await registry.IsRegisteredAsync(value.Email, cancellationToken)) + return ValidationResult.Success(value); +#pragma warning restore IDE0046 + + return ValidationResult.Failure( + ValidationError.Create( + EmailTakenCode, + "Email is already registered", + RegisterCustomerRequestSchema.Paths.Email + ) + ); + } +} diff --git a/src/src/Examples.Shared/ReturnRequest.cs b/src/src/Examples.Shared/ReturnRequest.cs new file mode 100644 index 00000000..b838b5ff --- /dev/null +++ b/src/src/Examples.Shared/ReturnRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp.Rules; + +namespace ZodSharp.Examples; + +/// +/// A request to return items from an order. Besides the DataAnnotations attributes it carries ZodSharp-specific rules +/// - [StartsWith] and [UUID] - which the Blazor sample's static-SSR form also checks in the browser +/// (AddZodSharpClientValidation). Errors are reported at Reference, ReturnAddress.City and +/// Items[0].Quantity. +/// +[ZodSchema] +public sealed class ReturnRequest +{ + /// + /// The prefix every return reference starts with. + /// + public const string ReferencePrefix = "RMA-"; + + [Required] + [StartsWith(ReferencePrefix)] + public string? Reference { get; set; } + + [Required] + [UUID] + public string? OrderId { get; set; } + + [Required] + public Address? ReturnAddress { get; set; } = new(); + + [MinCount(1)] + [MaxCount(10)] + public List Items { get; set; } = []; +} + +/// +/// One item being returned. +/// +[ZodSchema] +public sealed class ReturnItem +{ + [Required] + [RegularExpression("^[A-Z]{3}-[0-9]{4}$")] + public string? Sku { get; set; } + + [Range(1, 10)] + public int Quantity { get; set; } +} diff --git a/src/src/Extensions.Validation/Extensions.Validation.csproj b/src/src/Extensions.Validation/Extensions.Validation.csproj new file mode 100644 index 00000000..f6be2c0d --- /dev/null +++ b/src/src/Extensions.Validation/Extensions.Validation.csproj @@ -0,0 +1,58 @@ + + + + true + + net11.0 + + ZodSharp.Extensions.Validation + + Purview.ZodSharp.Extensions.Validation + Purview.ZodSharp.Extensions.Validation + true + true + true + true + + Microsoft.Extensions.Validation integration for ZodSharp: registers ZodSharp validators as the first + validation resolver, so minimal APIs, Blazor Server and Blazor WebAssembly validate with ZodSharp. + + zod;validation;schema;blazor;minimalapi + + true + + $(NoWarn);NU5104 + + + + + + + + + + + + + + + + + + diff --git a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs new file mode 100644 index 00000000..9650a786 --- /dev/null +++ b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Registers ZodSharp with Microsoft.Extensions.Validation. +/// +public static class ZodSharpValidationServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Registers ZodSharp as the first Microsoft.Extensions.Validation resolver, so minimal APIs, Blazor Server + /// and Blazor WebAssembly validate every type that has a ZodSharp validator with ZodSharp. + /// + /// Optional configuration of the bridge. + /// The for chaining. + /// + /// + /// Registers when none is registered yet (see AddZodSharpFactory), calls + /// AddValidation(), and registers as a singleton. The resolver + /// is placed by a post-configure step, so it ends up first whether the application calls + /// AddValidation() itself before this, after it, or not at all. Call AddValidation() in the + /// application too when DataAnnotations types should be validated: the Microsoft.Extensions.Validation + /// generator only intercepts calls in the application's own code. + /// + /// + /// Microsoft.Extensions.Validation's endpoint filter validates every parameter a registered resolver claims, so + /// this turns on minimal-API validation for ZodSharp-typed parameters: an invalid request is answered with a + /// 400 before the handler runs. Opt an endpoint or group out with DisableValidation(), or every + /// parameter with = . + /// + /// Calling this more than once registers the resolver once; each applies. + /// + public IServiceCollection AddZodSharpValidation(Action? configure = null) => + services.AddZodSharpValidation(configure, validateParameters: true); + + /// + /// Registers ZodSharp as the first Microsoft.Extensions.Validation resolver, choosing whether it claims + /// parameters (minimal-API endpoint validation); see AddZodSharpValidation(configure). + /// + /// Optional configuration of the bridge. + /// + /// to claim ZodSharp-typed parameters, so the endpoint filter validates them before the + /// handler runs. registers type validation only (Blazor forms, + /// ValidationOptions.ValidateAsync) and leaves the host's endpoints as they were. Any registration that + /// asks for parameters wins, and , when set, overrides + /// both. AddZodSharpBlazor() registers the bridge with . + /// + /// The for chaining. + public IServiceCollection AddZodSharpValidation( + Action? configure, + bool validateParameters + ) + { + ArgumentNullException.ThrowIfNull(services); + + if (validateParameters) + services.TryAddSingleton(new ZodParameterValidationRequest()); + + services.AddZodSharpFactory(); + services.AddValidation(); + services.AddOptions(); + if (configure is not null) + services.Configure(configure); + + services.TryAddSingleton(static sp => new ZodValidatableInfoResolver( + sp.GetService(), + sp.GetRequiredService>().Value, + parametersRequested: sp.GetService() is not null + )); + services.TryAddEnumerable( + ServiceDescriptor.Singleton, ZodValidationOptionsSetup>() + ); + + return services; + } + } +} diff --git a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs new file mode 100644 index 00000000..240538c4 --- /dev/null +++ b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs @@ -0,0 +1,31 @@ +using System.Runtime.CompilerServices; +using ZodSharp.Extensions.Validation; + +namespace Microsoft.Extensions.Validation; + +/// +/// ZodSharp extensions for . +/// +public static class ZodValidateContextExtensions +{ + static readonly ConditionalWeakTable Reports = []; + + extension(ValidateContext context) + { + /// + /// Gets the full-fidelity ZodSharp errors reported against this context, or when + /// ZodSharp reported nothing. The report is held weakly, so it lives exactly as long as the context. + /// + /// + /// Microsoft.Extensions.Validation validates members that complete asynchronously on cloned contexts + /// and merges only its own errors back. When ZodSharp runs nested under a DataAnnotations type whose + /// sibling members are asynchronous, its report can therefore be attached to a clone; register an + /// where that matters. When ZodSharp validates the root value + /// (minimal API parameters, Blazor forms of ZodSharp models), the report is always on the caller's context. + /// + public ZodValidationReport? ZodReport => Reports.TryGetValue(context, out var report) ? report : null; + } + + internal static ZodValidationReport GetOrCreateZodReport(ValidateContext context) => + Reports.GetValue(context, static _ => new()); +} diff --git a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensions.cs b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensions.cs new file mode 100644 index 00000000..c1f2b9bd --- /dev/null +++ b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensions.cs @@ -0,0 +1,71 @@ +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace Microsoft.Extensions.Validation; + +/// +/// ZodSharp extensions for . +/// +public static class ZodValidationOptionsExtensions +{ + extension(ValidationOptions options) + { + /// + /// Validates through the configured resolvers outside of HTTP or Blazor (worker + /// services, console hosts, tests), returning ZodSharp's error shape. + /// + /// The declared type; the runtime type of is used when it differs. + /// The value to validate. + /// The services made available to validators (service-aware rules, localization). + /// A token that cancels the validation. + /// + /// The report. ZodSharp errors keep full fidelity; errors from other resolvers (DataAnnotations) are + /// converted with code custom and their path parsed from the Microsoft.Extensions.Validation key. + /// + public async ValueTask ValidateAsync( + T value, + IServiceProvider? services = null, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(options); + + var type = value?.GetType() ?? typeof(T); + if (!options.TryGetValidatableTypeInfo(type, out var info)) + return new(); + + ValidateContext context = new() { ValidationOptions = options, ServiceProvider = services }; + await info.ValidateAsync(value, context, cancellationToken).ConfigureAwait(false); + + var zod = context.ZodReport; + var all = context.ValidationErrors; + if (all is null || all.Count == 0) + return zod ?? new(); + + // Keep ZodSharp's own errors, and add anything another resolver reported at a key ZodSharp did not. + var zodKeys = zod?.Keys; + List errors = [.. zod?.Errors ?? []]; + + foreach (var (path, atPath) in all) + { + if (zodKeys?.Contains(path) == true) + continue; + + foreach (var error in atPath) + { + errors.Add( + ZodValidationError.Create( + ZodIssueCategories.Custom, + error.ErrorMessage, + ZodPath.ParseDotted(path), + category: ZodIssueCategories.Custom + ) + ); + } + } + + return new(errors); + } + } +} diff --git a/src/src/Extensions.Validation/IZodValidatableInfoSource.cs b/src/src/Extensions.Validation/IZodValidatableInfoSource.cs new file mode 100644 index 00000000..928561f6 --- /dev/null +++ b/src/src/Extensions.Validation/IZodValidatableInfoSource.cs @@ -0,0 +1,18 @@ +using ZodSharp.Core; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Supplies ZodSharp validators by validated type without reflection. Sources registered in +/// are consulted before . +/// +public interface IZodValidatableInfoSource +{ + /// + /// Gets the validator for . + /// + /// The validated type. + /// The validator, when this source knows the type. + /// when this source knows the type. + bool TryGetValidator(Type type, out IZodUntypedValidator? validator); +} diff --git a/src/src/Extensions.Validation/IZodValidationReportSink.cs b/src/src/Extensions.Validation/IZodValidationReportSink.cs new file mode 100644 index 00000000..af08d532 --- /dev/null +++ b/src/src/Extensions.Validation/IZodValidationReportSink.cs @@ -0,0 +1,25 @@ +using System.Collections.Immutable; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Receives the full-fidelity ZodSharp errors of every validation the bridge performs. When +/// ValidateContext.ServiceProvider resolves one, the bridge appends to it in addition to the +/// ValidateContext.ZodReport side channel. +/// +/// +/// Register it with a lifetime that equals one validation run - in ASP.NET Core, scoped (one request). Do not +/// register it in Blazor Server, where the scope is the circuit and errors would accumulate across every +/// validation on the page. This package never registers a sink itself. +/// +public interface IZodValidationReportSink +{ + /// + /// Records the errors of one validated value. + /// + /// The validated type. + /// The ValidateContext.CurrentValidationPath the value was validated at. + /// The errors, with canonical paths that already include . + void Add(Type validatedType, string pathPrefix, ImmutableArray errors); +} diff --git a/src/src/Extensions.Validation/Sdk/README.md b/src/src/Extensions.Validation/Sdk/README.md new file mode 100644 index 00000000..0fedd9c3 --- /dev/null +++ b/src/src/Extensions.Validation/Sdk/README.md @@ -0,0 +1,107 @@ +# Purview.ZodSharp.Extensions.Validation + +[![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.Extensions.Validation.svg)](https://www.nuget.org/packages/Purview.ZodSharp.Extensions.Validation) +[![Release](https://github.com/purview-dev/zodsharp/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/zodsharp/actions/workflows/release.yml) + +[Microsoft.Extensions.Validation](https://www.nuget.org/packages/Microsoft.Extensions.Validation) integration for +[Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp). It registers ZodSharp as the first .NET 11 +validation resolver, so **minimal APIs, Blazor Server and Blazor WebAssembly** validate every type that has a +ZodSharp validator with ZodSharp - nested objects, collections and dictionaries included - without +`[ValidatableType]` and without the Microsoft.Extensions.Validation source generator. + +Targets `net11.0`. It references the `Microsoft.Extensions.Validation` package, never the ASP.NET Core shared +framework, so it works in a Blazor WebAssembly client and in worker or console hosts. + +## Installation + +```bash +dotnet add package Purview.ZodSharp.Extensions.Validation +``` + +## Usage + +```csharp +builder.Services.AddZodSharpFactory(factory => factory.Register(new OrderSchemaValidator())); +builder.Services.AddZodSharpValidation(); + +// Keep calling AddValidation() in the application when DataAnnotations types should be validated too: +// the Microsoft.Extensions.Validation generator only intercepts calls in your own code. +builder.Services.AddValidation(); +``` + +ZodSharp's resolver ends up first whether `AddValidation()` is called before `AddZodSharpValidation()`, after it, +or not at all. Types without a ZodSharp validator fall through to the next resolver. + +**This turns on minimal-API validation** for every ZodSharp-typed handler parameter: the platform's endpoint filter +validates every parameter a resolver claims, before the handler runs. Opt endpoints out with `DisableValidation()`, or +register type validation only with `AddZodSharpValidation(configure, validateParameters: false)` (what +`AddZodSharpBlazor()` does) or `ValidateParameters = false`. + +For trimmed and Native AOT apps (including Blazor WebAssembly), register the generated validators with the +per-assembly extension the source generator emits instead of scanning: `builder.Services.AddZodSharpMyAppSchemas();` +(`AddZodSharp{Assembly}Schemas()`, the assembly name in PascalCase). `[ValidatableType]` is not needed on a +`[ZodSchema]` type, and is reported as redundant (`ZODSGEN047`). + +### What each error carries + +| `ValidationError` | Value | +| --- | --- | +| `Path` | The ZodSharp path, prefixed with `ValidateContext.CurrentValidationPath`: `Customer.Address.Street`, `Lines[0].Sku`, `ByCode[abc].Sku` | +| `Name` | The failing member's name (`Street`); empty for a model-level error | +| `Container` | The object that owns the member (`order.Customer.Address`), which is what Blazor uses to place the message on the right field. Never a struct: an error inside a struct member (`Email.Value`) belongs to the nearest reference-type owner and its member (`Email`) | +| `ErrorMessage` | The ZodSharp message, localized when an `IStringLocalizerFactory` is registered (see below) | + +Runtime `Z.Object()` schemas over dictionaries have no owning object, so their errors carry `Container = null` +and the full path as `Name`: Blazor shows them in the validation summary. + +Parameters follow Microsoft.Extensions.Validation's conventions: a complex parameter is not prefixed, a +collection parameter is (`items[0].Name`), and an error about the value itself is keyed by the parameter name. + +### Full-fidelity errors + +Microsoft.Extensions.Validation keeps only a name, path, message and container. The full ZodSharp errors - code, +category, origin, bounds and parameters - are available alongside: + +```csharp +ValidateContext context = new() { ValidationOptions = options, ServiceProvider = services }; +await typeInfo.ValidateAsync(order, context, cancellationToken); + +ZodValidationReport? report = context.ZodReport; +``` + +Register an `IZodValidationReportSink` with a per-run lifetime (a scoped service in ASP.NET Core) to receive the +same errors where the `ValidateContext` is created for you. Do not register one in Blazor Server, where the scope is +the circuit. Outside HTTP and Blazor, `ValidationOptions.ValidateAsync(value, services)` validates and returns the +report directly: + +```csharp +var options = services.GetRequiredService>().Value; +ZodValidationReport report = await options.ValidateAsync(order, services, cancellationToken); + +foreach (var error in report.Errors) + Console.WriteLine($"{error.FullPath.ToDottedString()}: {error.Message} ({error.Code})"); +``` + +Errors from other resolvers (DataAnnotations types) are included with code `custom`, their path parsed from the +Microsoft.Extensions.Validation key. + +### Localization + +When the validation's service provider resolves an `IStringLocalizerFactory`, messages are localized by +`StringLocalizerZodMessageFormatter`, honouring `ValidationOptions.LocalizerProvider`. Keys are tried in order: +the message template, `{Type}_{Member}_{Code}`, `{Type}_{Code}`, `{Code}`; otherwise the ZodSharp message is +kept. Set `ZodValidationOptions.MessageFormatter` to replace it. + +### Options + +| Option | Default | Effect | +| --- | --- | --- | +| `Placement` | `First` | `AfterGenerated` places ZodSharp after the generated DataAnnotations resolvers | +| `AlsoRunDataAnnotations` | `false` | Also runs the next resolver for ZodSharp types, reporting identical messages once | +| `PathNamer` | `null` (C# names) | Renames members in `Path`, for example to camelCase | +| `MessageFormatter` | `null` | Formats every message | +| `Sources` | empty | Trim-safe validator sources consulted before `IZodSchemaFactory` | +| `ValidateParameters` | `null` (by registration) | Whether minimal-API parameters are claimed (endpoint validation): on after `AddZodSharpValidation()`, off when only `AddZodSharpBlazor()` registered the bridge | + +Register every validator before the first validation: lookups are cached, including misses. Synchronous +validation of a type with asynchronous rules throws, as `EditContext.Validate()` does; use the async APIs. diff --git a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs new file mode 100644 index 00000000..57a0b3cb --- /dev/null +++ b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using Microsoft.Extensions.Localization; +using ZodSharp.Core; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Localizes ZodSharp messages through , with the same lookup shape +/// Microsoft.Extensions.Validation uses for DataAnnotations, so one resource file serves both. +/// +/// +/// Keys are tried in order; the first one found is the template: +/// +/// the error's (or, for a literal message, +/// ) - localize by the English template; +/// {DeclaringType}_{Member}_{Code}; +/// {DeclaringType}_{Code}; +/// {Code}; +/// otherwise the error's own . +/// +/// +/// {DeclaringType} is the simple name of the type that owns the failing member (generic arity removed), +/// matching Microsoft.Extensions.Validation's key segment. A template is formatted with +/// , so it can use named placeholders such as {displayName} or +/// {minimum} and never throws. {displayName} is itself localized from the key +/// {DeclaringType}_{Member}_DisplayName when present. +/// +/// +public sealed class StringLocalizerZodMessageFormatter : IZodMessageFormatter +{ + readonly IStringLocalizerFactory _factory; + readonly Func _localizerProvider; + readonly Type? _sharedResourceType; + + /// + /// Creates the formatter. + /// + /// The localizer factory. + /// + /// Resolves the localizer for a declaring type; pass ValidationOptions.LocalizerProvider so ZodSharp + /// and DataAnnotations share resources. Defaults to factory.Create(type). + /// + /// + /// The resource type used when the declaring type is unknown (the + /// overload); when , such errors keep their own message. + /// + public StringLocalizerZodMessageFormatter( + IStringLocalizerFactory factory, + Func? localizerProvider = null, + Type? sharedResourceType = null + ) + { + ArgumentNullException.ThrowIfNull(factory); + + _factory = factory; + _localizerProvider = localizerProvider ?? (static (type, f) => f.Create(type)); + _sharedResourceType = sharedResourceType; + } + + /// + public string Format(in ZodValidationError validationError, CultureInfo culture) => + Format(validationError, null, culture); + + /// + /// Formats for , looking keys up in the localizer of + /// . + /// + /// The error. + /// The type that owns the failing member, when known. + /// The culture. + /// The localized message, or the error's own message when no key matches. + public string Format(in ZodValidationError error, Type? declaringType, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture); + + var resourceType = declaringType ?? _sharedResourceType; + if (resourceType is null) + return error.Message; + + var previous = CultureInfo.CurrentUICulture; + var switchCulture = !Equals(previous, culture); + if (switchCulture) + CultureInfo.CurrentUICulture = culture; + + try + { + var localizer = + _localizerProvider(resourceType, _factory) + ?? throw new InvalidOperationException( + $"The localizer provider returned null for type '{resourceType.FullName}'." + ); + + var typeName = GetKeySegment(declaringType, error); + var member = GetMember(error); + var template = FindTemplate(localizer, error, typeName, member); + if (template is null) + return error.Message; + + var subject = error; + if (typeName is not null && member is not null) + { + var displayName = localizer[$"{typeName}_{member}_DisplayName"]; + if (!displayName.ResourceNotFound) + subject = WithDisplayName(error, displayName.Value); + } + + return ZodMessageFormat.Format(template, subject, culture); + } + finally + { + if (switchCulture) + CultureInfo.CurrentUICulture = previous; + } + } + + static string? FindTemplate( + IStringLocalizer localizer, + in ZodValidationError error, + string? typeName, + string? member + ) + { + var explicitKey = error.MessageFormat ?? error.Message; + if (!string.IsNullOrEmpty(explicitKey) && Lookup(localizer, explicitKey) is { } explicitMatch) + return explicitMatch; + + if (string.IsNullOrEmpty(error.Code)) + return null; + + if (typeName is not null) + { + if (member is not null && Lookup(localizer, $"{typeName}_{member}_{error.Code}") is { } memberMatch) + return memberMatch; + + if (Lookup(localizer, $"{typeName}_{error.Code}") is { } typeMatch) + return typeMatch; + } + + return Lookup(localizer, error.Code); + } + + static string? Lookup(IStringLocalizer localizer, string key) + { + var value = localizer[key]; + return value.ResourceNotFound ? null : value.Value; + } + + static string? GetKeySegment(Type? declaringType, in ZodValidationError error) + { + string? name; + if (declaringType is not null) + name = (Nullable.GetUnderlyingType(declaringType) ?? declaringType).Name; + else if ( + error.Parameters?.TryGetValue(ZodIssueParameters.DeclaringType, out var raw) == true + && raw is string full + ) + name = full[(full.LastIndexOfAny(['.', '+']) + 1)..]; + else + return null; + + var arity = name.IndexOf('`', StringComparison.Ordinal); + return arity < 0 ? name : name[..arity]; + } + + static string? GetMember(in ZodValidationError error) + { + if (error.Parameters?.TryGetValue(ZodIssueParameters.Member, out var raw) == true && raw is string member) + return member; + + var segments = error.FullPath.Segments; + for (var i = segments.Length - 1; i >= 0; i--) + { + if (segments[i].Kind == ZodPathSegmentKind.Member) + return segments[i].Name; + } + + return null; + } + + static ZodValidationError WithDisplayName(in ZodValidationError error, string displayName) + { + Dictionary parameters = [with(StringComparer.Ordinal)]; + if (error.Parameters is not null) + { + foreach (var pair in error.Parameters) + parameters[pair.Key] = pair.Value; + } + + parameters[ZodIssueParameters.DisplayName] = displayName; + return error.WithParameters(parameters); + } +} diff --git a/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs b/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs new file mode 100644 index 00000000..325e44ab --- /dev/null +++ b/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs @@ -0,0 +1,119 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Validation; + +namespace ZodSharp.Extensions.Validation; + +/// +/// : validates with ZodSharp, then with the next +/// resolver that knows the type, reporting identical (Path, ErrorMessage) pairs once. +/// +/// +/// ZodSharp validates nested ZodSharp types itself, so the second pass runs on a marked clone of the context in +/// which nested ZodSharp types only run their second pass too. That keeps the nested ZodSharp errors from being +/// produced twice. +/// +sealed class ZodCompositeValidatableTypeInfo(ZodValidatableTypeInfo zod, ZodValidatableInfoResolver owner) + : IValidatableTypeInfo +{ + static readonly ConditionalWeakTable SecondPassContexts = []; + static readonly object Marker = new(); + + public ZodValidatableTypeInfo Zod => zod; + + public bool TryFindProperty( + string propertyName, + ValidationOptions validationOptions, + [NotNullWhen(true)] out IValidatablePropertyInfo? validatablePropertyInfo + ) => zod.TryFindProperty(propertyName, validationOptions, out validatablePropertyInfo); + + public void Validate(object? value, ValidateContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var next = owner.FindNextTypeInfo(zod.ValidatedType, context.ValidationOptions); + if (SecondPassContexts.TryGetValue(context, out _)) + { + next?.Validate(value, context); + return; + } + + zod.Validate(value, context); + if (next is null) + return; + + var second = CreateSecondPass(context); + next.Validate(value, second); + Merge(second, context); + } + + public async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var next = owner.FindNextTypeInfo(zod.ValidatedType, context.ValidationOptions); + if (SecondPassContexts.TryGetValue(context, out _)) + { + if (next is not null) + await next.ValidateAsync(value, context, cancellationToken).ConfigureAwait(false); + + return; + } + + await zod.ValidateAsync(value, context, cancellationToken).ConfigureAwait(false); + if (next is null) + return; + + var second = CreateSecondPass(context); + await next.ValidateAsync(value, second, cancellationToken).ConfigureAwait(false); + Merge(second, context); + } + + static ValidateContext CreateSecondPass(ValidateContext context) + { + ValidateContext second = new() + { + ValidationOptions = context.ValidationOptions, + ServiceProvider = context.ServiceProvider, + CurrentDepth = context.CurrentDepth, + CurrentValidationPath = context.CurrentValidationPath, + }; + SecondPassContexts.AddOrUpdate(second, Marker); + return second; + } + + static void Merge(ValidateContext second, ValidateContext context) + { + var added = second.ValidationErrors; + if (added is null) + return; + + var existing = context.ValidationErrors; + foreach (var (path, errors) in added) + { + foreach (var error in errors) + { + if (!Contains(existing, path, error.ErrorMessage)) + context.AddValidationError(error); + } + } + } + + static bool Contains( + IReadOnlyDictionary>? errors, + string path, + string message + ) + { + if (errors is null || !errors.TryGetValue(path, out var atPath)) + return false; + + foreach (var error in atPath) + { + if (string.Equals(error.ErrorMessage, message, StringComparison.Ordinal)) + return true; + } + + return false; + } +} diff --git a/src/src/Extensions.Validation/ZodErrorEmitter.cs b/src/src/Extensions.Validation/ZodErrorEmitter.cs new file mode 100644 index 00000000..2a0896b9 --- /dev/null +++ b/src/src/Extensions.Validation/ZodErrorEmitter.cs @@ -0,0 +1,139 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using MevValidationError = Microsoft.Extensions.Validation.ValidationError; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Converts ZodSharp errors into Microsoft.Extensions.Validation errors on a , and +/// records the full-fidelity errors on the side channels. +/// +static class ZodErrorEmitter +{ + /// + /// Reports against . + /// + /// The validator that produced the errors (used for member resolution). + /// The validated value; error paths are relative to it. + /// The errors. + /// The context. + /// The bridge options. + /// + /// When set (a parameter name), errors about the value itself are keyed by it with no container, which is + /// Microsoft.Extensions.Validation's parameter convention. + /// + /// + /// Whether errors below the value are also prefixed with (a collection + /// parameter validated as a whole: items[0].Name). + /// + public static void Emit( + IZodUntypedValidator validator, + object? value, + ImmutableArray errors, + ValidateContext context, + ZodValidationOptions options, + string? valueKey = null, + bool prefixWithKey = false + ) + { + if (errors.IsDefaultOrEmpty) + return; + + var prefixText = context.CurrentValidationPath ?? string.Empty; + var prefix = ZodPath.ParseDotted(prefixText); + var namer = options.PathNamer; + var formatter = ResolveFormatter(context, options); + var culture = CultureInfo.CurrentUICulture; + var dictionaryBacked = ZodMemberLocator.IsDictionaryBacked(value); + + var canonical = ImmutableArray.CreateBuilder(errors.Length); + var keys = new string[errors.Length]; + foreach (var error in errors) + { + var repathed = error; + var relative = error.FullPath; + object? container; + string name; + + if (relative.IsRoot && valueKey is not null) + { + repathed = error.WithPathPrefix(ZodPathSegment.Member(valueKey)); + container = null; + name = valueKey; + } + else if (relative.IsRoot) + { + container = value; + name = string.Empty; + } + else if (value is null || dictionaryBacked) + { + container = null; + name = string.Empty; + } + else + { + ZodMemberLocator.Locate(validator, value, relative, out container, out name); + } + + if (prefixWithKey && valueKey is not null && !relative.IsRoot) + repathed = repathed.WithPathPrefix(ZodPathSegment.Member(valueKey)); + + repathed = repathed.WithPathPrefix(prefix); + var path = repathed.FullPath.ToDottedString(namer); + if (container is null && name.Length == 0 && !repathed.FullPath.IsRoot) + name = path; // No owning object (dictionary-backed): Blazor binds the message to the model. + + keys[canonical.Count] = path; + canonical.Add(repathed); + + context.AddValidationError( + new MevValidationError + { + Name = name, + Path = path, + ErrorMessage = FormatMessage( + formatter, + repathed, + container?.GetType() ?? validator.ValidatedType, + culture + ), + Container = container, + } + ); + } + + var reported = canonical.MoveToImmutable(); + ZodValidateContextExtensions.GetOrCreateZodReport(context).Add(reported, keys); + + if (context.ServiceProvider?.GetService(typeof(IZodValidationReportSink)) is IZodValidationReportSink sink) + sink.Add(validator.ValidatedType, prefixText, reported); + } + + static IZodMessageFormatter? ResolveFormatter(ValidateContext context, ZodValidationOptions options) + { + return options.MessageFormatter + ?? ( + context.ServiceProvider?.GetService(typeof(IStringLocalizerFactory)) is IStringLocalizerFactory factory + ? new StringLocalizerZodMessageFormatter(factory, context.ValidationOptions.LocalizerProvider) + : null + ); + } + + static string FormatMessage( + IZodMessageFormatter? formatter, + in ZodValidationError error, + Type declaringType, + CultureInfo culture + ) => + formatter switch + { + null => error.Message, + StringLocalizerZodMessageFormatter localizer => localizer.Format(error, declaringType, culture), + _ => formatter.Format(error, culture), + }; +} diff --git a/src/src/Extensions.Validation/ZodMemberLocator.cs b/src/src/Extensions.Validation/ZodMemberLocator.cs new file mode 100644 index 00000000..6d94b066 --- /dev/null +++ b/src/src/Extensions.Validation/ZodMemberLocator.cs @@ -0,0 +1,226 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using ZodSharp.Core; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Turns a ZodSharp error path into the (Container, Name) pair Blazor uses to place a message on a +/// field. Validators that implement (generated ones do) resolve without +/// reflection; anything else falls back to a property walker. Only runs on the failure path and never throws. +/// +static class ZodMemberLocator +{ + const string WalkRequirement = + "Walks public properties of the validated value's runtime types, which a trimmer cannot follow. Guarded by " + + ZodReflectionFeature.SwitchName + + "; generated validators resolve members without it."; + + static readonly ConcurrentDictionary<(Type Type, string Name), PropertyInfo?> Properties = new(); + + /// + /// Gets whether is a key/value bag (a runtime Z.Object over a dictionary or + /// JSON) rather than a CLR object, so members have no owning object a field can be bound to. + /// + public static bool IsDictionaryBacked(object? value) => + value is IDictionary or IReadOnlyDictionary or JsonElement or JsonNode; + + public static void Locate( + IZodUntypedValidator validator, + object value, + ZodPath path, + out object? container, + out string memberName + ) + { + object? resolvedContainer; + string resolvedMember; + try + { + if ( + validator.TryResolveMember(value, path, out resolvedContainer, out resolvedMember) + && IsFieldOwner(resolvedContainer, value) + ) + { + container = resolvedContainer; + memberName = resolvedMember ?? string.Empty; + return; + } + } + catch (Exception) + { + resolvedContainer = null; + resolvedMember = string.Empty; + } + + container = value; + memberName = string.Empty; + + // The reflection walk is the fallback for a validator that does not resolve members itself (a runtime schema over + // a CLR type); it is removed when trimming, where the error stays on the nearest object the validator reached. + if (ZodReflectionFeature.IsSupported && Walk(value, path, out container, out memberName)) + return; + + // The validator followed part of the path itself: its nearest ancestor is better informed than ours - unless it + // is a struct, which a schema generated by an earlier 3.0 prerelease resolves a struct member to. + if (!string.IsNullOrEmpty(resolvedMember) && IsFieldOwner(resolvedContainer, value)) + { + container = resolvedContainer; + memberName = resolvedMember; + } + } + + // A struct cannot own a field: Blazor's FieldIdentifier rejects a value-type model, and a boxed copy is not the + // instance a form binds. A struct root has no reference owner, so its own members are the best answer there. + static bool IsFieldOwner(object? container, object root) => + container?.GetType().IsValueType != true || root.GetType().IsValueType; + + /// + /// Walks public instance properties along . Indexes step into lists, arrays and other + /// sequences; keys step into dictionaries by their invariant-rendered key. A path that ends in an index or a + /// key resolves to the owner of the collection member. A path that cannot be followed stops at the nearest + /// object reached. + /// + [RequiresUnreferencedCode(WalkRequirement)] + public static bool Walk(object root, ZodPath path, out object? container, out string memberName) + { + container = root; + memberName = string.Empty; + + var current = root; + foreach (var segment in path.Segments) + { + switch (segment.Kind) + { + case ZodPathSegmentKind.Member: + if (current is null) + return false; + + var name = segment.Name ?? string.Empty; + + // A struct cannot own a field (a boxed copy is not the instance a form binds), so a member of a + // struct stays attributed to the nearest reference-type owner and its member. + if (!current.GetType().IsValueType || memberName.Length == 0) + { + container = current; + memberName = name; + } + + var property = Properties.GetOrAdd((current.GetType(), name), static key => + FindProperty(key.Type, key.Name) + ); + if (property is null || !TryGetValue(property, current, out current)) + return false; + + break; + + case ZodPathSegmentKind.Index: + if (!TryGetElement(current, segment.Index, out current)) + return false; + + break; + + case ZodPathSegmentKind.Key: + default: + if (!TryGetEntry(current, segment.Name ?? string.Empty, out current)) + return false; + + break; + } + } + + return true; + } + + [RequiresUnreferencedCode(WalkRequirement)] + static PropertyInfo? FindProperty(Type type, string name) + { + try + { + var property = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); + return property is { CanRead: true } && property.GetIndexParameters().Length == 0 ? property : null; + } + catch (AmbiguousMatchException) + { + // A property hidden with 'new': take the most derived declaration. + foreach (var candidate in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if ( + string.Equals(candidate.Name, name, StringComparison.Ordinal) + && candidate.CanRead + && candidate.GetIndexParameters().Length == 0 + ) + return candidate; + } + + return null; + } + } + + static bool TryGetValue(PropertyInfo property, object instance, out object? value) + { + try + { + value = property.GetValue(instance); + return true; + } + catch (Exception) + { + value = null; + return false; + } + } + + static bool TryGetElement(object? sequence, int index, out object? element) + { + element = null; + switch (sequence) + { + case null: + case string: + return false; + case IList list: + if (index >= list.Count) + return false; + + element = list[index]; + return true; + case IEnumerable enumerable: + var position = 0; + foreach (var item in enumerable) + { + if (position++ == index) + { + element = item; + return true; + } + } + + return false; + default: + return false; + } + } + + static bool TryGetEntry(object? map, string key, out object? value) + { + value = null; + if (map is not IDictionary dictionary) + return false; + + foreach (DictionaryEntry entry in dictionary) + { + if (string.Equals(ZodPathSegment.Key(entry.Key).Name, key, StringComparison.Ordinal)) + { + value = entry.Value; + return true; + } + } + + return false; + } +} diff --git a/src/src/Extensions.Validation/ZodParameterValidationRequest.cs b/src/src/Extensions.Validation/ZodParameterValidationRequest.cs new file mode 100644 index 00000000..7b882f16 --- /dev/null +++ b/src/src/Extensions.Validation/ZodParameterValidationRequest.cs @@ -0,0 +1,8 @@ +namespace ZodSharp.Extensions.Validation; + +/// +/// Registered by AddZodSharpValidation() (and its validateParameters: true overload) to record that the +/// application asked for parameter validation, so the resolver claims parameters even when AddZodSharpBlazor(), +/// which does not, registered the bridge too. +/// +sealed class ZodParameterValidationRequest; diff --git a/src/src/Extensions.Validation/ZodReflectionFeature.cs b/src/src/Extensions.Validation/ZodReflectionFeature.cs new file mode 100644 index 00000000..7bb50761 --- /dev/null +++ b/src/src/Extensions.Validation/ZodReflectionFeature.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ZodSharp.Extensions.Validation; + +/// +/// The ZodSharp.Reflection.IsSupported feature switch, shared by every ZodSharp package, which guards the +/// reflection fallbacks that walk a model the library was not told about (here: placing an error on a field when the +/// validator does not resolve members itself, as generated validators do). +/// +/// +/// The property is a feature guard for : the trim analyzer accepts the +/// reflection it guards, and ILLink and ILC treat it as when trimming or compiling ahead of time, +/// so the fallback is removed rather than failing unpredictably on trimmed metadata. An untrimmed application keeps it +/// unless it sets the switch to . +/// +static class ZodReflectionFeature +{ + /// The feature switch name. + public const string SwitchName = "ZodSharp.Reflection.IsSupported"; + + /// Gets whether the reflection fallbacks may run. + [FeatureSwitchDefinition(SwitchName)] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + [UnconditionalSuppressMessage( + "Trimming", + "IL4000:FeatureGuard", + Justification = "A RequiresUnreferencedCode feature guard cannot be derived from another guard: none exists. ILLink and ILC " + + "treat this guard as false when trimming, which the trim verification harness proves (no IL2026 from the " + + "guarded calls)." + )] + public static bool IsSupported => !AppContext.TryGetSwitch(SwitchName, out var enabled) || enabled; +} diff --git a/src/src/Extensions.Validation/ZodResolverPlacement.cs b/src/src/Extensions.Validation/ZodResolverPlacement.cs new file mode 100644 index 00000000..ac9b6f4a --- /dev/null +++ b/src/src/Extensions.Validation/ZodResolverPlacement.cs @@ -0,0 +1,20 @@ +namespace ZodSharp.Extensions.Validation; + +/// +/// Where the ZodSharp resolver is placed in ValidationOptions.Resolvers. +/// +public enum ZodResolverPlacement +{ + /// + /// First, ahead of the Microsoft.Extensions.Validation generated (DataAnnotations) resolvers, so a type with + /// a ZodSharp validator is always validated by ZodSharp. + /// + First, + + /// + /// After the generated resolvers and before the runtime parameter resolvers they append, so a type the + /// Microsoft.Extensions.Validation generator knows is validated with DataAnnotations and ZodSharp handles + /// the rest. + /// + AfterGenerated, +} diff --git a/src/src/Extensions.Validation/ZodUnionValidatableTypeInfo.cs b/src/src/Extensions.Validation/ZodUnionValidatableTypeInfo.cs new file mode 100644 index 00000000..7b192969 --- /dev/null +++ b/src/src/Extensions.Validation/ZodUnionValidatableTypeInfo.cs @@ -0,0 +1,116 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Validation; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Validates a C# union that has no validator of its own by following its active case, the way +/// Microsoft.Extensions.Validation does: the case is reported under Value (Value.Name, +/// Value[0].Name) and validated by whichever resolver knows the case type. +/// +sealed class ZodUnionValidatableTypeInfo(Type unionType) : IValidatableTypeInfo +{ + const string ValueMember = "Value"; + + public bool TryFindProperty( + string propertyName, + ValidationOptions validationOptions, + [NotNullWhen(true)] out IValidatablePropertyInfo? validatablePropertyInfo + ) + { + validatablePropertyInfo = null; + return false; + } + + public void Validate(object? value, ValidateContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (value is not IUnion { Value: { } caseValue }) + return; + + ZodValidatableTypeInfo.ThrowIfTooDeep(context, unionType); + var prefix = context.CurrentValidationPath; + var casePath = string.IsNullOrEmpty(prefix) ? ValueMember : $"{prefix}.{ValueMember}"; + context.CurrentDepth++; + try + { + if (context.ValidationOptions.TryGetValidatableTypeInfo(caseValue.GetType(), out var caseInfo)) + { + context.CurrentValidationPath = casePath; + caseInfo.Validate(caseValue, context); + } + else if (caseValue is IEnumerable elements and not string) + { + var index = 0; + foreach (var element in elements) + { + if ( + element is not null + && context.ValidationOptions.TryGetValidatableTypeInfo(element.GetType(), out var elementInfo) + ) + { + context.CurrentValidationPath = ElementPath(casePath, index); + elementInfo.Validate(element, context); + } + + index++; + } + } + } + finally + { + context.CurrentDepth--; + context.CurrentValidationPath = prefix; + } + } + + public async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + if (value is not IUnion { Value: { } caseValue }) + return; + + ZodValidatableTypeInfo.ThrowIfTooDeep(context, unionType); + var prefix = context.CurrentValidationPath; + var casePath = string.IsNullOrEmpty(prefix) ? ValueMember : $"{prefix}.{ValueMember}"; + context.CurrentDepth++; + try + { + if (context.ValidationOptions.TryGetValidatableTypeInfo(caseValue.GetType(), out var caseInfo)) + { + context.CurrentValidationPath = casePath; + await caseInfo.ValidateAsync(caseValue, context, cancellationToken).ConfigureAwait(false); + } + else if (caseValue is IEnumerable elements and not string) + { + var index = 0; + foreach (var element in elements) + { + if ( + element is not null + && context.ValidationOptions.TryGetValidatableTypeInfo(element.GetType(), out var elementInfo) + ) + { + context.CurrentValidationPath = ElementPath(casePath, index); + await elementInfo.ValidateAsync(element, context, cancellationToken).ConfigureAwait(false); + } + + index++; + } + } + } + finally + { + context.CurrentDepth--; + context.CurrentValidationPath = prefix; + } + } + + static string ElementPath(string casePath, int index) => + string.Create(CultureInfo.InvariantCulture, $"{casePath}[{index}]"); +} diff --git a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs new file mode 100644 index 00000000..aaa39921 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs @@ -0,0 +1,273 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; + +namespace ZodSharp.Extensions.Validation; + +/// +/// The Microsoft.Extensions.Validation resolver for ZodSharp: supplies type and parameter validation for every +/// type that has a ZodSharp validator, so minimal APIs, Blazor Server and Blazor WebAssembly validate it with +/// ZodSharp. +/// +/// +/// +/// A type is looked up in first, then in +/// . A type found in neither is left to the next resolver (usually the +/// generated DataAnnotations resolver), except a C# union whose active case a ZodSharp or other validator knows. +/// +/// +/// Results are cached per type, including negative results. Register every validator before the first +/// validation: a validator registered with the factory after a type has been looked up is not seen. +/// +/// +public sealed class ZodValidatableInfoResolver : IValidatableInfoResolver +{ + readonly IZodSchemaFactory? _factory; + readonly ConcurrentDictionary _types = new(); + readonly ConditionalWeakTable _parameters = []; + ValidationOptions? _attachedTo; + + /// + /// Creates the resolver. + /// + /// The schema factory; to use only . + /// The bridge options; defaults when . + public ZodValidatableInfoResolver(IZodSchemaFactory? factory, ZodValidationOptions? options = null) + : this(factory, options, parametersRequested: true) { } + + internal ZodValidatableInfoResolver( + IZodSchemaFactory? factory, + ZodValidationOptions? options, + bool parametersRequested + ) + { + _factory = factory; + Options = options ?? new(); + ValidatesParameters = Options.ValidateParameters ?? parametersRequested; + } + + /// + /// Gets the bridge options. + /// + public ZodValidationOptions Options { get; } + + /// + /// Gets whether this resolver claims parameters, so Microsoft.Extensions.Validation's endpoint filter validates + /// ZodSharp-typed minimal-API parameters. See . + /// + public bool ValidatesParameters { get; } + + /// + /// Gets the ZodSharp validator for , from or + /// the factory. + /// + /// The validated type. + /// The validator, when one is registered. + /// when a validator is registered. + public bool TryGetValidator(Type type, [NotNullWhen(true)] out IZodUntypedValidator? validator) + { + ArgumentNullException.ThrowIfNull(type); + + foreach (var source in Options.Sources) + { + if (source.TryGetValidator(type, out validator) && validator is not null) + return true; + } + + if (_factory is not null && _factory.TryGetUntypedValidator(type, out validator) && validator is not null) + return true; + + validator = null; + return false; + } + + /// + public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableTypeInfo? validatableTypeInfo) + { + ArgumentNullException.ThrowIfNull(type); + + validatableTypeInfo = _types.GetOrAdd(type, static (key, self) => self.Create(key), this); + return validatableTypeInfo is not null; + } + + /// + /// + /// Claims a parameter whose type (a unwrapped) has a ZodSharp validator, or a + /// collection parameter whose element type has one. [SkipValidation] and [FromServices] + /// parameters, and primitive types, are never claimed, and no parameter is claimed when + /// is . + /// + public bool TryGetValidatableParameterInfo( + ParameterInfo parameterInfo, + [NotNullWhen(true)] out IValidatableParameterInfo? validatableParameterInfo + ) + { + ArgumentNullException.ThrowIfNull(parameterInfo); + + if (!ValidatesParameters) + { + validatableParameterInfo = null; + return false; + } + + validatableParameterInfo = _parameters.GetValue(parameterInfo, CreateParameter); + return validatableParameterInfo is not null; + } + + internal void AttachTo(ValidationOptions options) => _attachedTo = options; + + /// + /// Finds the type info the resolvers after this one would supply for . + /// + internal IValidatableTypeInfo? FindNextTypeInfo(Type type, ValidationOptions options) + { + var seen = false; + foreach (var resolver in options.Resolvers) + { + if (ReferenceEquals(resolver, this)) + { + seen = true; + continue; + } + + if (seen && resolver.TryGetValidatableTypeInfo(type, out var info)) + return info; + } + + return null; + } + + IValidatableTypeInfo? Create(Type type) + { + if (IsNeverValidated(type)) + return null; + + var lookup = Nullable.GetUnderlyingType(type) ?? type; + if (TryGetValidator(lookup, out var validator) || (lookup != type && TryGetValidator(type, out validator))) + { + ZodValidatableTypeInfo zod = new(validator, Options); + return Options.AlsoRunDataAnnotations ? new ZodCompositeValidatableTypeInfo(zod, this) : zod; + } + + // A union with no validator of its own: follow the active case, unless a later resolver (the + // Microsoft.Extensions.Validation generator) already knows the union. + if (typeof(IUnion).IsAssignableFrom(lookup)) + { + var attached = _attachedTo; + if (attached is null || FindNextTypeInfo(lookup, attached) is null) + return new ZodUnionValidatableTypeInfo(lookup); + } + + return null; + } + + IValidatableParameterInfo? CreateParameter(ParameterInfo parameter) + { + if (parameter.Name is null || IsExcluded(parameter)) + return null; + + var declared = parameter.ParameterType; + var type = Nullable.GetUnderlyingType(declared) ?? declared; + if (IsNeverValidated(type)) + return null; + + var allowsNull = AllowsNull(parameter); + var isCollection = type != typeof(string) && typeof(IEnumerable).IsAssignableFrom(type); + + if ( + TryGetValidatableTypeInfo(type, out var info) + && info is ZodValidatableTypeInfo or ZodCompositeValidatableTypeInfo + ) + return new ZodValidatableParameterInfo(parameter.Name, declared, allowsNull, isCollection, Unwrap(info)); + + // A C# union with no validator of its own: claim it here so the active case is validated even when the + // application never calls AddValidation() (which is what adds the runtime parameter resolver). + if (info is ZodUnionValidatableTypeInfo union) + return new ZodValidatableParameterInfo(parameter.Name, declared, allowsNull, false, null, union); + + // A conditional expression would nest the type, union, and collection lookups above. +#pragma warning disable IDE0046 // Convert to conditional expression + if (isCollection && GetElementType(type) is { } elementType && TryGetValidator(elementType, out _)) + return new ZodValidatableParameterInfo(parameter.Name, declared, allowsNull, true, null); +#pragma warning restore IDE0046 + + return null; + } + + static ZodValidatableTypeInfo Unwrap(IValidatableTypeInfo info) => + info as ZodValidatableTypeInfo ?? ((ZodCompositeValidatableTypeInfo)info).Zod; + + static bool IsNeverValidated(Type type) => + type.IsPrimitive + || type.IsEnum + || type.IsPointer + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(object) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(DateOnly) + || type == typeof(TimeOnly) + || type == typeof(TimeSpan) + || type == typeof(Guid) + || type == typeof(Uri) + || type == typeof(CancellationToken); + + static bool IsExcluded(ParameterInfo parameter) + { + if ( + parameter.IsDefined(typeof(SkipValidationAttribute), inherit: false) + || parameter.ParameterType.IsDefined(typeof(SkipValidationAttribute), inherit: false) + ) + return true; + + // [FromServices] / [FromKeyedServices] live in ASP.NET Core, which this package must not reference. + foreach (var attribute in parameter.GetCustomAttributesData()) + { + var name = attribute.AttributeType.Name; + if (name is "FromServicesAttribute" or "FromKeyedServicesAttribute") + return true; + } + + return false; + } + + static bool AllowsNull(ParameterInfo parameter) + { + if (parameter.HasDefaultValue || Nullable.GetUnderlyingType(parameter.ParameterType) is not null) + return true; + + if (parameter.ParameterType.IsValueType) + return false; + + try + { + NullabilityInfoContext nullability = new(); + return nullability.Create(parameter).WriteState == NullabilityState.Nullable; + } + catch (InvalidOperationException) + { + // Nullability metadata is unavailable (trimmed, or the feature switch is off): treat it as required. + return false; + } + } + + static Type? GetElementType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType(); + + if (collectionType.IsGenericType) + { + var arguments = collectionType.GetGenericArguments(); + if (arguments.Length == 1) + return arguments[0]; + } + + return null; + } +} diff --git a/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs b/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs new file mode 100644 index 00000000..5af60056 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs @@ -0,0 +1,159 @@ +using System.Collections; +using Microsoft.Extensions.Validation; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Validates a ZodSharp-validated method parameter (a minimal API argument), following +/// Microsoft.Extensions.Validation's parameter conventions exactly. +/// +/// +/// +/// A complex parameter is not prefixed: errors are keyed Name, +/// Address.Street. +/// A collection parameter prefixes each element with {parameter}[i]: +/// items[0].Name. +/// An error about the value itself (a missing body, a root refinement) is keyed by the +/// parameter name with no container. +/// +/// +public sealed class ZodValidatableParameterInfo : IValidatableParameterInfo +{ + readonly ZodValidatableTypeInfo? _typeInfo; + readonly IValidatableTypeInfo? _delegateInfo; + + internal ZodValidatableParameterInfo( + string name, + Type parameterType, + bool allowsNull, + bool isCollection, + ZodValidatableTypeInfo? typeInfo, + IValidatableTypeInfo? delegateInfo = null + ) + { + _delegateInfo = delegateInfo; + Name = name; + ParameterType = parameterType; + AllowsNull = allowsNull; + IsCollection = isCollection; + _typeInfo = typeInfo; + } + + /// + /// Gets the parameter name. + /// + public string Name { get; } + + /// + /// Gets the declared parameter type. + /// + public Type ParameterType { get; } + + /// + /// Gets whether is an acceptable argument (an optional or nullable parameter), in + /// which case a argument is not validated. + /// + public bool AllowsNull { get; } + + /// + /// Gets whether the parameter is a collection whose elements are validated individually. + /// + public bool IsCollection { get; } + + /// + public void Validate(object? value, ValidateContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (value is null && AllowsNull) + return; + + if (_delegateInfo is not null) + { + _delegateInfo.Validate(value, context); + return; + } + + if (_typeInfo is not null || value is not IEnumerable elements) + { + _typeInfo?.ValidateCore(value, context, Name, prefixWithKey: IsCollection); + return; + } + + var prefix = context.CurrentValidationPath; + var index = 0; + foreach (var element in elements) + { + if ( + element is not null + && context.ValidationOptions.TryGetValidatableTypeInfo(element.GetType(), out var info) + ) + { + context.CurrentValidationPath = ElementPath(prefix, index); + try + { + info.Validate(element, context); + } + finally + { + context.CurrentValidationPath = prefix; + } + } + + index++; + } + } + + /// + public async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + if (value is null && AllowsNull) + return; + + if (_delegateInfo is not null) + { + await _delegateInfo.ValidateAsync(value, context, cancellationToken).ConfigureAwait(false); + return; + } + + if (_typeInfo is not null || value is not IEnumerable elements) + { + if (_typeInfo is not null) + await _typeInfo + .ValidateCoreAsync(value, context, Name, cancellationToken, prefixWithKey: IsCollection) + .ConfigureAwait(false); + + return; + } + + var prefix = context.CurrentValidationPath; + var index = 0; + foreach (var element in elements) + { + if ( + element is not null + && context.ValidationOptions.TryGetValidatableTypeInfo(element.GetType(), out var info) + ) + { + context.CurrentValidationPath = ElementPath(prefix, index); + try + { + await info.ValidateAsync(element, context, cancellationToken).ConfigureAwait(false); + } + finally + { + context.CurrentValidationPath = prefix; + } + } + + index++; + } + } + + string ElementPath(string prefix, int index) => + string.IsNullOrEmpty(prefix) + ? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"{Name}[{index}]") + : string.Create(System.Globalization.CultureInfo.InvariantCulture, $"{prefix}.{Name}[{index}]"); +} diff --git a/src/src/Extensions.Validation/ZodValidatablePropertyInfo.cs b/src/src/Extensions.Validation/ZodValidatablePropertyInfo.cs new file mode 100644 index 00000000..ace047c1 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatablePropertyInfo.cs @@ -0,0 +1,92 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Validates one member of a ZodSharp-validated type on its own, for Blazor field-level validation. +/// +/// +/// Runs only the member's own rules (see ): no nested-schema recursion, no +/// type-level rules and no refinements. Blazor attributes every error of a field-level run to the field that +/// changed, so recursing into a complex member would pin all of its nested errors onto that one field. +/// +public sealed class ZodValidatablePropertyInfo : IValidatablePropertyInfo +{ + readonly ZodValidatableTypeInfo _owner; + + internal ZodValidatablePropertyInfo(ZodValidatableTypeInfo owner, string memberName) + { + _owner = owner; + MemberName = memberName; + } + + /// + /// Gets the C# member name. + /// + public string MemberName { get; } + + /// + /// The member has rules that only run asynchronously. + public void Validate(object containingObject, ValidateContext context) + { + ArgumentNullException.ThrowIfNull(containingObject); + ArgumentNullException.ThrowIfNull(context); + + var pending = _owner.Validator.ValidateMemberAsync( + containingObject, + MemberName, + context.ServiceProvider, + default + ); + if (!pending.IsCompleted) + throw new InvalidOperationException( + $"'{_owner.ValidatedType.FullName}.{MemberName}' has asynchronous validation rules, so it cannot be " + + "validated synchronously. Use ValidateAsync." + ); + + var errors = pending.IsCompletedSuccessfully ? pending.Result : pending.AsTask().GetAwaiter().GetResult(); + ZodErrorEmitter.Emit(_owner.Validator, containingObject, errors, context, _owner.Options); + } + + /// + public Task ValidateAsync(object containingObject, ValidateContext context, CancellationToken cancellationToken) + { + ValueTask> pending; + try + { + ArgumentNullException.ThrowIfNull(containingObject); + ArgumentNullException.ThrowIfNull(context); + + pending = _owner.Validator.ValidateMemberAsync( + containingObject, + MemberName, + context.ServiceProvider, + cancellationToken + ); + if (pending.IsCompletedSuccessfully) + { + ZodErrorEmitter.Emit(_owner.Validator, containingObject, pending.Result, context, _owner.Options); + return Task.CompletedTask; + } + } + catch (Exception ex) + { + return Task.FromException(ex); + } + + return AwaitAsync(pending, containingObject, context); + } + + async Task AwaitAsync( + ValueTask> pending, + object containingObject, + ValidateContext context + ) + { + var errors = await pending.ConfigureAwait(false); + ZodErrorEmitter.Emit(_owner.Validator, containingObject, errors, context, _owner.Options); + } +} diff --git a/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs b/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs new file mode 100644 index 00000000..78168c9b --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Adapts a ZodSharp validator to , so Microsoft.Extensions.Validation hosts +/// (minimal APIs, Blazor) validate the type with ZodSharp. +/// +/// +/// +/// ZodSharp validates the whole object graph itself, so this adapter does not recurse through +/// Microsoft.Extensions.Validation for nested members. Every ZodSharp error becomes one +/// Microsoft.Extensions.Validation.ValidationError: Path is the error path prefixed with +/// , and Container/Name are the object that +/// owns the failing member and the member's name, which is what Blazor needs to place the message on a field. +/// +/// +/// Like Microsoft.Extensions.Validation's own type info, it throws when +/// has reached . +/// +/// +public sealed class ZodValidatableTypeInfo : IValidatableTypeInfo +{ + readonly ConcurrentDictionary _properties = new(StringComparer.Ordinal); + + /// + /// Creates the adapter. + /// + /// The validator. + /// The bridge options; defaults when . + public ZodValidatableTypeInfo(IZodUntypedValidator validator, ZodValidationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(validator); + + Validator = validator; + Options = options ?? new(); + } + + /// + /// Gets the wrapped validator. + /// + public IZodUntypedValidator Validator { get; } + + /// + /// Gets the validated type. + /// + public Type ValidatedType => Validator.ValidatedType; + + internal ZodValidationOptions Options { get; } + + /// + /// + /// Returns an adapter only for members the validator can validate on their own (generated validators mark + /// them; see ). Field-level validation runs that member's own rules - + /// never nested-schema recursion or type-level rules, because Blazor attributes every error of a field-level + /// run to the changed field. + /// + public bool TryFindProperty( + string propertyName, + ValidationOptions validationOptions, + [NotNullWhen(true)] out IValidatablePropertyInfo? validatablePropertyInfo + ) + { + ArgumentNullException.ThrowIfNull(propertyName); + + var property = _properties.GetOrAdd( + propertyName, + static (name, self) => self.Validator.IsValidatableMember(name) ? new(self, name) : null, + this + ); + + validatablePropertyInfo = property; + return property is not null; + } + + /// + /// + /// The validator has rules that only run asynchronously, so a synchronous run cannot give a complete answer. + /// Use . + /// + public void Validate(object? value, ValidateContext context) => ValidateCore(value, context, valueKey: null); + + /// + public Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken) => + ValidateCoreAsync(value, context, valueKey: null, cancellationToken); + + internal void ValidateCore(object? value, ValidateContext context, string? valueKey, bool prefixWithKey = false) + { + ArgumentNullException.ThrowIfNull(context); + + ThrowIfTooDeep(context, ValidatedType); + if (Validator.RequiresAsyncValidation) + throw new InvalidOperationException( + $"'{ValidatedType.FullName}' has asynchronous validation rules, so it cannot be validated " + + "synchronously. Use ValidateAsync (EditContext.ValidateAsync in Blazor)." + ); + + var errors = value is null ? NullValue() : Validator.Validate(value); + ZodErrorEmitter.Emit(Validator, value, errors, context, Options, valueKey, prefixWithKey); + } + + internal Task ValidateCoreAsync( + object? value, + ValidateContext context, + string? valueKey, + CancellationToken cancellationToken, + bool prefixWithKey = false + ) + { + ValueTask> pending; + try + { + ArgumentNullException.ThrowIfNull(context); + ThrowIfTooDeep(context, ValidatedType); + + if (value is null) + { + ZodErrorEmitter.Emit(Validator, value, NullValue(), context, Options, valueKey, prefixWithKey); + return Task.CompletedTask; + } + + pending = Validator.ValidateAsync(value, context.ServiceProvider, cancellationToken); + if (pending.IsCompletedSuccessfully) + { + ZodErrorEmitter.Emit(Validator, value, pending.Result, context, Options, valueKey, prefixWithKey); + return Task.CompletedTask; + } + } + catch (Exception ex) + { + return Task.FromException(ex); + } + + return AwaitAsync(pending, value, context, valueKey, prefixWithKey); + } + + async Task AwaitAsync( + ValueTask> pending, + object? value, + ValidateContext context, + string? valueKey, + bool prefixWithKey + ) + { + var errors = await pending.ConfigureAwait(false); + ZodErrorEmitter.Emit(Validator, value, errors, context, Options, valueKey, prefixWithKey); + } + + // A null value has nothing to validate members on: report it rather than hand null to a validator that may + // not expect it. Optional and nullable parameters never get here (see ZodValidatableParameterInfo.AllowsNull). + ImmutableArray NullValue() => [ZodIssues.NullValue(ZodTypeNames.Of(ValidatedType))]; + + internal static void ThrowIfTooDeep(ValidateContext context, Type type) + { + // Same check and message as Microsoft.Extensions.Validation's generated type info. + if (context.CurrentDepth >= context.ValidationOptions.MaxDepth) + throw new InvalidOperationException( + $"Maximum validation depth of {context.ValidationOptions.MaxDepth} exceeded at " + + $"'{context.CurrentValidationPath}' in '{type.Name}'. " + + "This is likely caused by a circular reference in the object graph. " + + "Consider increasing the MaxDepth in ValidationOptions if deeper validation is required." + ); + } +} diff --git a/src/src/Extensions.Validation/ZodValidationOptions.cs b/src/src/Extensions.Validation/ZodValidationOptions.cs new file mode 100644 index 00000000..1b1293a3 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidationOptions.cs @@ -0,0 +1,61 @@ +using ZodSharp.Core; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Configures how ZodSharp participates in Microsoft.Extensions.Validation. +/// +public sealed class ZodValidationOptions +{ + /// + /// Gets or sets where the ZodSharp resolver is placed relative to the generated DataAnnotations resolvers. + /// Defaults to . + /// + public ZodResolverPlacement Placement { get; set; } = ZodResolverPlacement.First; + + /// + /// Gets or sets whether a type that has a ZodSharp validator is also validated by the next resolver that + /// knows it (usually the generated DataAnnotations resolver), ZodSharp first, with identical + /// (Path, ErrorMessage) pairs reported once. Defaults to : generated ZodSharp + /// validators already apply DataAnnotations, so running both duplicates every message. + /// + public bool AlsoRunDataAnnotations { get; set; } + + /// + /// Gets or sets how member names are rendered in ValidationError.Path. (the + /// default) renders C# member names, which is what Microsoft.Extensions.Validation itself produces, so a + /// ZodSharp type and a DataAnnotations type in the same API produce the same key shape. Set, for example, + /// ZodPathNamer.FromFunc(JsonNamingPolicy.CamelCase.ConvertName) for JSON-shaped keys. Blazor ignores + /// Path, so this never affects field placement. + /// + public IZodPathNamer? PathNamer { get; set; } + + /// + /// Gets or sets the formatter that produces ValidationError.ErrorMessage. When + /// (the default), a is used whenever the validation's + /// service provider resolves an IStringLocalizerFactory (the same trigger Microsoft.Extensions.Validation + /// uses), and the error's own message otherwise. + /// + public IZodMessageFormatter? MessageFormatter { get; set; } + + /// + /// Gets or sets whether the ZodSharp resolver claims parameters (minimal-API handler parameters and other + /// parameter-level validation). Microsoft.Extensions.Validation's endpoint filter validates every parameter a + /// registered resolver claims, before the handler runs, so claiming them turns on minimal-API validation for every + /// ZodSharp type in the application. + /// + /// + /// (the default) decides by registration: parameters are claimed when the application calls + /// AddZodSharpValidation(), and are not when the bridge was registered only by + /// AddZodSharpBlazor(), which needs type validation for forms but should not change how the host's APIs + /// behave. Set or to decide explicitly. A resolver constructed + /// directly, outside dependency injection, claims parameters unless this is . Endpoints can + /// also opt out one at a time with DisableValidation(). + /// + public bool? ValidateParameters { get; set; } + + /// + /// Gets explicit, trim-safe validator sources, consulted before . + /// + public IList Sources { get; } = []; +} diff --git a/src/src/Extensions.Validation/ZodValidationOptionsSetup.cs b/src/src/Extensions.Validation/ZodValidationOptionsSetup.cs new file mode 100644 index 00000000..2244332f --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidationOptionsSetup.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; + +namespace ZodSharp.Extensions.Validation; + +/// +/// Places the ZodSharp resolver in . +/// +/// +/// Runs as a post-configure step, after every AddValidation configuration. The +/// Microsoft.Extensions.Validation generator's interceptor inserts its generated resolver at index 0 inside its +/// own configure step, so inserting afterwards is what keeps ZodSharp first regardless of whether the +/// application calls AddValidation() before, after, or not at all. +/// +sealed class ZodValidationOptionsSetup(ZodValidatableInfoResolver resolver) : IPostConfigureOptions +{ + const string RuntimeParameterResolverName = "RuntimeValidatableParameterInfoResolver"; + + public void PostConfigure(string? name, ValidationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var resolvers = options.Resolvers; + if (!resolvers.Contains(resolver)) + { + if (resolver.Options.Placement == ZodResolverPlacement.AfterGenerated) + resolvers.Insert(FindAfterGeneratedIndex(resolvers), resolver); + else + resolvers.Insert(0, resolver); + } + + resolver.AttachTo(options); + + // Generated validators recurse internally with their own depth guard; keep it in step with the + // pipeline's so a cyclic graph fails at the same depth however it is validated. + ZodValidationDepth.MaxDepth = options.MaxDepth; + } + + static int FindAfterGeneratedIndex(IList resolvers) + { + // The generator inserts its type resolvers at the front and appends its runtime parameter resolvers; + // "after generated" is therefore just ahead of the first runtime parameter resolver. + for (var i = 0; i < resolvers.Count; i++) + { + // The generated classes are file-local, so their runtime names carry a file-scoped prefix. + if (resolvers[i].GetType().Name.EndsWith(RuntimeParameterResolverName, StringComparison.Ordinal)) + return i; + } + + return resolvers.Count; + } +} diff --git a/src/src/Extensions.Validation/ZodValidationReport.cs b/src/src/Extensions.Validation/ZodValidationReport.cs new file mode 100644 index 00000000..4e05180a --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidationReport.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +/// +/// The full-fidelity ZodSharp errors produced during one Microsoft.Extensions.Validation run. The +/// Microsoft.Extensions.Validation error carries only a name, path, message and container, so codes, +/// categories, origins, bounds and parameters travel here instead. +/// +/// +/// Error paths are canonical: each error's already includes the +/// ValidateContext.CurrentValidationPath it was reported under, so it renders to the same key as the +/// matching Microsoft.Extensions.Validation.ValidationError.Path. +/// +public sealed class ZodValidationReport +{ + readonly Lock _lock = new(); + ImmutableArray _errors = []; + + /// + /// Creates an empty report. + /// + public ZodValidationReport() { } + + /// + /// Creates a report holding . + /// + /// The errors. + public ZodValidationReport(IEnumerable errors) + { + ArgumentNullException.ThrowIfNull(errors); + _errors = [.. errors]; + } + + /// + /// Gets the errors, in the order they were reported. + /// + public IReadOnlyList Errors => _errors; + + /// + /// Gets whether any error was reported. + /// + public bool HasErrors => !_errors.IsEmpty; + + internal ImmutableHashSet Keys { get; private set; } = + ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); + + internal void Add(ImmutableArray errors, IEnumerable keys) + { + lock (_lock) + { + _errors = _errors.AddRange(errors); + Keys = Keys.Union(keys); + } + } +} diff --git a/src/src/Http/Extensions/System/Net/Http/ZodHttpClientExtensions.cs b/src/src/Http/Extensions/System/Net/Http/ZodHttpClientExtensions.cs new file mode 100644 index 00000000..3a0e89c0 --- /dev/null +++ b/src/src/Http/Extensions/System/Net/Http/ZodHttpClientExtensions.cs @@ -0,0 +1,349 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Core; +using ZodSharp.Http; + +namespace System.Net.Http; + +/// +/// Sends validated JSON request bodies with ZodSharp: the value is validated first and the request is only sent +/// when it is valid. +/// +/// +/// +/// Each method returns a failed , and sends nothing, when the value is invalid; otherwise +/// it returns the , whatever its status. A rejected request's validation problem is +/// read with TryReadZodValidationProblemAsync. Safe under trimming, Native AOT and Blazor WebAssembly. +/// +/// +/// The overloads that take an and an run +/// service-aware rules too (an async uniqueness check that resolves a repository, for example); pass a component's +/// injected in Blazor, or the request services on a server. +/// +/// +public static class ZodHttpClientExtensions +{ + extension(HttpClient client) + { + /// Validates and, when it is valid, POSTs it as JSON. + /// The validated type. + /// The URI the request is sent to. + /// The value to validate and send. + /// The schema to validate with. + /// The contract for . + /// Cancels the validation and the request. + /// The response, or the validation errors when nothing was sent. + public Task> PostAsJsonValidatedAsync( + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + T value, + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Post, + CreateUri(requestUri), + value, + schema, + null, + null, + jsonTypeInfo, + cancellationToken + ); + + /// + public Task> PostAsJsonValidatedAsync( + Uri? requestUri, + T value, + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Post, + requestUri, + value, + schema, + null, + null, + jsonTypeInfo, + cancellationToken + ); + + /// + /// Validates , running service-aware rules with , and, when it + /// is valid, POSTs it as JSON. + /// + /// The validated type. + /// The URI the request is sent to. + /// The value to validate and send. + /// The validator, typically a generated {TypeName}SchemaValidator. + /// The contract for . + /// The services service-aware rules resolve from. + /// Cancels the validation and the request. + /// The response, or the validation errors when nothing was sent. + public Task> PostAsJsonValidatedAsync( + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + T value, + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Post, + CreateUri(requestUri), + value, + validator, + validator, + services, + jsonTypeInfo, + cancellationToken + ); + + /// + public Task> PostAsJsonValidatedAsync( + Uri? requestUri, + T value, + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Post, + requestUri, + value, + validator, + validator, + services, + jsonTypeInfo, + cancellationToken + ); + + /// Validates and, when it is valid, PUTs it as JSON. + /// + public Task> PutAsJsonValidatedAsync( + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + T value, + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Put, + CreateUri(requestUri), + value, + schema, + null, + null, + jsonTypeInfo, + cancellationToken + ); + + /// Validates and, when it is valid, PUTs it as JSON. + /// + public Task> PutAsJsonValidatedAsync( + Uri? requestUri, + T value, + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Put, + requestUri, + value, + schema, + null, + null, + jsonTypeInfo, + cancellationToken + ); + + /// + /// Validates , running service-aware rules with , and, when it + /// is valid, PUTs it as JSON. + /// + /// + public Task> PutAsJsonValidatedAsync( + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + T value, + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Put, + CreateUri(requestUri), + value, + validator, + validator, + services, + jsonTypeInfo, + cancellationToken + ); + + /// + /// Validates , running service-aware rules with , and, when it + /// is valid, PUTs it as JSON. + /// + /// + public Task> PutAsJsonValidatedAsync( + Uri? requestUri, + T value, + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Put, + requestUri, + value, + validator, + validator, + services, + jsonTypeInfo, + cancellationToken + ); + + /// Validates and, when it is valid, PATCHes it as JSON. + /// + public Task> PatchAsJsonValidatedAsync( + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + T value, + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Patch, + CreateUri(requestUri), + value, + schema, + null, + null, + jsonTypeInfo, + cancellationToken + ); + + /// Validates and, when it is valid, PATCHes it as JSON. + /// + public Task> PatchAsJsonValidatedAsync( + Uri? requestUri, + T value, + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Patch, + requestUri, + value, + schema, + null, + null, + jsonTypeInfo, + cancellationToken + ); + + /// + /// Validates , running service-aware rules with , and, when it + /// is valid, PATCHes it as JSON. + /// + /// + public Task> PatchAsJsonValidatedAsync( + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + T value, + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Patch, + CreateUri(requestUri), + value, + validator, + validator, + services, + jsonTypeInfo, + cancellationToken + ); + + /// + /// Validates , running service-aware rules with , and, when it + /// is valid, PATCHes it as JSON. + /// + /// + public Task> PatchAsJsonValidatedAsync( + Uri? requestUri, + T value, + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + SendValidatedAsync( + client, + HttpMethod.Patch, + requestUri, + value, + validator, + validator, + services, + jsonTypeInfo, + cancellationToken + ); + } + + // HttpClient's own string overloads convert the same way: an empty string is "no URI" (BaseAddress alone). + static Uri? CreateUri(string? uri) => string.IsNullOrEmpty(uri) ? null : new Uri(uri, UriKind.RelativeOrAbsolute); + + static async Task> SendValidatedAsync( + HttpClient client, + HttpMethod method, + Uri? requestUri, + T value, + IZodSchema schema, + IZodSchemaValidator? servicesValidator, + IServiceProvider? services, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(client); + + var content = await ZodJsonContent + .CreateCoreAsync( + schema, + servicesValidator, + services, + value, + jsonTypeInfo, + mediaType: null, + cancellationToken + ) + .ConfigureAwait(false); + if (!content.IsSuccess) + return ValidationResult.Failure(content.Errors); + + // Disposing the request disposes the body; the response's content is already buffered (ResponseContentRead). + using HttpRequestMessage request = new(method, requestUri) { Content = content.Value }; + var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return ValidationResult.Success(response); + } +} diff --git a/src/src/Http/Extensions/System/Net/Http/ZodHttpContentExtensions.cs b/src/src/Http/Extensions/System/Net/Http/ZodHttpContentExtensions.cs new file mode 100644 index 00000000..75dc6904 --- /dev/null +++ b/src/src/Http/Extensions/System/Net/Http/ZodHttpContentExtensions.cs @@ -0,0 +1,87 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using ZodSharp; +using ZodSharp.Core; + +namespace System.Net.Http; + +/// +/// Reads and validates JSON HTTP bodies with ZodSharp. +/// +public static class ZodHttpContentExtensions +{ + extension(HttpContent content) + { + /// + /// Deserializes the JSON body and validates it, in one pass. A body that cannot be read as + /// (malformed JSON, a document, a value the serializer cannot + /// convert) is an invalid_json error rather than an exception, exactly as + /// DeserializeAndValidateAsync reports it. + /// + /// The validated type. + /// The schema to validate with. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// Cancels the read and the validation. + /// The validated value, or the errors. + /// + /// Safe under trimming, Native AOT and Blazor WebAssembly. The body is read as UTF-8, the encoding JSON + /// exchanged over a network must use (RFC 8259). + /// + public async Task> ReadFromJsonAndValidateAsync( + IZodSchema schema, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + var stream = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await schema + .DeserializeAndValidateAsync(stream, jsonTypeInfo, cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// Deserializes the JSON body and validates it with a validator that has access to + /// , so service-aware rules run. Unreadable bodies are invalid_json errors, + /// as for the overload without services. + /// + /// The validated type. + /// The validator, typically a generated {TypeName}SchemaValidator. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// The services service-aware rules resolve from. + /// Cancels the read and the validation. + /// The validated value, or the errors. + /// Safe under trimming, Native AOT and Blazor WebAssembly. The body is read as UTF-8. + public async Task> ReadFromJsonAndValidateAsync( + IZodSchemaValidator validator, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(validator); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + var stream = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await validator + .DeserializeAndValidateAsync(stream, jsonTypeInfo, services, cancellationToken) + .ConfigureAwait(false); + } + } + } +} diff --git a/src/src/Http/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs b/src/src/Http/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs new file mode 100644 index 00000000..99850888 --- /dev/null +++ b/src/src/Http/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs @@ -0,0 +1,69 @@ +using System.Net.Http.Json; +using System.Text.Json; +using ZodSharp.Http; + +namespace System.Net.Http; + +/// +/// Reads ZodSharp validation problems from HTTP responses. +/// +public static class ZodHttpResponseMessageExtensions +{ + extension(HttpResponseMessage response) + { + /// + /// Reads the response as a validation problem when it is one: an unsuccessful response whose JSON body carries + /// errors or issues. Trim- and AOT-safe (source-generated JSON metadata). + /// + /// A token that cancels reading. + /// The problem, or when the response is not a validation problem. + /// + /// + /// var response = await http.PostAsJsonAsync("/orders", order); + /// if (await response.TryReadZodValidationProblemAsync() is { } problem) + /// editContext.AddZodProblemDetails(problem); + /// + /// + public async Task TryReadZodValidationProblemAsync( + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(response); + + if (response.IsSuccessStatusCode || response.Content is null) + return null; + + var mediaType = response.Content.Headers.ContentType?.MediaType; + if ( + mediaType is null + || !( + mediaType.Equals("application/problem+json", StringComparison.OrdinalIgnoreCase) + || mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) + || mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) + ) + ) + return null; + + ZodValidationProblemDetails? problem; + try + { + problem = await response + .Content.ReadFromJsonAsync( + ZodHttpJsonContext.Default.ZodValidationProblemDetails, + cancellationToken + ) + .ConfigureAwait(false); + } + catch (JsonException) + { + return null; + } + + if (problem is null || (problem.Errors is null && problem.Issues is null)) + return null; + + problem.Status ??= (int)response.StatusCode; + return problem; + } + } +} diff --git a/src/src/Http/Http.csproj b/src/src/Http/Http.csproj new file mode 100644 index 00000000..a6f3b915 --- /dev/null +++ b/src/src/Http/Http.csproj @@ -0,0 +1,37 @@ + + + + true + ZodSharp.Http + + Purview.ZodSharp.Http + Purview.ZodSharp.Http + true + true + + HttpClient integration for ZodSharp: validates request bodies before they are sent and response bodies as they + are read, and reads the validation problems Purview.ZodSharp.AspNetCore writes back into ZodSharp errors. + Trim-, Native AOT- and Blazor WebAssembly-safe. + + zod;validation;schema;http;httpclient;problemdetails;webassembly + + true + + + + + + + + + + + + + diff --git a/src/src/Http/Sdk/README.md b/src/src/Http/Sdk/README.md new file mode 100644 index 00000000..905101e3 --- /dev/null +++ b/src/src/Http/Sdk/README.md @@ -0,0 +1,145 @@ +# Purview.ZodSharp.Http + +[![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.Http.svg)](https://www.nuget.org/packages/Purview.ZodSharp.Http) +[![Release](https://github.com/purview-dev/zodsharp/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/zodsharp/actions/workflows/release.yml) + +`HttpClient` integration for [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.svg)](https://www.nuget.org/packages/Purview.ZodSharp). Validate request bodies before they are sent, validate response bodies as they are read, and turn the validation problems [Purview.ZodSharp.AspNetCore](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore) writes back into ZodSharp errors - with the same codes, typed paths and bounds on both sides of the wire. + +- **Where it runs:** Blazor WebAssembly, Blazor Server, MAUI, console apps and server-to-server calls. It targets + `net10.0` and `net11.0` and references only the base framework (never the ASP.NET Core shared framework). +- **Trim-, Native AOT- and WebAssembly-safe:** every API takes a `JsonTypeInfo` from a source-generated + `JsonSerializerContext`; there are deliberately no reflection-based `JsonSerializerOptions` overloads. + +## Installation + +```bash +dotnet add package Purview.ZodSharp.Http +``` + +## Contracts + +```csharp +[ZodSchema] +public partial class Order +{ + [MinLengthZod(3)] + public string Reference { get; set; } = string.Empty; + + [MinValue(1)] + public int Quantity { get; set; } +} + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(Order))] +partial class AppJsonContext : JsonSerializerContext; +``` + +## Send a validated body + +The value is validated first (async refinements included); an invalid value returns the errors and **nothing is +sent**. A valid value returns the response, whatever its status. + +```csharp +var sent = await http.PostAsJsonValidatedAsync( + "api/orders", order, new OrderSchemaValidator(), AppJsonContext.Default.Order); + +if (!sent.IsSuccess) + return sent.Errors; // the request was never made + +using var response = sent.Value!; // whatever the server answered +``` + +`PutAsJsonValidatedAsync` and `PatchAsJsonValidatedAsync` work the same way (each with `string?` and `Uri?` overloads), +and `ZodJsonContent.Create`/`CreateAsync` build the validated `JsonContent` for any other request. + +### Service-aware rules + +Rules that need services - an async uniqueness check that resolves a repository or calls an API - run when you pass a +generated `{TypeName}SchemaValidator` and an `IServiceProvider`: + +```csharp +var sent = await http.PostAsJsonValidatedAsync( + "api/customers", customer, new RegisterCustomerRequestSchemaValidator(), + AppJsonContext.Default.RegisterCustomerRequest, services, cancellationToken); +``` + +Pass a component's injected `IServiceProvider` in Blazor (the browser's services in WebAssembly, the circuit's on the +server), or the request services on a server. Every send method, `ZodJsonContent.CreateAsync` and +`ReadFromJsonAndValidateAsync` has this overload. A generated validator with a service-aware rule throws +`InvalidOperationException` when it is used without services, so the rule is never skipped silently. + +## Read a validated body + +```csharp +var result = await response.Content.ReadFromJsonAndValidateAsync( + new OrderSchemaValidator(), AppJsonContext.Default.Order); +``` + +A body that cannot be read as the type (malformed JSON, a `null` document, a value the serializer cannot convert) is an +`invalid_json` error rather than an exception, exactly as `DeserializeAndValidateAsync` in +[Purview.ZodSharp.SystemTextJson](https://www.nuget.org/packages/Purview.ZodSharp.SystemTextJson) reports it. The body +is read as UTF-8, the encoding JSON exchanged over a network must use. + +## Read a server validation problem + +```csharp +if (await response.TryReadZodValidationProblemAsync() is { } problem) +{ + var errors = problem.ToValidationErrors(); // codes, typed paths, bounds, parameters, union options + var result = problem.ToValidationResult(); // or as a failed result +} +``` + +`TryReadZodValidationProblemAsync` returns `null` for anything that is not a validation problem: a successful response, a +non-JSON body, or a ProblemDetails without `errors`/`issues`. It reads both the full ZodSharp contract (`issues`) and a +plain ASP.NET Core `ValidationProblemDetails` (`errors` only); `Status` falls back to the response status. The types +(`ZodValidationProblemDetails`, `ZodValidationProblemIssue`) are in `ZodSharp.Http`; the extensions are in +`System.Net.Http`. + +## A typed client + +The pattern the repository's Blazor sample uses: one class for every caller, so the same rules guard the request in the +browser and on the server. + +```csharp +sealed class CustomerAPIClient(HttpClient http, IServiceProvider services) +{ + public async Task> RegisterAsync( + RegisterCustomerRequest request, CancellationToken cancellationToken = default) + { + var sent = await http.PostAsJsonValidatedAsync("api/customers", request, + new RegisterCustomerRequestSchemaValidator(), AppJsonContext.Default.RegisterCustomerRequest, + services, cancellationToken); + if (!sent.IsSuccess) + return ValidationResult.Failure(sent.Errors); // rejected before sending + + using var response = sent.Value!; + if (await response.TryReadZodValidationProblemAsync(cancellationToken) is { } problem) + return problem.ToValidationResult(); // rejected by the server + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAndValidateAsync( + new CustomerRegisteredSchemaValidator(), AppJsonContext.Default.CustomerRegistered, + cancellationToken); + } +} + +// Blazor WebAssembly: the browser's HttpClient and services. +builder.Services.AddScoped(); + +// A server (Blazor Server, a worker, another API): a typed client from IHttpClientFactory. +builder.Services.AddHttpClient(http => http.BaseAddress = new("https://api.example.com/")); +``` + +In Blazor, [Purview.ZodSharp.Blazor](https://www.nuget.org/packages/Purview.ZodSharp.Blazor) puts either kind of +rejection on the form's fields: `editContext.AddZodErrors(errors)`, or `editContext.AddZodProblemDetails(problem)`. + +## Documentation + +- [HttpClient Integration](https://purview.dev/docs/zodsharp/http-integration/) +- [Homepage](https://purview.dev/projects/zodsharp/) +- [Documentation](https://purview.dev/docs/zodsharp/) + +## License + +MIT — see the package metadata in `Purview.ZodSharp.Http` on NuGet. diff --git a/src/src/Http/ZodJsonContent.cs b/src/src/Http/ZodJsonContent.cs new file mode 100644 index 00000000..56f1c1de --- /dev/null +++ b/src/src/Http/ZodJsonContent.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Headers; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Core; + +namespace ZodSharp.Http; + +/// +/// Creates request bodies from validated values, so an invalid value is never sent. +/// +/// +/// Safe under trimming, Native AOT and Blazor WebAssembly: the contract always comes from a supplied +/// , typically from a source-generated . +/// +public static class ZodJsonContent +{ + /// + /// Validates and, when it is valid, creates a JSON request body for the validated value. + /// + /// The validated type. + /// The schema to validate with. + /// The value to validate and send. + /// The contract for . + /// The media type of the body; application/json; charset=utf-8 when omitted. + /// The request body, or the validation errors. + public static ValidationResult Create( + IZodSchema schema, + T value, + JsonTypeInfo jsonTypeInfo, + MediaTypeHeaderValue? mediaType = null + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + return ToContent(schema.Validate(value), jsonTypeInfo, mediaType); + } + + /// + /// Validates (running async refinements) and, when it is valid, creates a JSON request + /// body for the validated value. + /// + /// The validated type. + /// The schema to validate with. + /// The value to validate and send. + /// The contract for . + /// The media type of the body; application/json; charset=utf-8 when omitted. + /// Cancels the validation. + /// The request body, or the validation errors. + public static ValueTask> CreateAsync( + IZodSchema schema, + T value, + JsonTypeInfo jsonTypeInfo, + MediaTypeHeaderValue? mediaType = null, + CancellationToken cancellationToken = default + ) => CreateCoreAsync(schema, null, null, value, jsonTypeInfo, mediaType, cancellationToken); + + /// + /// Validates with a validator that has access to , so + /// service-aware rules run (for example an async uniqueness check that resolves a repository), and, when it is + /// valid, creates a JSON request body for the validated value. + /// + /// The validated type. + /// The validator, typically a generated {TypeName}SchemaValidator. + /// The value to validate and send. + /// The contract for . + /// + /// The services service-aware rules resolve from: a component's injected in Blazor, + /// the request services on a server. + /// + /// The media type of the body; application/json; charset=utf-8 when omitted. + /// Cancels the validation. + /// The request body, or the validation errors. + public static ValueTask> CreateAsync( + IZodSchemaValidator validator, + T value, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + MediaTypeHeaderValue? mediaType = null, + CancellationToken cancellationToken = default + ) => CreateCoreAsync(validator, validator, services, value, jsonTypeInfo, mediaType, cancellationToken); + + /// + /// The one validation path: , when supplied, validates with + /// ; otherwise validates without them. + /// + internal static async ValueTask> CreateCoreAsync( + IZodSchema schema, + IZodSchemaValidator? servicesValidator, + IServiceProvider? services, + T value, + JsonTypeInfo jsonTypeInfo, + MediaTypeHeaderValue? mediaType, + CancellationToken cancellationToken + ) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + var result = servicesValidator is null + ? await schema.ValidateAsync(value, cancellationToken).ConfigureAwait(false) + : await servicesValidator.ValidateAsync(value, services, cancellationToken).ConfigureAwait(false); + + return ToContent(result, jsonTypeInfo, mediaType); + } + + static ValidationResult ToContent( + ValidationResult result, + JsonTypeInfo jsonTypeInfo, + MediaTypeHeaderValue? mediaType + ) + { + if (!result.IsSuccess) + return ValidationResult.Failure(result.Errors); + + // Ownership passes to the caller, through the result. +#pragma warning disable CA2000 // Dispose objects before losing scope + return ValidationResult.Success(JsonContent.Create(result.Value, jsonTypeInfo, mediaType)); +#pragma warning restore CA2000 // Dispose objects before losing scope + } +} diff --git a/src/src/Http/ZodValidationProblemDetails.cs b/src/src/Http/ZodValidationProblemDetails.cs new file mode 100644 index 00000000..35edfc0a --- /dev/null +++ b/src/src/Http/ZodValidationProblemDetails.cs @@ -0,0 +1,259 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZodSharp.Core; + +namespace ZodSharp.Http; + +/// +/// A validation problem returned by an API: the RFC 9457 ProblemDetails body that Purview.ZodSharp.AspNetCore writes +/// (errors, issues and traceId), or a plain ASP.NET Core ValidationProblemDetails with +/// only errors. Read one with HttpResponseMessage.TryReadZodValidationProblemAsync(), convert it with +/// , or (with Purview.ZodSharp.Blazor) map it onto a form with +/// EditContext.AddZodProblemDetails. +/// +public sealed class ZodValidationProblemDetails +{ + /// Gets or sets the problem type URI. + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// Gets or sets the short summary of the problem. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// Gets or sets the HTTP status code. + [JsonPropertyName("status")] + public int? Status { get; set; } + + /// Gets or sets the explanation of this occurrence of the problem. + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + /// Gets or sets the URI of this occurrence of the problem. + [JsonPropertyName("instance")] + public string? Instance { get; set; } + + /// Gets or sets the messages per dotted path (Lines[0].Quantity); the root is "". + [JsonPropertyName("errors")] + public IReadOnlyDictionary? Errors { get; set; } + + /// Gets or sets the full-fidelity issues, when the server wrote them. + [JsonPropertyName("issues")] + public IReadOnlyList? Issues { get; set; } + + /// Gets or sets the trace identifier of the failed request. + [JsonPropertyName("traceId")] + public string? TraceId { get; set; } + + /// + /// Converts the problem into ZodSharp errors: one per issue when is present (with its typed + /// path, code, category, origin, bounds and parameters), otherwise one per message, with the + /// path parsed from the dotted key and the code custom. + /// + /// The errors. + public ImmutableArray ToValidationErrors() + { + var builder = ImmutableArray.CreateBuilder(); + if (Issues is { Count: > 0 } issues) + { + foreach (var issue in issues) + builder.Add(issue.ToValidationError()); + + return builder.ToImmutable(); + } + + if (Errors is { Count: > 0 } errors) + { + foreach (var (key, messages) in errors) + { + var path = ZodPath.ParseDotted(key); + foreach (var message in messages ?? []) + { + builder.Add( + ValidationError.Create( + ZodIssueCategories.Custom, + message ?? string.Empty, + path, + category: ZodIssueCategories.Custom + ) + ); + } + } + } + + return builder.ToImmutable(); + } + + /// + /// Converts the problem into a failed carrying , + /// so a server's verdict reads the same as a local Validate. + /// + /// The type the request was validating. + /// The failed result. + public ValidationResult ToValidationResult() => ValidationResult.Failure(ToValidationErrors()); +} + +/// +/// One issue of a , in Zod's issue shape. +/// +public sealed class ZodValidationProblemIssue +{ + /// Gets or sets the dotted path (Items[0].Quantity); empty for the root. + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// Gets or sets the typed path: strings for members and keys, integers for indexes. + [JsonPropertyName("segments")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays")] + public JsonElement[]? Segments { get; set; } + + /// Gets or sets the issue code. + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// Gets or sets the category (the Zod issue code for built-in errors). + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// Gets or sets the origin (string, number, ...). + [JsonPropertyName("origin")] + public string? Origin { get; set; } + + /// Gets or sets the lower bound: a JSON number, or an ISO-8601 string for dates. + [JsonPropertyName("minimum")] + public JsonElement? Minimum { get; set; } + + /// Gets or sets the upper bound: a JSON number, or an ISO-8601 string for dates. + [JsonPropertyName("maximum")] + public JsonElement? Maximum { get; set; } + + /// Gets or sets whether the bound is inclusive. + [JsonPropertyName("inclusive")] + public bool? Inclusive { get; set; } + + /// Gets or sets the message. + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// Gets or sets the issue parameters (invariant scalars and arrays of them). + [JsonPropertyName("parameters")] + public IReadOnlyDictionary? Parameters { get; set; } + + /// Gets or sets, for a failed union, the issues of each option. + [JsonPropertyName("unionErrors")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays")] + public ZodValidationProblemIssue[][]? UnionErrors { get; set; } + + /// + /// Gets the typed path: from when present, otherwise parsed from . + /// + /// The path. + public ZodPath ToZodPath() + { + if (Segments is not { Length: > 0 } segments) + return ZodPath.ParseDotted(Path); + + var builder = ImmutableArray.CreateBuilder(segments.Length); + foreach (var segment in segments) + { + // The ValueKind switch only distinguishes indexes from names: everything else is rendered as a name. +#pragma warning disable IDE0010 // Add missing cases + switch (segment.ValueKind) + { + case JsonValueKind.Number when segment.TryGetInt32(out var index) && index >= 0: + builder.Add(ZodPathSegment.At(index)); + break; + case JsonValueKind.String: + builder.Add(ZodPathSegment.Member(segment.GetString() ?? string.Empty)); + break; + default: + builder.Add(ZodPathSegment.Member(segment.GetRawText())); + break; + } +#pragma warning restore IDE0010 // Add missing cases + } + + return ZodPath.From(builder.MoveToImmutable()); + } + + /// + /// Converts the issue into a ZodSharp error. + /// + /// The error. + public ValidationError ToValidationError() + { + Dictionary? parameters = null; + if (Parameters is { Count: > 0 }) + { + parameters = [with(StringComparer.Ordinal)]; + foreach (var (name, value) in Parameters) + parameters[name] = ToScalar(value); + } + + if (UnionErrors is { Length: > 0 } unionErrors) + { + var options = ImmutableArray.CreateBuilder>(unionErrors.Length); + foreach (var option in unionErrors) + options.Add([.. (option ?? []).Select(static issue => issue.ToValidationError())]); + + parameters ??= [with(StringComparer.Ordinal)]; + parameters[ZodIssueParameters.UnionErrors] = options.MoveToImmutable(); + } + + var error = ValidationError.Create( + Code ?? ZodIssueCategories.Custom, + Message ?? string.Empty, + ToZodPath(), + parameters, + Origin, + category: Category + ); + + var lower = ToBound(Minimum); + var upper = ToBound(Maximum); + return lower is null && upper is null ? error : error with { Lower = lower, Upper = upper }; + } + + ErrorBound? ToBound(JsonElement? element) => + element is { } value && ToScalar(value) is { } scalar ? new ErrorBound(scalar, Inclusive ?? true) : null; + + static object? ToScalar(JsonElement value) + { +#pragma warning disable IDE0010 // Add missing cases + switch (value.ValueKind) + { + case JsonValueKind.String: + return value.GetString(); + case JsonValueKind.Number: + if (value.TryGetInt64(out var integer)) + return integer; + + return value.TryGetDouble(out var number) ? number : value.GetRawText(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return null; + case JsonValueKind.Array: + return value.EnumerateArray().Select(ToScalar).ToImmutableArray(); + default: + return value.GetRawText(); + } +#pragma warning restore IDE0010 // Add missing cases + } + + /// + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"{Path}: {Code} {Message}"); +} + +/// +/// Source-generated JSON metadata for reading validation problems, so reading is trim- and AOT-safe in Blazor +/// WebAssembly. +/// +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(ZodValidationProblemDetails))] +sealed partial class ZodHttpJsonContext : JsonSerializerContext; diff --git a/src/src/NewtonsoftJson/CompatibilitySuppressions.xml b/src/src/NewtonsoftJson/CompatibilitySuppressions.xml new file mode 100644 index 00000000..3301894e --- /dev/null +++ b/src/src/NewtonsoftJson/CompatibilitySuppressions.xml @@ -0,0 +1,12 @@ + + + + + PKV006 + net8.0 + + + PKV006 + net9.0 + + diff --git a/src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs b/src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs new file mode 100644 index 00000000..e004fbb3 --- /dev/null +++ b/src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs @@ -0,0 +1,170 @@ +using System.Collections.Immutable; +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// Turns Newtonsoft.Json deserialization failures into s. +/// +/// +/// +/// Every failure uses the one code and the category +/// . The location is kept: the serializer's path becomes the +/// error's and the line number and line position become the +/// line and position parameters. +/// +/// +/// The message is fixed and generic. The serializer's own message names CLR types and quotes the input, so it +/// is never passed to a client; the expected type is described with Zod's type names instead. +/// +/// +[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] +[System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] +static class JsonDeserializationErrors +{ + /// The message format of every deserialization failure. + public const string MessageFormat = "The JSON value could not be converted to {expected}."; + + /// The parameter holding the (one-based) line number reported by the serializer. + public const string LineParameter = "line"; + + /// The parameter holding the (one-based) position in the line reported by the serializer. + public const string PositionParameter = "position"; + + /// + /// Maps an exception thrown while deserializing to validation errors. + /// + /// The exception. + /// The contract resolver, used to describe the expected type at the failing path. + /// The deserialized type. + /// The errors, when the exception is a deserialization failure. + /// when the exception is a deserialization failure. + public static bool TryMap( + Exception exception, + IContractResolver? resolver, + Type rootType, + out ImmutableArray errors + ) + { + switch (exception) + { + // A validating converter failed: its errors are the result, already located under its path. + case ZodNewtonsoftJsonValidationException validation: + errors = validation.Errors; + return true; + + case JsonReaderException reader: + errors = [Create(reader.Path, resolver, rootType, reader.LineNumber, reader.LinePosition)]; + return true; + + case JsonSerializationException serialization: + errors = + [ + Create( + serialization.Path, + resolver, + rootType, + serialization.LineNumber, + serialization.LinePosition + ), + ]; + return true; + + case JsonException: + errors = [Create(null, resolver, rootType, 0, 0)]; + return true; + + default: + errors = default; + return false; + } + } + + /// + /// The error reported when the JSON deserialized to . + /// + /// The contract resolver. + /// The deserialized type. + /// The error. + public static ValidationError NullValue(IContractResolver? resolver, Type rootType) => + Create(null, resolver, rootType, 0, 0, received: "null"); + + static ValidationError Create( + string? jsonPath, + IContractResolver? resolver, + Type rootType, + int line, + int position, + string? received = null + ) + { + var path = ZodPath.ParseJsonPath(jsonPath); + var expected = DescribeExpected(resolver ?? JsonSerializer.CreateDefault().ContractResolver, rootType, path); + + Dictionary parameters = new(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = expected, + }; + if (received is not null) + parameters[ZodIssueParameters.Received] = received; + + // Newtonsoft reports zero when it has no line information. + if (line > 0) + { + parameters[LineParameter] = line; + parameters[PositionParameter] = position; + } + + return ValidationError.Create( + NewtonsoftJsonExtensions.InvalidJsonCode, + ZodMessageFormat.Format(MessageFormat, parameters, CultureInfo.InvariantCulture), + path, + parameters, + category: ZodIssueCategories.InvalidType + ) with + { + MessageFormat = MessageFormat, + }; + } + + /// + /// Walks through the contracts to name the JSON expected where reading failed. + /// + static string DescribeExpected(IContractResolver resolver, Type rootType, ZodPath path) + { + try + { + var type = rootType; + foreach (var segment in path.Segments) + { + var next = Step(resolver.ResolveContract(type), segment); + if (next is null) + return JsonKinds.Unknown; + + type = next; + } + + return JsonKinds.OfContract(resolver.ResolveContract(type)) ?? JsonKinds.Unknown; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) + { + // Describing the expected type is best effort; the error itself is still reported. + return JsonKinds.Unknown; + } + } + + static Type? Step(JsonContract contract, ZodPathSegment segment) => + contract switch + { + JsonObjectContract obj when segment.Kind == ZodPathSegmentKind.Member => obj + .Properties.GetClosestMatchProperty(segment.Name!) + ?.PropertyType, + JsonArrayContract array when segment.Kind == ZodPathSegmentKind.Index => array.CollectionItemType, + JsonDictionaryContract dictionary when segment.Kind != ZodPathSegmentKind.Index => + dictionary.DictionaryValueType, + _ => null, + }; +} diff --git a/src/src/NewtonsoftJson/Json/JsonKinds.cs b/src/src/NewtonsoftJson/Json/JsonKinds.cs new file mode 100644 index 00000000..28789299 --- /dev/null +++ b/src/src/NewtonsoftJson/Json/JsonKinds.cs @@ -0,0 +1,121 @@ +using System.Collections; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace ZodSharp.Json; + +/// +/// Names JSON values and CLR types with Zod's type names (string, number, boolean, +/// object, array, null), so messages describe the JSON a client sent rather than the +/// CLR or type that happened to hold it. Only the kind is ever reported, never the +/// value, so no input is echoed into a message. +/// +static class JsonKinds +{ + /// The name used when the expected type cannot be determined. + public const string Unknown = "the expected type"; + + static readonly Dictionary ScalarNames = new() + { + [typeof(bool)] = "boolean", + [typeof(byte)] = "number", + [typeof(sbyte)] = "number", + [typeof(short)] = "number", + [typeof(ushort)] = "number", + [typeof(int)] = "number", + [typeof(uint)] = "number", + [typeof(long)] = "number", + [typeof(ulong)] = "number", + [typeof(float)] = "number", + [typeof(double)] = "number", + [typeof(decimal)] = "number", + [typeof(Half)] = "number", + [typeof(Int128)] = "number", + [typeof(UInt128)] = "number", + [typeof(System.Numerics.BigInteger)] = "number", + [typeof(string)] = "string", + [typeof(char)] = "string", + [typeof(Guid)] = "string", + [typeof(DateTime)] = "string", + [typeof(DateTimeOffset)] = "string", + [typeof(DateOnly)] = "string", + [typeof(TimeOnly)] = "string", + [typeof(TimeSpan)] = "string", + [typeof(Uri)] = "string", + [typeof(Version)] = "string", + [typeof(byte[])] = "string", + }; + + /// + /// The Zod name of a value held as a or as a plain CLR value. + /// + /// The value. + /// The Zod type name. + public static string OfValue(object? value) => + value switch + { + null => "null", + JToken token => OfTokenType(token.Type), + string or char or Guid or DateTime or DateTimeOffset => "string", + bool => "boolean", + byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal => "number", + IDictionary => "object", + IEnumerable => "array", + _ => "object", + }; + + /// + /// The Zod name of a . + /// + /// The token type. + /// The Zod type name. + public static string OfTokenType(JTokenType type) => + type switch + { + JTokenType.Object => "object", + JTokenType.Array => "array", + JTokenType.Integer => "number", + JTokenType.Float => "number", + JTokenType.String => "string", + JTokenType.Guid => "string", + JTokenType.Uri => "string", + JTokenType.TimeSpan => "string", + JTokenType.Date => "string", + JTokenType.Bytes => "string", + JTokenType.Boolean => "boolean", + JTokenType.Null => "null", + JTokenType.Undefined => "undefined", + JTokenType.None => "undefined", + JTokenType.Constructor => "object", + JTokenType.Property => "object", + JTokenType.Comment => "undefined", + JTokenType.Raw => "undefined", + _ => "undefined", + }; + + /// + /// The Zod name of the JSON a contract reads, or when it cannot be named. + /// + /// The contract. + /// The Zod type name, or . + public static string? OfContract(JsonContract contract) => + contract switch + { + JsonArrayContract => "array", + JsonObjectContract or JsonDictionaryContract or JsonDynamicContract => "object", + JsonPrimitiveContract or JsonStringContract => OfScalarType(contract.UnderlyingType), + _ => null, + }; + + /// + /// The Zod name of a scalar CLR type, or when it cannot be named. + /// + /// The type. + /// The Zod type name, or . + public static string? OfScalarType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + + return type.IsEnum ? "enum" : ScalarNames.GetValueOrDefault(type); + } +} diff --git a/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs b/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs index 5ef2ce2d..981a42f6 100644 --- a/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs +++ b/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs @@ -7,6 +7,8 @@ namespace ZodSharp.Json; /// /// JsonConverter that validates using a Zod schema. /// +[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] +[System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] sealed class ZodJsonConverter(IZodSchema schema) : JsonConverter { public override T ReadJson( @@ -17,20 +19,54 @@ public override T ReadJson( JsonSerializer serializer ) { + // Capture the location before the value is consumed, so validation errors are reported under it. + var path = reader.Path; + var (line, position) = LineInfo(reader as IJsonLineInfo); + var token = JToken.Load(reader); var validationSerializer = CreateSerializerWithoutThisConverter(serializer); - var deserialized = - token.ToObject(validationSerializer) - ?? throw new JsonSerializationException("Failed to deserialize JSON"); - var result = schema.Validate(deserialized); - if (!result.IsSuccess) + T deserialized; + try + { + deserialized = + token.ToObject(validationSerializer) + ?? throw new JsonSerializationException("Failed to deserialize JSON", path, line, position, null); + } + catch (ZodNewtonsoftJsonValidationException nested) + { + // A validating converter for a nested value only knows its path within this token; locate it + // under this value's path too. + throw new ZodNewtonsoftJsonValidationException(nested.Errors, path, line, position); + } + catch (JsonSerializationException unreadable) { - var errorMessages = string.Join(", ", result.Errors.Select(e => e.Message)); - throw new JsonSerializationException($"Validation failed: {errorMessages}"); + // The value was read from the buffered token, so the serializer's path is relative to it ("Age"); + // report it under this value's path ("Members[1].Age"). + throw new JsonSerializationException( + unreadable.Message, + CombinePath(path, unreadable.Path), + line, + position, + unreadable + ); } + catch (JsonReaderException unreadable) + { + throw new JsonSerializationException( + unreadable.Message, + CombinePath(path, unreadable.Path), + line, + position, + unreadable + ); + } + + var result = schema.Validate(deserialized); - return result.Value; + return result.IsSuccess + ? result.Value + : throw new ZodNewtonsoftJsonValidationException(result.Errors, path, line, position); } public override void WriteJson(JsonWriter writer, T? value, JsonSerializer serializer) @@ -43,15 +79,22 @@ public override void WriteJson(JsonWriter writer, T? value, JsonSerializer seria var result = schema.Validate(value); if (!result.IsSuccess) - { - var errorMessages = string.Join(", ", result.Errors.Select(e => e.Message)); - throw new JsonSerializationException($"Validation failed: {errorMessages}"); - } + throw new ZodNewtonsoftJsonValidationException(result.Errors, writer.Path, 0, 0); var token = JToken.FromObject(value, CreateSerializerWithoutThisConverter(serializer)); token.WriteTo(writer); } + /// Joins this value's JSON path with a path relative to it. + static string CombinePath(string outer, string? relative) => + string.IsNullOrEmpty(relative) ? outer + : string.IsNullOrEmpty(outer) ? relative + : relative[0] == '[' ? outer + relative + : outer + "." + relative; + + static (int Line, int Position) LineInfo(IJsonLineInfo? info) => + info is { } lineInfo && lineInfo.HasLineInfo() ? (lineInfo.LineNumber, lineInfo.LinePosition) : (0, 0); + JsonSerializer CreateSerializerWithoutThisConverter(JsonSerializer serializer) { JsonSerializer clone = new() diff --git a/src/src/NewtonsoftJson/Json/ZodNewtonsoftJsonValidationException.cs b/src/src/NewtonsoftJson/Json/ZodNewtonsoftJsonValidationException.cs new file mode 100644 index 00000000..57c943a2 --- /dev/null +++ b/src/src/NewtonsoftJson/Json/ZodNewtonsoftJsonValidationException.cs @@ -0,0 +1,95 @@ +using System.Collections.Immutable; +using Newtonsoft.Json; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// The thrown by a validating converter (see +/// ) when the value it read or is writing +/// fails its schema. It carries the full s rather than a flattened message. +/// +/// +/// The converter records the reader's (or writer's) path when it throws, so is located +/// under the converted value's path: a nested Child property whose Name failed reports +/// Child.Name. DeserializeAndValidate unwraps the exception into those errors. +/// +public sealed class ZodNewtonsoftJsonValidationException : JsonSerializationException +{ + const string DefaultMessage = "Validation failed."; + + /// + /// Initializes a new instance with no errors. + /// + public ZodNewtonsoftJsonValidationException() + : this(DefaultMessage) { } + + /// + /// Initializes a new instance with a message and no errors. + /// + /// The message. + public ZodNewtonsoftJsonValidationException(string message) + : base(message) + { + Errors = []; + } + + /// + /// Initializes a new instance with a message, an inner exception and no errors. + /// + /// The message. + /// The inner exception. + public ZodNewtonsoftJsonValidationException(string message, Exception innerException) + : base(message, innerException) + { + Errors = []; + } + + /// + /// Initializes a new instance carrying validation errors relative to the converted value, which are + /// located under . + /// + /// The validation errors, with paths relative to the converted value. + /// The JSON path of the converted value (as reported by Newtonsoft.Json). + /// The line number of the converted value, or zero when unknown. + /// The line position of the converted value, or zero when unknown. + public ZodNewtonsoftJsonValidationException( + IEnumerable errors, + string? path, + int lineNumber, + int linePosition + ) + : this([.. errors ?? throw new ArgumentNullException(nameof(errors))], path, lineNumber, linePosition) { } + + ZodNewtonsoftJsonValidationException( + ImmutableArray errors, + string? path, + int lineNumber, + int linePosition + ) + : base(BuildMessage(errors), path ?? string.Empty, lineNumber, linePosition, null) + { + var prefix = ZodPath.ParseJsonPath(path); + if (prefix.IsRoot) + { + Errors = errors; + return; + } + + var builder = ImmutableArray.CreateBuilder(errors.Length); + foreach (var error in errors) + builder.Add(error.WithPathPrefix(prefix)); + + Errors = builder.MoveToImmutable(); + } + + /// + /// Gets the validation errors, located under . + /// + public ImmutableArray Errors { get; } + + static string BuildMessage(ImmutableArray errors) => + errors.IsDefaultOrEmpty + ? DefaultMessage + : "Validation failed: " + string.Join(", ", errors.Select(static e => e.Message)); +} diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs index 0246696b..d6840d30 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -18,6 +18,8 @@ public static class FromJsonSchemaParser /// /// The JSON Schema definition /// A ZodSharp schema that validates according to the JSON Schema + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static IZodSchema Parse(JsonSchemaDefinition schema) { if (schema is null) @@ -35,6 +37,8 @@ public static IZodSchema Parse(JsonSchemaDefinition schema) /// /// The JSON Schema as a string /// A ZodSharp schema that validates according to the JSON Schema + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static IZodSchema Parse(string jsonSchema) { var schema = JsonConvert.DeserializeObject( @@ -58,6 +62,8 @@ sealed class ConversionContext(JsonSchemaDefinition rootSchema, Dictionary Processing { get; } = []; } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] static IZodSchema ConvertSchema(JsonSchemaDefinition schema, ConversionContext ctx) { if (schema is null) @@ -102,6 +108,8 @@ static IZodSchema ConvertSchema(JsonSchemaDefinition schema, Con return baseSchema; } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] static IZodSchema ConvertBaseSchema(JsonSchemaDefinition schema, ConversionContext ctx) { // Handle $ref @@ -228,6 +236,8 @@ static IZodSchema ConvertNumberSchema(JsonSchemaDefinition schem "Performance", "CA1859:Use concrete types when possible for improved performance" )] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] static IZodSchema ConvertObjectSchema(JsonSchemaDefinition schema, ConversionContext ctx) { // An object with no declared properties whose additionalProperties is a schema is a record: every @@ -274,6 +284,8 @@ static IZodSchema ConvertObjectSchema(JsonSchemaDefinition schem /// The conversion context. /// The converted value schema, when the object is a record. /// when the object is a record. + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] static bool TryConvertAdditionalProperties( object? additionalProperties, ConversionContext ctx, @@ -329,6 +341,8 @@ out IZodSchema valueSchema "Performance", "CA1859:Use concrete types when possible for improved performance" )] + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] static IZodSchema ConvertArraySchema(JsonSchemaDefinition schema, ConversionContext ctx) { var elementSchema = schema.Items != null ? ConvertSchema(schema.Items, ctx) : new AnySchema(); @@ -336,6 +350,8 @@ static IZodSchema ConvertArraySchema(JsonSchemaDefinition schema return new ArraySchemaWrapper(elementSchema, schema.MinItems, schema.MaxItems); } + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] static IZodSchema ResolveRef(string refPath, ConversionContext ctx) { if (!refPath.StartsWith('#')) @@ -429,6 +445,98 @@ static IZodSchema CreateLiteralFromJToken(JToken token) }; } + // ========== Errors ========== + + const string InvalidTypeFormat = "Expected {expected}, but got {received}"; + const string InvalidLiteralFormat = "Expected literal value '{expected}', but got {received}"; + const string TooSmallArrayFormat = "Array must have at least {minimum} elements, but got {count}"; + const string TooBigArrayFormat = "Array must have at most {maximum} elements, but got {count}"; + + /// + /// An invalid_type error naming the expected and received JSON kinds with Zod's type names. Only the + /// kind of is reported, never the value. + /// + static ValidationError InvalidType(string expected, object? value) => + CreateError( + "invalid_type", + InvalidTypeFormat, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = expected, + [ZodIssueParameters.Received] = Json.JsonKinds.OfValue(value), + }, + ZodIssueCategories.InvalidType + ); + + /// + /// An invalid_literal error. The expected literal comes from the schema; only the kind of the + /// received value is reported, never the value. + /// + static ValidationError InvalidLiteral(T expected, object? value) => + CreateError( + "invalid_literal", + InvalidLiteralFormat, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = string.Create(CultureInfo.InvariantCulture, $"{expected}"), + [ZodIssueParameters.Received] = Json.JsonKinds.OfValue(value), + }, + ZodIssueCategories.InvalidValue + ); + + /// + /// The minItems/maxItems failure for , shaped exactly like a runtime + /// ZodArray length failure (code, origin array, bounds, inclusivity, "elements" wording), or + /// when the length is acceptable. + /// + static ValidationError? ArrayLengthFailure(int count, int? minItems, int? maxItems) + { + var tooSmall = minItems is { } min && count < min; + if (!tooSmall && !(maxItems is { } max && count > max)) + return null; + + Dictionary parameters = new(StringComparer.Ordinal) { [ZodIssueParameters.Count] = count }; + if (minItems is { } minimum) + parameters["minimum"] = minimum; + if (maxItems is { } maximum) + parameters["maximum"] = maximum; + + var format = tooSmall ? TooSmallArrayFormat : TooBigArrayFormat; + ValidationError error = new( + tooSmall ? "too_small" : "too_big", + ZodMessageFormat.Format(format, parameters, CultureInfo.InvariantCulture), + path: null, + parameters: new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.Count] = count }, + origin: "array", + minimum: minItems, + maximum: maxItems, + inclusive: true, + category: tooSmall ? ZodIssueCategories.TooSmall : ZodIssueCategories.TooBig + ); + + return error with + { + MessageFormat = format, + }; + } + + static ValidationError CreateError( + string code, + string format, + Dictionary parameters, + string category + ) => + new ValidationError( + code, + ZodMessageFormat.Format(format, parameters, CultureInfo.InvariantCulture), + path: null, + parameters, + category: category + ) with + { + MessageFormat = format, + }; + // ========== Wrapper Classes ========== sealed class StringSchemaWrapper(ZodString inner) : IZodSchema @@ -444,13 +552,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected string, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("string", value)); } } @@ -485,13 +587,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected number, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("number", value)); } var result = inner.Validate(num); @@ -523,13 +619,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected boolean, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("boolean", value)); } var result = _inner.Validate(b); @@ -550,9 +640,7 @@ public ValidationResult Validate(object value) { if (value != null && !(value is JToken jt && jt.Type == JTokenType.Null)) { - return ValidationResult.Failure( - new ValidationError("invalid_type", $"Expected null, but got {value.GetType().Name}", []) - ); + return ValidationResult.Failure(InvalidType("null", value)); } // If matches, return success with null @@ -585,13 +673,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected object, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("object", value)); } var result = inner.Validate(dict); @@ -628,13 +710,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected object, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("object", value)); } Dictionary validated = []; @@ -646,24 +722,7 @@ public ValidationResult Validate(object value) if (!result.IsSuccess) { foreach (var error in result.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = key; - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add( - ValidationError.Create( - error.Code, - error.Message, - System.Collections.Immutable.ImmutableArray.Create(path), - error.Parameters, - error.Origin, - error.Minimum, - error.Maximum, - error.Inclusive, - error.Category - ) - ); - } + errors.Add(error.WithPathPrefix(ZodPathSegment.Key(key))); } else { @@ -693,35 +752,19 @@ public ValidationResult Validate(object value) { items = [.. jArray.Cast()]; } - else if (value is System.Collections.IEnumerable enumerable and not string) + // A JToken is enumerable whatever its kind (a JValue enumerates nothing), so only a JArray is an array. + else if (value is System.Collections.IEnumerable enumerable and not string and not JToken) { items = [.. enumerable.Cast()]; } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected array, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("array", value)); } - // Check length constraints - if (minItems.HasValue && items.Count < minItems.Value) - { - return ValidationResult.Failure( - new ValidationError("too_small", $"Array must contain at least {minItems.Value} items", []) - ); - } - - if (maxItems.HasValue && items.Count > maxItems.Value) - { - return ValidationResult.Failure( - new ValidationError("too_big", $"Array must contain at most {maxItems.Value} items", []) - ); - } + // Length is checked before any element, exactly as ZodArray does. + if (ArrayLengthFailure(items.Count, minItems, maxItems) is { } lengthFailure) + return ValidationResult.Failure(lengthFailure); // Validate each item List errors = []; @@ -737,12 +780,7 @@ public ValidationResult Validate(object value) else { foreach (var error in itemResult.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = i.ToString(CultureInfo.InvariantCulture); - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add(new ValidationError(error.Code, error.Message, path, error.Parameters)); - } + errors.Add(error.WithPathPrefix(ZodPathSegment.At(i))); } } @@ -829,9 +867,7 @@ JTokenType.Float when typeof(T) == typeof(double) => jt.Value(), if (!matches) { - return ValidationResult.Failure( - new ValidationError("invalid_literal", $"Expected literal value '{_value}', but got '{value}'", []) - ); + return ValidationResult.Failure(InvalidLiteral(_value, value)); } // If matches, return success with the typed value diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs index 0791df44..0b57ac94 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/JsonSchemaSerializerOptions.cs @@ -27,6 +27,8 @@ public override string GetPropertyName(string name, bool hasSpecifiedName) => /// /// Options for JSON serialization/deserialization of JSON Schema. /// +[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] +[System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static class JsonSchemaSerializerOptions { /// diff --git a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs index d609cd8d..39eb9f65 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/ZExtensions.cs @@ -14,6 +14,8 @@ public static class ZExtensions /// Creates a ZodSharp schema from a JSON Schema definition. /// Enables consuming schemas defined in TypeScript Zod. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static IZodSchema FromJsonSchema(JsonSchemaDefinition schema) => FromJsonSchemaParser.Parse(schema); @@ -21,6 +23,8 @@ public static IZodSchema FromJsonSchema(JsonSchemaDefinition sch /// Creates a ZodSharp schema from a JSON Schema string. /// Enables consuming schemas defined in TypeScript Zod via JSON files or APIs. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static IZodSchema FromJsonSchema(string jsonSchema) => FromJsonSchemaParser.Parse(jsonSchema); } diff --git a/src/src/NewtonsoftJson/NewtonsoftJson.csproj b/src/src/NewtonsoftJson/NewtonsoftJson.csproj index 6c45985d..2df543bc 100644 --- a/src/src/NewtonsoftJson/NewtonsoftJson.csproj +++ b/src/src/NewtonsoftJson/NewtonsoftJson.csproj @@ -13,6 +13,18 @@ zod;validation;schema;json + + + true + true + + diff --git a/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs b/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs index 196fd8ca..b1a4c3b4 100644 --- a/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs +++ b/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -14,40 +15,42 @@ namespace ZodSharp; #endif public static class NewtonsoftJsonExtensions { - static readonly string[] EmptyPath = []; + /// + /// The code of every failure to read JSON: malformed JSON, a value that cannot be converted to the target + /// type, or a document. The error's is + /// , its is the location + /// the serializer reported, and its line and position parameters hold the serializer's + /// (one-based) line number and line position when it has them. + /// + public const string InvalidJsonCode = "invalid_json"; /// /// Deserializes JSON and validates it using a Zod schema. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static ValidationResult DeserializeAndValidate( this IZodSchema schema, string json, JsonSerializerSettings? settings = null ) { + // A conditional expression would turn the argument guard into a throw expression inside the return. +#pragma warning disable IDE0046 // Convert to conditional expression if (schema == null) throw new ArgumentNullException(nameof(schema)); +#pragma warning restore IDE0046 - try - { - var deserialized = JsonConvert.DeserializeObject(json, settings); - return deserialized == null - ? ValidationResult.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : schema.Validate(deserialized); - } - catch (JsonException ex) - { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); - } + return TryDeserialize(json, settings, out var deserialized, out var errors) + ? schema.Validate(deserialized) + : ValidationResult.Failure(errors); } /// /// Deserializes JSON and validates it using a Zod schema (async). /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static async Task> DeserializeAndValidateAsync( this IZodSchema schema, Stream jsonStream, @@ -55,29 +58,30 @@ public static async Task> DeserializeAndValidateAsync( CancellationToken cancellationToken = default ) { - try - { + if (schema == null) + throw new ArgumentNullException(nameof(schema)); + if (jsonStream == null) + throw new ArgumentNullException(nameof(jsonStream)); + #if NETSTANDARD2_1_OR_GREATER - using StreamReader reader = new(jsonStream, Encoding.UTF8, true, 1024, true); - var json = await reader.ReadToEndAsync(); + using StreamReader reader = new(jsonStream, Encoding.UTF8, true, 1024, true); + var json = await reader.ReadToEndAsync().ConfigureAwait(false); #else - using StreamReader reader = new(jsonStream, Encoding.UTF8, leaveOpen: true); - var json = await reader.ReadToEndAsync(cancellationToken); + using StreamReader reader = new(jsonStream, Encoding.UTF8, leaveOpen: true); + var json = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); #endif - return schema.DeserializeAndValidate(json, settings); - } - catch (JsonException ex) - { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); - } + // Validate asynchronously, so a schema's async refinements run rather than being skipped. + return TryDeserialize(json, settings, out var deserialized, out var errors) + ? await schema.ValidateAsync(deserialized, cancellationToken).ConfigureAwait(false) + : ValidationResult.Failure(errors); } /// /// Deserializes JSON from a JToken and validates it using a Zod schema. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static ValidationResult DeserializeAndValidate( this IZodSchema schema, JToken token, @@ -89,32 +93,69 @@ public static ValidationResult DeserializeAndValidate( if (token == null) throw new ArgumentNullException(nameof(token)); + serializer ??= JsonSerializer.CreateDefault(); + + T? deserialized; try { - var deserialized = token.ToObject(serializer ?? JsonSerializer.CreateDefault()); - return deserialized == null - ? ValidationResult.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : schema.Validate(deserialized); + deserialized = token.ToObject(serializer); } - catch (JsonException ex) + catch (Exception ex) + when (JsonDeserializationErrors.TryMap(ex, serializer.ContractResolver, typeof(T), out var errors)) { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); + return ValidationResult.Failure(errors); } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(serializer.ContractResolver, typeof(T))) + : schema.Validate(deserialized); + } + + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] + static bool TryDeserialize( + string json, + JsonSerializerSettings? settings, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? deserialized, + out ImmutableArray errors + ) + { + var serializer = JsonSerializer.CreateDefault(settings); + try + { + deserialized = JsonConvert.DeserializeObject(json, settings); + } + catch (Exception ex) + when (JsonDeserializationErrors.TryMap(ex, serializer.ContractResolver, typeof(T), out var mapped)) + { + deserialized = default; + errors = mapped; + return false; + } + + if (deserialized == null) + { + errors = [JsonDeserializationErrors.NullValue(serializer.ContractResolver, typeof(T))]; + return false; + } + + errors = []; + return true; } /// /// Creates a custom JsonConverter that validates using a Zod schema. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static JsonConverter CreateValidatingConverter(this IZodSchema schema) => schema == null ? throw new ArgumentNullException(nameof(schema)) : new ZodJsonConverter(schema); /// /// Validates a value and serializes it to a JSON string. /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static ValidationResult ValidateAndSerialize( this IZodSchema schema, T value, @@ -136,6 +177,8 @@ public static ValidationResult ValidateAndSerialize( /// /// Validates a value and serializes it to a stream (async). /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(NewtonsoftJsonTrimming.Message)] + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode(NewtonsoftJsonTrimming.Message)] public static async Task> ValidateAndSerializeAsync( this IZodSchema schema, T value, diff --git a/src/src/NewtonsoftJson/NewtonsoftJsonTrimming.cs b/src/src/NewtonsoftJson/NewtonsoftJsonTrimming.cs new file mode 100644 index 00000000..cfd7b7e4 --- /dev/null +++ b/src/src/NewtonsoftJson/NewtonsoftJsonTrimming.cs @@ -0,0 +1,17 @@ +namespace ZodSharp; + +/// +/// The trimming and Native AOT requirement shared by every public API in this package that reaches Newtonsoft.Json +/// serialization. +/// +/// +/// Newtonsoft.Json discovers members and constructors by reflection and emits code at runtime, and it carries no +/// trimming annotations of its own, so the analyzers cannot see the hazard. These APIs declare it explicitly instead, +/// so a trimmed or Native AOT application is warned at the call site rather than failing at runtime. +/// +static class NewtonsoftJsonTrimming +{ + public const string Message = + "Newtonsoft.Json is not trim/AOT compatible; use Purview.ZodSharp.SystemTextJson " + + "with a source-generated JsonSerializerContext instead."; +} diff --git a/src/src/NewtonsoftJson/Sdk/README.md b/src/src/NewtonsoftJson/Sdk/README.md index 92c3cc35..51ce5f67 100644 --- a/src/src/NewtonsoftJson/Sdk/README.md +++ b/src/src/NewtonsoftJson/Sdk/README.md @@ -45,6 +45,19 @@ var converter = userSchema.CreateValidatingConverter(); var value = JsonConvert.DeserializeObject(json, converter); ``` +## Errors + +A failure to read the JSON (malformed text, a `null` document, a value the serializer cannot convert) is reported as one +error with code `invalid_json` (`NewtonsoftJsonExtensions.InvalidJsonCode`), category `invalid_type` and the fixed +message `"The JSON value could not be converted to {expected}."`. The serializer's path becomes the error's `FullPath` +(`items[1].age`) and its line and position become the `line` and `position` parameters. The serializer's own message +is never surfaced, because it names CLR types and can quote the input. `DeserializeAndValidateAsync` awaits the +schema's `ValidateAsync`, so asynchronous refinements run. + +The validating converter throws `ZodNewtonsoftJsonValidationException` (namespace `ZodSharp.Json`, a +`JsonSerializationException`) carrying the schema's full `Errors`, located under the converter's JSON path; its +message still starts with `"Validation failed: "`. `DeserializeAndValidate` unwraps it into the result. + ## UTC DateTimeOffset contract The default `PreserveOffset` schema is exact and needs no converter. When the generated JSON Schema uses @@ -77,6 +90,17 @@ var result = schema.Validate(data); This lets you share schemas defined in TypeScript/Zod with your .NET backend. (Export via `Z.ToJsonSchema` lives in the core package.) +## Trimming and Native AOT + +Newtonsoft.Json is not trim- or Native AOT-compatible: it discovers members and constructors by reflection and +generates code at runtime. Every API in this package that reaches Newtonsoft serialization (`DeserializeAndValidate`, +`DeserializeAndValidateAsync`, `ValidateAndSerialize`, `ValidateAndSerializeAsync`, `CreateValidatingConverter`, +`Z.FromJsonSchema` and `FromJsonSchemaParser.Parse`) is annotated `[RequiresUnreferencedCode]` and +`[RequiresDynamicCode]`, so a trimmed or AOT application gets an IL2026/IL3050 warning at the call site instead of a +runtime failure. Use [Purview.ZodSharp.SystemTextJson](https://www.nuget.org/packages/Purview.ZodSharp.SystemTextJson) +with a source-generated `JsonSerializerContext` in those applications. See +[Trimming and Native AOT](https://purview.dev/docs/zodsharp/trimming-and-aot/). + ## Documentation - [Homepage](https://purview.dev/projects/zodsharp/) diff --git a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md index 89d8f922..aef89cb6 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Shipped.md @@ -77,3 +77,18 @@ | Rule ID | Category | Severity | Notes | |---|---|---|---| | ZODSGEN046 | ZodSharp.SourceGenerator | Warning | A [CollectionBehavior] attribute is applied to a property that is not a collection of nullable elements, so the setting can never take effect | + +## Release 3.0.0 + +### New Rules + +| Rule ID | Category | Severity | Notes | +|---|---|---|---| +| ZODSGEN047 | ZodSharp.SourceGenerator | Info | [ValidatableType] (Microsoft.Extensions.Validation) is redundant on a [ZodSchema] type | +| ZODSGEN048 | ZodSharp.SourceGenerator | Warning | A type has asynchronous-only rules (AsyncValidationAttribute, IAsyncValidatableObject) but a synchronous Validate is generated | +| ZODSGEN049 | ZodSharp.SourceGenerator | Warning | A member typed as a closed class has a derived type without [ZodSchema] | +| ZODSGEN050 | ZodSharp.SourceGenerator | Error | A generated IValidateOptions validator conflicts with a hand-authored [OptionsValidator] class (SYSLIB1205) | +| ZODSGEN051 | ZodSharp.SourceGenerator | Warning | A [ZodSchema] type is declared in a Razor file, which the generator cannot see | +| ZODSGEN052 | ZodSharp.SourceGenerator | Warning | An attribute ErrorMessage uses a placeholder the attribute does not supply, or an unbalanced brace | +| ZODSGEN053 | ZodSharp.SourceGenerator | Warning | [MinLength]/[MaxLength]/[Length] on a collection member in a trimmed or AOT project; use [MinCount]/[MaxCount]/[CountRange] | +| ZODSGEN054 | ZodSharp.SourceGenerator | Error | A three-parameter (service-aware) custom validation method's second parameter is not IServiceProvider | diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index 47f93166..6555705f 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -6,3 +6,5 @@ ; ; ZODSGEN002 and ZODSGEN022-ZODSGEN026 were retired during development and are never reused. +; ZODSGEN046 now recognizes the default ImmutableArray behavior option, including value-element +; collections. Its message describes option applicability; its category and severity are unchanged. diff --git a/src/src/SourceGenerators/CollectionCountAttributeAnalyzer.cs b/src/src/SourceGenerators/CollectionCountAttributeAnalyzer.cs new file mode 100644 index 00000000..d8fb0e26 --- /dev/null +++ b/src/src/SourceGenerators/CollectionCountAttributeAnalyzer.cs @@ -0,0 +1,159 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +/// +/// Reports ZODSGEN053 for [MinLength], [MaxLength] or [Length] on a collection member of a +/// [ZodSchema] type when the project is trimmed, AOT-compiled, or analyzed as trimmable/AOT-compatible. The +/// BCL attributes' constructors are [RequiresUnreferencedCode] (they may reflect over a Count property), +/// so each usage raises IL2026 even though the generated validator never runs their reflection. The ZodSharp +/// collection-count attributes ([MinCount], [MaxCount], [CountRange]) are validated identically. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CollectionCountAttributeAnalyzer : DiagnosticAnalyzer +{ + /// The diagnostic property naming the replacement attribute (without the Attribute suffix). + public const string ReplacementProperty = "Replacement"; + + /// + /// The MSBuild properties that mark a project as trimmed or AOT-compiled. Any one set to true enables the + /// diagnostic: the publish switches, the library switches, and the analyzer switches they turn on. + /// + internal static readonly ImmutableArray TrimmingProperties = + [ + "PublishTrimmed", + "PublishAot", + "IsTrimmable", + "IsAotCompatible", + "EnableTrimAnalyzer", + "EnableAotAnalyzer", + ]; + + const string DataAnnotationsNamespace = "System.ComponentModel.DataAnnotations"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + [DiagnosticLibrary.UseTrimSafeCollectionCountAttribute]; + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(static start => + { + if (!IsTrimmedOrAot(start.Options.AnalyzerConfigOptionsProvider.GlobalOptions)) + return; + + // The replacements ship in Purview.ZodSharp; without it there is nothing to suggest. + if (start.Compilation.GetTypeByMetadataName("ZodSharp.Rules.MinCountAttribute") is null) + return; + + start.RegisterSymbolAction(AnalyzeProperty, SymbolKind.Property); + start.RegisterSymbolAction(AnalyzeParameter, SymbolKind.Parameter); + }); + } + + /// + /// Whether any of is true. + /// + /// The global analyzer config options. + /// for a trimmed or AOT project. + internal static bool IsTrimmedOrAot(AnalyzerConfigOptions options) + { + foreach (var property in TrimmingProperties) + { + if ( + options.TryGetValue($"build_property.{property}", out var value) + && bool.TryParse(value, out var enabled) + && enabled + ) + return true; + } + + return false; + } + + static void AnalyzeProperty(SymbolAnalysisContext context) + { + if (context.Symbol is IPropertySymbol property && IsZodSchemaType(property.ContainingType)) + Analyze(context, property.Name, property.Type, property.GetAttributes()); + } + + // A positional record's attributes are written on the primary-constructor parameter. + static void AnalyzeParameter(SymbolAnalysisContext context) + { + if ( + context.Symbol + is IParameterSymbol + { + ContainingSymbol: IMethodSymbol { MethodKind: MethodKind.Constructor } constructor + } parameter + && constructor.ContainingType is { IsRecord: true } record + && IsZodSchemaType(record) + ) + Analyze(context, parameter.Name, parameter.Type, parameter.GetAttributes()); + } + + static bool IsZodSchemaType(INamedTypeSymbol? type) => + type is not null && ZodSchemaAttributeData.FromAttributeData(type, out _).Exists; + + static void Analyze( + SymbolAnalysisContext context, + string memberName, + ITypeSymbol memberType, + ImmutableArray attributes + ) + { + if (!CustomRuleResolver.IsCountableCollectionType(memberType)) + return; + + foreach (var attribute in attributes) + { + if ( + attribute.AttributeClass is not { } attributeClass + || attributeClass.ContainingNamespace.ToDisplayString() != DataAnnotationsNamespace + || GetReplacement(attributeClass.Name) is not { } replacement + || attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken) is not { } syntax + ) + continue; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.UseTrimSafeCollectionCountAttribute, + syntax.GetLocation(), + ImmutableDictionary.Empty.Add(ReplacementProperty, replacement), + TrimSuffix(attributeClass.Name), + memberName, + replacement + ) + ); + } + } + + /// + /// The ZodSharp collection-count attribute that replaces a DataAnnotations size attribute. + /// + /// The DataAnnotations attribute class name. + /// The replacement attribute name without its suffix, or . + internal static string? GetReplacement(string attributeName) => + attributeName switch + { + "MinLengthAttribute" => "MinCount", + "MaxLengthAttribute" => "MaxCount", + "LengthAttribute" => "CountRange", + _ => null, + }; + + static string TrimSuffix(string name) => + name.EndsWith("Attribute", StringComparison.Ordinal) + ? name.Substring(0, name.Length - "Attribute".Length) + : name; +} diff --git a/src/src/SourceGenerators/ErrorTypeGenerator.BuildSource.cs b/src/src/SourceGenerators/ErrorTypeGenerator.BuildSource.cs index 0e126053..fe8b6940 100644 --- a/src/src/SourceGenerators/ErrorTypeGenerator.BuildSource.cs +++ b/src/src/SourceGenerators/ErrorTypeGenerator.BuildSource.cs @@ -72,7 +72,7 @@ ImmutableArray parameters method.Line(); method.Return( - $"ValidationError.Create(errorType.Code, errorType.FormatMessage({parametersName}), path is null ? [] : [.. path], {parametersName}, origin, minimum, maximum, inclusive, errorType.Category)" + $"ValidationError.Create(errorType.Code, errorType.FormatMessage({parametersName}), path is null ? [] : [.. path], {parametersName}, origin ?? errorType.Origin, minimum, maximum, inclusive, errorType.Category) with {{ MessageFormat = errorType.MessageFormat }}" ); } ); diff --git a/src/src/SourceGenerators/ErrorTypeGenerator.cs b/src/src/SourceGenerators/ErrorTypeGenerator.cs index 76e725d4..806eafbe 100644 --- a/src/src/SourceGenerators/ErrorTypeGenerator.cs +++ b/src/src/SourceGenerators/ErrorTypeGenerator.cs @@ -1,6 +1,5 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; using ZodSharp.SourceGenerators.Helpers; using ZodSharp.SourceGenerators.Models; @@ -18,12 +17,7 @@ public sealed partial class ErrorTypeGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { - context - .RegisterEmbeddedAttribute() - .RegisterPostInitializationOutput(static ctx => - ctx.AddSource("ErrorTypeAttribute.g.cs", ErrorTypeAttributeSource()) - ); - + // No post-initialization output: [ErrorType] is defined once in Purview.ZodSharp (see ZodSchemaGenerator). var generationContext = IncrementalPipeline.GenerationContextValueProvider< ErrorTypeGeneratorCapabilities, ErrorTypeGenerator @@ -49,70 +43,45 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "CollectErrorTypeFields" ); - context.RegisterSourceOutput( - generationModels, - static (spc, model) => - { - if (model.Context.Settings.IsSourceGeneratorDisabled) - return; + context.RegisterSourceOutput(generationModels, static (spc, model) => + { + if (model.Context.Settings.IsSourceGeneratorDisabled) + return; - if ( - !model.Context.Capabilities.HasErrorType - || !model.Context.Capabilities.HasErrorTypeParameter - || !model.Context.Capabilities.HasErrorTypeParameters - ) - return; + if ( + !model.Context.Capabilities.HasErrorType + || !model.Context.Capabilities.HasErrorTypeParameter + || !model.Context.Capabilities.HasErrorTypeParameters + ) + return; - foreach (var result in model.Fields) - { - foreach (var diagnostic in result.Diagnostics) - spc.ReportDiagnostic(diagnostic.ToDiagnostic()); + foreach (var result in model.Fields) + { + foreach (var diagnostic in result.Diagnostics) + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); - if (!result.ShouldProcess) - continue; + if (!result.ShouldProcess) + continue; - try - { - var writer = model.Context.CreateCodeWriter(); - Emit(writer, result.Value); - spc.AddSource(result.Value.HintName, writer); - } - catch (Exception ex) - { - spc.ReportDiagnostic( - ReportableDiagnostic - .Create( - DiagnosticLibrary.ErrorTypeUnhandledException, - true, - [result.Value.ClassName, ex.Message] - ) - .ToDiagnostic() - ); - } + try + { + var writer = model.Context.CreateCodeWriter(); + Emit(writer, result.Value); + spc.AddSource(result.Value.HintName, writer); + } + catch (Exception ex) + { + spc.ReportDiagnostic( + ReportableDiagnostic + .Create( + DiagnosticLibrary.ErrorTypeUnhandledException, + true, + [result.Value.ClassName, ex.Message] + ) + .ToDiagnostic() + ); } } - ); - } - - static SourceText ErrorTypeAttributeSource() - { - CodeWriter writer = new(GenerationSettings.Create()); - - writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.ZodSharp.Core.ErrorTypeAttribute); - - writer - .XmlSummary( - "Attribute to mark a static ErrorType field for strongly-typed helper generation.", - "When applied to a static ErrorType field in a partial class, Create/Throw helpers are generated at compile time." - ) - .AttributeClass( - new TypeDeclarationOptions(TypeLibrary.ZodSharp.Core.ErrorTypeAttribute), - AttributeTargets.Field, - static _ => { }, - inherited: false, - allowMultiple: false - ); - - return writer; + }); } } diff --git a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs b/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs deleted file mode 100644 index cb5c2867..00000000 --- a/src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.ComponentModel; - -namespace Purview.SourceGeneratorFramework; - -[EditorBrowsable(EditorBrowsableState.Never)] -static class CodeWriterExtensions -{ - extension(CodeWriter writer) - { - public CodeWriter Rule(string propertyName, string comparison, string errorCode, string errorMessage) - { - using (writer.IfBlockScope(comparison)) - { - writer.IfBlock( - "errors is null", - ifBody => - ifBody.Assignment( - "errors", - $"new {TypeLibrary.System.Collections.Generic.List.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationError)}()" - ) - ); - writer.OpenDelimitedBlock( - $"errors.Add(new {TypeLibrary.ZodSharp.Core.ValidationError}", - "(", - "));", - bodyWriter => - { - bodyWriter.Write(errorCode.Surround()).Line(","); - bodyWriter.Write(errorMessage.StringLiteral()).Line(","); - bodyWriter.Line($"new[] {{ \"{propertyName}\" }}"); - } - ); - } - - return writer.NewLine(); - } - } -} diff --git a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs index 30cd6c9f..b385182a 100644 --- a/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/AttributeGenHelper.cs @@ -1,4 +1,3 @@ -using Microsoft.CodeAnalysis.Text; using ZodSharp.SourceGenerators.Models; namespace ZodSharp.SourceGenerators.Helpers; @@ -6,7 +5,7 @@ namespace ZodSharp.SourceGenerators.Helpers; static class AttributeGenHelper { /// - /// The name of the optional AllowNulls property on the generated [ZodSchema] attribute. + /// The name of the optional AllowNulls property on the [ZodSchema] attribute (ZodSharp.ZodSchemaAttribute, defined in Purview.ZodSharp). /// /// /// The property is read from the raw AttributeData rather than through @@ -23,206 +22,4 @@ static class AttributeGenHelper /// [assembly: ZodSharpSchemaDefaults(...)]. /// internal const string DateTimeOffsetSerializationPropertyName = "DateTimeOffsetSerialization"; - - public static IEnumerable<(string HintName, SourceText SourceText)> GenerateMarkers() - { - yield return (nameof(ZodSchemaAttribute), ZodSchemaAttribute()); - } - - static SourceText ZodSchemaAttribute() - { - CodeWriter writer = new(GenerationSettings.Create()); - - writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.ZodSharp.ZodSchemaAttribute); - - writer - .XmlSummary( - "Attribute to mark a class for automatic schema generation.", - "When applied to a class, a zero-allocation validator will be generated at compile time." - ) - .AttributeClass( - new(TypeLibrary.ZodSharp.ZodSchemaAttribute), - AttributeTargets.Class | AttributeTargets.Struct, - body => - { - body.XmlSummary( - "Optional name for the generated schema class.", - "If not specified, uses \"{ClassName}Schema\"." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.SchemaName), - PurviewTypeLibrary.System.String.AsTypeReference().Nullable(body), - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - } - ); - - body.XmlSummary("Whether to generate a static Validate method.", "Default is true.") - .Property( - new( - nameof(ZodSchemaAttributeData.GenerateValidateMethod), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "true", - } - ); - - body.XmlSummary( - "Whether to generate a static Parse method (throws on failure).", - $"Default is true." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.GenerateParseMethod), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "true", - } - ); - - body.XmlSummary( - "Whether to enable composition methods (.and(), .or(), .refine()).", - $"Default is true." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.EnableComposition), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "true", - } - ); - - body.XmlSummary( - "Optional name of a custom asynchronous validation method to invoke", - "after synchronous validation. When set, the generator looks for a", - "method with the signature:", - "ValueTask<ValidationResult<T>> MethodName(T value, CancellationToken cancellationToken)", - "declared as a static method on the model type or an instance method on the", - "generated {TypeName}SchemaValidator partial.", - "If null, the default name CustomValidationAsync is used.", - "No diagnostic is reported when the default name has no matching method.", - "Mutually exclusive with the OnZodValidate refinement hook (ZODSGEN029)." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.CustomValidationMethodName), - PurviewTypeLibrary.System.String.AsTypeReference().Nullable(body), - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - } - ); - - body.XmlSummary( - "Whether to generate an IValidateOptions<T> validator for this type.", - "Generation is driven by auto-detection by default: enabled when the type name ends", - "with a configured suffix (e.g. Options or Settings) and the", - "ZodSharpAutoGenerateOptionsValidators MSBuild property is not disabled.", - "Set to true to force generation or set SuppressIValidateOptions to opt out." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.GenerateIValidateOptions), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "false", - } - ); - - body.XmlSummary( - "Suppresses generation of the IValidateOptions<T> validator for this type,", - "even when auto-detection would otherwise enable it." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.SuppressIValidateOptions), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "false", - } - ); - - body.XmlSummary( - "Whether enum properties are validated automatically: the generated validator rejects a", - "value that is not a defined member of the enum type, or that resolves to a member", - "excluded from the allowed set (an enum member marked [ZodIgnore], or a value the", - "property's [DeniedValues] attribute lists). [Flags] enums are never validated.", - "Default is true." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.ValidateEnumValues), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "true", - } - ); - - body.XmlSummary( - "Whether to generate the JSON Schema export members for this type: the static", - "BuildJsonSchema method and the IJsonSchemaProvider implementation on the", - "generated validator adapter. Set to false to keep a validation-only type out of the", - "cross-platform (TypeScript/JavaScript) contract surface. Default is true.", - "A type whose export is disabled cannot be referenced by an exported type; the generator", - "falls back to an empty schema and the analyzer reports ZODSGEN045." - ) - .Property( - new( - nameof(ZodSchemaAttributeData.GenerateJsonSchema), - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "true", - } - ); - - body.XmlSummary( - "Default for whether this type's collection properties allow null elements.", - "When unset, the assembly-level default applies (the ZodSharpAllowNulls MSBuild", - "property, overridden by [assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]).", - "When set, it applies to this type's collections unless a property overrides it with", - "[CollectionBehavior(AllowNulls = ...)] or an explicitly nullable element type", - "(T?). The built-in default is to reject null elements." - ) - .Property( - new( - AllowNullsPropertyName, - PurviewTypeLibrary.System.Boolean, - TypeDeclarationAccessibility.Public - ) - { - IsInitOnly = true, - Initializer = "false", - } - ); - } - ); - - return writer; - } } diff --git a/src/src/SourceGenerators/Helpers/CodeGenHelpers.cs b/src/src/SourceGenerators/Helpers/CodeGenHelpers.cs index eba30f15..4f05fd13 100644 --- a/src/src/SourceGenerators/Helpers/CodeGenHelpers.cs +++ b/src/src/SourceGenerators/Helpers/CodeGenHelpers.cs @@ -2,7 +2,14 @@ namespace ZodSharp.SourceGenerators.Helpers; static class CodeGenHelpers { - public static string GetPathFieldName(string propertyName) => $"Path_{propertyName}"; + public static string GetMemberFieldName(string propertyName) => $"Member_{propertyName}"; + + public static string GetMemberMethodName(string propertyName) => $"ValidateMember_{propertyName}"; + + public static string GetMemberAsyncMethodName(string propertyName) => $"ValidateMemberAsync_{propertyName}"; + + public static string GetAsyncAttributeFieldName(string propertyName, int index) => + $"AsyncAttribute_{propertyName}_{index.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; public static string GetLocalIdentifier(string propertyName, string suffix) => string.IsNullOrEmpty(propertyName) diff --git a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs index dcf3d0f9..52361971 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -101,6 +101,11 @@ public static EquatableArray Resolve( if (IsBuiltInDataAnnotation(attributeClass)) continue; + // So do the collection-count attributes on a collection: they are emitted through the [MinLength]/ + // [MaxLength]/[Length] collection-size path (SourceGenLibrary.ApplyCollectionCountAttributes). + if (IsCollectionCountAttribute(attributeClass) && IsCountableCollectionType(ruleTargetType)) + continue; + if (!TryGetRuleMapping(attributeClass, out var mapping)) continue; @@ -221,6 +226,36 @@ out var unmappedParameterName static bool IsBuiltInDataAnnotation(INamedTypeSymbol attributeClass) => attributeClass.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.SystemDataAnnotations; + /// + /// Determines whether is one of the ZodSharp collection-count attributes + /// ([MinCount], [MaxCount], [CountRange]) generated from the built-in count rules. + /// + /// The attribute type to test. + /// for a collection-count attribute. + internal static bool IsCollectionCountAttribute(INamedTypeSymbol attributeClass) => + attributeClass.Name is "MinCountAttribute" or "MaxCountAttribute" or "CountRangeAttribute" + && attributeClass.ContainingNamespace.ToDisplayString() == TypeLibraryGenerator.ZodSharpRulesNamespace; + + /// + /// Determines whether is collection-shaped: an array or a non-string type that + /// implements IEnumerable, so its element count is what a size rule constrains. + /// + /// The member type, optionally . + /// for a collection-shaped type. + internal static bool IsCountableCollectionType(ITypeSymbol type) + { + type = TypeHelpers.UnwrapNullableType(type); + + return type is IArrayTypeSymbol + || ( + type.SpecialType != SpecialType.System_String + && ( + TypeHelpers.IsOrImplements(type, TypeLibrary.System.Collections.IEnumerable) + || TypeHelpers.IsOrImplements(type, TypeLibrary.System.Collections.Generic.IEnumerable) + ) + ); + } + /// /// Determines whether is mapped to a validation rule through /// [ZodRule(typeof(...))]. Used by the analyzer to flag rule attributes that can never run. @@ -1487,7 +1522,7 @@ static bool CanPassNull(ITypeSymbol type) => || type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } || (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.None); - static bool TryConvertConstant(TypedConstant constant, ITypeSymbol targetType, out string expression) + internal static bool TryConvertConstant(TypedConstant constant, ITypeSymbol targetType, out string expression) { if (constant.IsNull) { diff --git a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index 2f0617a3..75666145 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -72,7 +72,7 @@ static class DiagnosticLibrary public static readonly DiagnosticDescriptor CustomValidationInvalidParameterCount = new( id: "ZODSGEN009", title: "Invalid custom validation method parameter count", - messageFormat: "Custom validation method '{0}' on schema type '{1}' must accept exactly two parameters: (T value, CancellationToken cancellationToken)", + messageFormat: "Custom validation method '{0}' on schema type '{1}' must accept (T value, CancellationToken cancellationToken) or, to use the caller's services, (T value, IServiceProvider services, CancellationToken cancellationToken)", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true @@ -90,7 +90,16 @@ static class DiagnosticLibrary public static readonly DiagnosticDescriptor CustomValidationInvalidCancellationToken = new( id: "ZODSGEN011", title: "Invalid custom validation method cancellation token parameter", - messageFormat: "Custom validation method '{0}' on schema type '{1}' must have 'CancellationToken' as its second parameter", + messageFormat: "Custom validation method '{0}' on schema type '{1}' must have 'CancellationToken' as its last parameter", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor CustomValidationInvalidServicesParameter = new( + id: "ZODSGEN054", + title: "Invalid custom validation method services parameter", + messageFormat: "Custom validation method '{0}' on schema type '{1}' takes three parameters, so its second parameter must be 'IServiceProvider' (T value, IServiceProvider services, CancellationToken cancellationToken)", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true @@ -469,12 +478,108 @@ static class DiagnosticLibrary /// /// ZODSGEN046: a [CollectionBehavior] attribute is applied to a property that is not a collection, - /// or whose elements cannot be null (a non-nullable value element), so the setting can never take effect. + /// or whose configured settings cannot take effect. /// public static readonly DiagnosticDescriptor CollectionBehaviorNotApplicable = new( id: "ZODSGEN046", title: "Collection behavior attribute is not applicable", - messageFormat: "[CollectionBehavior] on '{0}' has no effect: the property is not a collection of nullable elements", + messageFormat: "[CollectionBehavior] on '{0}' has no effect: the configured options do not apply to this collection type", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN047: [ValidatableType] (Microsoft.Extensions.Validation) on a [ZodSchema] type is + /// redundant: the ZodSharp bridge resolves the generated validator first, and the attribute makes the M.E.V + /// generator emit a second, DataAnnotations-only validator for the same type. + /// + public static readonly DiagnosticDescriptor ValidatableTypeRedundant = new( + id: "ZODSGEN047", + title: "[ValidatableType] is redundant on a [ZodSchema] type", + messageFormat: "[ValidatableType] on '{0}' is redundant: the type is validated by its generated ZodSharp schema", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN048: a type has rules that only run asynchronously (an AsyncValidationAttribute or + /// IAsyncValidatableObject) but a synchronous Validate is generated; it throws rather than + /// skip those rules. + /// + public static readonly DiagnosticDescriptor AsyncOnlyRulesWithSyncValidate = new( + id: "ZODSGEN048", + title: "Type has asynchronous-only rules but a synchronous Validate is generated", + messageFormat: "'{0}' has rules that only run asynchronously ({1}); the generated synchronous Validate throws InvalidOperationException, so call ValidateAsync", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN049: a member is typed as a closed class whose derived type has no [ZodSchema], so a value + /// of that derived type is not validated. + /// + public static readonly DiagnosticDescriptor ClosedHierarchyDerivedTypeWithoutSchema = new( + id: "ZODSGEN049", + title: "Closed-hierarchy member has a derived type without [ZodSchema]", + messageFormat: "Property '{0}' is typed as the closed type '{1}', but the derived type '{2}' has no [ZodSchema], so a value of that type is not validated", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN050: a generated IValidateOptions validator would conflict with a hand-authored + /// [OptionsValidator] class (SYSLIB1205); the ZodSharp validator is not generated. + /// + public static readonly DiagnosticDescriptor OptionsValidatorConflict = new( + id: "ZODSGEN050", + title: "Generated options validator conflicts with [OptionsValidator]", + messageFormat: "'{0}' would get a generated IValidateOptions validator, but '{1}' is an [OptionsValidator] class (SYSLIB1205); keep one generator by removing [OptionsValidator] or setting [ZodSchema(SuppressIValidateOptions = true)]. The ZodSharp validator was not generated.", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN051: a [ZodSchema] type is declared in a Razor component. Razor compiles components with its + /// own source generator, and generators cannot see each other's output, so no schema is generated. + /// + public static readonly DiagnosticDescriptor ZodSchemaInRazorFile = new( + id: "ZODSGEN051", + title: "[ZodSchema] type declared in a Razor file", + messageFormat: "The [ZodSchema] type '{0}' is declared in a Razor file, which the ZodSharp source generator cannot see; move it to a .cs file", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN052: an attribute's ErrorMessage uses a positional placeholder the attribute does not + /// supply, or an unbalanced brace. The message is formatted with a never-throw engine, so the text is shown + /// literally rather than failing; the diagnostic catches the typo at build time. + /// + public static readonly DiagnosticDescriptor UnresolvableMessagePlaceholder = new( + id: "ZODSGEN052", + title: "Error message placeholder cannot be resolved", + messageFormat: "The ErrorMessage of [{0}] on '{1}' contains '{2}', which the attribute does not supply; it is shown literally", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// ZODSGEN053: [MinLength], [MaxLength] or [Length] is applied to a collection member of a + /// [ZodSchema] type in a project that is trimmed or AOT-compiled (or analyzed as such). The BCL attributes' + /// constructors are [RequiresUnreferencedCode], so every usage raises IL2026; the ZodSharp collection-count + /// attributes validate the same count, with the same error identity, without it. + /// + public static readonly DiagnosticDescriptor UseTrimSafeCollectionCountAttribute = new( + id: "ZODSGEN053", + title: "Use the trim-safe ZodSharp collection-count attribute", + messageFormat: "[{0}] on the collection member '{1}' is not trim-safe (its constructor is [RequiresUnreferencedCode]); use [{2}] from ZodSharp.Rules, which the generator validates identically", category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true diff --git a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs index d1669df7..18565047 100644 --- a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs +++ b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs @@ -141,8 +141,44 @@ static bool IsPubliclyAccessible(INamedTypeSymbol type) return true; } - static bool HasSchemaShape(INamedTypeSymbol type) => - type.TypeKind is TypeKind.Class or TypeKind.Struct && !type.IsAbstract && !type.IsStatic; + /// + /// Reports the entry points an existing (external) schema exposes: whether it was generated with the + /// depth-aware, async and member-resolution methods (ValidateCore), and the value of its + /// RequiresAsyncValidation constant. A schema generated by an older ZodSharp has neither. + /// + /// The complex type whose existing schema is inspected. + /// The features; both false when there is no existing schema. + public (bool IsModern, bool RequiresAsync) GetExistingSchemaFeatures(INamedTypeSymbol type) + { + if (!TryGetExistingSchema(type, out var schema)) + return (false, false); + + if (_compilation.GetTypeByMetadataName(schema.MetadataFullName) is not INamedTypeSymbol schemaSymbol) + return (false, false); + + var isModern = schemaSymbol.GetMembers("ValidateCore").Length > 0; + var requiresAsync = false; + foreach (var member in schemaSymbol.GetMembers("RequiresAsyncValidation")) + { + if (member is IFieldSymbol { HasConstantValue: true, ConstantValue: true }) + requiresAsync = true; + } + + return (isModern, requiresAsync); + } + + /// + /// Whether a type can own a generated schema: a concrete class or struct. A C# 15 union is validated by + /// dispatching on its active case, so it only owns a schema when it is marked [ZodSchema] itself. + /// + internal static bool HasSchemaShape(INamedTypeSymbol type) => + type.TypeKind is TypeKind.Class or TypeKind.Struct + && !type.IsAbstract + && !type.IsStatic + && !( + ValidationInputsResolver.RoslynFeatures.IsUnion(type) + && !Models.ZodSchemaAttributeData.FromAttributeData(type, out _).Exists + ); /// /// The cached outcome of resolving an existing schema: the schema identity when one was found. diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs index 2d67488a..33c0cc3b 100644 --- a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs +++ b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs @@ -19,7 +19,8 @@ internal readonly record struct AutoDetectSettings( bool EnumAsString, EnumJsonNamingPolicy EnumNamingPolicy, bool AllowNulls, - DateTimeOffsetSerialization DateTimeOffsetSerialization + DateTimeOffsetSerialization DateTimeOffsetSerialization, + DefaultImmutableArrayBehavior DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.ValidationError ); /// @@ -28,7 +29,8 @@ DateTimeOffsetSerialization DateTimeOffsetSerialization /// internal readonly record struct AssemblyDefaults( bool? AllowNulls, - DateTimeOffsetSerialization? DateTimeOffsetSerialization + DateTimeOffsetSerialization? DateTimeOffsetSerialization, + DefaultImmutableArrayBehavior? DefaultImmutableArrayBehavior = null ); public static AutoDetectSettings Read(AnalyzerConfigOptionsProvider provider) @@ -36,7 +38,7 @@ public static AutoDetectSettings Read(AnalyzerConfigOptionsProvider provider) // Auto-detection is on by default; only an explicit "false" disables it. var autoGenerate = !provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsProperty}", + PropertyLibrary.BuildPropertyKey(PropertyLibrary.AutoGenerateIValidateOptionsProperty), out var autoGenerateValue ) || !bool.TryParse(autoGenerateValue, out var autoGenerateEnabled) @@ -44,7 +46,7 @@ out var autoGenerateValue var suffixes = ParseAutoGenerateIValidateOptionsSuffixes( provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AutoGenerateIValidateOptionsSuffixesProperty}", + PropertyLibrary.BuildPropertyKey(PropertyLibrary.AutoGenerateIValidateOptionsSuffixesProperty), out var suffixValue ) ? suffixValue @@ -54,7 +56,7 @@ out var suffixValue // JSON Schema export is on by default; the assembly-level switch disables it for every type. var generateJsonSchemas = !provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.GenerateJsonSchemasProperty}", + PropertyLibrary.BuildPropertyKey(PropertyLibrary.GenerateJsonSchemasProperty), out var generateJsonSchemasValue ) || !bool.TryParse(generateJsonSchemasValue, out var generateJsonSchemasEnabled) @@ -69,7 +71,7 @@ out var generateJsonSchemasValue // them; only an explicit true enables it, so an unparsable value keeps the default. var allowNulls = provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.AllowNullsProperty}", + PropertyLibrary.BuildPropertyKey(PropertyLibrary.AllowNullsProperty), out var allowNullsValue ) && bool.TryParse(allowNullsValue, out var allowNullsEnabled) @@ -85,7 +87,13 @@ out var allowNullsValue enumAsString, enumNamingPolicy, allowNulls, - dateTimeOffsetSerialization + dateTimeOffsetSerialization, + provider.GlobalOptions.TryGetValue( + PropertyLibrary.BuildPropertyKey(PropertyLibrary.DefaultImmutableArrayBehaviorProperty), + out var defaultArrayBehavior + ) && string.Equals(defaultArrayBehavior?.Trim(), "TreatAsEmpty", StringComparison.OrdinalIgnoreCase) + ? DefaultImmutableArrayBehavior.TreatAsEmpty + : DefaultImmutableArrayBehavior.ValidationError ); } @@ -111,8 +119,12 @@ out var allow : (bool?)null; DateTimeOffsetSerialization? dateTimeOffsetSerialization = null; + DefaultImmutableArrayBehavior? defaultImmutableArrayBehavior = null; foreach (var argument in attribute.NamedArguments) { + if (argument.Key == "DefaultImmutableArrayBehavior" && argument.Value.Value is int behavior) + defaultImmutableArrayBehavior = (DefaultImmutableArrayBehavior)behavior; + if ( argument.Key == AttributeGenHelper.DateTimeOffsetSerializationPropertyName && argument.Value.Value is int serialization @@ -122,7 +134,7 @@ out var allow } } - return new(allowNulls, dateTimeOffsetSerialization); + return new(allowNulls, dateTimeOffsetSerialization, defaultImmutableArrayBehavior); } return new(null, null); @@ -131,7 +143,7 @@ out var allow static DateTimeOffsetSerialization ReadDateTimeOffsetSerialization(AnalyzerConfigOptionsProvider provider) { var value = provider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.DateTimeOffsetSerializationProperty}", + PropertyLibrary.BuildPropertyKey(PropertyLibrary.DateTimeOffsetSerializationProperty), out var configured ) ? configured?.Trim() @@ -143,12 +155,16 @@ out var configured } static bool ReadEnumSerializationAsString(AnalyzerConfigOptionsProvider provider) => - provider.GlobalOptions.TryGetValue($"build_property:{PropertyLibrary.EnumSerializationProperty}", out var value) - && string.Equals(value?.Trim(), "String", StringComparison.OrdinalIgnoreCase); + provider.GlobalOptions.TryGetValue( + PropertyLibrary.BuildPropertyKey(PropertyLibrary.EnumSerializationProperty), + out var value + ) && string.Equals(value?.Trim(), "String", StringComparison.OrdinalIgnoreCase); static EnumJsonNamingPolicy ReadEnumNamingPolicy(AnalyzerConfigOptionsProvider provider) => - provider.GlobalOptions.TryGetValue($"build_property:{PropertyLibrary.EnumNamingPolicyProperty}", out var value) - && string.Equals(value?.Trim(), "CamelCase", StringComparison.OrdinalIgnoreCase) + provider.GlobalOptions.TryGetValue( + PropertyLibrary.BuildPropertyKey(PropertyLibrary.EnumNamingPolicyProperty), + out var value + ) && string.Equals(value?.Trim(), "CamelCase", StringComparison.OrdinalIgnoreCase) ? EnumJsonNamingPolicy.CamelCase : EnumJsonNamingPolicy.Unchanged; @@ -231,7 +247,7 @@ AssemblyDefaults assemblyDefaults changed = true; } - var resolvedProperties = ResolveAllowNulls(schema.Properties, settings, assemblyDefaults); + var resolvedProperties = ResolveCollectionBehavior(schema.Properties, settings, assemblyDefaults); if (resolvedProperties is { } properties) { schema = schema with { Properties = properties }; @@ -258,14 +274,13 @@ AssemblyDefaults assemblyDefaults } /// - /// Resolves each collection property's AllowNulls against the assembly defaults when neither the - /// property nor its containing type stated one. + /// Resolves each property's collection behavior overrides against the assembly and MSBuild defaults. /// /// The schema's properties. /// The assembly-level MSBuild settings. /// The source-level assembly defaults. /// The rewritten properties, or when no property changed. - static EquatableArray>? ResolveAllowNulls( + static EquatableArray>? ResolveCollectionBehavior( EquatableArray> properties, AutoDetectSettings settings, AssemblyDefaults assemblyDefaults @@ -286,13 +301,25 @@ AssemblyDefaults assemblyDefaults var property = propertyResult.Value; var resolved = property.AllowNulls ?? assemblyDefaults.AllowNulls ?? settings.AllowNulls; - if (property.AllowNulls == resolved) + var arrayBehavior = + property.DefaultImmutableArrayBehavior + ?? assemblyDefaults.DefaultImmutableArrayBehavior + ?? settings.DefaultImmutableArrayBehavior; + if (property.AllowNulls == resolved && property.DefaultImmutableArrayBehavior == arrayBehavior) { builder.Add(propertyResult); continue; } - builder.Add(GeneratorResult.Create(property with { AllowNulls = resolved })); + builder.Add( + GeneratorResult.Create( + property with + { + AllowNulls = resolved, + DefaultImmutableArrayBehavior = arrayBehavior, + } + ) + ); changed = true; } diff --git a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs index 22224de0..f791c98f 100644 --- a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs @@ -2,6 +2,19 @@ namespace ZodSharp.SourceGenerators.Helpers; static class PropertyLibrary { + /// + /// The prefix the compiler gives an MSBuild property declared as CompilerVisibleProperty in the + /// generated globalconfig (build_property.Name = value). The separator is a dot, not a colon. + /// + public const string BuildPropertyPrefix = "build_property."; + + /// + /// Builds the analyzer config key the compiler uses for an MSBuild property. + /// + /// The MSBuild property name. + /// The build_property.{propertyName} key. + public static string BuildPropertyKey(string propertyName) => BuildPropertyPrefix + propertyName; + public const string DisableZodSharpSourceGeneratorProperty = "DisableZodSharpSourceGenerator"; public const string DisableErrorTypeGeneratorProperty = "DisableErrorTypeGenerator"; @@ -18,5 +31,7 @@ static class PropertyLibrary public const string AllowNullsProperty = "ZodSharpAllowNulls"; + public const string DefaultImmutableArrayBehaviorProperty = "ZodSharpDefaultImmutableArrayBehavior"; + public const string DateTimeOffsetSerializationProperty = "ZodSharpDateTimeOffsetSerialization"; } diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs index b68daf7f..65dc4db5 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs @@ -115,13 +115,50 @@ AttributeData zodSchemaAttribute } // Exactly one valid method. + var method = validCandidates[0]; return new CustomValidationMethodData( IsConfigured: true, Exists: true, IsValid: true, MethodName: methodName, InvocationKind: invocationKind - ); + ) + { + RequiresServices = method.Parameters.Length == 3, + AsyncMembers = GetAsyncMembers(method), + }; + } + + /// + /// Reads the member names declared by [ZodAsyncMember(...)] on the custom validation method, in + /// declaration order and without duplicates. + /// + static EquatableArray GetAsyncMembers(IMethodSymbol method) + { + var builder = ImmutableArray.CreateBuilder(); + HashSet seen = [with(StringComparer.Ordinal)]; + foreach (var attribute in method.GetAttributes()) + { + if ( + attribute.AttributeClass is not { Name: "ZodAsyncMemberAttribute" } attributeClass + || attributeClass.ContainingNamespace?.ToDisplayString() != TypeLibraryGenerator.ZodSharpNamespace + ) + { + continue; + } + + foreach (var argument in attribute.ConstructorArguments) + { + var values = argument.Kind == TypedConstantKind.Array ? argument.Values : [argument]; + foreach (var value in values) + { + if (value.Value is string { Length: > 0 } name && seen.Add(name)) + builder.Add(name); + } + } + } + + return new(builder.ToImmutable()); } static (INamedTypeSymbol?, List) FindCandidatesFromSchemaValidator( @@ -264,8 +301,9 @@ param.RefKind is RefKind.Ref or RefKind.In or RefKind.Out } } - // Must have exactly two parameters. - if (method.Parameters.Length != 2) + // Must have two parameters (value, CancellationToken), or three for the service-aware shape + // (value, IServiceProvider, CancellationToken). + if (method.Parameters.Length is not (2 or 3)) { diagnostics.Add( ReportableDiagnostic.Create( @@ -296,8 +334,22 @@ param.RefKind is RefKind.Ref or RefKind.In or RefKind.Out ); } - // Second parameter must be CancellationToken. - var secondParam = method.Parameters[1]; + // A service-aware method takes the caller's services second. + if (method.Parameters.Length == 3 && !IsServiceProvider(method.Parameters[1].Type)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.CustomValidationInvalidServicesParameter, + true, + methodLocation, + method.Name, + typeName + ) + ); + } + + // The last parameter must be CancellationToken. + var secondParam = method.Parameters[method.Parameters.Length - 1]; if (!TypeLibraryGenerator.CancellationToken.Equals(secondParam.Type)) { diagnostics.Add( @@ -360,6 +412,10 @@ static TypeIdentity GetExpectedReturnType(INamedTypeSymbol classSymbol) => ) ); + static bool IsServiceProvider(ITypeSymbol type) => + type is INamedTypeSymbol { Name: "IServiceProvider", ContainingNamespace.Name: "System" } named + && named.ContainingNamespace.ContainingNamespace is { IsGlobalNamespace: true }; + static bool IsValidIdentifier(string name) => !string.IsNullOrWhiteSpace(name) && SyntaxFacts.IsValidIdentifier(name); } diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 85143787..28cf94d9 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -36,6 +36,14 @@ IncrementalGeneratorInitializationContext context TypeLibrary.System.ComponentModel.DataAnnotations.RequiredAttribute ), HasIValidateOptions = HasIValidateOptionsType(compilation), + HasIAsyncValidateOptions = + compilation.GetTypeByMetadataName("Microsoft.Extensions.Options.IAsyncValidateOptions`1") + is not null, + HasServiceCollection = + compilation.GetTypeByMetadataName("Microsoft.Extensions.DependencyInjection.IServiceCollection") + is not null + && compilation.GetTypeByMetadataName("ZodSharp.Core.ZodSchemaRegistration") is not null, + AssemblyName = compilation.AssemblyName ?? string.Empty, }, PropertyLibrary.DisableZodSharpSourceGeneratorProperty ); @@ -137,6 +145,8 @@ CancellationToken cancellationToken if (customValidation.Value.HasCustomValidation && refinementHook.Value.IsImplemented) customValidation = CustomValidationMethodData.None; var isValueType = symbol.TypeKind == TypeKind.Struct; + var (implementsValidatableObject, implementsAsyncValidatableObject) = + ValidationInputsResolver.GetValidatableObjectKinds(symbol); // A Purview.ValueObjects scalar serialises as its underlying value, so its JSON Schema is the // underlying type's schema (for example a Guid scalar is a canonical Guid string, not Zod's @@ -186,6 +196,11 @@ CancellationToken cancellationToken default, typeDateTimeOffsetSerialization ) + { + ImplementsValidatableObject = implementsValidatableObject, + ImplementsAsyncValidatableObject = implementsAsyncValidatableObject, + HasOptionsValidatorConflict = ValidationInputsResolver.HasOptionsValidatorConflict(symbol), + } ); foreach ( @@ -206,11 +221,48 @@ var property in symbol if (TryGetNestedSchemaType(property, externalSchemas, out var nested)) queue.Enqueue((nested, false)); } + + EnqueueUnionCaseTypes(symbol, externalSchemas, queue); } return GeneratorResult.Create(new SchemaSet(schemas.ToImmutable())); } + /// + /// Queues the case types of a type's C# 15 union members, so they get schemas like nested types do and + /// the member can dispatch to them (Change 7). A closed hierarchy's derived types need their own + /// [ZodSchema]. + /// + static void EnqueueUnionCaseTypes( + INamedTypeSymbol symbol, + ExternalSchemaResolver externalSchemas, + Queue<(INamedTypeSymbol Symbol, bool IsPrimary)> queue + ) + { + foreach (var member in symbol.GetMembers()) + { + if ( + member + is not IPropertySymbol + { + DeclaredAccessibility: Accessibility.Public, + IsStatic: false + } unionProperty + || TypeHelpers.UnwrapNullableType(unionProperty.Type) is not INamedTypeSymbol unionType + || !ValidationInputsResolver.RoslynFeatures.IsUnion(unionType) + ) + { + continue; + } + + foreach (var caseType in ValidationInputsResolver.GetUnionCaseTypes(unionType)) + { + if (externalSchemas.IsSchemaOwnedByThisCompilation(caseType)) + queue.Enqueue((caseType, false)); + } + } + } + static bool IsPropertyWithNestedSchema(IPropertySymbol property, ExternalSchemaResolver externalSchemas) { var propertyType = TypeHelpers.UnwrapNullableType(property.Type); @@ -562,6 +614,17 @@ out var attribute ); } + ApplyCollectionCountAttributes( + property.Name, + validationAttributes, + originalPropertyType, + validationKind == PropertyValidationKind.Collection && lengthAccessor.IsSupported, + ref minLengthAttribute, + ref maxLengthAttribute, + ref lengthAttribute, + diagnostics + ); + var rangeAttribute = RangeAttributeData.FromAttributeData(validationAttributes, out attribute); var rangeAttributeResult = TryBuildRangeBoundaryExpressions( originalPropertyType, @@ -589,6 +652,7 @@ rangeAttribute with ValidateCompareProperty(property, validationAttributes, compareAttribute, diagnostics); ValidateErrorMessageResourceConfiguration(validationAttributes, property.Name, diagnostics); + ValidateErrorMessagePlaceholders(validationAttributes, property.Name, diagnostics); diagnostics.AddRange(regularExpressionAttribute.Diagnostics); diagnostics.AddRange(lengthAttribute.Diagnostics); @@ -610,6 +674,11 @@ rangeAttribute with diagnostics ); + var (dispatchKind, dispatchCases) = nestedSchemaType is null + ? ValidationInputsResolver.ResolveDispatch(originalPropertyType, externalSchemas) + : (DispatchKind.None, new EquatableArray([])); + var (nestedIsModern, nestedRequiresAsync) = GetNestedSchemaFeatures(property, validationKind, externalSchemas); + var compareViaCompareTo = validationKind == PropertyValidationKind.Comparable && originalPropertyType is INamedTypeSymbol namedPropertyType @@ -659,11 +728,56 @@ rangeAttribute with enumSerializedAsString, enumJsonNames, dateTimeOffsetSerialization - ), + ) + { + IsImmutableArray = IsImmutableArray(originalPropertyType), + DefaultImmutableArrayBehavior = GetDefaultImmutableArrayBehavior(validationAttributes), + DisplayNameResourceExpression = ValidationInputsResolver.GetDisplayNameResourceExpression( + validationAttributes + ), + NestedSchemaIsModern = nestedIsModern, + NestedIsScalarValueObject = IsNestedScalarValueObject( + validationKind, + nestedSchemaType, + originalPropertyType + ), + ExternalNestedSchemaRequiresAsync = nestedRequiresAsync, + AsyncValidationAttributes = ValidationInputsResolver.GetAsyncValidationAttributes(validationAttributes), + DispatchKind = dispatchKind, + DispatchCases = dispatchCases, + }, diagnostics.ToImmutable() ); } + /// + /// Reports whether a member's nested schema exposes the depth-aware, async and member-resolution entry + /// points, and whether an external one requires asynchronous validation. A schema generated into this + /// compilation always has the entry points; one generated by an older ZodSharp may not. + /// + static (bool IsModern, bool RequiresAsync) GetNestedSchemaFeatures( + IPropertySymbol property, + PropertyValidationKind validationKind, + ExternalSchemaResolver? externalSchemas + ) + { + if ( + externalSchemas is null + || validationKind is not (PropertyValidationKind.Complex or PropertyValidationKind.Collection) + ) + return (true, false); + + var targetType = + validationKind == PropertyValidationKind.Collection + ? GetCollectionItemTypeSymbol(property.Type) + : property.Type; + targetType = targetType is null ? null : TypeHelpers.UnwrapNullableType(targetType); + + return targetType is not INamedTypeSymbol namedType || externalSchemas.IsSchemaOwnedByThisCompilation(namedType) + ? (true, false) + : externalSchemas.GetExistingSchemaFeatures(namedType); + } + /// /// Determines whether the nested complex type a property references exports JSON Schema. A nested type /// can opt out with [ZodSchema(GenerateJsonSchema = false)], in which case the parent's schema @@ -922,6 +1036,7 @@ candidate is null /// static ITypeSymbol? GetCollectionItemTypeSymbol(ITypeSymbol type) { + type = TypeHelpers.UnwrapNullableType(type); if (type is IArrayTypeSymbol arrayType) return arrayType.ElementType; @@ -1064,6 +1179,27 @@ attribute is not null return typeAllowNulls; } + static DefaultImmutableArrayBehavior? GetDefaultImmutableArrayBehavior(ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + if (!TypeLibrary.ZodSharp.CollectionBehaviorAttribute.Equals(attribute.AttributeClass)) + continue; + + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "DefaultImmutableArrayBehavior" && argument.Value.Value is int behavior) + return (DefaultImmutableArrayBehavior)behavior; + } + } + + return null; + } + + static bool IsImmutableArray(ITypeSymbol type) => + type is INamedTypeSymbol { Name: "ImmutableArray", Arity: 1 } named + && named.ContainingNamespace.ToDisplayString() == "System.Collections.Immutable"; + /// /// Determines whether a collection element type is explicitly declared nullable — T? (an /// annotated reference type) or Nullable<T> — which opts the element out of the default @@ -1082,13 +1218,30 @@ static bool IsExplicitlyNullableElement(ITypeSymbol elementType) => /// /// Determines whether a [CollectionBehavior] attribute can take effect on a property: it must be a - /// collection whose elements can be null. + /// collection whose elements can be null, or an immutable array with a configured default-state policy. /// /// The property to inspect. + /// The compilation used to resolve positional parameter attributes. /// when the attribute is meaningful for the property. - internal static bool IsCollectionBehaviorApplicable(IPropertySymbol property) + internal static bool IsCollectionBehaviorApplicable(IPropertySymbol property, Compilation compilation) { var type = TypeHelpers.UnwrapNullableType(property.Type); + var attributes = GetValidationAttributes(property, compilation); + if (GetDefaultImmutableArrayBehavior(attributes) is not null) + { + if (IsImmutableArray(type)) + return true; + + if ( + !attributes.Any(attribute => + TypeLibrary.ZodSharp.CollectionBehaviorAttribute.Equals(attribute.AttributeClass) + && attribute.NamedArguments.Any(static argument => + argument.Key == AttributeGenHelper.AllowNullsPropertyName + ) + ) + ) + return false; + } if ( type is not IArrayTypeSymbol && !TypeHelpers.IsOrImplements(type, TypeLibrary.System.Collections.IEnumerable) @@ -1102,6 +1255,20 @@ type is not IArrayTypeSymbol return elementType is not null && TypeHelpers.CanBeNull(elementType); } + /// + /// Whether a complex member's nested schema is a Purview.ValueObjects scalar: the scalar's own member (Value) + /// is an implementation detail, so its errors are named after the member that holds it. + /// + static bool IsNestedScalarValueObject( + PropertyValidationKind validationKind, + TypeIdentity? nestedSchemaType, + ITypeSymbol propertyType + ) => + validationKind == PropertyValidationKind.Complex + && nestedSchemaType is not null + && propertyType is INamedTypeSymbol namedType + && CustomRuleResolver.TryGetScalarAttribute(namedType, out _); + static TypeIdentity? GetNestedSchemaTypeIdentity( IPropertySymbol property, PropertyValidationKind validationKind, @@ -1145,18 +1312,22 @@ static LengthAccessor ClassifyLengthAccessor(ITypeSymbol propertyType) if (propertyType.SpecialType == SpecialType.System_String) return new("propertyValue.Length", "string", true); - if (propertyType is IArrayTypeSymbol) + if (propertyType is IArrayTypeSymbol || IsImmutableArray(propertyType)) return new("propertyValue.Length", "array", true); if (propertyType is INamedTypeSymbol namedType) { - if (TypeHelpers.IsOrImplements(namedType, TypeLibrary.System.Collections.Generic.ICollection)) - return new("propertyValue.Count", "collection", true); + // The origin is the Zod type of the JSON the member reads, as the runtime schemas and the null-element + // errors report it: a dictionary is a record, every other collection an array. + var origin = TryGetMapArguments(namedType, out _, out _) ? "record" : "array"; + + if (TypeHelpers.HasAccessibleCountProperty(namedType)) + return new("propertyValue.Count", origin, true); if (TypeHelpers.IsOrImplements(namedType, TypeLibrary.System.Collections.IEnumerable)) return new( "global::ZodSharp.Optimizations.CollectionCountHelper.GetCount(propertyValue)", - "collection", + origin, true ); } @@ -1247,6 +1418,123 @@ ImmutableArray.Builder diagnostics } } + /// + /// Maps the ZodSharp collection-count attributes ([MinCount], [MaxCount], [CountRange]) onto + /// the [MinLength]/[MaxLength]/[Length] slots, so a collection member is validated - and + /// exported to JSON Schema - by the same collection-size emitter, with the same error identity. They are the + /// trim-safe spelling: the BCL attributes' constructors are [RequiresUnreferencedCode]. + /// + /// The member name, for diagnostics. + /// The member's validation attributes. + /// The member type. + /// Whether the member is a collection the size emitter can count. + /// The [MinLength] slot; filled from [MinCount] when it is empty. + /// The [MaxLength] slot; filled from [MaxCount] when it is empty. + /// The [Length] slot; filled from [CountRange] when it is empty. + /// Receives ZODSGEN030 for a count attribute on a member that is not a countable collection. + static void ApplyCollectionCountAttributes( + string propertyName, + ImmutableArray validationAttributes, + ITypeSymbol propertyType, + bool isCountableCollection, + ref MinLengthAttributeData minLength, + ref MaxLengthAttributeData maxLength, + ref GeneratorResult length, + ImmutableArray.Builder diagnostics + ) + { + var isCollectionType = CustomRuleResolver.IsCountableCollectionType(propertyType); + + if ( + MinCountAttributeData.TryFromAttributeData( + validationAttributes, + out var minCount, + out var minCountAttribute + ) + ) + { + if (!ReportUncountable(minCountAttribute, "MinCountRule") && !minLength.Exists) + minLength = new(true, minCount.MinCount, WithMessage(minCount.ValidationAttribute, minCount.Message)); + } + + if ( + MaxCountAttributeData.TryFromAttributeData( + validationAttributes, + out var maxCount, + out var maxCountAttribute + ) + ) + { + if (!ReportUncountable(maxCountAttribute, "MaxCountRule") && !maxLength.Exists) + maxLength = new(true, maxCount.MaxCount, WithMessage(maxCount.ValidationAttribute, maxCount.Message)); + } + + if ( + CountRangeAttributeData.TryFromAttributeData( + validationAttributes, + out var countRange, + out var rangeAttribute + ) + ) + { + if (ReportUncountable(rangeAttribute, "CountRangeRule") || (length.ShouldProcess && length.Value.Exists)) + return; + + LengthAttributeData data = new( + true, + countRange.Minimum, + countRange.Maximum, + WithMessage(countRange.ValidationAttribute, countRange.Message) + ); + + length = + data.MinimumLength > data.MaximumLength + ? GeneratorResult.Create( + data, + ReportableDiagnostic.Create( + DiagnosticLibrary.InvalidLengthAttribute, + true, + GetAttributeLocation(rangeAttribute), + propertyName + ) + ) + : GeneratorResult.Create(data); + } + + // A count attribute on a member that is not a collection: when the member is not collection-shaped at all, + // CustomRuleResolver resolves the attribute as a rule and reports ZODSGEN030 itself (the rule validates + // IEnumerable). Only the collection-shaped member the size emitter cannot count is reported here. + bool ReportUncountable(AttributeData? attribute, string ruleName) + { + if (isCountableCollection) + return false; + + if (isCollectionType && attribute is not null) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.UnsupportedCustomRuleTarget, + true, + GetAttributeLocation(attribute), + ruleName, + attribute.AttributeClass?.Name ?? ruleName, + propertyType.ToDisplayString() + ) + ); + } + + return true; + } + + static ValidationAttributeData WithMessage(ValidationAttributeData validationAttribute, string? message) => + string.IsNullOrEmpty(validationAttribute.ErrorMessage) && !string.IsNullOrEmpty(message) + ? validationAttribute with + { + ErrorMessage = message, + } + : validationAttribute; + } + static GeneratorResult BuildLengthAttributeResult( IPropertySymbol property, LengthAttributeData lengthData, @@ -1386,13 +1674,134 @@ attribute.AttributeClass is null } } + /// + /// Reports (ZODSGEN052) an attribute ErrorMessage that uses a positional placeholder the attribute + /// does not supply, or an unbalanced brace. The message is formatted at runtime by a never-throw engine + /// (defect G2), so this only catches the typo at build time. + /// + static void ValidateErrorMessagePlaceholders( + ImmutableArray attributes, + string propertyName, + ImmutableArray.Builder diagnostics + ) + { + foreach (var attribute in attributes) + { + if ( + attribute.AttributeClass is not { } attributeClass + || !TypeHelpers.InheritsFrom( + attributeClass, + TypeLibrary.System.ComponentModel.DataAnnotations.ValidationAttribute + ) + ) + { + continue; + } + + string? message = null; + foreach (var namedArgument in attribute.NamedArguments) + { + if (namedArgument.Key == "ErrorMessage" && namedArgument.Value.Value is string text) + message = text; + } + + if ( + message is null + || FindUnresolvablePlaceholder(message, GetMaxMessageOrdinal(attributeClass.Name)) is not { } token + ) + continue; + + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.UnresolvableMessagePlaceholder, + false, + GetAttributeLocation(attribute), + attributeClass.Name.EndsWith("Attribute", StringComparison.Ordinal) + ? attributeClass.Name.Substring(0, attributeClass.Name.Length - "Attribute".Length) + : attributeClass.Name, + propertyName, + token + ) + ); + } + } + + /// + /// The highest positional placeholder an attribute's FormatErrorMessage supplies ({0} is the + /// display name). + /// + static int GetMaxMessageOrdinal(string attributeName) => + attributeName switch + { + "StringLengthAttribute" or "LengthAttribute" or "RangeAttribute" => 2, + "MinLengthAttribute" + or "MaxLengthAttribute" + or "RegularExpressionAttribute" + or "CompareAttribute" + or "AllowedValuesAttribute" + or "DeniedValuesAttribute" => 1, + _ => 0, + }; + + /// + /// Returns the first positional placeholder beyond or unbalanced brace, or + /// when the message is well formed. Named placeholders are always accepted. + /// + internal static string? FindUnresolvablePlaceholder(string message, int maxOrdinal) + { + for (var i = 0; i < message.Length; i++) + { + var c = message[i]; + if (c == '}') + { + if (i + 1 < message.Length && message[i + 1] == '}') + { + i++; + continue; + } + + return "}"; + } + + if (c != '{') + continue; + + if (i + 1 < message.Length && message[i + 1] == '{') + { + i++; + continue; + } + + var close = message.IndexOf('}', i + 1); + var nextOpen = message.IndexOf('{', i + 1); + if (close < 0 || (nextOpen >= 0 && nextOpen < close)) + return "{"; + + var token = message.Substring(i + 1, close - i - 1); + var nameEnd = token.IndexOfAny([',', ':']); + var name = (nameEnd < 0 ? token : token.Substring(0, nameEnd)).Trim(); + if ( + name.Length > 0 + && char.IsDigit(name[0]) + && ( + !int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out var ordinal) + || ordinal > maxOrdinal + ) + ) + { + return message.Substring(i, close - i + 1); + } + + i = close; + } + + return null; + } + // Fallback used when no resolver is available (the analyzer call path): a type declared in source is // the type this compilation owns. static bool IsSchemaDefinedInSource(INamedTypeSymbol type) => - type.Locations.Any(static location => location.IsInSource) - && type.TypeKind is TypeKind.Class or TypeKind.Struct - && !type.IsAbstract - && !type.IsStatic; + type.Locations.Any(static location => location.IsInSource) && ExternalSchemaResolver.HasSchemaShape(type); internal static bool TryGetNestedSchemaType( IPropertySymbol property, diff --git a/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs b/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs new file mode 100644 index 00000000..d52d9a96 --- /dev/null +++ b/src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs @@ -0,0 +1,447 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators.Helpers; + +/// +/// Resolves the validation inputs that are not DataAnnotations attribute values: the .NET 11 async +/// DataAnnotations contracts, IValidatableObject, localized display names, C# 15 union and +/// closed-hierarchy members, and conflicting hand-authored options validators. Every result is a value +/// (strings, flags, identities), so nothing from the compilation outlives the transform. +/// +static class ValidationInputsResolver +{ + public const string DataAnnotationsNamespace = "System.ComponentModel.DataAnnotations"; + + const string AsyncValidationAttributeName = "AsyncValidationAttribute"; + + const string ValidatableObjectName = "IValidatableObject"; + + const string AsyncValidatableObjectName = "IAsyncValidatableObject"; + + /// + /// Determines whether implements the DataAnnotations + /// IValidatableObject or (.NET 11) IAsyncValidatableObject interface. + /// + public static (bool Sync, bool Async) GetValidatableObjectKinds(INamedTypeSymbol type) + { + var sync = false; + var isAsync = false; + foreach (var iface in type.AllInterfaces) + { + if (iface.ContainingNamespace?.ToDisplayString() != DataAnnotationsNamespace) + continue; + + if (iface.MetadataName == ValidatableObjectName) + sync = true; + else if (iface.MetadataName == AsyncValidatableObjectName) + isAsync = true; + } + + return (sync, isAsync); + } + + /// + /// Renders a construction expression for every AsyncValidationAttribute declared on a member, so the + /// generated validator can hold an identical instance in a static field and await it. + /// + /// The member's effective attributes. + /// The construction expressions, in declaration order. + public static EquatableArray GetAsyncValidationAttributes(ImmutableArray attributes) + { + ImmutableArray.Builder? expressions = null; + foreach (var attribute in attributes) + { + if (attribute.AttributeClass is not { } attributeClass || !InheritsAsyncValidationAttribute(attributeClass)) + continue; + + if (TryRenderAttributeConstruction(attribute, out var expression)) + (expressions ??= ImmutableArray.CreateBuilder()).Add(expression); + } + + return expressions is null ? new([]) : new(expressions.ToImmutable()); + } + + /// + /// Determines whether derives from the .NET 11 AsyncValidationAttribute. + /// + public static bool InheritsAsyncValidationAttribute(INamedTypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if ( + current.MetadataName == AsyncValidationAttributeName + && current.ContainingNamespace?.ToDisplayString() == DataAnnotationsNamespace + ) + { + return true; + } + } + + return false; + } + + /// + /// Resolves the localized display name expression for [Display(Name = ..., ResourceType = ...)]: the + /// resource type's public static string property named by Name, read directly so the lookup is + /// reflection-free and follows the current UI culture. + /// + /// The member's effective attributes. + /// The expression, or when no resource is declared or it cannot be resolved. + public static string? GetDisplayNameResourceExpression(ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + if ( + attribute.AttributeClass is not { Name: "DisplayAttribute" } attributeClass + || attributeClass.ContainingNamespace?.ToDisplayString() != DataAnnotationsNamespace + ) + { + continue; + } + + ITypeSymbol? resourceType = null; + string? name = null; + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "ResourceType" && argument.Value.Value is ITypeSymbol type) + resourceType = type; + else if (argument.Key == "Name" && argument.Value.Value is string text) + name = text; + } + + if (resourceType is null || string.IsNullOrEmpty(name)) + return null; + + foreach (var member in resourceType.GetMembers(name!)) + { + if ( + member is IPropertySymbol + { + IsStatic: true, + DeclaredAccessibility: Accessibility.Public or Accessibility.Internal, + Type.SpecialType: SpecialType.System_String, + GetMethod: not null, + } + ) + { + return $"{new TypeIdentity(resourceType).RenderFullName}.{name}"; + } + } + + return null; + } + + return null; + } + + /// + /// Resolves how a member typed as a C# 15 union or a closed class dispatches to case schemas. + /// + /// The member type, with a nullable wrapper removed. + /// The resolver deciding schema ownership. + /// The cancellation token. + /// The dispatch kind and cases; when the member does not dispatch. + public static (DispatchKind Kind, EquatableArray Cases) ResolveDispatch( + ITypeSymbol memberType, + ExternalSchemaResolver? externalSchemas, + CancellationToken cancellationToken = default + ) + { + if (memberType is not INamedTypeSymbol namedType) + return (DispatchKind.None, new([])); + + if (RoslynFeatures.IsUnion(namedType)) + { + var cases = ResolveCases(GetUnionCaseTypes(namedType), externalSchemas, requireSchemaAttribute: false); + return cases.Count == 0 ? (DispatchKind.None, cases) : (DispatchKind.Union, cases); + } + + if (namedType.TypeKind == TypeKind.Class && RoslynFeatures.IsClosed(namedType)) + { + var cases = ResolveCases( + RoslynFeatures.GetClosedDerivedTypes(namedType, cancellationToken), + externalSchemas, + requireSchemaAttribute: true + ); + return cases.Count == 0 ? (DispatchKind.None, cases) : (DispatchKind.ClosedHierarchy, cases); + } + + return (DispatchKind.None, new([])); + } + + /// + /// Gets the case types of a C# 15 union: the parameter types of its single-parameter case constructors. + /// + public static ImmutableArray GetUnionCaseTypes(INamedTypeSymbol union) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var constructor in union.InstanceConstructors) + { + if ( + constructor.DeclaredAccessibility != Accessibility.Public + || constructor.Parameters.Length != 1 + || constructor.Parameters[0].Type is not INamedTypeSymbol caseType + || SymbolEqualityComparer.Default.Equals(caseType, union) + ) + { + continue; + } + + builder.Add(caseType); + } + + return builder.ToImmutable(); + } + + /// + /// Gets the direct derived types of a closed class that have no [ZodSchema], for the analyzer. + /// + public static ImmutableArray GetClosedDerivedTypesWithoutSchema( + INamedTypeSymbol closedType, + CancellationToken cancellationToken = default + ) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var derived in RoslynFeatures.GetClosedDerivedTypes(closedType, cancellationToken)) + { + if (!ZodSchemaAttributeData.FromAttributeData(derived, out _).Exists && !derived.IsAbstract) + builder.Add(derived); + } + + return builder.ToImmutable(); + } + + /// + /// Determines whether a hand-authored [OptionsValidator] validator named {Type}Validator + /// exists alongside (or itself carries the attribute), which + /// the generated options validator would conflict with (SYSLIB1205). + /// + public static bool HasOptionsValidatorConflict(INamedTypeSymbol type) + { + if (HasOptionsValidatorAttribute(type)) + return true; + + var validatorName = $"{type.Name}Validator"; + var siblings = type.ContainingType is { } containing + ? containing.GetTypeMembers(validatorName) + : type.ContainingNamespace.GetTypeMembers(validatorName); + + foreach (var sibling in siblings) + { + if (HasOptionsValidatorAttribute(sibling)) + return true; + } + + return false; + } + + static bool HasOptionsValidatorAttribute(INamedTypeSymbol type) + { + foreach (var attribute in type.GetAttributes()) + { + if ( + attribute.AttributeClass is { Name: "OptionsValidatorAttribute" } attributeClass + && attributeClass.ContainingNamespace?.ToDisplayString() == "Microsoft.Extensions.Options" + ) + { + return true; + } + } + + return false; + } + + static EquatableArray ResolveCases( + ImmutableArray caseTypes, + ExternalSchemaResolver? externalSchemas, + bool requireSchemaAttribute + ) + { + var builder = ImmutableArray.CreateBuilder(); + HashSet seen = []; + foreach (var caseType in caseTypes) + { + if (!TypeIdentity.TryCreate(caseType, out var caseIdentity) || !seen.Add(caseIdentity)) + continue; + + var owned = externalSchemas?.IsSchemaOwnedByThisCompilation(caseType) ?? IsInSource(caseType); + if (owned) + { + if (requireSchemaAttribute && !ZodSchemaAttributeData.FromAttributeData(caseType, out _).Exists) + continue; + + builder.Add(new(caseIdentity, caseIdentity with { Name = $"{caseIdentity.Name}Schema" }, true)); + continue; + } + + if (externalSchemas is not null && externalSchemas.TryGetExistingSchema(caseType, out var existing)) + { + var (isModern, _) = externalSchemas.GetExistingSchemaFeatures(caseType); + builder.Add(new(caseIdentity, existing, isModern)); + } + } + + return new(builder.ToImmutable()); + } + + static bool IsInSource(INamedTypeSymbol type) => + type.Locations.Any(static location => location.IsInSource) + && type.TypeKind is TypeKind.Class or TypeKind.Struct + && !type.IsAbstract + && !type.IsStatic; + + static bool TryRenderAttributeConstruction(AttributeData attribute, out string expression) + { + expression = string.Empty; + if (attribute.AttributeClass is not { } attributeClass || attribute.AttributeConstructor is not { } constructor) + return false; + + List arguments = []; + for (var i = 0; i < attribute.ConstructorArguments.Length && i < constructor.Parameters.Length; i++) + { + if ( + !CustomRuleResolver.TryConvertConstant( + attribute.ConstructorArguments[i], + constructor.Parameters[i].Type, + out var argument + ) + ) + { + return false; + } + + arguments.Add(argument); + } + + List initializers = []; + foreach (var named in attribute.NamedArguments) + { + var memberType = FindMemberType(attributeClass, named.Key); + if (memberType is null || !CustomRuleResolver.TryConvertConstant(named.Value, memberType, out var value)) + return false; + + initializers.Add($"{named.Key} = {value}"); + } + + var typeName = new TypeIdentity(attributeClass).RenderFullName; + expression = + initializers.Count == 0 + ? $"new {typeName}({string.Join(", ", arguments)})" + : $"new {typeName}({string.Join(", ", arguments)}) {{ {string.Join(", ", initializers)} }}"; + return true; + } + + static ITypeSymbol? FindMemberType(INamedTypeSymbol type, string name) + { + for (var current = type; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers(name)) + { + switch (member) + { + case IPropertySymbol property: + return property.Type; + case IFieldSymbol field: + return field.Type; + default: + break; + } + } + } + + return null; + } + + /// + /// Reads the C# 15 symbol APIs behind a feature check. The generator is compiled against Roslyn 5.9, but a + /// compiler host without those members throws when the calling method + /// is JIT-compiled; isolating each call in a non-inlined method turns that into "not supported". + /// + internal static class RoslynFeatures + { + static bool IsUnavailable; + + public static bool IsUnion(ITypeSymbol type) + { + if (IsUnavailable) + return false; + + try + { + return IsUnionCore(type); + } + catch (MissingMemberException) + { + IsUnavailable = true; + return false; + } + } + + public static bool IsClosed(ITypeSymbol type) + { + if (IsUnavailable) + return false; + + try + { + return IsClosedCore(type); + } + catch (MissingMemberException) + { + IsUnavailable = true; + return false; + } + } + + public static ImmutableArray GetClosedDerivedTypes( + ITypeSymbol type, + CancellationToken cancellationToken + ) + { + if (IsUnavailable) + return []; + + try + { + return GetClosedDerivedTypesCore(type, cancellationToken); + } + catch (MissingMemberException) + { + IsUnavailable = true; + return []; + } + catch (InvalidOperationException) + { + return []; + } + } + +#pragma warning disable RSEXPERIMENTAL006 // C# 15 union and closed-type symbol APIs are experimental in Roslyn 5.9; isolated here. + [MethodImpl(MethodImplOptions.NoInlining)] + static bool IsUnionCore(ITypeSymbol type) => type.IsUnion; + + [MethodImpl(MethodImplOptions.NoInlining)] + static bool IsClosedCore(ITypeSymbol type) => type.IsClosed; + + [MethodImpl(MethodImplOptions.NoInlining)] + static ImmutableArray GetClosedDerivedTypesCore( + ITypeSymbol type, + CancellationToken cancellationToken + ) + { + var info = type.GetClosedDerivedTypeInfo(cancellationToken); + var builder = ImmutableArray.CreateBuilder(); + foreach (var derived in info.ClosedDerivedTypes) + { + if (derived is INamedTypeSymbol named) + builder.Add(named); + } + + return builder.ToImmutable(); + } +#pragma warning restore RSEXPERIMENTAL006 + } +} diff --git a/src/src/SourceGenerators/Models/CustomValidationMethodData.cs b/src/src/SourceGenerators/Models/CustomValidationMethodData.cs index 0811d912..120d4495 100644 --- a/src/src/SourceGenerators/Models/CustomValidationMethodData.cs +++ b/src/src/SourceGenerators/Models/CustomValidationMethodData.cs @@ -36,4 +36,15 @@ CustomValidationInvocationKind InvocationKind ); public bool HasCustomValidation => IsValid && InvocationKind != CustomValidationInvocationKind.None; -}; + + /// + /// Whether the method takes the caller's services: (T value, IServiceProvider services, CancellationToken ct). + /// + public bool RequiresServices { get; init; } + + /// + /// The members declared by [ZodAsyncMember] on the method; member-level async validation runs the method + /// only for these. + /// + public EquatableArray AsyncMembers { get; init; } = new([]); +} diff --git a/src/src/SourceGenerators/Models/DataAttributes/GeneratedAttributeDataModels.cs b/src/src/SourceGenerators/Models/DataAttributes/GeneratedAttributeDataModels.cs index fc6ffaeb..bd186cf4 100644 --- a/src/src/SourceGenerators/Models/DataAttributes/GeneratedAttributeDataModels.cs +++ b/src/src/SourceGenerators/Models/DataAttributes/GeneratedAttributeDataModels.cs @@ -239,6 +239,31 @@ static EquatableArray GetValues(AttributeData attributeData) } } +// The ZodSharp collection-count attributes, generated into Purview.ZodSharp from MinCountRule, MaxCountRule and +// CountRangeRule. They are read here so the generator can emit them through the same collection-size path as +// [MinLength]/[MaxLength]/[Length], which keeps the error identity identical. +[Generate("ZodSharp.Rules.MinCountAttribute")] +readonly partial record struct MinCountAttributeData( + [Argument("minCount")] int MinCount, + [Property] string? Message, + [NestedModel] ValidationAttributeData ValidationAttribute +); + +[Generate("ZodSharp.Rules.MaxCountAttribute")] +readonly partial record struct MaxCountAttributeData( + [Argument("maxCount")] int MaxCount, + [Property] string? Message, + [NestedModel] ValidationAttributeData ValidationAttribute +); + +[Generate("ZodSharp.Rules.CountRangeAttribute")] +readonly partial record struct CountRangeAttributeData( + [Argument("minimum")] int Minimum, + [Argument("maximum")] int Maximum, + [Property] string? Message, + [NestedModel] ValidationAttributeData ValidationAttribute +); + [Generate("System.ComponentModel.DataAnnotations.LengthAttribute")] readonly partial record struct LengthAttributeData( [Argument("minimumLength")] int MinimumLength, diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index fb7ce7fa..c74ef56e 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -13,8 +13,38 @@ sealed record SchemaGenerationCapabilities : IGenerationCapabilities public bool HasRequiredAttribute { get; init; } public bool HasIValidateOptions { get; init; } + + /// Whether Microsoft.Extensions.Options.IAsyncValidateOptions<T> (.NET 11) resolves. + public bool HasIAsyncValidateOptions { get; init; } + + /// Whether Microsoft.Extensions.DependencyInjection.IServiceCollection resolves. + public bool HasServiceCollection { get; init; } + + /// The compilation's assembly name, used to name the per-assembly registration. + public string AssemblyName { get; init; } = string.Empty; } +/// +/// How a member typed as a C# 15 union or a closed class hierarchy is validated: by dispatching on the +/// runtime type of the value (the union's active case, or the derived type) to that type's schema. +/// +enum DispatchKind +{ + /// No dispatch. + None, + + /// The member is a C# 15 union; dispatch on its Value. + Union, + + /// The member is typed as a closed class; dispatch on the derived type. + ClosedHierarchy, +} + +/// The case (or derived) type. +/// The generated schema of . +/// Whether the schema exposes the depth-aware and member-resolution entry points. +readonly record struct DispatchCase(TypeIdentity CaseType, TypeIdentity SchemaType, bool IsModern); + readonly record struct SchemaSet(EquatableArray Schemas); enum PropertyValidationKind @@ -28,6 +58,12 @@ enum PropertyValidationKind Unsupported, } +enum DefaultImmutableArrayBehavior +{ + ValidationError, + TreatAsEmpty, +} + /// /// How a string-serialized enum's member names are transformed, mirroring the /// JsonNamingPolicy a JsonStringEnumConverter may be constructed with. @@ -130,7 +166,20 @@ readonly record struct ZodSchemaDescriptor( bool EnumAsString, EnumJsonNamingPolicy EnumNamingPolicy, DateTimeOffsetSerialization? DateTimeOffsetSerialization = null -); +) +{ + /// Whether the target implements System.ComponentModel.DataAnnotations.IValidatableObject. + public bool ImplementsValidatableObject { get; init; } + + /// Whether the target implements System.ComponentModel.DataAnnotations.IAsyncValidatableObject (.NET 11). + public bool ImplementsAsyncValidatableObject { get; init; } + + /// + /// Whether a hand-authored [OptionsValidator] class named {Type}Validator exists, so the generated + /// options validator is skipped (SYSLIB1205). + /// + public bool HasOptionsValidatorConflict { get; init; } +} /// The declared type of the property, with a nullable wrapper removed. /// The name of the property. @@ -221,7 +270,45 @@ readonly record struct ZodPropertyDescriptor( bool EnumSerializedAsString, EquatableArray EnumJsonNames, DateTimeOffsetSerialization? DateTimeOffsetSerialization = null -); +) +{ + public bool IsImmutableArray { get; init; } + + public DefaultImmutableArrayBehavior? DefaultImmutableArrayBehavior { get; init; } + + /// + /// The expression that reads a localized display name ([Display(Name = ..., ResourceType = ...)]), or + /// when the display name is a literal. + /// + public string? DisplayNameResourceExpression { get; init; } + + /// + /// Whether the nested schema exposes the depth-aware, async and member-resolution entry points. Always true + /// for a schema generated into this compilation; false for one generated by an older ZodSharp. + /// + public bool NestedSchemaIsModern { get; init; } = true; + + /// + /// Whether the nested complex type is a Purview.ValueObjects scalar, so its errors are named after this member (its + /// display name replaces the scalar's own Value). + /// + public bool NestedIsScalarValueObject { get; init; } + + /// Whether an external nested schema reports RequiresAsyncValidation. + public bool ExternalNestedSchemaRequiresAsync { get; init; } + + /// + /// The construction expressions of the AsyncValidationAttributes (.NET 11) declared on the member, in + /// declaration order. + /// + public EquatableArray AsyncValidationAttributes { get; init; } = new([]); + + /// How the member dispatches to a case or derived type's schema, when it does. + public DispatchKind DispatchKind { get; init; } + + /// The cases dispatched to when is not . + public EquatableArray DispatchCases { get; init; } = new([]); +} [System.Diagnostics.CodeAnalysis.SuppressMessage( "Naming", diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj index 5f7544a1..d71ff624 100644 --- a/src/src/SourceGenerators/SourceGenerators.csproj +++ b/src/src/SourceGenerators/SourceGenerators.csproj @@ -1,4 +1,4 @@ - + true Purview.ZodSharp.SourceGenerators @@ -20,7 +20,14 @@ + + + + + + + diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index ede59f1a..f84d96b1 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -21,6 +21,7 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.CustomValidationInvalidParameterCount, DiagnosticLibrary.CustomValidationInvalidModelParameter, DiagnosticLibrary.CustomValidationInvalidCancellationToken, + DiagnosticLibrary.CustomValidationInvalidServicesParameter, DiagnosticLibrary.CustomValidationGenericMethod, DiagnosticLibrary.CustomValidationInvalidStaticInstance, DiagnosticLibrary.CustomValidationInaccessible, @@ -45,6 +46,11 @@ public sealed class ZodSchemaAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.AutomaticScalarSchemaUnavailable, DiagnosticLibrary.NestedSchemaExportDisabled, DiagnosticLibrary.CollectionBehaviorNotApplicable, + DiagnosticLibrary.ValidatableTypeRedundant, + DiagnosticLibrary.AsyncOnlyRulesWithSyncValidate, + DiagnosticLibrary.ClosedHierarchyDerivedTypeWithoutSchema, + DiagnosticLibrary.OptionsValidatorConflict, + DiagnosticLibrary.UnresolvableMessagePlaceholder, ]; public override ImmutableArray SupportedDiagnostics => SupportedDiagnosticsList; @@ -131,6 +137,8 @@ ImmutableHashSet schemaReachableTypes ReportCollectionBehaviorDiagnostics(context, type, typeLocation); + ReportValidationInputDiagnostics(context, type, zodSchemaData, hasIValidateOptions, typeLocation); + if (!hasDataAnnotations) { context.ReportDiagnostic( @@ -305,6 +313,134 @@ Location typeLocation } } + /// + /// Reports the diagnostics for the validation inputs the generator reads besides DataAnnotations values: + /// a redundant [ValidatableType] (ZODSGEN047), async-only rules behind a synchronous + /// Validate (ZODSGEN048), a closed-hierarchy member whose derived type has no schema (ZODSGEN049), + /// and a generated options validator that would conflict with [OptionsValidator] (ZODSGEN050). + /// + static void ReportValidationInputDiagnostics( + SymbolAnalysisContext context, + INamedTypeSymbol type, + ZodSchemaAttributeData zodSchemaData, + bool hasIValidateOptions, + Location typeLocation + ) + { + foreach (var attribute in type.GetAttributes()) + { + if ( + attribute.AttributeClass is { Name: "ValidatableTypeAttribute" } attributeClass + && attributeClass.ContainingNamespace?.ToDisplayString() == "Microsoft.Extensions.Validation" + ) + { + var location = + attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() + ?? typeLocation; + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticLibrary.ValidatableTypeRedundant, location, type.Name) + ); + } + } + + List asyncOnlyRules = []; + if (ValidationInputsResolver.GetValidatableObjectKinds(type).Async) + asyncOnlyRules.Add("IAsyncValidatableObject"); + + foreach (var property in type.GetMembers().OfType()) + { + if (property.DeclaredAccessibility != Accessibility.Public || property.IsStatic || property.IsIndexer) + continue; + + foreach (var attribute in SourceGenLibrary.GetValidationAttributes(property, context.Compilation)) + { + if ( + attribute.AttributeClass is { } attributeClass + && ValidationInputsResolver.InheritsAsyncValidationAttribute(attributeClass) + ) + { + asyncOnlyRules.Add($"[{attributeClass.Name}] on {property.Name}"); + } + } + + if ( + TypeHelpers.UnwrapNullableType(property.Type) + is INamedTypeSymbol { TypeKind: TypeKind.Class } memberType + && ValidationInputsResolver.RoslynFeatures.IsClosed(memberType) + ) + { + foreach ( + var derived in ValidationInputsResolver.GetClosedDerivedTypesWithoutSchema( + memberType, + context.CancellationToken + ) + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.ClosedHierarchyDerivedTypeWithoutSchema, + GetMemberLocation(property), + property.Name, + memberType.Name, + derived.Name + ) + ); + } + } + } + + if (zodSchemaData.GenerateValidateMethod && asyncOnlyRules.Count > 0) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.AsyncOnlyRulesWithSyncValidate, + typeLocation, + type.Name, + string.Join(", ", asyncOnlyRules) + ) + ); + } + + if ( + hasIValidateOptions + && type.TypeKind != TypeKind.Struct + && zodSchemaData.GenerateValidateMethod + && WouldGenerateOptionsValidator(context.Options, type, zodSchemaData) + && ValidationInputsResolver.HasOptionsValidatorConflict(type) + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.OptionsValidatorConflict, + typeLocation, + type.Name, + $"{type.Name}Validator" + ) + ); + } + } + + /// + /// Mirrors the generator's options-validator decision: requested on the attribute, or picked up by the + /// assembly-level suffix auto-detection unless suppressed. + /// + static bool WouldGenerateOptionsValidator( + AnalyzerOptions options, + INamedTypeSymbol type, + ZodSchemaAttributeData zodSchemaData + ) + { + if (zodSchemaData.GenerateIValidateOptions) + return true; + + if (zodSchemaData.SuppressIValidateOptions) + return false; + + var settings = GenerationSettingsResolver.Read(options.AnalyzerConfigOptionsProvider); + return settings.AutoGenerate + && settings.Suffixes.Any(suffix => type.Name.EndsWith(suffix, StringComparison.Ordinal)); + } + static Location GetTypeLocation(INamedTypeSymbol type) { foreach (var location in type.Locations) @@ -335,7 +471,7 @@ static Location GetMemberLocation(ISymbol member) /// when JSON Schema export is enabled for the compilation. static bool ReadGenerateJsonSchemas(AnalyzerOptions options) => !options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( - $"build_property:{PropertyLibrary.GenerateJsonSchemasProperty}", + PropertyLibrary.BuildPropertyKey(PropertyLibrary.GenerateJsonSchemasProperty), out var value ) || !bool.TryParse(value, out var enabled) @@ -442,7 +578,7 @@ Location typeLocation var hasAttribute = SourceGenLibrary .GetValidationAttributes(property, context.Compilation) .Any(attribute => TypeLibrary.ZodSharp.CollectionBehaviorAttribute.Equals(attribute.AttributeClass)); - if (!hasAttribute || SourceGenLibrary.IsCollectionBehaviorApplicable(property)) + if (!hasAttribute || SourceGenLibrary.IsCollectionBehaviorApplicable(property, context.Compilation)) continue; var location = GetMemberLocation(property); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 985a31c9..22c2dab4 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Microsoft.CodeAnalysis; using ZodSharp.SourceGenerators.Helpers; using ZodSharp.SourceGenerators.Models; @@ -7,6 +6,12 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { + const string TypeMemberFieldName = "TypeMember"; + + const string ServiceProviderType = "global::System.IServiceProvider"; + + const string CancellationTokenType = "global::System.Threading.CancellationToken"; + static void BuildSchema( SchemaGenerationOutputContext outputContext, SourceProductionContext context, @@ -16,39 +21,61 @@ bool isPrimary context.CancellationToken.ThrowIfCancellationRequested(); var writer = outputContext.Context.CreateCodeWriter(); - GenerateSchemaClass(outputContext, writer, isPrimary, context.CancellationToken); - context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}.g.cs", writer); + GenerateSchemaClass(outputContext, writer, isPrimary, context); + context.AddSource($"{GetHintNameStem(outputContext.ZodSchema)}.g.cs", writer); GenerateRefinementHookDeclaration(outputContext, context); context.CancellationToken.ThrowIfCancellationRequested(); - if (!isPrimary || outputContext.ZodSchema.ContainingTypes.Count > 0) - return; - - // The registration marker advertises a validator that assembly scanning can resolve; without - // the generated Validate method there is nothing to register. - if (!outputContext.ZodSchema.GenerateValidateMethod) + if (!IsRegistrable(outputContext.ZodSchema)) return; // The validator type is named here as a typeof rather than left for the consumer to rebuild from a // string at runtime. That is what makes RegisterFromAssembly trim- and AOT-safe: the typeof in // attribute metadata roots the validator, and the attribute's DynamicallyAccessedMembers annotation // keeps its parameterless constructor. The adapter name must match GenerateValidatorAdapter. - var validatorTypeName = - $"{outputContext.ZodSchema.SchemaType.Namespace}.{outputContext.ZodSchema.SchemaType.Name}Validator"; - var registrationWriter = outputContext.Context.CreateCodeWriter(); registrationWriter.AutoGeneratedHeader(); registrationWriter.Line( $"[assembly: global::{TypeLibrary.ZodSharp.Core.ZodSchemaGeneratedAttribute.MetadataFullName}(" + $"typeof({outputContext.ZodSchema.TargetType.AsTypeReference()}), " - + $"typeof(global::{validatorTypeName}))]" + + $"typeof(global::{GetValidatorTypeName(outputContext.ZodSchema)}))]" ); - context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}Registration.g.cs", registrationWriter); + context.AddSource($"{GetHintNameStem(outputContext.ZodSchema)}Registration.g.cs", registrationWriter); } + /// + /// Whether the schema has a DI adapter that assembly-level registration can name: a top-level primary + /// schema with a generated Validate method. + /// + static bool IsRegistrable(ZodSchemaDescriptor schema) => + schema.IsPrimary && schema.ContainingTypes.Count == 0 && schema.GenerateValidateMethod; + + /// + /// The hint-name stem of a schema's per-type outputs: the schema's namespace, containing types and name + /// (Alpha.Outer.ItemSchema), so two schemas with the same simple name in different namespaces or + /// containing types never produce the same hint name. Derived from the declaration only, so it is stable + /// across runs. + /// + internal static string GetHintNameStem(ZodSchemaDescriptor schema) + { + System.Text.StringBuilder builder = new(); + if (!string.IsNullOrEmpty(schema.SchemaType.Namespace)) + builder.Append(schema.SchemaType.Namespace).Append('.'); + + foreach (var containingType in schema.ContainingTypes) + builder.Append(containingType.Name).Append('.'); + + return builder.Append(schema.SchemaType.Name).ToString(); + } + + static string GetValidatorTypeName(ZodSchemaDescriptor schema) => + string.IsNullOrEmpty(schema.SchemaType.Namespace) + ? $"{schema.SchemaType.Name}Validator" + : $"{schema.SchemaType.Namespace}.{schema.SchemaType.Name}Validator"; + /// /// Emits the optional OnZodValidate refinement hook declaration onto the target type, so the IDE /// offers the implementation with the generator's signature. Nothing is emitted when the target and its @@ -78,16 +105,15 @@ SourceProductionContext context for (var index = 0; index < hook.DeclarationChain.Count - 1; index++) wrapperBlocks.Add(writer.TypeScope(hook.DeclarationChain[index])); - writer.Type( - hook.DeclarationChain[hook.DeclarationChain.Count - 1], - body => EmitRefinementHook(body, outputContext.ZodSchema) + writer.Type(hook.DeclarationChain[hook.DeclarationChain.Count - 1], body => + EmitRefinementHook(body, outputContext.ZodSchema) ); foreach (var block in wrapperBlocks) block.Dispose(); } - context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}.Hooks.g.cs", writer); + context.AddSource($"{GetHintNameStem(outputContext.ZodSchema)}.Hooks.g.cs", writer); } /// @@ -108,7 +134,8 @@ static void EmitRefinementHook(CodeWriter writer, ZodSchemaDescriptor schema) "Optional ZodSharp refinement hook. Implement this partial method to add Zod-compatible", $"validation rules to {schema.TargetType.Name}.", "Issues added to the context are reported by the generated validator: Validate, Parse, the DI", - "adapter, IValidateOptions, and any factory that validates through the generated schema." + "adapter, IValidateOptions, and any factory that validates through the generated schema.", + $"Use the {schema.SchemaType.Name}.Paths constants to place an issue on a member." ); writer.XmlParam( "context", @@ -144,11 +171,14 @@ static void GenerateSchemaClass( SchemaGenerationOutputContext outputContext, CodeWriter writer, bool isPrimary, - CancellationToken cancellationToken + SourceProductionContext context ) { + var cancellationToken = context.CancellationToken; cancellationToken.ThrowIfCancellationRequested(); + var schema = outputContext.ZodSchema; + writer.AutoGeneratedHeader(); writer .Using("System") @@ -158,23 +188,20 @@ CancellationToken cancellationToken .Using(TypeLibraryGenerator.ZodSharpCoreNamespace) .NewLine(); - using (writer.BlockNamespaceScope(outputContext.ZodSchema.TargetType.AsTypeReference())) + using (writer.BlockNamespaceScope(schema.TargetType.AsTypeReference())) { writer.XmlSummary( - $"Auto-generated schema validator for {CodeWriter.XmlSee(outputContext.ZodSchema.TargetType.Name)}.", + $"Auto-generated schema validator for {CodeWriter.XmlSee(schema.TargetType.Name)}.", "This class is generated at compile time for zero-allocation validation." ); List wrapperBlocks = []; - foreach (var containingTypeDeclaration in outputContext.ZodSchema.ContainingTypes) + foreach (var containingTypeDeclaration in schema.ContainingTypes) wrapperBlocks.Add(writer.TypeScope(containingTypeDeclaration)); using ( writer.ClassScope( - new TypeDeclarationOptions( - outputContext.ZodSchema.SchemaType, - outputContext.ZodSchema.TargetAccessibility - ) + new TypeDeclarationOptions(schema.SchemaType, schema.TargetAccessibility) { IsPartial = true, IsStatic = true, @@ -185,49 +212,50 @@ CancellationToken cancellationToken writer.Field( new FieldDeclarationOptions( "EmptyPath", - TypeLibrary - .System.Collections.Immutable.ImmutableArray.MakeGeneric(TypeLibrary.System.String) - .AsTypeReference(), + new TypeReference(new TypeIdentity("ZodPath", TypeLibraryGenerator.ZodSharpCoreNamespace)), TypeDeclarationAccessibility.Private ) { IsStatic = true, IsReadOnly = true, - Initializer = "[]", + Initializer = $"{ZodPathType}.Root", } ); - GeneratePathFields(writer, outputContext.ZodSchema, cancellationToken); - GenerateStaticAttributeFields(writer, outputContext.ZodSchema, cancellationToken); + GeneratePathsClass(writer, schema, cancellationToken); + GenerateJsonNamesClass(writer, schema, cancellationToken); + GenerateStaticAttributeFields(writer, schema, cancellationToken); - if (outputContext.ZodSchema.GenerateJsonSchema) - GenerateJsonSchemaMethod(writer, outputContext.ZodSchema, cancellationToken); + if (schema.GenerateJsonSchema) + GenerateJsonSchemaMethod(writer, schema, cancellationToken); - if (outputContext.ZodSchema.GenerateValidateMethod) + if (schema.GenerateValidateMethod) { - GenerateValidationHelpers(writer, cancellationToken); + GenerateMemberFields(writer, outputContext, cancellationToken); + GenerateValidateMethods(writer, outputContext, cancellationToken); + GenerateMemberValidationMethods(writer, outputContext, cancellationToken); + GenerateResolutionMethods(writer, outputContext, cancellationToken); - GenerateValidateMethod(writer, outputContext.ZodSchema, cancellationToken); + if (schema.GenerateParseMethod) + GenerateParseMethod(writer, schema, cancellationToken); - if (outputContext.ZodSchema.GenerateParseMethod) - GenerateParseMethod(writer, outputContext.ZodSchema, cancellationToken); - - if (outputContext.ZodSchema.EnableComposition) - GenerateCompositionMethods(writer, outputContext.ZodSchema, cancellationToken); + if (schema.EnableComposition) + GenerateCompositionMethods(writer, schema, cancellationToken); } } - if (isPrimary && outputContext.ZodSchema.GenerateValidateMethod) + if (isPrimary && schema.GenerateValidateMethod) { - GenerateValidatorAdapter(writer, outputContext.ZodSchema, cancellationToken); + GenerateValidatorAdapter(writer, outputContext, cancellationToken); if ( - outputContext.ZodSchema.GenerateIValidateOptions == true + schema.GenerateIValidateOptions == true && outputContext.Context.Capabilities.HasIValidateOptions - && !outputContext.ZodSchema.IsValueType + && !schema.IsValueType + && !schema.HasOptionsValidatorConflict ) { - GenerateIValidateOptionsValidator(writer, outputContext.ZodSchema, cancellationToken); + GenerateIValidateOptionsValidator(writer, outputContext, cancellationToken); } } @@ -238,17 +266,50 @@ CancellationToken cancellationToken static void GenerateValidatorAdapter( CodeWriter writer, - ZodSchemaDescriptor schema, + SchemaGenerationOutputContext outputContext, CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); + var schema = outputContext.ZodSchema; var adapterName = $"{schema.SchemaType.Name}Validator"; + var schemaName = schema.SchemaType.Name; + var targetType = schema.TargetType.AsTypeReference(); + var resultType = TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType).AsTypeReference(); + var resultTaskReference = TypeLibraryGenerator + .ValueTask.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)) + .AsTypeReference(); + var cancellationTokenReference = TypeLibraryGenerator.CancellationToken.AsTypeReference(); + TypeReference zodPathReference = new(new TypeIdentity("ZodPath", TypeLibraryGenerator.ZodSharpCoreNamespace)); + var errorsType = "global::System.Collections.Immutable.ImmutableArray"; var baseType = TypeLibrary.ZodSharp.Core.IZodSchemaValidator.MakeGeneric(schema.TargetType).AsTypeReference(); + var usesAdapterInstance = + schema.CustomValidationMethod.Value.InvocationKind + == CustomValidationInvocationKind.DefinedOnSchemaValidator; + + List interfaces = + [ + new( + new TypeIdentity("IZodMemberValidator", TypeLibraryGenerator.ZodSharpCoreNamespace, 1).MakeGeneric( + schema.TargetType + ) + ), + new( + new TypeIdentity("IZodMemberResolver", TypeLibraryGenerator.ZodSharpCoreNamespace, 1).MakeGeneric( + schema.TargetType + ) + ), + new(new TypeIdentity("IZodUntypedValidator", TypeLibraryGenerator.ZodSharpCoreNamespace)), + new(new TypeIdentity("IZodJsonPathMapper", TypeLibraryGenerator.ZodSharpCoreNamespace)), + ]; + if (schema.GenerateJsonSchema) + interfaces.Add(JsonSchemaProviderIdentity.AsTypeReference()); writer.XmlSummary( - $"DI-friendly validator adapter for {schema.TargetType.Name}, delegating to {schema.SchemaType.Name}." + $"DI-friendly validator adapter for {schema.TargetType.Name}, delegating to {schemaName}.", + "Implements member validation, member resolution and the untyped validator contract, so hosts", + "(Blazor, Microsoft.Extensions.Validation, endpoint filters) need no reflection." ); writer.Class( @@ -257,33 +318,298 @@ CancellationToken cancellationToken Accessibility = schema.TargetAccessibility, IsPartial = true, BaseType = baseType, - Interfaces = schema.GenerateJsonSchema ? [JsonSchemaProviderIdentity.AsTypeReference()] : [], + Interfaces = [.. interfaces], }, body => { body.XmlSummary("Validates an instance of the target type, returning a ValidationResult.") .XmlParam("value", "The value to validate.") .XmlReturn("ValidationResult indicating success or failure of the validation.") + .Method( + new MethodDeclarationOptions("Validate", resultType, TypeDeclarationAccessibility.Public) + { + Parameters = [new ParameterDeclarationOptions("value", targetType)], + }, + methodBody => methodBody.Return($"{schemaName}.Validate(value)") + ); + + var instanceArgument = usesAdapterInstance ? ", this" : string.Empty; + + body.XmlSummary( + "Asynchronously validates an instance of the target type, returning a ValidationResult." + ) + .XmlParam("value", "The value to validate.") + .XmlParam("cancellationToken", "The cancellation token.") + .XmlReturn("ValidationResult indicating success or failure of the validation.") .Method( new MethodDeclarationOptions( - "Validate", - TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType).AsTypeReference(), + "ValidateAsync", + resultTaskReference, + TypeDeclarationAccessibility.Public + ) + { + Parameters = + [ + new("value", targetType), + new("cancellationToken", cancellationTokenReference) { DefaultValue = "default" }, + ], + }, + methodBody => + methodBody.Return( + $"{schemaName}.ValidateCoreAsync(value, null, 0, cancellationToken{instanceArgument})" + ) + ); + + body.XmlSummary( + "Asynchronously validates an instance of the target type with the caller's services, so", + "service-aware rules run, returning a ValidationResult." + ) + .XmlParam("value", "The value to validate.") + .XmlParam("services", "The caller's services.") + .XmlParam("cancellationToken", "The cancellation token.") + .XmlReturn("ValidationResult indicating success or failure of the validation.") + .Method( + new MethodDeclarationOptions( + "ValidateAsync", + resultTaskReference, TypeDeclarationAccessibility.Public ) { Parameters = [ - new ParameterDeclarationOptions("value", schema.TargetType.AsTypeReference()), + new("value", targetType), + new( + "services", + new TypeIdentity(typeof(IServiceProvider)).AsTypeReference().Nullable(body) + ), + new("cancellationToken", cancellationTokenReference) { DefaultValue = "default" }, ], }, - methodBody => methodBody.Return($"{schema.SchemaType.Name}.Validate(value)") + methodBody => + methodBody.Return( + $"{schemaName}.ValidateCoreAsync(value, services, 0, cancellationToken{instanceArgument})" + ) + ); + + body.XmlSummary("Gets whether some rules only run asynchronously.") + .PropertyExpression( + "RequiresAsyncValidation", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public, + expression => expression.Write($"{schemaName}.RequiresAsyncValidation") + ) + .NewLine(); + + body.XmlSummary("Gets whether the member has rules that ValidateMember runs.") + .XmlParam("memberName", "The C# member name.") + .XmlReturn("True when the member is individually validatable.") + .MethodExpression( + "IsValidatableMember", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public, + expression => expression.Write($"{schemaName}.IsValidatableMember(memberName)"), + declaration => + declaration with + { + Parameters = [new("memberName", PurviewTypeLibrary.System.String)], + } + ) + .NewLine(); + + body.XmlSummary("Validates one member of the value, without recursing into nested schemas.") + .XmlParam("value", "The containing object.") + .XmlParam("memberName", "The C# member name.") + .XmlReturn("The member's errors.") + .MethodExpression( + "ValidateMember", + resultType, + TypeDeclarationAccessibility.Public, + expression => expression.Write($"{schemaName}.ValidateMember(value, memberName)"), + declaration => + declaration with + { + Parameters = + [ + new("value", targetType), + new("memberName", PurviewTypeLibrary.System.String), + ], + } + ) + .NewLine(); + + body.XmlSummary("Validates one member of the value asynchronously, including its asynchronous rules.") + .XmlParam("value", "The containing object.") + .XmlParam("memberName", "The C# member name.") + .XmlParam("services", "The caller's services, if any.") + .XmlParam("cancellationToken", "The cancellation token.") + .XmlReturn("The member's errors.") + .MethodExpression( + "ValidateMemberAsync", + resultTaskReference, + TypeDeclarationAccessibility.Public, + expression => + expression.Write( + $"{schemaName}.ValidateMemberAsync(value, memberName, services, cancellationToken)" + ), + declaration => + declaration with + { + Parameters = + [ + new("value", targetType), + new("memberName", PurviewTypeLibrary.System.String), + new( + "services", + new TypeIdentity(typeof(IServiceProvider)).AsTypeReference().Nullable(body) + ) + { + DefaultValue = "null", + }, + new("cancellationToken", cancellationTokenReference) { DefaultValue = "default" }, + ], + } + ) + .NewLine(); + + body.XmlSummary("Resolves the object that owns the last member of an error path.") + .XmlParam("value", "The root value.") + .XmlParam("path", "The error path.") + .XmlParam("container", "The owning object.") + .XmlParam("memberName", "The owning member name.") + .XmlReturn("True when the full path was followed.") + .MethodExpression( + "TryResolveMember", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public, + expression => + expression.Write( + $"{schemaName}.TryResolveMember(value, path, out container, out memberName)" + ), + declaration => + declaration with + { + Parameters = + [ + new("value", targetType), + new("path", zodPathReference), + new("container", new TypeIdentity(typeof(object)).AsTypeReference().Nullable(body)) + { + Modifier = ParameterModifier.Out, + }, + new("memberName", PurviewTypeLibrary.System.String.AsTypeReference()) + { + Modifier = ParameterModifier.Out, + }, + ], + } + ) + .NewLine(); + + body.XmlSummary("Renames the member segments of an error path to their JSON names.") + .XmlParam("path", "The error path.") + .XmlParam("namingPolicy", "The naming policy for members without an explicit JSON name.") + .XmlReturn("The renamed path.") + .MethodExpression( + "ToJsonNamedPath", + zodPathReference, + TypeDeclarationAccessibility.Public, + expression => expression.Write($"{schemaName}.ToJsonNamedPath(path, namingPolicy)"), + declaration => + declaration with + { + Parameters = + [ + new("path", zodPathReference), + new( + "namingPolicy", + new TypeIdentity(typeof(Func)).AsTypeReference().Nullable(body) + ) + { + DefaultValue = "null", + }, + ], + } + ) + .NewLine(); + + // The untyped contract is implemented explicitly, so the typed members stay the ones callers see. + body.Line( + "global::System.Type global::ZodSharp.Core.IZodUntypedValidator.ValidatedType => typeof(" + + targetType + + ");" + ) + .NewLine(); + + using ( + body.OpenBlockScope( + $"{errorsType} global::ZodSharp.Core.IZodUntypedValidator.Validate(object? value)" + ) + ) + { + body.Assignment("var", "result", $"{schemaName}.Validate(Cast(value))"); + body.Return("result.IsSuccess ? [] : result.Errors"); + } + + body.NewLine(); + // An explicit interface implementation cannot carry an accessibility modifier, and the + // structured member APIs always emit one, so these stay raw text. +#pragma warning disable PSGFR19 // Prefer a structured CodeWriter statement API + body.Line( + $"global::System.Threading.Tasks.ValueTask<{errorsType}> global::ZodSharp.Core.IZodUntypedValidator.ValidateAsync(object? value, {ServiceProviderType}? services, {CancellationTokenType} cancellationToken) => {GeneratedValidation}.ToErrorsAsync({schemaName}.ValidateCoreAsync(Cast(value), services, 0, cancellationToken{instanceArgument}));" + ); + body.NewLine(); + body.Line( + $"global::System.Threading.Tasks.ValueTask<{errorsType}> global::ZodSharp.Core.IZodUntypedValidator.ValidateMemberAsync(object value, string memberName, {ServiceProviderType}? services, {CancellationTokenType} cancellationToken) => {GeneratedValidation}.ToErrorsAsync({schemaName}.ValidateMemberAsync(Cast(value), memberName, services, cancellationToken));" + ); + + body.NewLine(); + body.Line( + $"bool global::ZodSharp.Core.IZodUntypedValidator.TryResolveMember(object value, {ZodPathType} path, out object? container, out string memberName) => {schemaName}.TryResolveMember(Cast(value), path, out container, out memberName);" + ); +#pragma warning restore PSGFR19 + body.NewLine(); + + if (usesAdapterInstance) + { + var custom = schema.CustomValidationMethod.Value; + var servicesArgument = custom.RequiresServices ? ", services" : string.Empty; + body.XmlSummary("Invokes the custom validation method for the generated schema."); + body.MethodExpression( + CustomValidationBridgeName, + resultTaskReference, + TypeDeclarationAccessibility.Internal, + expression => + expression.Write($"{custom.MethodName}(value{servicesArgument}, cancellationToken)"), + declaration => + declaration with + { + Parameters = custom.RequiresServices + ? + [ + new("value", targetType), + new("services", new TypeIdentity(typeof(IServiceProvider)).AsTypeReference()), + new("cancellationToken", cancellationTokenReference), + ] + : [new("value", targetType), new("cancellationToken", cancellationTokenReference)], + } ); + body.NewLine(); + } - GenerateValidateAsyncMethod(body, schema, cancellationToken); + using (body.OpenBlockScope($"static {targetType} Cast(object? value)")) + { + body.IfBlock($"value is {targetType} typed", ifBody => ifBody.Return("typed")); + if (schema.TargetCanBeNull) + body.IfBlock("value is null", ifBody => ifBody.Return("default!")); + body.Throw( + $"new global::System.ArgumentException($\"Value of type '{{value?.GetType().FullName}}' cannot be validated as '{schema.TargetType.Name}'.\", nameof(value))" + ); + } if (!schema.GenerateJsonSchema) return; + body.NewLine(); body.XmlSummary( "Builds the JSON Schema (Draft 2020-12) definition for the target type.", "Delegates to the generated schema, so an attribute-generated validator can be exported." @@ -295,20 +621,26 @@ CancellationToken cancellationToken JsonSchemaDefinitionIdentity.AsTypeReference(), TypeDeclarationAccessibility.Public ), - methodBody => methodBody.Return($"{schema.SchemaType.Name}.BuildJsonSchema()") + methodBody => methodBody.Return($"{schemaName}.BuildJsonSchema()") ); } ); } + /// + /// Emits {Type}Validator : IValidateOptions<T> and, when .NET 11's + /// IAsyncValidateOptions<T> resolves and the type has asynchronous rules, the async interface + /// too. Failures read {path}: {message} ({code}), so the setting at fault can be found. + /// static void GenerateIValidateOptionsValidator( CodeWriter writer, - ZodSchemaDescriptor schema, + SchemaGenerationOutputContext outputContext, CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); + var schema = outputContext.ZodSchema; var validatorName = $"{schema.TargetType.Name}Validator"; var targetType = schema.TargetType.AsTypeReference(); var baseType = TypeLibrary @@ -316,9 +648,14 @@ CancellationToken cancellationToken .AsTypeReference(); var validateOptionsResult = TypeLibrary.Microsoft.Extensions.Options.ValidateOptionsResult.AsTypeReference(); var nullableString = PurviewTypeLibrary.System.String.AsTypeReference().Nullable(writer); + var emitAsync = + outputContext.Context.Capabilities.HasIAsyncValidateOptions && outputContext.RequiresAsync(schema); writer.XmlSummary( - $"Validates {CodeWriter.XmlSee(schema.TargetType.Name)} via the source-generated schema, for use with Microsoft.Extensions.Options." + $"Validates {CodeWriter.XmlSee(schema.TargetType.Name)} via the source-generated schema, for use with Microsoft.Extensions.Options.", + emitAsync + ? "The synchronous Validate runs the synchronous rules; ValidateAsync (used by ValidateOnStart) runs every rule." + : "Each failure reads '{path}: {message} ({code})'." ); writer.Class( @@ -327,8 +664,19 @@ CancellationToken cancellationToken Accessibility = schema.TargetAccessibility, IsSealed = true, BaseType = baseType, + Interfaces = emitAsync + ? + [ + new TypeReference( + new TypeIdentity("IAsyncValidateOptions", "Microsoft.Extensions.Options", 1).MakeGeneric( + schema.TargetType + ) + ), + ] + : [], }, body => + { body.XmlSummary("Validates the options instance against the source-generated schema.") .XmlParam("name", "The options name (unused by schema validation).") .XmlParam("options", "The options instance to validate.") @@ -349,201 +697,48 @@ CancellationToken cancellationToken method => { method.Assignment("var", "schemaResult", $"{schema.SchemaType.Name}.Validate(options)"); - method.IfBlock( - "schemaResult.IsSuccess", - ifBody => ifBody.Return($"{validateOptionsResult}.Success") + method.IfBlock("schemaResult.IsSuccess", ifBody => + ifBody.Return($"{validateOptionsResult}.Success") ); method.NewLine(); method.Return( - $"{validateOptionsResult}.Fail(schemaResult.Errors.Select(static error => error.Message))" + $"{validateOptionsResult}.Fail(schemaResult.Errors.Select(static error => {GeneratedValidation}.FormatOptionsFailure(error)))" ); } - ) - ); - - writer.NewLine(); - } - - static void GenerateValidateAsyncMethod( - CodeWriter writer, - ZodSchemaDescriptor schema, - CancellationToken cancellationToken - ) - { - cancellationToken.ThrowIfCancellationRequested(); - - var customValidation = schema.CustomValidationMethod.Value; - var returnType = TypeLibraryGenerator - .ValueTask.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)) - .AsTypeReference(); - var valueType = schema.TargetType.AsTypeReference(); - var cancellationTokenType = TypeLibraryGenerator.CancellationToken.AsTypeReference(); + ); - MethodDeclarationOptions methodDeclaration = new( - "ValidateAsync", - returnType, - TypeDeclarationAccessibility.Public - ) - { - IsAsync = customValidation.InvocationKind != CustomValidationInvocationKind.None, - Parameters = - [ - new ParameterDeclarationOptions("value", valueType), - new ParameterDeclarationOptions("cancellationToken", cancellationTokenType) - { - DefaultValue = "default", - }, - ], - }; + if (!emitAsync) + return; - writer - .XmlSummary("Asynchronously validates an instance of the target type, returning a ValidationResult.") - .XmlParam("value", "The value to validate.") - .XmlParam("cancellationToken", "The cancellation token.") - .XmlReturn("ValidationResult indicating success or failure of the validation.") - .Method( - methodDeclaration, - method => + body.NewLine(); + body.XmlSummary("Validates the options instance against every rule of the source-generated schema.") + .XmlParam("name", "The options name (unused by schema validation).") + .XmlParam("options", "The options instance to validate.") + .XmlParam("cancellationToken", "The cancellation token.") + .XmlReturn("A ValidateOptionsResult indicating success or the validation failure messages."); + using ( + body.OpenBlockScope( + $"public async global::System.Threading.Tasks.Task<{validateOptionsResult}> ValidateAsync(string? name, {targetType} options, {CancellationTokenType} cancellationToken = default)" + ) + ) { - if (customValidation.InvocationKind == CustomValidationInvocationKind.None) - { - method.Return( - $"global::System.Threading.Tasks.ValueTask.FromResult({schema.SchemaType.Name}.Validate(value))" - ); - return; - } - - method.Assignment("var", "syncResult", $"{schema.SchemaType.Name}.Validate(value)"); - - var prefix = - customValidation.InvocationKind == CustomValidationInvocationKind.DefinedOnSchemaValidator - ? "this." - : $"{schema.TargetType}."; - - method.Assignment( + body.Assignment( "var", - "customResult", - $"await {prefix}{customValidation.MethodName}(value, cancellationToken).ConfigureAwait(false)" + "schemaResult", + $"await {schema.SchemaType.Name}.ValidateAsync(options, null, cancellationToken).ConfigureAwait(false)" ); - - method.NewLine(); - method.Return( - $"{TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)}.Merge(syncResult, customResult)" + body.IfBlock("schemaResult.IsSuccess", ifBody => ifBody.Return($"{validateOptionsResult}.Success")); + body.NewLine(); + body.Return( + $"{validateOptionsResult}.Fail(schemaResult.Errors.Select(static error => {GeneratedValidation}.FormatOptionsFailure(error)))" ); } - ); - } - - static void GenerateValidateMethod( - CodeWriter writer, - ZodSchemaDescriptor schema, - CancellationToken cancellationToken - ) - { - cancellationToken.ThrowIfCancellationRequested(); - - writer.XmlSummary("Validates an instance of the target type."); - MethodDeclarationOptions methodDeclaration = new( - "Validate", - TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType).AsTypeReference(), - TypeDeclarationAccessibility.Public - ) - { - IsStatic = true, - Parameters = [new ParameterDeclarationOptions("value", schema.TargetType.AsTypeReference())], - }; - - writer.Method( - methodDeclaration, - method => - { - if (schema.TargetCanBeNull) - { - method.IfBlock( - "value is null", - ifBody => - ifBody.Return( - $"{TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)}.Failure({TypeLibrary.ZodSharp.Core.ValidationError}.Create({"invalid_type".Surround()}, {"Value cannot be null".Surround()}, EmptyPath))" - ) - ); - } - - var errorsListType = TypeLibrary - .System.Collections.Generic.List.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationError) - .AsTypeReference() - .Nullable(method); - - method.NewLine().Assignment(errorsListType, "errors", "null"); - - foreach (var property in schema.Properties) - { - if (property.ShouldProcess) - GeneratePropertyValidation( - writer, - property.Value, - schema.ValidateEnumValues, - cancellationToken - ); - } - - GenerateTypeRuleValidations(writer, schema); - - GenerateZodRefinement(schema, method); - - method.IfBlock( - "errors is not null", - ifBody => - ifBody.Return( - TypeLibrary - .ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType) - .StaticMember("Failure(errors)") - ) - ); - - method - .Line() - .Return( - TypeLibrary - .ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType) - .StaticMember("Success(value)") - ); } ); writer.NewLine(); } - static void GenerateZodRefinement(ZodSchemaDescriptor schema, CodeWriter method) - { - var hook = schema.RefinementHook.Value; - if (!hook.IsImplemented) - return; - - // The hook runs after the attribute and type rules, so its issues merge into the same error set - // the generated Validate returns. Nothing is emitted when the target supplies no body, so a type - // that only leaves the generated declaration in place stays on the zero-allocation path. - method.Line(); - method.Assignment( - $"global::{TypeLibraryGenerator.ZodSharpSchemasNamespace}.RefineCtx<{schema.TargetType.AsTypeReference()}>", - "refineContext", - "new(value, EmptyPath)" - ); - method.MethodCall( - $"{schema.TargetType.AsTypeReference()}.{TypeLibraryGenerator.ZodRefinementHookInvokerName}", - "value", - "refineContext" - ); - using (method.IfBlockScope("refineContext.HasIssues")) - { - method.Foreach( - "var refinementIssue in refineContext.Issues", - foreachBody => foreachBody.MethodCall("AddError", "ref errors", "refinementIssue") - ); - } - - method.NewLine(); - } - static void GenerateParseMethod(CodeWriter writer, ZodSchemaDescriptor schema, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -584,9 +779,11 @@ CancellationToken cancellationToken .AsTypeReference(); var stringType = PurviewTypeLibrary.System.String.MakeNullable(writer); + // A failed predicate is a refinement: the same refinement_failed code and custom category the runtime + // ZodRefinement reports. void WriteFailure(CodeWriter body, string fallbackMessage) => body.Return( - $"{validationResultType}.Failure({TypeLibrary.ZodSharp.Core.ValidationError}.Create({"validation_failed".Surround()}, message ?? {fallbackMessage.Surround()}, EmptyPath))" + $"{validationResultType}.Failure(global::ZodSharp.Core.ZodIssues.RefinementFailed(message ?? {fallbackMessage.StringLiteral()}, EmptyPath))" ); writer @@ -612,9 +809,8 @@ void WriteFailure(CodeWriter body, string fallbackMessage) => method.IfBlock("!result.IsSuccess", ifBody => ifBody.Return("result")); method.NewLine(); - method.IfBlock( - "!additionalValidation(value)", - ifBody => WriteFailure(ifBody, $"Additional validation failed for {schema.TargetType.Name}") + method.IfBlock("!additionalValidation(value)", ifBody => + WriteFailure(ifBody, $"Additional validation failed for {schema.TargetType.Name}") ); method.NewLine(); method.Return("result"); @@ -643,9 +839,8 @@ void WriteFailure(CodeWriter body, string fallbackMessage) => method.Assignment("var", "result", "Validate(value)"); method.IfBlock("result.IsSuccess", ifBody => ifBody.Return("result")); method.NewLine(); - method.IfBlock( - "alternativeValidation(value)", - ifBody => ifBody.Return($"{validationResultType}.Success(value)") + method.IfBlock("alternativeValidation(value)", ifBody => + ifBody.Return($"{validationResultType}.Success(value)") ); method.NewLine(); WriteFailure(method, $"Neither validation passed for {schema.TargetType.Name}"); @@ -674,9 +869,8 @@ void WriteFailure(CodeWriter body, string fallbackMessage) => method.Assignment("var", "result", "Validate(value)"); method.IfBlock("!result.IsSuccess", ifBody => ifBody.Return("result")); method.NewLine(); - method.IfBlock( - "!refinement(value)", - ifBody => WriteFailure(ifBody, $"Refinement validation failed for {schema.TargetType.Name}") + method.IfBlock("!refinement(value)", ifBody => + WriteFailure(ifBody, $"Refinement validation failed for {schema.TargetType.Name}") ); method.NewLine(); method.Return("result"); @@ -686,426 +880,6 @@ void WriteFailure(CodeWriter body, string fallbackMessage) => writer.NewLine(); } - static void GeneratePropertyValidation( - CodeWriter writer, - ZodPropertyDescriptor property, - bool validateEnumValues, - CancellationToken cancellationToken - ) - { - cancellationToken.ThrowIfCancellationRequested(); - - var propertyName = property.Name; - var displayName = property.DisplayName; - var attributes = property.ValidationAttributes; - CodeWriter.BlockScope? block = null; - - // The enum rule owns the property's value set: the values a [DeniedValues] attribute lists are - // absorbed into the rule's disallowed set, so the standalone denied-values validation is skipped. - var emitEnumRule = IsEnumValidationEmitted(property, validateEnumValues); - - // A rule that rejects an absent value (IRequiredRule) stands in for [Required]: it is emitted outside - // the non-null guard, so a missing value fails instead of being skipped. - var hasRequiredRule = TryGetRequiredRule(property, out var requiredRule); - - if (property.CanBeNull && attributes.Required.ShouldProcess && attributes.Required.Value.Exists) - { - var required = attributes.Required.Value; - var isStringAndAllowEmptyStrings = - property.PropertyType.SpecialType == SpecialType.System_String && required.AllowEmptyStrings; - var localErrorMessage = isStringAndAllowEmptyStrings - ? "Field '{0}' is required and cannot be null or empty" - : "Required field '{0}' is null"; - var errorMessage = string.Format( - CultureInfo.InvariantCulture, - required.ValidationAttribute.ErrorMessage ?? localErrorMessage, - displayName - ); - var comparison = isStringAndAllowEmptyStrings - ? $"string.IsNullOrEmpty(value.{propertyName})" - : $"value.{propertyName} == null"; - - writer.Rule(propertyName, comparison, "missing_field", errorMessage); - block = writer.ElseScope(); - GenerateValueSetValidations(writer, property, emitEnumRule); - } - else if (hasRequiredRule) - { - GenerateRequiredRuleValidation(writer, property, requiredRule); - block = writer.ElseScope(); - GenerateValueSetValidations(writer, property, emitEnumRule); - } - else if (!property.IsDeclaredNullable && property.CanBeNull) - { - // A non-nullable reference type rejects null — the exported JSON Schema marks it required — so - // the rules below run only for a present value. This keeps null acceptance consistent with the - // schema and makes length/count access null-safe. - writer.Rule( - propertyName, - $"value.{propertyName} == null", - "invalid_type", - $"Field '{displayName}' cannot be null" - ); - block = writer.ElseScope(); - GenerateValueSetValidations(writer, property, emitEnumRule); - } - else - { - GenerateValueSetValidations(writer, property, emitEnumRule); - if (property.IsDeclaredNullable) - block = writer.IfBlockScope($"value.{propertyName} != null"); - } - - GenerateTypeSpecificValidations(writer, property, emitEnumRule, skipRequiredRule: hasRequiredRule); - block?.Dispose(); - - GenerateCompareValidation(writer, property); - writer.NewLine(); - } - - /// - /// Finds the rule that rejects an absent value (IRequiredRule) among the property's custom rules, - /// when the property can be null. - /// - /// The property to inspect. - /// The required rule, when one is bound to the property. - /// when a required rule is bound to a nullable property. - static bool TryGetRequiredRule(ZodPropertyDescriptor property, out CustomRuleDescriptor requiredRule) - { - requiredRule = default; - - if (!property.CanBeNull) - return false; - - foreach (var rule in property.CustomRules) - { - if (!rule.IsRequired) - continue; - - requiredRule = rule; - return true; - } - - return false; - } - - static void GenerateTypeSpecificValidations( - CodeWriter writer, - ZodPropertyDescriptor property, - bool emitEnumRule, - bool skipRequiredRule - ) - { - switch (property.ValidationKind) - { - case PropertyValidationKind.String: - GenerateStringValidations(writer, property); - break; - case PropertyValidationKind.Numeric: - case PropertyValidationKind.Comparable: - GenerateRangeValidations(writer, property); - break; - case PropertyValidationKind.Collection: - GenerateCollectionValidations(writer, property); - break; - case PropertyValidationKind.Complex: - GenerateComplexTypeValidation(writer, property); - break; - case PropertyValidationKind.Enum: - if (emitEnumRule) - GenerateEnumValidation(writer, property); - break; - case PropertyValidationKind.Unsupported: - break; - default: - break; - } - - GenerateCustomRuleValidations(writer, property, skipRequiredRule); - } - - static void GenerateCompareValidation(CodeWriter writer, ZodPropertyDescriptor property) - { - var compareAttribute = property.ValidationAttributes.Compare; - if (!compareAttribute.ShouldProcess || !compareAttribute.Value.Exists) - return; - - var compare = compareAttribute.Value; - var displayName = property.DisplayName; - var otherDisplayName = compare.OtherPropertyDisplayName ?? compare.OtherProperty; - - var messageExpression = BuildErrorMessageExpression( - compare.ValidationAttribute, - "Field '{0}' must match '{1}'.", - displayName.StringLiteral(), - otherDisplayName.StringLiteral() - ); - - writer.IfBlock( - $"!global::System.Collections.Generic.EqualityComparer.Default.Equals(value.{property.Name}, value.{compare.OtherProperty})", - ifBody => - WriteValidationError( - ifBody, - "mismatch", - messageExpression, - CodeGenHelpers.GetPathFieldName(property.Name) - ) - ); - - writer.NewLine(); - } - - static void GenerateCollectionElementValidation(CodeWriter writer, ZodPropertyDescriptor property) - { - var hasNestedSchema = property.NestedSchemaType is not null; - var rejectNullItems = property.ElementTypeCanBeNull && property.AllowNulls != true; - - // A value-element collection with no nested schema has nothing to check per element. - if (!hasNestedSchema && !rejectNullItems) - return; - - var collectionValueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Collection"); - - writer.Assignment("var", collectionValueName, $"value.{property.Name}"); - using (writer.IfBlockScope($"{collectionValueName} is not null")) - { - if (property.IsMap) - { - GenerateMapEntryValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); - } - else - { - GenerateSequenceItemValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); - } - } - - writer.NewLine(); - } - - /// - /// Emits the per-element validation loop for a sequence collection. - /// - static void GenerateSequenceItemValidation( - CodeWriter writer, - ZodPropertyDescriptor property, - bool hasNestedSchema, - bool rejectNullItems, - string collectionValueName - ) - { - var propertyName = property.Name; - var displayName = property.DisplayName; - var itemValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Item"); - var itemResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "ItemResult"); - var indexName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Index"); - var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemValueName}.Value" : itemValueName; - - writer.Assignment("var", indexName, "0"); - using (writer.ForeachScope($"var {itemValueName} in {collectionValueName}")) - { - if (property.ElementTypeCanBeNull) - { - using (writer.IfBlockScope($"{itemValueName} is null")) - { - if (rejectNullItems) - WriteNullItemError(writer, propertyName, displayName, $"{indexName}.ToString()", "elements"); - - writer.Line($"{indexName}++;"); - writer.Line("continue;"); - } - } - - if (hasNestedSchema) - { - writer.Assignment( - "var", - itemResultName, - property.NestedSchemaType!.Value.StaticMember($"Validate({itemExpression})") - ); - using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) - { - writer.MethodCall( - "AddNestedErrors", - "ref errors", - itemResultName, - propertyName.Surround(), - indexName - ); - } - } - - writer.Line($"{indexName}++;"); - } - } - - /// - /// Emits the per-value validation loop for a dictionary property. The key is used only in error paths, - /// matching Zod's z.record issue path, and is converted to a string inside the failing branch so a - /// valid dictionary allocates nothing for it. - /// - static void GenerateMapEntryValidation( - CodeWriter writer, - ZodPropertyDescriptor property, - bool hasNestedSchema, - bool rejectNullItems, - string collectionValueName - ) - { - var propertyName = property.Name; - var displayName = property.DisplayName; - var entryName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Entry"); - var itemValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Item"); - var itemResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "ItemResult"); - var keyExpression = $"{entryName}.Key.ToString()"; - var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemValueName}.Value" : itemValueName; - - using (writer.ForeachScope($"var {entryName} in {collectionValueName}")) - { - writer.Assignment("var", itemValueName, $"{entryName}.Value"); - - if (property.ElementTypeCanBeNull) - { - using (writer.IfBlockScope($"{itemValueName} is null")) - { - if (rejectNullItems) - WriteNullItemError(writer, propertyName, displayName, keyExpression, "values"); - - writer.Line("continue;"); - } - } - - if (hasNestedSchema) - { - writer.Assignment( - "var", - itemResultName, - property.NestedSchemaType!.Value.StaticMember($"Validate({itemExpression})") - ); - using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) - { - writer.MethodCall( - "AddNestedErrors", - "ref errors", - itemResultName, - propertyName.Surround(), - keyExpression - ); - } - } - } - } - - /// - /// Emits the invalid_type error for a null entry in a collection that rejects null entries, at the - /// entry's own path (["Items", index] or ["Items", key]), matching Zod's per-entry issue path. - /// - /// The writer to emit to. - /// The property name, used as the path prefix. - /// The display name used in the message. - /// The already-rendered path component (an index or a dictionary key). - /// The word describing a null entry (elements or values). - static void WriteNullItemError( - CodeWriter writer, - string propertyName, - string displayName, - string pathExpression, - string noun - ) - { - var message = $"Field '{displayName}' cannot contain null {noun}".StringLiteral(); - - writer.IfBlock( - "errors is null", - ifBody => - ifBody.Assignment( - "errors", - "new global::System.Collections.Generic.List()" - ) - ); - writer.MethodCallOn( - "errors", - "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({"invalid_type".Surround()}, {message}, global::System.Collections.Immutable.ImmutableArray.Create({propertyName.Surround()}, {pathExpression}))" - ); - } - - static void GenerateComplexTypeValidation(CodeWriter writer, ZodPropertyDescriptor property) - { - if (property.NestedSchemaType is null) - return; - - var propertyName = property.Name; - var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); - var nestedResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Result"); - var schemaType = property.NestedSchemaType.Value; - - if (property.IsNullableValueType) - { - // A Nullable property is validated through its unwrapped value; the nested schema is declared - // for T, not Nullable. - using (writer.IfBlockScope($"value.{propertyName} is {{ }} {propertyValueName}")) - { - writer.Assignment("var", nestedResultName, $"{schemaType}.Validate({propertyValueName})"); - using (writer.IfBlockScope($"!{nestedResultName}.IsSuccess")) - { - writer.MethodCall("AddNestedErrors", "ref errors", nestedResultName, propertyName.Surround()); - } - } - - return; - } - - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - if (property.IsDeclaredNullable) - { - using (writer.IfBlockScope($"{propertyValueName} is not null")) - { - writer.Assignment("var", nestedResultName, $"{schemaType}.Validate({propertyValueName})"); - using (writer.IfBlockScope($"!{nestedResultName}.IsSuccess")) - { - writer.MethodCall("AddNestedErrors", "ref errors", nestedResultName, propertyName.Surround()); - } - } - } - else - { - writer.Assignment("var", nestedResultName, $"{schemaType}.Validate({propertyValueName})"); - using (writer.IfBlockScope($"!{nestedResultName}.IsSuccess")) - { - writer.MethodCall("AddNestedErrors", "ref errors", nestedResultName, propertyName.Surround()); - } - } - } - - static void GeneratePathFields(CodeWriter writer, ZodSchemaDescriptor schema, CancellationToken cancellationToken) - { - var pathFieldType = TypeLibrary.System.Collections.Immutable.ImmutableArray.MakeGeneric( - TypeLibrary.System.String - ); - - foreach (var property in schema.Properties) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!property.ShouldProcess) - continue; - - writer.Field( - new( - CodeGenHelpers.GetPathFieldName(property.Value.Name), - pathFieldType, - TypeDeclarationAccessibility.Private - ) - { - IsStatic = true, - IsReadOnly = true, - Initializer = new TypeIdentity(typeof(System.Collections.Immutable.ImmutableArray)).StaticMember( - $"Create({property.Value.Name.Surround()})" - ), - } - ); - } - } - static void GenerateStaticAttributeFields( CodeWriter writer, ZodSchemaDescriptor schema, @@ -1124,18 +898,23 @@ CancellationToken cancellationToken if (prop.ValidationAttributes.RegularExpression.ShouldProcess) { - var pattern = prop.ValidationAttributes.RegularExpression.Value.Pattern ?? string.Empty; + var regex = prop.ValidationAttributes.RegularExpression.Value; + var pattern = regex.Pattern ?? string.Empty; + + // The pattern is author-supplied but runs against untrusted request input, so it carries a match + // timeout: the attribute's own MatchTimeoutInMilliseconds (2s by default, as RegexRule uses). + // Without it a catastrophically backtracking pattern hangs the request thread. + var timeout = + regex.MatchTimeoutInMilliseconds > 0 + ? $"global::System.TimeSpan.FromMilliseconds({regex.MatchTimeoutInMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)})" + : "global::ZodSharp.Rules.RegexRule.DefaultMatchTimeout"; writer.Field( new(GetRegexFieldName(prop.Name), regexType, TypeDeclarationAccessibility.Private) { IsStatic = true, IsReadOnly = true, - // The pattern is author-supplied but runs against untrusted request input, so it - // carries the same match timeout RegexRule applies to string patterns. Without - // it a catastrophically backtracking pattern hangs the request thread; note - // RegularExpressionAttribute, which this mirrors, defaults to a 2s timeout. Initializer = - $"new({pattern.StringLiteral()}, global::System.Text.RegularExpressions.RegexOptions.CultureInvariant, global::ZodSharp.Rules.RegexRule.DefaultMatchTimeout)", + $"new({pattern.StringLiteral()}, global::System.Text.RegularExpressions.RegexOptions.CultureInvariant, {timeout})", } ); } @@ -1165,133 +944,4 @@ CancellationToken cancellationToken writer.NewLine(); } - - static void GenerateValidationHelpers(CodeWriter writer, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var listOfErrorsType = TypeLibrary.System.Collections.Generic.List.MakeGeneric( - TypeLibrary.ZodSharp.Core.ValidationError - ); - var errorType = TypeLibrary.ZodSharp.Core.ValidationError.AsTypeReference(); - TypeIdentity immutableArrayType = new(typeof(System.Collections.Immutable.ImmutableArray)); - var typeParameterT = new TypeIdentity("T", null).AsTypeReference(); - - writer.Method( - new("AddError", TypeDeclarationAccessibility.Private) - { - IsStatic = true, - Parameters = - [ - new("errors", listOfErrorsType.MakeNullable(writer)) { Modifier = ParameterModifier.Ref }, - new("error", errorType), - ], - }, - body => body.MethodCallOn($"(errors ??= new {listOfErrorsType}())", "Add", "error") - ); - - writer.Method( - new("AddNestedErrors", TypeDeclarationAccessibility.Private) - { - IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions("T")], - Parameters = - [ - new("errors", listOfErrorsType.MakeNullable(writer)) { Modifier = ParameterModifier.Ref }, - new( - "nestedResult", - TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(typeParameterT).AsTypeReference() - ), - new("prefix", new TypeIdentity(SpecialType.System_String).AsTypeReference()), - ], - }, - body => - body.Foreach( - "var error in nestedResult.Errors", - foreachBody => - foreachBody.MethodCall( - "AddError", - "ref errors", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create(error.Code, error.Message, {immutableArrayType}.Create(prefix).AddRange(error.Path), error.Parameters, error.Origin, error.Minimum, error.Maximum, error.Inclusive, error.Category)" - ) - ) - ); - - writer.Method( - new("AddNestedErrors", TypeDeclarationAccessibility.Private) - { - IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions("T")], - Parameters = - [ - new("errors", listOfErrorsType.MakeNullable(writer)) { Modifier = ParameterModifier.Ref }, - new( - "nestedResult", - TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(typeParameterT).AsTypeReference() - ), - new("prefix", new TypeIdentity(SpecialType.System_String).AsTypeReference()), - new("index", new TypeIdentity(SpecialType.System_Int32).AsTypeReference()), - ], - }, - body => - body.Foreach( - "var error in nestedResult.Errors", - foreachBody => - foreachBody.MethodCall( - "AddError", - "ref errors", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create(error.Code, error.Message, {immutableArrayType}.Create(prefix, index.ToString()).AddRange(error.Path), error.Parameters, error.Origin, error.Minimum, error.Maximum, error.Inclusive, error.Category)" - ) - ) - ); - - writer.Method( - new("AddNestedErrors", TypeDeclarationAccessibility.Private) - { - IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions("T")], - Parameters = - [ - new("errors", listOfErrorsType.MakeNullable(writer)) { Modifier = ParameterModifier.Ref }, - new( - "nestedResult", - TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(typeParameterT).AsTypeReference() - ), - new("prefix", new TypeIdentity(SpecialType.System_String).AsTypeReference()), - new("key", new TypeIdentity(SpecialType.System_String).AsTypeReference()), - ], - }, - body => - body.Foreach( - "var error in nestedResult.Errors", - foreachBody => - foreachBody.MethodCall( - "AddError", - "ref errors", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create(error.Code, error.Message, {immutableArrayType}.Create(prefix, key).AddRange(error.Path), error.Parameters, error.Origin, error.Minimum, error.Maximum, error.Inclusive, error.Category)" - ) - ) - ); - - writer.NewLine(); - writer.Method( - new( - "FormatCount", - new TypeIdentity(SpecialType.System_String).AsTypeReference(), - TypeDeclarationAccessibility.Private - ) - { - IsStatic = true, - Parameters = - [ - new("count", new TypeIdentity(SpecialType.System_Int32).AsTypeReference()), - new("singularNoun", new TypeIdentity(SpecialType.System_String).AsTypeReference()), - new("pluralNoun", new TypeIdentity(SpecialType.System_String).AsTypeReference()), - ], - }, - body => body.Return("count == 1 ? $\"1 {singularNoun}\" : $\"{count} {pluralNoun}\"") - ); - - writer.NewLine(); - } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs index 7894f205..5f06d7e5 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs @@ -1,4 +1,3 @@ -using System.Globalization; using ZodSharp.SourceGenerators.Helpers; using ZodSharp.SourceGenerators.Models; @@ -6,39 +5,82 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { - static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescriptor property) + static bool RejectsDefaultArray(ZodPropertyDescriptor property) => + property.IsImmutableArray + && property.DefaultImmutableArrayBehavior != Models.DefaultImmutableArrayBehavior.TreatAsEmpty; + + static string MissingValueCondition(ZodPropertyDescriptor property) + { + var value = $"value.{property.Name}"; + if (!RejectsDefaultArray(property)) + return $"{value} is null"; + + return property.IsNullableValueType + ? $"({value} is null || {value}.GetValueOrDefault().IsDefault)" + : $"{value}.IsDefault"; + } + + static string CollectionValueExpression(ZodPropertyDescriptor property) + { + var value = property.IsNullableValueType + ? $"value.{property.Name}.GetValueOrDefault()" + : $"value.{property.Name}"; + return property.IsImmutableArray && !RejectsDefaultArray(property) + ? $"({value}.IsDefault ? {property.PropertyType.AsTypeReference()}.Empty : {value})" + : value; + } + + static CodeWriter.BlockScope CollectionValueScope( + CodeWriter writer, + ZodPropertyDescriptor property, + string localName + ) + { + writer.Assignment("var", localName, CollectionValueExpression(property)); + var condition = + property.IsNullableValueType ? $"value.{property.Name}.HasValue" + : property.CanBeNull ? $"{localName} is not null" + : null; + if (RejectsDefaultArray(property)) + condition = condition is null ? $"!{localName}.IsDefault" : $"{condition} && !{localName}.IsDefault"; + + return condition is null ? writer.OpenBlockScope() : writer.IfBlockScope(condition); + } + + static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescriptor property, bool recurse) { var lengthAttr = property.ValidationAttributes.Length; var minLengthAttr = property.ValidationAttributes.MinLength; var maxLengthAttr = property.ValidationAttributes.MaxLength; + var lengthAccessor = property.LengthAccessor; - if ( - (!lengthAttr.ShouldProcess || !lengthAttr.Value.Exists) - && (!minLengthAttr.ShouldProcess || !minLengthAttr.Value.Exists) - && (!maxLengthAttr.ShouldProcess || !maxLengthAttr.Value.Exists) - ) - { - GenerateCollectionElementValidation(writer, property); - return; - } + var hasSizeRules = + (lengthAttr.ShouldProcess && lengthAttr.Value.Exists) + || (minLengthAttr.ShouldProcess && minLengthAttr.Value.Exists) + || (maxLengthAttr.ShouldProcess && maxLengthAttr.Value.Exists); + + if (hasSizeRules && lengthAccessor.IsSupported) + GenerateCollectionSizeValidations(writer, property); + GenerateCollectionElementValidation(writer, property, recurse); + } + + static void GenerateCollectionSizeValidations(CodeWriter writer, ZodPropertyDescriptor property) + { + var lengthAttr = property.ValidationAttributes.Length; + var minLengthAttr = property.ValidationAttributes.MinLength; + var maxLengthAttr = property.ValidationAttributes.MaxLength; var lengthAccessor = property.LengthAccessor; - if (!lengthAccessor.IsSupported) - { - GenerateCollectionElementValidation(writer, property); - return; - } var propertyName = property.Name; - var displayName = property.DisplayName; var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); var propertyLengthName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Length"); - var origin = lengthAccessor.Origin; + var origin = lengthAccessor.Origin.Surround(); var singularNoun = property.IsMap ? "entry" : "element"; var pluralNoun = property.IsMap ? "entries" : "elements"; + var countValues = ValuesExpression(("count", propertyLengthName)); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - using (writer.IfBlockScope($"{propertyValueName} is not null")) + using (CollectionValueScope(writer, property, propertyValueName)) { writer.Assignment("var", "propertyValue", propertyValueName); writer.Assignment("var", propertyLengthName, lengthAccessor.LengthExpression); @@ -48,30 +90,37 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript var length = lengthAttr.Value; if (length.MinimumLength >= 0 && length.MaximumLength >= length.MinimumLength) { - var tooSmallMessage = BuildMessageExpression( + // LengthAttribute formats a custom message with (name, MinimumLength, MaximumLength). + var tooSmall = BuildMessageTemplate( length.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({length.MinimumLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - length.MaximumLength.ToString(CultureInfo.InvariantCulture), - length.MinimumLength.ToString(CultureInfo.InvariantCulture) + AtLeast(length.MinimumLength, singularNoun, pluralNoun), + "minimum", + "maximum" ); - var tooBigMessage = BuildMessageExpression( + var tooBig = BuildMessageTemplate( length.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({length.MaximumLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - length.MaximumLength.ToString(CultureInfo.InvariantCulture), - length.MinimumLength.ToString(CultureInfo.InvariantCulture) + NoMoreThan(length.MaximumLength, singularNoun, pluralNoun), + "minimum", + "maximum" ); + var lower = Including(length.MinimumLength); + var upper = Including(length.MaximumLength); using (writer.IfBlockScope($"{propertyLengthName} < {length.MinimumLength}")) { - WriteValidationError( + WriteAddError( writer, - "too_small", - tooSmallMessage, - CodeGenHelpers.GetPathFieldName(propertyName), - origin, - minimum: length.MinimumLength + ErrorExpression( + "\"too_small\"", + tooSmall, + PathOf(property), + MemberOf(property), + origin: origin, + category: Category("TooSmall"), + lowerExpression: lower, + upperExpression: upper, + valuesExpression: countValues + ) ); } @@ -79,13 +128,19 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript using (writer.OpenBlockScope($"else if ({propertyLengthName} > {length.MaximumLength})")) #pragma warning restore PSGFR23 { - WriteValidationError( + WriteAddError( writer, - "too_big", - tooBigMessage, - CodeGenHelpers.GetPathFieldName(propertyName), - origin, - maximum: length.MaximumLength + ErrorExpression( + "\"too_big\"", + tooBig, + PathOf(property), + MemberOf(property), + origin: origin, + category: Category("TooBig"), + lowerExpression: lower, + upperExpression: upper, + valuesExpression: countValues + ) ); } } @@ -94,22 +149,26 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript if (minLengthAttr.ShouldProcess && minLengthAttr.Value.Exists && minLengthAttr.Value.Length > 0) { var minLength = minLengthAttr.Value.Length; - var messageExpression = BuildMessageExpression( + var message = BuildMessageTemplate( minLengthAttr.Value.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({minLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - minLength.ToString(CultureInfo.InvariantCulture) + AtLeast(minLength, singularNoun, pluralNoun), + "minimum" ); using (writer.IfBlockScope($"{propertyLengthName} < {minLength}")) { - WriteValidationError( + WriteAddError( writer, - "too_small", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName), - origin, - minimum: minLength + ErrorExpression( + "\"too_small\"", + message, + PathOf(property), + MemberOf(property), + origin: origin, + category: Category("TooSmall"), + lowerExpression: Including(minLength), + valuesExpression: countValues + ) ); } } @@ -117,28 +176,211 @@ static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescript if (maxLengthAttr.ShouldProcess && maxLengthAttr.Value.Exists && maxLengthAttr.Value.Length >= 0) { var maxLength = maxLengthAttr.Value.Length; - var messageExpression = BuildMessageExpression( + var message = BuildMessageTemplate( maxLengthAttr.Value.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({maxLength}, {singularNoun.Surround()}, {pluralNoun.Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - maxLength.ToString(CultureInfo.InvariantCulture) + NoMoreThan(maxLength, singularNoun, pluralNoun), + "maximum" ); using (writer.IfBlockScope($"{propertyLengthName} > {maxLength}")) { - WriteValidationError( + WriteAddError( writer, - "too_big", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName), - origin, - maximum: maxLength + ErrorExpression( + "\"too_big\"", + message, + PathOf(property), + MemberOf(property), + origin: origin, + category: Category("TooBig"), + upperExpression: Including(maxLength), + valuesExpression: countValues + ) ); } } } writer.NewLine(); - GenerateCollectionElementValidation(writer, property); } + + /// + /// Whether the collection rejects null elements (or map values), so the element loop runs even when nested + /// schemas are not recursed into. + /// + static bool RejectsNullItems(ZodPropertyDescriptor property) => + property.ElementTypeCanBeNull && property.AllowNulls != true; + + static void GenerateCollectionElementValidation(CodeWriter writer, ZodPropertyDescriptor property, bool recurse) + { + var hasNestedSchema = recurse && property.NestedSchemaType is not null; + var rejectNullItems = RejectsNullItems(property); + + // A value-element collection with no nested schema has nothing to check per element. + if (!hasNestedSchema && !rejectNullItems) + return; + + var collectionValueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Collection"); + + using (CollectionValueScope(writer, property, collectionValueName)) + { + if (property.IsMap) + GenerateMapEntryValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); + else + GenerateSequenceItemValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); + } + + writer.NewLine(); + } + + /// + /// Emits the per-element loop for a sequence. Nested element validation runs only when the member method is + /// asked to recurse, so ValidateMember keeps the null-element check (it belongs to the member) without + /// validating the elements' own members. + /// + static void GenerateSequenceItemValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + bool hasNestedSchema, + bool rejectNullItems, + string collectionValueName + ) + { + var propertyName = property.Name; + var itemValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Item"); + var itemResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "ItemResult"); + var indexName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Index"); + var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemValueName}.Value" : itemValueName; + + writer.Assignment("var", indexName, "0"); + using (writer.ForeachScope($"var {itemValueName} in {collectionValueName}")) + { + if (property.ElementTypeCanBeNull) + { + using (writer.IfBlockScope($"{itemValueName} is null")) + { + if (rejectNullItems) + WriteNullItemError(writer, property, $"{ZodPathSegmentType}.At({indexName})", "array"); + + writer.Line($"{indexName}++;"); + writer.Line("continue;"); + } + } + + if (hasNestedSchema) + { + using (writer.IfBlockScope("recurse")) + { + writer.Assignment("var", itemResultName, NestedValidateCall(property, itemExpression)); + using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) + { + writer.MethodCall( + $"{GeneratedValidation}.AddNested", + "ref errors", + itemResultName, + $"{PathOf(property)}.Append({ZodPathSegmentType}.At({indexName}))" + ); + } + } + } + + writer.Line($"{indexName}++;"); + } + } + + /// + /// Emits the per-value loop for a dictionary. The key is used only in error paths, as a typed key segment + /// rendered with the invariant culture, matching Zod's z.record issue path. + /// + static void GenerateMapEntryValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + bool hasNestedSchema, + bool rejectNullItems, + string collectionValueName + ) + { + var propertyName = property.Name; + var entryName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Entry"); + var itemValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Item"); + var itemResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "ItemResult"); + var keySegment = $"{ZodPathSegmentType}.Key({entryName}.Key)"; + var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemValueName}.Value" : itemValueName; + + using (writer.ForeachScope($"var {entryName} in {collectionValueName}")) + { + writer.Assignment("var", itemValueName, $"{entryName}.Value"); + + if (property.ElementTypeCanBeNull) + { + using (writer.IfBlockScope($"{itemValueName} is null")) + { + if (rejectNullItems) + WriteNullItemError(writer, property, keySegment, "record"); + + writer.Line("continue;"); + } + } + + if (hasNestedSchema) + { + using (writer.IfBlockScope("recurse")) + { + writer.Assignment("var", itemResultName, NestedValidateCall(property, itemExpression)); + using (writer.IfBlockScope($"!{itemResultName}.IsSuccess")) + { + writer.MethodCall( + $"{GeneratedValidation}.AddNested", + "ref errors", + itemResultName, + $"{PathOf(property)}.Append({keySegment})" + ); + } + } + } + } + } + + /// + /// Emits the invalid_type error for a null entry in a collection that rejects null entries, at the + /// entry's own path (Items[0] or Map[key]), matching Zod's per-entry issue path and the + /// runtime schemas' null message. + /// + static void WriteNullItemError( + CodeWriter writer, + ZodPropertyDescriptor property, + string segmentExpression, + string origin + ) + { + var expected = property.ElementType is { } elementType + ? $"global::ZodSharp.Core.ZodTypeNames.Of(typeof({elementType.AsTypeReference()}))" + : "global::ZodSharp.Core.ZodTypeNames.Unknown"; + + WriteAddError( + writer, + MemberError( + $"global::ZodSharp.Core.ZodIssues.NullValue({expected}, {PathOf(property)}.Append({segmentExpression}), {origin.Surround()})", + MemberOf(property) + ) + ); + } + + /// + /// The nested Validate call for a member, threading the depth when the nested schema supports it. + /// + static string NestedValidateCall(ZodPropertyDescriptor property, string valueExpression) => + NestedValidateCall(property.NestedSchemaType!.Value, property.NestedSchemaIsModern, valueExpression); + + static string NestedValidateCall(TypeIdentity schemaType, bool isModern, string valueExpression) => + isModern + ? schemaType.StaticMember($"ValidateCore({valueExpression}, depth + 1)") + : schemaType.StaticMember($"Validate({valueExpression})"); + + /// + /// Adds the member's parameters (displayName, member, declaringType) to an error + /// built by a shared factory. + /// + static string MemberError(string errorExpression, string memberExpression) => + $"{GeneratedValidation}.ForMember({errorExpression}, {memberExpression})"; } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index 78252f5b..dad1bd7f 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -23,7 +23,10 @@ static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescript // The resolver closes a generic rule over the property's underlying type, so a nullable value type is // unwrapped to match. The validation runs inside the non-null guard, so the unwrap cannot throw. - var valueExpression = property.IsNullableValueType ? $"value.{property.Name}!.Value" : $"value.{property.Name}"; + var valueExpression = + property.ValidationKind == PropertyValidationKind.Collection ? CollectionValueExpression(property) + : property.IsNullableValueType ? $"value.{property.Name}!.Value" + : $"value.{property.Name}"; for (var i = 0; i < property.CustomRules.Count; i++) { @@ -40,9 +43,10 @@ static void GenerateCustomRuleValidations(CodeWriter writer, ZodPropertyDescript property.Name, i, valueExpression, - CodeGenHelpers.GetPathFieldName(property.Name), - property.DisplayName, - declareValueLocal: true + PathOf(property), + MemberOf(property), + declareValueLocal: true, + fallbackOrigin: GetFallbackOrigin(property) ); } } @@ -87,14 +91,17 @@ CustomRuleDescriptor requiredRule ruleVariable, identityVariable, "default!", - CodeGenHelpers.GetPathFieldName(propertyName), - property.DisplayName, - invalidCondition: $"value.{propertyName} == null || {scalarExpression}.{scalarPropertyName} == null" + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property), + invalidCondition: property.CanBeNull + ? $"value.{propertyName} is null || {scalarExpression}.{scalarPropertyName} is null" + : $"{scalarExpression}.{scalarPropertyName} is null" ); return; } - if (property.IsNullableValueType) + if (property.IsNullableValueType || RejectsDefaultArray(property)) { // The rule is closed over the underlying value, so it cannot be handed null. An absent value is // reported directly, matching how [Required] handles a Nullable member. @@ -104,9 +111,10 @@ CustomRuleDescriptor requiredRule ruleVariable, identityVariable, "default!", - CodeGenHelpers.GetPathFieldName(propertyName), - property.DisplayName, - invalidCondition: $"value.{propertyName} == null" + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property), + invalidCondition: MissingValueCondition(property) ); return; } @@ -116,9 +124,12 @@ CustomRuleDescriptor requiredRule requiredRule, ruleVariable, identityVariable, - $"value.{propertyName}", - CodeGenHelpers.GetPathFieldName(propertyName), - property.DisplayName + property.ValidationKind == PropertyValidationKind.Collection + ? CollectionValueExpression(property) + : $"value.{propertyName}", + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property) ); } @@ -137,7 +148,7 @@ static void GenerateTypeRuleValidations(CodeWriter writer, ZodSchemaDescriptor s schema.TargetType.Name, "value", "EmptyPath", - schema.TargetType.Name, + TypeMemberFieldName, declareValueLocal: false ); } @@ -148,7 +159,7 @@ static void GenerateRuleValidations( string localPrefix, string valueExpression, string pathExpression, - string displayName, + string memberExpression, bool declareValueLocal ) { @@ -161,8 +172,9 @@ bool declareValueLocal i, valueExpression, pathExpression, - displayName, - declareValueLocal + memberExpression, + declareValueLocal, + fallbackOrigin: null ); } } @@ -174,8 +186,9 @@ static void EmitCustomRule( int index, string valueExpression, string pathExpression, - string displayName, - bool declareValueLocal + string memberExpression, + bool declareValueLocal, + string? fallbackOrigin ) { if (rule.GuardedScalarPropertyName is { Length: > 0 } scalarPropertyName) @@ -188,7 +201,8 @@ bool declareValueLocal valueExpression, scalarPropertyName, pathExpression, - displayName + memberExpression, + fallbackOrigin ); return; } @@ -210,7 +224,16 @@ bool declareValueLocal arguments ); - WriteRuleFailure(writer, rule, ruleVariable, identityVariable, valueVariable, pathExpression, displayName); + WriteRuleFailure( + writer, + rule, + ruleVariable, + identityVariable, + valueVariable, + pathExpression, + memberExpression, + fallbackOrigin + ); writer.NewLine(); } @@ -227,7 +250,8 @@ bool declareValueLocal /// The expression holding the scalar value object. /// The scalar property that holds the nullable underlying value. /// The error path expression. - /// The display name used for a custom error message. + /// The member-info expression for the failing member. + /// The origin reported when neither the rule nor the attribute supplies one. static void EmitGuardedScalarRule( CodeWriter writer, CustomRuleDescriptor rule, @@ -236,7 +260,8 @@ static void EmitGuardedScalarRule( string scalarExpression, string scalarPropertyName, string pathExpression, - string displayName + string memberExpression, + string? fallbackOrigin ) { var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{index}"); @@ -255,21 +280,28 @@ string displayName requiredIdentity, "default!", pathExpression, - displayName, - invalidCondition: $"{underlyingExpression} == null" + memberExpression, + fallbackOrigin, + invalidCondition: $"{underlyingExpression} is null" ); writer.NewLine(); return; } - writer.IfBlock( - $"{underlyingExpression} is {{ }} scalarValue", - body => - { - var identity = EmitRuleInstance(body, rule, identityVariable, ruleVariable, arguments); - WriteRuleFailure(body, rule, ruleVariable, identity, "scalarValue", pathExpression, displayName); - } - ); + writer.IfBlock($"{underlyingExpression} is {{ }} scalarValue", body => + { + var identity = EmitRuleInstance(body, rule, identityVariable, ruleVariable, arguments); + WriteRuleFailure( + body, + rule, + ruleVariable, + identity, + "scalarValue", + pathExpression, + memberExpression, + fallbackOrigin + ); + }); writer.NewLine(); } @@ -321,7 +353,8 @@ string arguments /// The local name holding the rule that owns the error identity. /// The expression passed to the rule for the message. /// The error path expression. - /// The display name used for a custom error message. + /// The member-info expression for the failing member. + /// The origin reported when neither the rule nor the attribute supplies one. /// /// The condition that reports the failure. Defaults to the rule's own !IsValid(...) contract; a /// required rule closed over a value type supplies an explicit null comparison instead. @@ -333,7 +366,8 @@ static void WriteRuleFailure( string identityVariable, string valueVariable, string pathExpression, - string displayName, + string memberExpression, + string? fallbackOrigin, string? invalidCondition = null ) { @@ -345,35 +379,61 @@ static void WriteRuleFailure( var codeExpression = rule.RuleOwnsIdentity ? $"(({zodRuleInterface}){identityVariable}).Code ?? {codeFallback.Surround()}" : codeFallback.Surround(); - var originFallback = rule.Origin is { Length: > 0 } customOrigin ? customOrigin.Surround() : null; - var originExpression = rule.RuleOwnsIdentity - ? originFallback is null - ? $"(({zodRuleInterface}){identityVariable}).Origin" - : $"(({zodRuleInterface}){identityVariable}).Origin ?? {originFallback}" - : originFallback ?? "null"; - - var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) - ? BuildErrorMessageExpression(rule.Message, "Field '{0}' is invalid.", displayName.StringLiteral()) - : $"{ruleVariable}.GetErrorMessage({valueVariable})"; - - writer.IfBlock( - invalidCondition ?? $"!{ruleVariable}.IsValid({valueVariable})", - ifBody => - { - ifBody.IfBlock( - "errors is null", - errorsBody => - errorsBody.Assignment( - "errors", - "new global::System.Collections.Generic.List()" - ) - ); - ifBody.MethodCallOn( - "errors", - "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({codeExpression}, {message}, {pathExpression}, origin: {originExpression})" - ); - } + var originExpression = + rule.Origin is { Length: > 0 } customOrigin ? customOrigin.Surround() + : fallbackOrigin is null ? "null" + : fallbackOrigin; + + // The rule's structured identity (origin, category, bounds, parameters, its failure description) is + // copied by ZodRuleErrors, so a generated failure matches the runtime schema's failure for the same + // rule. An adapted rule has no identity of its own, so the wrapped rule supplies it. + var originArgument = originExpression == "null" ? string.Empty : $", fallbackOrigin: {originExpression}"; + + // The identity is passed with a direct cast when the rule implements IZodRule, and as null otherwise: the + // rule is constructed with its exact type, so the answer is known here. A runtime type test on a boxed + // value-type rule ((object)rule as IZodRule) crashes the Mono AOT compiler (Blazor WebAssembly with + // RunAOTCompilation=true), so the generator never emits one. + var identityArgument = rule.RuleOwnsIdentity ? $"({zodRuleInterface}){identityVariable}" : "null"; + var ruleError = + identityVariable == ruleVariable && valueVariable != "default!" + ? $"global::ZodSharp.Core.ZodRuleErrors.Create({ruleVariable}, {valueVariable}, {pathExpression}{originArgument}, code: {codeExpression})" + : $"global::ZodSharp.Core.ZodRuleErrors.Build({codeExpression}, {ruleVariable}.GetErrorMessage({valueVariable}), {pathExpression}, {identityArgument}{originArgument})"; + + var errorExpression = + !string.IsNullOrEmpty(rule.Message.ErrorMessage) + || ( + !string.IsNullOrEmpty(rule.Message.ErrorMessageResourceName) + && rule.Message.ErrorMessageResourceType is not null + ) + ? ForMemberWithMessage( + ruleError, + memberExpression, + BuildMessageTemplate(rule.Message, "Field '{displayName}' is invalid.") + ) + : $"{GeneratedValidation}.ForMember({ruleError}, {memberExpression}, fallbackCategory: {Category("Custom")})"; + + writer.IfBlock(invalidCondition ?? $"!{ruleVariable}.IsValid({valueVariable})", ifBody => + WriteAddError(ifBody, errorExpression) ); } + + static string ForMemberWithMessage(string errorExpression, string memberExpression, MessageTemplate message) => + message.OrdinalNamesExpression is null + ? $"{GeneratedValidation}.ForMember({errorExpression}, {memberExpression}, fallbackCategory: {Category("Custom")}, messageFormat: {message.FormatExpression})" + : $"{GeneratedValidation}.ForMember({errorExpression}, {memberExpression}, fallbackCategory: {Category("Custom")}, messageFormat: {message.FormatExpression}, ordinalNames: {message.OrdinalNamesExpression})"; + + /// + /// The origin reported for a custom rule that supplies none: the member's Zod type for the scalar kinds the + /// generator recognizes, so a custom string rule reports string like the built-in string checks. + /// + static string? GetFallbackOrigin(ZodPropertyDescriptor property) => + property.ValidationKind switch + { + PropertyValidationKind.String => StringOrigin, + PropertyValidationKind.Numeric or PropertyValidationKind.Comparable or PropertyValidationKind.Enum => + $"global::ZodSharp.Core.ZodTypeNames.Of(typeof({property.PropertyType.AsTypeReference()}))", + PropertyValidationKind.Collection or PropertyValidationKind.Complex or PropertyValidationKind.Unsupported => + null, + _ => null, + }; } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs index e4d5b5cd..65bc7dab 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.EnumValidators.cs @@ -35,9 +35,7 @@ static void GenerateEnumValidation(CodeWriter writer, ZodPropertyDescriptor prop var propertyName = property.Name; var ruleVariable = CodeGenHelpers.GetLocalIdentifier(propertyName, "EnumRule"); var valueVariable = CodeGenHelpers.GetLocalIdentifier(propertyName, "EnumRuleValue"); - var pathFieldName = CodeGenHelpers.GetPathFieldName(propertyName); var ruleType = GetEnumRuleType(property); - var zodRuleInterface = TypeLibrary.ZodSharp.Core.IZodRule.AsTypeReference().RenderFullName; // The rule is closed with the underlying enum type, so a nullable property is unwrapped. The // validation always runs inside the non-null guard, so the unwrap cannot throw. @@ -45,32 +43,16 @@ static void GenerateEnumValidation(CodeWriter writer, ZodPropertyDescriptor prop writer.Assignment("var", valueVariable, valueExpression); writer.Assignment("var", ruleVariable, $"new {ruleType}({GetDisallowedEnumValuesExpression(property)})"); - // The rule implements IZodRule, so it owns its error identity; the rule's constant is only the - // fallback when the interface reports none. The cast is required because the interface may be - // implemented explicitly. - var codeExpression = $"(({zodRuleInterface}){ruleVariable}).Code ?? {ruleType}.ErrorCode"; - var originExpression = $"(({zodRuleInterface}){ruleVariable}).Origin"; - var messageExpression = GetEnumValidationMessageExpression(property, ruleVariable, valueVariable); + // The rule implements IZodRule, so it owns its error identity (code, origin, category, options); a + // [DeniedValues] attribute that configures its own message replaces only the message. + var message = GetEnumValidationMessage(property); + var ruleError = + $"global::ZodSharp.Core.ZodRuleErrors.Create({ruleVariable}, {valueVariable}, {PathOf(property)}, \"enum\")"; + var errorExpression = message is { } template + ? $"{GeneratedValidation}.ForMember({ruleError}, {MemberOf(property)}, messageFormat: {template.FormatExpression}{(template.OrdinalNamesExpression is null ? string.Empty : $", ordinalNames: {template.OrdinalNamesExpression}")})" + : MemberError(ruleError, MemberOf(property)); - writer.IfBlock( - $"!{ruleVariable}.IsValid({valueVariable})", - ifBody => - { - ifBody.IfBlock( - "errors is null", - errorsBody => - errorsBody.Assignment( - "errors", - "new global::System.Collections.Generic.List()" - ) - ); - ifBody.MethodCallOn( - "errors", - "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({codeExpression}, {messageExpression}, {pathFieldName}, origin: {originExpression})" - ); - } - ); + writer.IfBlock($"!{ruleVariable}.IsValid({valueVariable})", ifBody => WriteAddError(ifBody, errorExpression)); writer.NewLine(); } @@ -100,33 +82,19 @@ static string GetDisallowedEnumValuesExpression(ZodPropertyDescriptor property) } /// - /// Builds the message expression: a [DeniedValues] attribute that configures its own message is - /// honoured, and otherwise the rule formats its own. + /// Builds the replacement message template when a [DeniedValues] attribute configures its own + /// message; otherwise the rule's own message is used. /// /// The enum property the rule validates. - /// The local the rule instance is assigned to. - /// The local holding the property value. - /// The message expression. - static string GetEnumValidationMessageExpression( - ZodPropertyDescriptor property, - string ruleVariable, - string valueVariable - ) + /// The template, or to keep the rule's message. + static MessageTemplate? GetEnumValidationMessage(ZodPropertyDescriptor property) { var deniedValues = property.ValidationAttributes.DeniedValues; var validationAttribute = deniedValues.Value.ValidationAttribute; - if (deniedValues.ShouldProcess && deniedValues.Value.Exists && HasConfiguredMessage(validationAttribute)) - { - return BuildErrorMessageExpression( - validationAttribute, - "Field '{0}' is invalid.", - property.DisplayName.StringLiteral() - ); - } - - // The rule formats its own message, so the property name is not needed. - return $"{ruleVariable}.GetErrorMessage({valueVariable})"; + return deniedValues.ShouldProcess && deniedValues.Value.Exists && HasConfiguredMessage(validationAttribute) + ? BuildMessageTemplate(validationAttribute, "Field '{displayName}' is invalid.") + : null; } static bool HasConfiguredMessage(ValidationAttributeData validationAttribute) => diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs index ddb636ee..1e17d4f8 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.JsonSchema.cs @@ -342,7 +342,7 @@ static string BuildRuleInstanceExpression(CustomRuleDescriptor rule) // A scalar rule adapter wraps a rule written against the scalar's underlying value; the wrapped rule // owns the JSON Schema meaning, so construct that one. The adapter itself has none. var ruleType = (rule.AdaptedFrom ?? rule.RuleType).AsTypeReference().RenderFullName; - var arguments = rule.Arguments.Count == 0 ? string.Empty : $"({string.Join(", ", rule.Arguments)})"; + var arguments = $"({string.Join(", ", rule.Arguments)})"; return $"new {ruleType}{arguments}"; } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.LengthValidationHelpers.cs b/src/src/SourceGenerators/ZodSchemaGenerator.LengthValidationHelpers.cs index fdcad526..88fe5916 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.LengthValidationHelpers.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.LengthValidationHelpers.cs @@ -1,41 +1,48 @@ using System.Globalization; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; using ZodSharp.SourceGenerators.Models.DataAttributes; namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { - static string BuildMessageExpression( - ValidationAttributeData validationAttribute, - string defaultMessageExpression, - params string[] formatArguments - ) - { - if ( - !string.IsNullOrEmpty(validationAttribute.ErrorMessageResourceName) - && validationAttribute.ErrorMessageResourceType is not null - ) - { - var resourceType = validationAttribute.ErrorMessageResourceType.Value; - return BuildFormatExpression( - $"{new TypeReference(resourceType).RenderFullName}.{validationAttribute.ErrorMessageResourceName}", - formatArguments - ); - } + const string GeneratedValidation = "global::ZodSharp.Core.ZodGeneratedValidation"; - var errorMessage = validationAttribute.ErrorMessage; - return errorMessage is { Length: > 0 } - ? BuildFormatExpression(errorMessage.StringLiteral(), formatArguments) - : defaultMessageExpression; - } + const string ZodPathType = "global::ZodSharp.Core.ZodPath"; + + const string ZodPathSegmentType = "global::ZodSharp.Core.ZodPathSegment"; + + const string ErrorBoundType = "global::ZodSharp.Core.ErrorBound"; + + const string IssueCategories = "global::ZodSharp.Core.ZodIssueCategories"; + + /// + /// The message template of an attribute-driven error, with the names its positional placeholders bind to. + /// The generator never formats the text: the template is formatted at runtime by the never-throw + /// ZodMessageFormat engine, so a stray brace in a custom ErrorMessage cannot fail the build + /// or the validation. + /// + /// The expression that yields the template (a literal or a resource property). + /// + /// The string[] expression naming the positional placeholders, or when the + /// template uses named placeholders only. + /// + readonly record struct MessageTemplate(string FormatExpression, string? OrdinalNamesExpression); - static string BuildErrorMessageExpression( + /// + /// Resolves the message template for an attribute: its ErrorMessageResourceType/Name, its + /// ErrorMessage, or the generator's named default. A user-supplied message keeps the DataAnnotations + /// positional convention ({0} is the display name, then the attribute's own arguments), bound through + /// . + /// + static MessageTemplate BuildMessageTemplate( ValidationAttributeData validationAttribute, string defaultFormat, - params string[] formatArguments + params string[] ordinalNames ) { - string formatExpression; + var ordinals = BuildOrdinalNames(ordinalNames); if ( !string.IsNullOrEmpty(validationAttribute.ErrorMessageResourceName) @@ -43,76 +50,87 @@ params string[] formatArguments ) { var resourceType = validationAttribute.ErrorMessageResourceType.Value; - formatExpression = - $"{new TypeReference(resourceType).RenderFullName}.{validationAttribute.ErrorMessageResourceName}"; - } - else - { - var errorMessage = validationAttribute.ErrorMessage; - formatExpression = errorMessage is { Length: > 0 } - ? errorMessage.StringLiteral() - : defaultFormat.StringLiteral(); + return new( + $"{new TypeReference(resourceType).RenderFullName}.{validationAttribute.ErrorMessageResourceName}", + ordinals + ); } - // If there are no format arguments, we can return the format expression directly. - return BuildFormatExpression(formatExpression, formatArguments); + return validationAttribute.ErrorMessage is { Length: > 0 } errorMessage + ? new(errorMessage.StringLiteral(), ordinals) + : new(defaultFormat.StringLiteral(), null); } - static string BuildFormatExpression(string formatExpression, params string[] formatArguments) => - formatArguments.Length == 0 - ? formatExpression - : $"string.Format(global::System.Globalization.CultureInfo.CurrentCulture, {formatExpression}, {string.Join(", ", formatArguments)})"; - - static void WriteValidationError( - CodeWriter writer, - string errorCode, - string messageExpression, - string pathFieldName, - string origin, - int? minimum = null, - int? maximum = null - ) - { - writer.IfBlock( - "errors is null", - ifBody => - ifBody.Assignment( - "errors", - "new global::System.Collections.Generic.List()" - ) - ); - writer.MethodCallOn( - "errors", - "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({errorCode.Surround()}, {messageExpression}, {pathFieldName}, origin: {origin.Surround()}, minimum: {(minimum.HasValue ? minimum.Value.ToString(CultureInfo.InvariantCulture) : "null")}, maximum: {(maximum.HasValue ? maximum.Value.ToString(CultureInfo.InvariantCulture) : "null")}, inclusive: true)" - ); - } + static string BuildOrdinalNames(string[] ordinalNames) => + $"new string[] {{ {string.Join(", ", ["displayName".Surround(), .. ordinalNames.Select(static name => name.Surround())])} }}"; - static void WriteValidationError( - CodeWriter writer, - string errorCode, - string messageExpression, - string pathFieldName + /// + /// Renders a ZodGeneratedValidation.Error(...) call. + /// + static string ErrorExpression( + string codeExpression, + MessageTemplate message, + string pathExpression, + string memberExpression, + string? origin = null, + string? category = null, + string? lowerExpression = null, + string? upperExpression = null, + string? valuesExpression = null ) { - writer.IfBlock( - "errors is null", - ifBody => - ifBody.Assignment( - "errors", - "new global::System.Collections.Generic.List()" - ) - ); - writer.MethodCallOn( - "errors", - "Add", - $"{TypeLibrary.ZodSharp.Core.ValidationError}.Create({errorCode.Surround()}, {messageExpression}, {pathFieldName})" - ); + List arguments = [codeExpression, message.FormatExpression, pathExpression, memberExpression]; + if (origin is not null) + arguments.Add($"origin: {origin}"); + if (category is not null) + arguments.Add($"category: {category}"); + if (lowerExpression is not null) + arguments.Add($"lower: {lowerExpression}"); + if (upperExpression is not null) + arguments.Add($"upper: {upperExpression}"); + if (message.OrdinalNamesExpression is not null) + arguments.Add($"ordinalNames: {message.OrdinalNamesExpression}"); + if (valuesExpression is not null) + arguments.Add($"values: {valuesExpression}"); + + return $"{GeneratedValidation}.Error({string.Join(", ", arguments)})"; } + /// + /// Renders the named values passed alongside an error, as a KeyValuePair<string, object?>[]. + /// + static string ValuesExpression(params (string Name, string ValueExpression)[] values) => + $"new global::System.Collections.Generic.KeyValuePair[] {{ {string.Join(", ", values.Select(static value => $"new global::System.Collections.Generic.KeyValuePair({value.Name.Surround()}, {value.ValueExpression})"))} }}"; + + static string Category(string name) => $"{IssueCategories}.{name}"; + + static string Including(int value) => $"{ErrorBoundType}.Including({value.ToString(CultureInfo.InvariantCulture)})"; + + static string Bound(string valueExpression, bool inclusive) => + $"{ErrorBoundType}.{(inclusive ? "Including" : "Excluding")}({valueExpression})"; + + /// + /// Emits ZodGeneratedValidation.Add(ref errors, error). + /// + static void WriteAddError(CodeWriter writer, string errorExpression) => + writer.MethodCall($"{GeneratedValidation}.Add", "ref errors", errorExpression); + static string GetRegexFieldName(string propertyName) => $"Regex_{propertyName}"; static string GetRangeMinimumFieldName(string propertyName) => $"RangeMinimum_{propertyName}"; static string GetRangeMaximumFieldName(string propertyName) => $"RangeMaximum_{propertyName}"; + + /// The static path field (Paths.X) for a property. + static string PathOf(ZodPropertyDescriptor property) => $"Paths.{GetPathMemberName(property.Name)}"; + + /// The static member-info field for a property. + static string MemberOf(ZodPropertyDescriptor property) => CodeGenHelpers.GetMemberFieldName(property.Name); + + /// + /// The name of a property's field in the generated Paths class. A member cannot share its enclosing + /// type's name (CS0542), so a property named Paths gets a trailing underscore. + /// + static string GetPathMemberName(string propertyName) => + propertyName is "Paths" or "JsonNames" ? propertyName + "_" : propertyName; } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.NumericValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.NumericValidators.cs index 85ad6a61..a440d829 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.NumericValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.NumericValidators.cs @@ -6,6 +6,12 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { + /// + /// Emits the [Range] check. A failure reports the runtime RangeRule's code + /// (invalid_range) with the category by direction (too_small/too_big), the origin of the + /// value's Zod type, and both bounds with their exclusivity (MinimumIsExclusive, + /// MaximumIsExclusive). + /// static void GenerateRangeValidations(CodeWriter writer, ZodPropertyDescriptor property) { var rangeAttribute = property.ValidationAttributes.Range; @@ -17,38 +23,84 @@ static void GenerateRangeValidations(CodeWriter writer, ZodPropertyDescriptor pr return; var propertyName = property.Name; - var displayName = property.DisplayName; var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); + var minimumField = GetRangeMinimumFieldName(propertyName); + var maximumField = GetRangeMaximumFieldName(propertyName); var minComparison = range.MinimumIsExclusive ? "<=" : "<"; var maxComparison = range.MaximumIsExclusive ? ">=" : ">"; var minimumDescription = range.MinimumIsExclusive ? "greater than" : "greater than or equal to"; var maximumDescription = range.MaximumIsExclusive ? "less than" : "less than or equal to"; - var minimumDisplay = Convert.ToString(range.Minimum, CultureInfo.InvariantCulture) ?? string.Empty; - var maximumDisplay = Convert.ToString(range.Maximum, CultureInfo.InvariantCulture) ?? string.Empty; - var messageExpression = BuildMessageExpression( + + // The bounds are reported as typed values (Lower/Upper). A number range also reports its minimum/maximum + // parameters as numbers (so ProblemDetails writes JSON numbers), which render in the message exactly as + // declared. A converted non-number range (a date or time string) keeps the attribute's literal text, so its + // message reads as declared rather than in the type's invariant rendering. + var message = BuildMessageTemplate( range.ValidationAttribute, - $"Field '{displayName}' must be {minimumDescription} {minimumDisplay} and {maximumDescription} {maximumDisplay}.".StringLiteral(), - displayName.StringLiteral(), - minimumDisplay.StringLiteral(), - maximumDisplay.StringLiteral() + $"Field '{{displayName}}' must be {minimumDescription} {{minimum}} and {maximumDescription} {{maximum}}.", + "minimum", + "maximum" ); + var values = + property.ValidationKind == PropertyValidationKind.Numeric + ? ValuesExpression(("minimum", minimumField), ("maximum", maximumField)) + : ValuesExpression( + ( + "minimum", + (Convert.ToString(range.Minimum, CultureInfo.InvariantCulture) ?? string.Empty).StringLiteral() + ), + ( + "maximum", + (Convert.ToString(range.Maximum, CultureInfo.InvariantCulture) ?? string.Empty).StringLiteral() + ) + ); + var lower = Bound(minimumField, !range.MinimumIsExclusive); + var upper = Bound(maximumField, !range.MaximumIsExclusive); + var origin = $"global::ZodSharp.Core.ZodTypeNames.Of(typeof({property.PropertyType.AsTypeReference()}))"; - var comparisonExpression = property.CompareViaCompareTo - ? $"{propertyValueName}.CompareTo({GetRangeMinimumFieldName(propertyName)}) {minComparison} 0 || {propertyValueName}.CompareTo({GetRangeMaximumFieldName(propertyName)}) {maxComparison} 0" - : $"{propertyValueName} {minComparison} {GetRangeMinimumFieldName(propertyName)} || {propertyValueName} {maxComparison} {GetRangeMaximumFieldName(propertyName)}"; + var tooSmallCondition = property.CompareViaCompareTo + ? $"{propertyValueName}.CompareTo({minimumField}) {minComparison} 0" + : $"{propertyValueName} {minComparison} {minimumField}"; + var tooBigCondition = property.CompareViaCompareTo + ? $"{propertyValueName}.CompareTo({maximumField}) {maxComparison} 0" + : $"{propertyValueName} {maxComparison} {maximumField}"; using (writer.OpenBlockScope()) { writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - using (writer.IfBlockScope(comparisonExpression)) - { - WriteValidationError( - writer, - "invalid_range", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName) + writer + .IfBlock(tooSmallCondition, w => + WriteAddError( + w, + ErrorExpression( + "global::ZodSharp.Rules.RangeRule.ErrorCode", + message, + PathOf(property), + MemberOf(property), + origin: origin, + category: Category("TooSmall"), + lowerExpression: lower, + upperExpression: upper, + valuesExpression: values + ) + ) + ) + .ElseIf(tooBigCondition, w => + WriteAddError( + w, + ErrorExpression( + "global::ZodSharp.Rules.RangeRule.ErrorCode", + message, + PathOf(property), + MemberOf(property), + origin: origin, + category: Category("TooBig"), + lowerExpression: lower, + upperExpression: upper, + valuesExpression: values + ) + ) ); - } } writer.NewLine(); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs b/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs index 1bee4e9e..92c2986b 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.OutputContext.cs @@ -10,10 +10,97 @@ partial class ZodSchemaGenerator /// sealed class SchemaGenerationOutputContext( GenerationContext context, - ZodSchemaDescriptor zodSchema + ZodSchemaDescriptor zodSchema, + AsyncSchemaIndex asyncIndex ) { public GenerationContext Context { get; } = context; + public ZodSchemaDescriptor ZodSchema { get; } = zodSchema; + + public AsyncSchemaIndex AsyncIndex { get; } = asyncIndex; + + public bool RequiresAsync(ZodSchemaDescriptor schema) => AsyncIndex.RequiresAsync(schema.SchemaType); + + /// Whether a member's nested schema (or any dispatched case schema) needs async validation. + public bool NestedRequiresAsync(ZodPropertyDescriptor property) => AsyncIndex.NestedRequiresAsync(property); + } + + /// + /// Decides, at generation time, which schemas need asynchronous validation: those with a custom async + /// method, an IAsyncValidatableObject target, an AsyncValidationAttribute member, or a nested + /// schema (transitively) that does. A schema that needs none keeps the synchronous fast path in + /// ValidateAsync. + /// + /// + /// Built once per output callback from the collected schemas, so it is not pipeline state. A cycle is + /// resolved by treating a schema that is still being visited as synchronous; any asynchronous member on the + /// cycle still marks every schema on it. + /// + sealed class AsyncSchemaIndex + { + readonly Dictionary _schemas = []; + readonly Dictionary _resolved = []; + readonly HashSet _visiting = []; + + public AsyncSchemaIndex(IEnumerable schemas) + { + foreach (var schema in schemas) + _schemas[schema.SchemaType] = schema; + } + + public bool RequiresAsync(TypeIdentity schemaType) + { + if (_resolved.TryGetValue(schemaType, out var known)) + return known; + + if (!_schemas.TryGetValue(schemaType, out var schema) || !_visiting.Add(schemaType)) + return false; + + var requiresAsync = HasOwnAsyncRules(schema); + if (!requiresAsync) + { + foreach (var property in schema.Properties) + { + if (property.ShouldProcess && MemberRequiresAsync(property.Value)) + { + requiresAsync = true; + break; + } + } + } + + _visiting.Remove(schemaType); + + // Only a top-level answer is cached: an answer computed while another schema is still being visited + // may have assumed that schema synchronous to break a cycle. + if (_visiting.Count == 0 || requiresAsync) + _resolved[schemaType] = requiresAsync; + + return requiresAsync; + } + + public bool NestedRequiresAsync(ZodPropertyDescriptor property) + { + if (property.NestedSchemaType is { } nested) + { + if (_schemas.ContainsKey(nested) ? RequiresAsync(nested) : property.ExternalNestedSchemaRequiresAsync) + return true; + } + + foreach (var dispatchCase in property.DispatchCases) + { + if (dispatchCase.IsModern && RequiresAsync(dispatchCase.SchemaType)) + return true; + } + + return false; + } + + bool MemberRequiresAsync(ZodPropertyDescriptor property) => + property.AsyncValidationAttributes.Count > 0 || NestedRequiresAsync(property); + + static bool HasOwnAsyncRules(ZodSchemaDescriptor schema) => + schema.CustomValidationMethod.Value.HasCustomValidation || schema.ImplementsAsyncValidatableObject; } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs new file mode 100644 index 00000000..0f57f88d --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs @@ -0,0 +1,165 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + const string RegistrationNamespace = "ZodSharp.Generated"; + + /// + /// Emits the per-assembly registration (Change 5): ZodSharp.Generated.{Assembly}ZodSharpSchemas.Register + /// names every generated validator directly, so registering them is trim- and AOT-safe (no attribute + /// scanning, no Activator), and the generated validators register as IZodUntypedValidator + /// without reflection. When Microsoft.Extensions.DependencyInjection is referenced, an + /// AddZodSharp{Assembly}Schemas() extension adds the registration to the container's factory. + /// + static void GenerateAssemblyRegistration( + SourceProductionContext context, + SchemaGenerationModel model, + IReadOnlyList schemas + ) + { + var registrable = schemas + .Where(IsRegistrable) + .OrderBy(static schema => schema.TargetType.AsTypeReference().ToString(), StringComparer.Ordinal) + .ToList(); + + if (registrable.Count == 0) + return; + + var identifier = GetAssemblyIdentifier(model.Context.Capabilities.AssemblyName); + var className = $"{identifier}ZodSharpSchemas"; + var factoryType = "global::ZodSharp.Core.IZodSchemaFactory"; + + var writer = model.Context.CreateCodeWriter(); + writer.AutoGeneratedHeader(); + + using (writer.BlockNamespaceScope(RegistrationNamespace)) + { + writer.XmlSummary( + $"Registers every validator the ZodSharp source generator produced for this assembly.", + "Trim- and AOT-safe: each validator is named directly, so nothing is discovered through metadata." + ); + using ( + writer.ClassScope( + new TypeDeclarationOptions(className, TypeDeclarationAccessibility.Public) { IsStatic = true } + ) + ) + { + writer.XmlSummary("Registers every generated validator in this assembly into the factory."); + writer.XmlParam("factory", "The factory to register into."); + writer.XmlReturn("The factory, for chaining."); + using (writer.OpenBlockScope($"public static {factoryType} Register({factoryType} factory)")) + { + writer.IfBlock("factory is null", body => + body.Throw("new global::System.ArgumentNullException(nameof(factory))") + ); + + foreach (var schema in registrable) + { + writer.MethodCallOn( + "factory", + $"Register<{schema.TargetType.AsTypeReference()}>", + $"new global::{GetValidatorTypeName(schema)}()" + ); + } + + writer.Return("factory"); + } + } + } + + if (model.Context.Capabilities.HasServiceCollection) + { + writer.NewLine(); + using (writer.BlockNamespaceScope("Microsoft.Extensions.DependencyInjection")) + { + writer.XmlSummary( + $"Registers the validators the ZodSharp source generator produced for this assembly with dependency injection." + ); + using ( + writer.ClassScope( + new TypeDeclarationOptions( + $"{identifier}ZodSharpServiceCollectionExtensions", + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + } + ) + ) + { + writer.XmlSummary( + "Adds every generated validator in this assembly to the IZodSchemaFactory registered by", + "AddZodSharpFactory (which this call also registers when it is missing)." + ); + writer.XmlParam("services", "The service collection."); + writer.XmlReturn("The service collection, for chaining."); + writer.MethodExpression( + $"AddZodSharp{identifier}Schemas", + new TypeIdentity("IServiceCollection", "Microsoft.Extensions.DependencyInjection"), + TypeDeclarationAccessibility.Public, + expression => + expression.Write( + $"services.AddZodSchemaRegistration(static factory => global::{RegistrationNamespace}.{className}.Register(factory))" + ), + declaration => + declaration with + { + IsStatic = true, + Parameters = + [ + new( + "services", + new TypeIdentity( + "IServiceCollection", + "Microsoft.Extensions.DependencyInjection" + ) + ) + { + IsThis = true, + }, + ], + } + ); + } + } + } + + context.AddSource("ZodSharpAssemblyRegistration.g.cs", writer); + } + + /// + /// Turns an assembly name into a PascalCase identifier: Contoso.Orders.Api becomes + /// ContosoOrdersApi. + /// + internal static string GetAssemblyIdentifier(string? assemblyName) + { + if (string.IsNullOrWhiteSpace(assemblyName)) + return "Assembly"; + + StringBuilder builder = new(assemblyName!.Length); + var upperNext = true; + foreach (var c in assemblyName) + { + if (!char.IsLetterOrDigit(c) && c != '_') + { + upperNext = true; + continue; + } + + builder.Append(upperNext ? char.ToUpperInvariant(c) : c); + upperNext = false; + } + + if (builder.Length == 0) + return "Assembly"; + + if (char.IsDigit(builder[0])) + builder.Insert(0, '_'); + + return builder.ToString(); + } +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs new file mode 100644 index 00000000..61258c1a --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs @@ -0,0 +1,419 @@ +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + const string SegmentKindType = "global::ZodSharp.Core.ZodPathSegmentKind"; + + /// + /// Emits the JsonNames table: the explicit [JsonPropertyName] of each member, recorded at + /// compile time so a host can render JSON-named paths without reflection (defect G3). + /// + static void GenerateJsonNamesClass( + CodeWriter writer, + ZodSchemaDescriptor schema, + CancellationToken cancellationToken + ) + { + List<(string Member, string JsonName)> names = []; + foreach (var property in schema.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + if (property.ShouldProcess && property.Value.JsonPropertyName is { Length: > 0 } jsonName) + names.Add((property.Value.Name, jsonName)); + } + + writer.XmlSummary( + $"The explicit JSON names ([JsonPropertyName]) of {CodeWriter.XmlSee(schema.TargetType.Name)}'s members." + ); + using ( + writer.ClassScope( + new TypeDeclarationOptions("JsonNames", TypeDeclarationAccessibility.Public) { IsStatic = true } + ) + ) + { + writer.XmlSummary( + "Returns the explicit JSON name of a member, or null when it has none (a naming policy then applies)." + ); + writer.XmlParam("memberName", "The C# member name."); + writer.XmlReturn("The JSON name, or null."); + + if (names.Count == 0) + { + writer.MethodExpression( + "Get", + PurviewTypeLibrary.System.String.AsTypeReference().Nullable(writer), + TypeDeclarationAccessibility.Public, + static expression => expression.Write("null"), + declaration => + declaration with + { + IsStatic = true, + Parameters = [new("memberName", PurviewTypeLibrary.System.String)], + } + ); + } + else + { + using (writer.OpenBlockScope("public static string? Get(string memberName) => memberName switch")) + { + foreach (var (member, jsonName) in names) + writer.Line($"{member.StringLiteral()} => {jsonName.StringLiteral()},"); + writer.Line("_ => null,"); + } + + // The switch expression is closed by the block scope; terminate the expression-bodied member. + writer.Line(";"); + } + } + + writer.NewLine(); + } + + /// + /// Emits TryResolveMember (Change 3) and ToJsonNamedPath: reflection-free walks of an error + /// path through the generated schemas, used by Blazor to find a field's container and by hosts that + /// render JSON names. + /// + static void GenerateResolutionMethods( + CodeWriter writer, + SchemaGenerationOutputContext outputContext, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var schema = outputContext.ZodSchema; + var targetType = schema.TargetType.AsTypeReference(); + TypeReference zodPathReference = new(new TypeIdentity("ZodPath", TypeLibraryGenerator.ZodSharpCoreNamespace)); + List walked = []; + foreach (var property in schema.Properties) + { + if ((property.ShouldProcess && HasNestedValidation(property.Value)) || IsCollection(property)) + walked.Add(property.Value); + } + + writer.XmlSummary( + "Resolves the object that owns the last member of an error path, without reflection. A path that ends", + "in an index or key resolves to the owner of the collection member; a path that cannot be followed", + "resolves to the nearest object reached." + ); + writer.XmlParam("value", "The root value."); + writer.XmlParam("path", "The error path, relative to the value."); + writer.XmlParam("container", "The owning object."); + writer.XmlParam("memberName", "The member name on the container; empty for a model-level error."); + writer.XmlReturn("True when the full path was followed."); + writer.MethodExpression( + "TryResolveMember", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public, + static expression => expression.Write("TryResolveMember(value, path, 0, out container, out memberName)"), + declaration => + declaration with + { + IsStatic = true, + Parameters = + [ + new("value", targetType), + new("path", zodPathReference), + new("container", new TypeIdentity(typeof(object)).AsTypeReference().Nullable(writer)) + { + Modifier = ParameterModifier.Out, + }, + new("memberName", PurviewTypeLibrary.System.String.AsTypeReference()) + { + Modifier = ParameterModifier.Out, + }, + ], + } + ); + writer.NewLine(); + + writer.XmlSummary("Resolves an error path from a segment offset; see the overload without an offset."); + writer.XmlParam("value", "The value the path is relative to at the offset."); + writer.XmlParam("path", "The error path."); + writer.XmlParam("offset", "The first segment that applies to the value."); + writer.XmlParam("container", "The owning object."); + writer.XmlParam("memberName", "The member name on the container."); + writer.XmlReturn("True when the full path was followed."); + writer.Line( + "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]" + ); + using ( + writer.OpenBlockScope( + $"public static bool TryResolveMember({targetType} value, {ZodPathType} path, int offset, out object? container, out string memberName)" + ) + ) + { + writer.Assignment("container", "value"); + writer.Assignment("memberName", "string.Empty"); + if (schema.TargetCanBeNull) + writer.IfBlock("value is null", body => body.Return("false")); + + writer.IfBlock("offset >= path.Length", body => body.Return("true")); + writer.Assignment("var", "segment", "path[offset]"); + writer.IfBlock($"segment.Kind != {SegmentKindType}.Member", body => body.Return("false")); + writer.Assignment("memberName", "segment.Name!"); + writer.Assignment("var", "last", "offset + 1 >= path.Length"); + + if (walked.Count > 0) + { + using (writer.OpenBlockScope("switch (memberName)")) + { + foreach (var property in walked) + { + writer.Line($"case {property.Name.StringLiteral()}:"); + using (writer.OpenBlockScope()) + { + writer.IfBlock("last", body => body.Return("true")); + WriteResolveMember(writer, property); + } + } + } + } + + writer.Return("last"); + } + + writer.NewLine(); + + writer.XmlSummary( + "Returns the path with every member segment renamed to its JSON name: the member's [JsonPropertyName],", + "otherwise the naming policy (or the C# name when none is given). Index and key segments are unchanged." + ); + writer.XmlParam("path", "The error path, relative to the target type."); + writer.XmlParam("namingPolicy", "The naming policy for members without an explicit JSON name."); + writer.XmlReturn("The renamed path."); + using ( + writer.OpenBlockScope( + $"public static {ZodPathType} ToJsonNamedPath({ZodPathType} path, global::System.Func? namingPolicy = null)" + ) + ) + { + writer.IfBlock("path.IsRoot", body => body.Return("path")); + writer.Assignment( + "var", + "segments", + $"global::System.Collections.Immutable.ImmutableArray.CreateBuilder<{ZodPathSegmentType}>(path.Length)" + ); + writer.MethodCall("AppendJsonNamedPath", "path", "0", "namingPolicy", "segments"); + writer.Return($"{ZodPathType}.From(segments.ToImmutable())"); + } + + writer.NewLine(); + + writer.XmlSummary("Appends the JSON-named segments of a path from an offset; see ToJsonNamedPath."); + writer.XmlParam("path", "The error path."); + writer.XmlParam("offset", "The first segment that applies to the target type."); + writer.XmlParam("namingPolicy", "The naming policy for members without an explicit JSON name."); + writer.XmlParam("segments", "The segments being built."); + writer.Line( + "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]" + ); + using ( + writer.OpenBlockScope( + $"public static void AppendJsonNamedPath({ZodPathType} path, int offset, global::System.Func? namingPolicy, global::System.Collections.Immutable.ImmutableArray<{ZodPathSegmentType}>.Builder segments)" + ) + ) + { + writer.IfBlock("offset >= path.Length", body => body.Return()); + writer.Assignment("var", "segment", "path[offset]"); + writer.IfBlock($"segment.Kind != {SegmentKindType}.Member", body => + { + body.MethodCall($"{GeneratedValidation}.AppendRemaining", "path", "offset", "namingPolicy", "segments"); + body.Return(); + }); + writer.Assignment("var", "name", "segment.Name!"); + writer.MethodCallOn( + "segments", + "Add", + $"{ZodPathSegmentType}.Member(JsonNames.Get(name) ?? (namingPolicy is null ? name : namingPolicy(name)))" + ); + + var renamed = walked.Where(static property => GetNestedSchemaForNaming(property) is not null).ToList(); + if (renamed.Count > 0) + { + using (writer.OpenBlockScope("switch (name)")) + { + foreach (var property in renamed) + { + var nested = GetNestedSchemaForNaming(property)!.Value; + writer.Line($"case {property.Name.StringLiteral()}:"); + using (writer.OpenBlockScope()) + { + if (property.ValidationKind == PropertyValidationKind.Collection) + { + using ( + writer.IfBlockScope( + $"offset + 1 < path.Length && path[offset + 1].Kind != {SegmentKindType}.Member" + ) + ) + { + writer.MethodCallOn("segments", "Add", "path[offset + 1]"); + writer.MethodCall( + nested.StaticMember("AppendJsonNamedPath"), + "path", + "offset + 2", + "namingPolicy", + "segments" + ); + writer.Return(); + } + + writer.Line("break;"); + } + else + { + writer.MethodCall( + nested.StaticMember("AppendJsonNamedPath"), + "path", + "offset + 1", + "namingPolicy", + "segments" + ); + writer.Return(); + } + } + } + } + } + + writer.MethodCall( + $"{GeneratedValidation}.AppendRemaining", + "path", + "offset + 1", + "namingPolicy", + "segments" + ); + } + + writer.NewLine(); + } + + static bool IsCollection(GeneratorResult property) => + property.ShouldProcess && property.Value.ValidationKind == PropertyValidationKind.Collection; + + /// + /// The nested schema a member's remaining path is renamed by, when it is a modern generated schema. A + /// union or closed hierarchy cannot be resolved without a value, so its remaining segments only get the + /// naming policy. + /// + static TypeIdentity? GetNestedSchemaForNaming(ZodPropertyDescriptor property) => + property.DispatchKind == DispatchKind.None + && property.NestedSchemaType is { } nested + && property.NestedSchemaIsModern + ? nested + : null; + + static void WriteResolveMember(CodeWriter writer, ZodPropertyDescriptor property) + { + var valueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Value"); + + if (property.DispatchKind != DispatchKind.None) + { + using (writer.OpenBlockScope($"switch ({DispatchValueExpression(property)})")) + { + for (var i = 0; i < property.DispatchCases.Count; i++) + { + var dispatchCase = property.DispatchCases[i]; + if (!dispatchCase.IsModern) + continue; + + var caseName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"Case{i}"); + writer.Line($"case {dispatchCase.CaseType.AsTypeReference()} {caseName}:"); + writer.Return( + ReferenceOwned( + dispatchCase.SchemaType.StaticMember( + $"TryResolveMember({caseName}, path, offset + 1, out container, out memberName)" + ), + property + ) + ); + } + } + + writer.Return("false"); + return; + } + + if (property.ValidationKind == PropertyValidationKind.Collection) + { + // An error on an element itself (a null element) belongs to the collection member. + writer.IfBlock("offset + 2 >= path.Length", body => + body.Return($"path[offset + 1].Kind != {SegmentKindType}.Member") + ); + + if (property.NestedSchemaType is not { } elementSchema || !property.NestedSchemaIsModern) + { + writer.Return("false"); + return; + } + + var elementName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Element"); + var lookup = property.IsMap ? "TryGetEntryValue" : "TryGetElement"; + if (property.IsNullableValueType) + writer.IfBlock($"!value.{property.Name}.HasValue", body => body.Return("false")); + if (property.IsImmutableArray) + writer.IfBlock( + $"value.{property.Name}{(property.IsNullableValueType ? ".GetValueOrDefault()" : string.Empty)}.IsDefault", + body => body.Return("false") + ); + writer.IfBlock( + $"!{GeneratedValidation}.{lookup}({CollectionValueExpression(property)}, path[offset + 1], out var {elementName})", + body => body.Return("false") + ); + + var elementExpression = elementName; + if (property.ElementTypeIsNullableValueType) + { + writer.IfBlock($"{elementName} is not {{ }} {elementName}Value", body => body.Return("false")); + elementExpression = $"{elementName}Value"; + } + else if (property.ElementTypeCanBeNull) + { + writer.IfBlock($"{elementName} is null", body => body.Return("false")); + } + + var elementCall = elementSchema.StaticMember( + $"TryResolveMember({elementExpression}, path, offset + 2, out container, out memberName)" + ); + writer.Return( + property.ElementTypeIsNullableValueType || !property.ElementTypeCanBeNull + ? ReferenceOwned(elementCall, property) + : elementCall + ); + return; + } + + if (property.NestedSchemaType is not { } schemaType || !property.NestedSchemaIsModern) + { + writer.Return("false"); + return; + } + + if (property.IsNullableValueType) + { + writer.IfBlock($"value.{property.Name} is not {{ }} {valueName}", body => body.Return("false")); + } + else + { + writer.Assignment("var", valueName, $"value.{property.Name}"); + if (property.CanBeNull) + writer.IfBlock($"{valueName} is null", body => body.Return("false")); + } + + var call = schemaType.StaticMember( + $"TryResolveMember({valueName}, path, offset + 1, out container, out memberName)" + ); + writer.Return(property.IsNullableValueType || !property.CanBeNull ? ReferenceOwned(call, property) : call); + } + + /// + /// Wraps a nested resolution step whose value can be a struct, so its result is attributed to this object and + /// member rather than to a boxed copy of the struct, which cannot own a field. + /// + static string ReferenceOwned(string nestedCall, ZodPropertyDescriptor property) => + $"{GeneratedValidation}.ResolveToReferenceOwner({nestedCall}, value, {property.Name.StringLiteral()}, ref container, ref memberName)"; +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.StringValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.StringValidators.cs index fb2582eb..aa3df102 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.StringValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.StringValidators.cs @@ -1,4 +1,3 @@ -using System.Globalization; using ZodSharp.SourceGenerators.Helpers; using ZodSharp.SourceGenerators.Models; using ZodSharp.SourceGenerators.Models.DataAttributes; @@ -7,6 +6,8 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { + const string StringOrigin = "\"string\""; + static void GenerateStringValidations(CodeWriter writer, ZodPropertyDescriptor property) { StringLengthValidators(writer, property); @@ -14,176 +15,146 @@ static void GenerateStringValidations(CodeWriter writer, ZodPropertyDescriptor p var emailAttribute = property.ValidationAttributes.EmailAddress; if (emailAttribute.ShouldProcess && emailAttribute.Value.Exists) { - var propertyName = property.Name; - var displayName = property.DisplayName; - var messageExpression = BuildErrorMessageExpression( + GenerateFormatValidation( + writer, + property, emailAttribute.Value.ValidationAttributeData, - "Field '{0}' must be a valid email address.", - displayName.StringLiteral() + "Field '{displayName}' must be a valid email address.", + valueName => $"!global::ZodSharp.Rules.EmailRule.EmailRegex.IsMatch({valueName})", + "email" ); - - using (writer.OpenBlockScope()) - { - var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - using ( - writer.IfBlockScope( - $"{propertyValueName}.Length != 0 && !global::ZodSharp.Rules.EmailRule.EmailRegex.IsMatch({propertyValueName})" - ) - ) - { - WriteValidationError( - writer, - "invalid_string", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName), - "string" - ); - } - } - - writer.NewLine(); } var regularExpressionAttribute = property.ValidationAttributes.RegularExpression; if (regularExpressionAttribute.ShouldProcess && regularExpressionAttribute.Value.Exists) { - var propertyName = property.Name; - var displayName = property.DisplayName; var pattern = regularExpressionAttribute.Value.Pattern ?? string.Empty; - var messageExpression = BuildErrorMessageExpression( + GenerateFormatValidation( + writer, + property, regularExpressionAttribute.Value.ValidationAttribute, - "Field '{0}' must match the regular expression '{1}'.", - displayName.StringLiteral(), - pattern.StringLiteral() + "Field '{displayName}' must match the regular expression '{pattern}'.", + valueName => $"!{GetRegexFieldName(property.Name)}.IsMatch({valueName})", + "regex", + [("pattern", pattern.StringLiteral())], + "pattern" ); - - using (writer.OpenBlockScope()) - { - var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - using ( - writer.IfBlockScope( - $"{propertyValueName}.Length != 0 && !{GetRegexFieldName(propertyName)}.IsMatch({propertyValueName})" - ) - ) - { - WriteValidationError( - writer, - "invalid_string", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName), - "string" - ); - } - } - - writer.NewLine(); } - GenerateURLValidation(writer, property); - GeneratePhoneValidation(writer, property); - GenerateCreditCardValidation(writer, property); - GenerateBase64StringValidation(writer, property); - } - - static void GenerateURLValidation(CodeWriter writer, ZodPropertyDescriptor property) - { - var urlAttribute = property.ValidationAttributes.Url; - if (!urlAttribute.ShouldProcess || !urlAttribute.Value.Exists) - return; - GenerateStringRuleValidation( writer, property, - urlAttribute.Value.ValidationAttribute, + property.ValidationAttributes.Url, "global::ZodSharp.Rules.UrlRule", - "Field '{0}' must be a valid URL.", - "invalid_string" + "Field '{displayName}' must be a valid URL.", + "url" ); - } - - static void GeneratePhoneValidation(CodeWriter writer, ZodPropertyDescriptor property) - { - var phoneAttribute = property.ValidationAttributes.Phone; - if (!phoneAttribute.ShouldProcess || !phoneAttribute.Value.Exists) - return; - GenerateStringRuleValidation( writer, property, - phoneAttribute.Value.ValidationAttribute, + property.ValidationAttributes.Phone, "global::ZodSharp.Rules.PhoneRule", - "Field '{0}' must be a valid phone number.", - "invalid_string" + "Field '{displayName}' must be a valid phone number.", + "phone" ); - } - - static void GenerateCreditCardValidation(CodeWriter writer, ZodPropertyDescriptor property) - { - var creditCardAttribute = property.ValidationAttributes.CreditCard; - if (!creditCardAttribute.ShouldProcess || !creditCardAttribute.Value.Exists) - return; - GenerateStringRuleValidation( writer, property, - creditCardAttribute.Value.ValidationAttribute, + property.ValidationAttributes.CreditCard, "global::ZodSharp.Rules.CreditCardRule", - "Field '{0}' must be a valid credit card number.", - "invalid_string" + "Field '{displayName}' must be a valid credit card number.", + "credit_card" + ); + GenerateStringRuleValidation( + writer, + property, + property.ValidationAttributes.Base64String, + "global::ZodSharp.Rules.Base64StringRule", + "Field '{displayName}' must be a valid Base64 string.", + "base64" ); } - static void GenerateBase64StringValidation(CodeWriter writer, ZodPropertyDescriptor property) + static void GenerateStringRuleValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + GeneratorResult attribute, + string ruleType, + string defaultFormat, + string format + ) + where TAttribute : struct { - var base64StringAttribute = property.ValidationAttributes.Base64String; - if (!base64StringAttribute.ShouldProcess || !base64StringAttribute.Value.Exists) + if (!attribute.ShouldProcess || !TryGetValidationAttribute(attribute.Value, out var data)) return; - GenerateStringRuleValidation( + GenerateFormatValidation( writer, property, - base64StringAttribute.Value.ValidationAttribute, - "global::ZodSharp.Rules.Base64StringRule", - "Field '{0}' must be a valid Base64 string.", - "invalid_string" + data, + defaultFormat, + valueName => $"!new {ruleType}().IsValid({valueName})", + format ); } - static void GenerateStringRuleValidation( + static bool TryGetValidationAttribute(TAttribute attribute, out ValidationAttributeData data) + { + switch (attribute) + { + case UrlAttribute { Exists: true } url: + data = url.ValidationAttribute; + return true; + case PhoneAttribute { Exists: true } phone: + data = phone.ValidationAttribute; + return true; + case CreditCardAttributeData { Exists: true } creditCard: + data = creditCard.ValidationAttribute; + return true; + case Base64StringAttributeData { Exists: true } base64: + data = base64.ValidationAttribute; + return true; + default: + data = default; + return false; + } + } + + /// + /// Emits a string format check (email, regex, URL, …): an empty string is valid, as in DataAnnotations, and + /// a failure reports invalid_string with category invalid_format and the format name. + /// + static void GenerateFormatValidation( CodeWriter writer, ZodPropertyDescriptor property, ValidationAttributeData validationAttribute, - string ruleType, string defaultFormat, - string errorCode + Func invalidCondition, + string format, + (string Name, string ValueExpression)[]? extraValues = null, + params string[] ordinalNames ) { - var propertyName = property.Name; - var displayName = property.DisplayName; - var messageExpression = BuildErrorMessageExpression( - validationAttribute, - defaultFormat, - displayName.StringLiteral() - ); + var propertyValueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Value"); + var message = BuildMessageTemplate(validationAttribute, defaultFormat, ordinalNames); + (string Name, string ValueExpression)[] values = [("format", format.Surround()), .. extraValues ?? []]; using (writer.OpenBlockScope()) { - var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - using ( - writer.IfBlockScope( - $"{propertyValueName}.Length != 0 && !new {ruleType}().IsValid({propertyValueName})" - ) - ) + writer.Assignment("var", propertyValueName, $"value.{property.Name}"); + using (writer.IfBlockScope($"{propertyValueName}.Length != 0 && {invalidCondition(propertyValueName)}")) { - WriteValidationError( + WriteAddError( writer, - errorCode, - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName), - "string" + ErrorExpression( + "\"invalid_string\"", + message, + PathOf(property), + MemberOf(property), + origin: StringOrigin, + category: Category("InvalidFormat"), + valuesExpression: ValuesExpression(values) + ) ); } } @@ -194,10 +165,9 @@ string errorCode static void StringLengthValidators(CodeWriter writer, ZodPropertyDescriptor property) { var propertyName = property.Name; - var displayName = property.DisplayName; - var propertyPath = CodeGenHelpers.GetPathFieldName(propertyName); var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); var propertyLengthName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Length"); + var countValues = ValuesExpression(("count", propertyLengthName)); var lengthAttr = property.ValidationAttributes.Length; if (lengthAttr.ShouldProcess && lengthAttr.Value.Exists) @@ -205,57 +175,31 @@ static void StringLengthValidators(CodeWriter writer, ZodPropertyDescriptor prop var length = lengthAttr.Value; if (length.MinimumLength >= 0 && length.MaximumLength >= length.MinimumLength) { - using (writer.OpenBlockScope()) - { - var tooSmallMessage = BuildMessageExpression( - length.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({length.MinimumLength}, {"character".Surround()}, {"characters".Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - length.MaximumLength.ToString(CultureInfo.InvariantCulture), - length.MinimumLength.ToString(CultureInfo.InvariantCulture) - ); - var tooBigMessage = BuildMessageExpression( - length.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({length.MaximumLength}, {"character".Surround()}, {"characters".Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - length.MaximumLength.ToString(CultureInfo.InvariantCulture), - length.MinimumLength.ToString(CultureInfo.InvariantCulture) - ); - - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - using (writer.IfBlockScope($"({propertyValueName} is not null)")) - { - writer.Assignment("var", propertyLengthName, $"{propertyValueName}.Length"); - - writer - .IfBlock( - $"{propertyLengthName} < {length.MinimumLength}", - w => - WriteValidationError( - w, - "too_small", - tooSmallMessage, - propertyPath, - "string", - minimum: length.MinimumLength - ) - ) - .ElseIf( - $"{propertyLengthName} > {length.MaximumLength}", - w => - WriteValidationError( - w, - "too_big", - tooBigMessage, - propertyPath, - "string", - maximum: length.MaximumLength - ) - ); - } - } + // LengthAttribute formats a custom message with (name, MinimumLength, MaximumLength). + var tooSmall = BuildMessageTemplate( + length.ValidationAttribute, + AtLeast(length.MinimumLength, "character", "characters"), + "minimum", + "maximum" + ); + var tooBig = BuildMessageTemplate( + length.ValidationAttribute, + NoMoreThan(length.MaximumLength, "character", "characters"), + "minimum", + "maximum" + ); - writer.NewLine(); + WriteStringLengthCheck( + writer, + property, + "global::ZodSharp.Rules.LengthRule.ErrorCode", + length.MinimumLength, + length.MaximumLength, + tooSmall, + tooBig, + checkNull: true, + countValues + ); } } @@ -263,80 +207,62 @@ static void StringLengthValidators(CodeWriter writer, ZodPropertyDescriptor prop if (stringLengthAttr.ShouldProcess && stringLengthAttr.Value.Exists) { var stringLength = stringLengthAttr.Value; - using (writer.OpenBlockScope()) - { - var tooSmallMessage = BuildMessageExpression( - stringLength.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({stringLength.MinimumLength}, {"character".Surround()}, {"characters".Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - stringLength.MaximumLength.ToString(CultureInfo.InvariantCulture), - stringLength.MinimumLength.ToString(CultureInfo.InvariantCulture) - ); - var tooBigMessage = BuildMessageExpression( - stringLength.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({stringLength.MaximumLength}, {"character".Surround()}, {"characters".Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - stringLength.MaximumLength.ToString(CultureInfo.InvariantCulture), - stringLength.MinimumLength.ToString(CultureInfo.InvariantCulture) - ); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); - writer.Assignment("var", propertyLengthName, $"{propertyValueName}.Length"); - if (stringLength.MinimumLength > 0) - { - using (writer.IfBlockScope($"{propertyLengthName} < {stringLength.MinimumLength}")) - { - WriteValidationError( - writer, - "too_small", - tooSmallMessage, - propertyPath, - "string", - minimum: stringLength.MinimumLength - ); - } - } - - using (writer.IfBlockScope($"{propertyLengthName} > {stringLength.MaximumLength}")) - { - WriteValidationError( - writer, - "too_big", - tooBigMessage, - propertyPath, - "string", - maximum: stringLength.MaximumLength - ); - } - } + // StringLengthAttribute formats a custom message with (name, MaximumLength, MinimumLength). + var tooSmall = BuildMessageTemplate( + stringLength.ValidationAttribute, + AtLeast(stringLength.MinimumLength, "character", "characters"), + "maximum", + "minimum" + ); + var tooBig = BuildMessageTemplate( + stringLength.ValidationAttribute, + NoMoreThan(stringLength.MaximumLength, "character", "characters"), + "maximum", + "minimum" + ); - writer.NewLine(); + WriteStringLengthCheck( + writer, + property, + "global::ZodSharp.Rules.StringLengthRule.ErrorCode", + stringLength.MinimumLength, + stringLength.MaximumLength, + tooSmall, + tooBig, + checkNull: false, + countValues + ); } var minLengthAttr = property.ValidationAttributes.MinLength; if (minLengthAttr.ShouldProcess && minLengthAttr.Value.Exists && minLengthAttr.Value.Length > 0) { var minLength = minLengthAttr.Value.Length; + var message = BuildMessageTemplate( + minLengthAttr.Value.ValidationAttribute, + AtLeast(minLength, "character", "characters"), + "minimum" + ); + using (writer.OpenBlockScope()) { - var messageExpression = BuildMessageExpression( - minLengthAttr.Value.ValidationAttribute, - $"{$"Field '{displayName}' must contain at least ".StringLiteral()} + FormatCount({minLength}, {"character".Surround()}, {"characters".Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - minLength.ToString(CultureInfo.InvariantCulture) - ); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); writer.Assignment("var", propertyLengthName, $"{propertyValueName}.Length"); using (writer.IfBlockScope($"{propertyLengthName} < {minLength}")) { - WriteValidationError( + WriteAddError( writer, - "too_small", - messageExpression, - propertyPath, - "string", - minimum: minLength + ErrorExpression( + "\"too_small\"", + message, + PathOf(property), + MemberOf(property), + origin: StringOrigin, + category: Category("TooSmall"), + lowerExpression: Including(minLength), + valuesExpression: countValues + ) ); } } @@ -348,26 +274,30 @@ static void StringLengthValidators(CodeWriter writer, ZodPropertyDescriptor prop if (maxLengthAttr.ShouldProcess && maxLengthAttr.Value.Exists && maxLengthAttr.Value.Length >= 0) { var maxLength = maxLengthAttr.Value.Length; + var message = BuildMessageTemplate( + maxLengthAttr.Value.ValidationAttribute, + NoMoreThan(maxLength, "character", "characters"), + "maximum" + ); + using (writer.OpenBlockScope()) { - var messageExpression = BuildMessageExpression( - maxLengthAttr.Value.ValidationAttribute, - $"{$"Field '{displayName}' must contain no more than ".StringLiteral()} + FormatCount({maxLength}, {"character".Surround()}, {"characters".Surround()}) + {".".Surround()}", - displayName.StringLiteral(), - maxLength.ToString(CultureInfo.InvariantCulture) - ); - writer.Assignment("var", propertyValueName, $"value.{propertyName}"); writer.Assignment("var", propertyLengthName, $"{propertyValueName}.Length"); using (writer.IfBlockScope($"{propertyLengthName} > {maxLength}")) { - WriteValidationError( + WriteAddError( writer, - "too_big", - messageExpression, - propertyPath, - "string", - maximum: maxLength + ErrorExpression( + "\"too_big\"", + message, + PathOf(property), + MemberOf(property), + origin: StringOrigin, + category: Category("TooBig"), + upperExpression: Including(maxLength), + valuesExpression: countValues + ) ); } } @@ -375,4 +305,80 @@ static void StringLengthValidators(CodeWriter writer, ZodPropertyDescriptor prop writer.NewLine(); } } + + /// + /// Emits a two-sided string length check that reports the rule's own code (invalid_length, the same + /// code the runtime LengthRule/StringLengthRule report), with the category by direction and + /// both bounds. + /// + static void WriteStringLengthCheck( + CodeWriter writer, + ZodPropertyDescriptor property, + string codeExpression, + int minimum, + int maximum, + MessageTemplate tooSmall, + MessageTemplate tooBig, + bool checkNull, + string countValues + ) + { + var propertyValueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Value"); + var propertyLengthName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Length"); + var lower = Including(minimum); + var upper = Including(maximum); + + using (writer.OpenBlockScope()) + { + writer.Assignment("var", propertyValueName, $"value.{property.Name}"); + CodeWriter.BlockScope? nullGuard = checkNull + ? writer.IfBlockScope($"({propertyValueName} is not null)") + : null; + + writer.Assignment("var", propertyLengthName, $"{propertyValueName}.Length"); + writer + .IfBlock($"{propertyLengthName} < {minimum}", w => + WriteAddError( + w, + ErrorExpression( + codeExpression, + tooSmall, + PathOf(property), + MemberOf(property), + origin: StringOrigin, + category: Category("TooSmall"), + lowerExpression: lower, + upperExpression: upper, + valuesExpression: countValues + ) + ) + ) + .ElseIf($"{propertyLengthName} > {maximum}", w => + WriteAddError( + w, + ErrorExpression( + codeExpression, + tooBig, + PathOf(property), + MemberOf(property), + origin: StringOrigin, + category: Category("TooBig"), + lowerExpression: lower, + upperExpression: upper, + valuesExpression: countValues + ) + ) + ); + + nullGuard?.Dispose(); + } + + writer.NewLine(); + } + + static string AtLeast(int count, string singular, string plural) => + $"Field '{{displayName}}' must contain at least {{minimum}} {(count == 1 ? singular : plural)}."; + + static string NoMoreThan(int count, string singular, string plural) => + $"Field '{{displayName}}' must contain no more than {{maximum}} {(count == 1 ? singular : plural)}."; } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs new file mode 100644 index 00000000..46cb9c7a --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -0,0 +1,1425 @@ +using Microsoft.CodeAnalysis; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGenerator +{ + const string ErrorsListType = "global::System.Collections.Generic.List"; + + const string CustomValidatorFieldName = "CustomValidator"; + + const string CustomValidationBridgeName = "InvokeZodCustomValidationAsync"; + + static TypeReference ErrorsListReference(CodeWriter writer) => + TypeLibrary + .System.Collections.Generic.List.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationError) + .AsTypeReference() + .Nullable(writer); + + /// + /// Emits the public Paths class: one ZodPath constant per member, so refinement hooks and + /// hosts can place or match an issue without hand-writing member names (defect G13). + /// + static void GeneratePathsClass(CodeWriter writer, ZodSchemaDescriptor schema, CancellationToken cancellationToken) + { + writer.XmlSummary( + $"The canonical error paths of {CodeWriter.XmlSee(schema.TargetType.Name)}'s members.", + "Use them to place an issue on a member (RefineCtx.AddIssue) or to match an error's FullPath." + ); + + using ( + writer.ClassScope( + new TypeDeclarationOptions("Paths", TypeDeclarationAccessibility.Public) { IsStatic = true } + ) + ) + { + TypeReference zodPathReference = new( + new TypeIdentity("ZodPath", TypeLibraryGenerator.ZodSharpCoreNamespace) + ); + + foreach (var property in schema.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!property.ShouldProcess) + continue; + + var name = property.Value.Name; + writer.XmlSummary($"The path of {CodeWriter.XmlSee(name)}."); + if (IsInheritedStaticMemberName(name)) + { + // FieldDeclarationOptions has no `new` modifier, and this member must hide object.Equals + // or object.GetType, so the structured API cannot express it. +#pragma warning disable PSGFR18 // Prefer a structured CodeWriter declaration API + writer.Line( + $"public static new readonly {ZodPathType} {GetPathMemberName(name)} = {ZodPathType}.Member({name.StringLiteral()});" + ); +#pragma warning restore PSGFR18 + } + else + { + writer.Field( + new FieldDeclarationOptions(GetPathMemberName(name), zodPathReference) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsStatic = true, + IsReadOnly = true, + Initializer = $"{ZodPathType}.Member({name.StringLiteral()})", + } + ); + } + } + } + + writer.NewLine(); + } + + /// + /// Whether a static field named hides a member inherited from + /// (CS0108), so it needs the new modifier. + /// + static bool IsInheritedStaticMemberName(string name) => + name is "Equals" or "ReferenceEquals" or "GetHashCode" or "GetType" or "ToString" or "MemberwiseClone"; + + /// + /// Emits the member-info fields (declaring type, member, display name and path) read by the error + /// factories, the cached async attribute instances, and the custom validator instance when the custom + /// method lives on the adapter. + /// + static void GenerateMemberFields( + CodeWriter writer, + SchemaGenerationOutputContext outputContext, + CancellationToken cancellationToken + ) + { + var schema = outputContext.ZodSchema; + TypeReference memberTypeReference = new( + new TypeIdentity("ZodGeneratedMember", TypeLibraryGenerator.ZodSharpCoreNamespace) + ); + var declaringType = GetDeclaringTypeName(schema); + + writer.Field( + new FieldDeclarationOptions(TypeMemberFieldName, memberTypeReference) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = + $"new({declaringType.StringLiteral()}, \"\", {schema.TargetType.Name.StringLiteral()}, {ZodPathType}.Root)", + } + ); + + foreach (var propertyResult in schema.Properties) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!propertyResult.ShouldProcess) + continue; + + var property = propertyResult.Value; + if (!HasMemberRules(property, schema.ValidateEnumValues)) + continue; + + var resolver = property.DisplayNameResourceExpression is { } resource + ? $", static () => {resource}" + : string.Empty; + writer.Field( + new FieldDeclarationOptions(MemberOf(property), memberTypeReference) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = + $"new({declaringType.StringLiteral()}, {property.Name.StringLiteral()}, {property.DisplayName.StringLiteral()}, {PathOf(property)}{resolver})", + } + ); + + for (var i = 0; i < property.AsyncValidationAttributes.Count; i++) + { + writer.Field( + new FieldDeclarationOptions( + CodeGenHelpers.GetAsyncAttributeFieldName(property.Name, i), + new TypeIdentity("AsyncValidationAttribute", "System.ComponentModel.DataAnnotations") + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = property.AsyncValidationAttributes[i], + } + ); + } + } + + if ( + schema.CustomValidationMethod.Value.InvocationKind + == CustomValidationInvocationKind.DefinedOnSchemaValidator + ) + writer.Field( + new FieldDeclarationOptions( + CustomValidatorFieldName, + new TypeIdentity($"{schema.SchemaType.Name}Validator", null).AsTypeReference().Nullable(writer) + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + } + ); + + writer.NewLine(); + } + + static string GetDeclaringTypeName(ZodSchemaDescriptor schema) + { + var rendered = schema.TargetType.AsTypeReference().ToString(); + return rendered.StartsWith("global::", StringComparison.Ordinal) ? rendered.Substring(8) : rendered; + } + + /// + /// Whether the member's own rules (not counting nested recursion) or nested recursion produce anything, + /// so it gets a per-member method and is reported by IsValidatableMember. + /// + static bool HasMemberRules(ZodPropertyDescriptor property, bool validateEnumValues) + { + var attributes = property.ValidationAttributes; + + if (property.CanBeNull && Exists(attributes.Required, static required => required.Exists)) + return true; + + if (property.CanBeNull && property.CustomRules.Any(static rule => rule.IsRequired)) + return true; + + if (!property.IsDeclaredNullable && property.CanBeNull) + return true; + + // The six-part condition is the final branch of a chain of early returns; a conditional expression + // would bury it under the checks above. +#pragma warning disable IDE0046 // Convert to conditional expression + if ( + Exists(attributes.AllowedValues, static values => values.Exists) + || Exists(attributes.DeniedValues, static values => values.Exists) + || Exists(attributes.Compare, static compare => compare.Exists) + || property.CustomRules.Count > 0 + || property.AsyncValidationAttributes.Count > 0 + || HasNestedValidation(property) + ) + { + return true; + } +#pragma warning restore IDE0046 + + return HasKindRules(property, validateEnumValues); + } + + static bool HasKindRules(ZodPropertyDescriptor property, bool validateEnumValues) + { + var attributes = property.ValidationAttributes; + return property.ValidationKind switch + { + PropertyValidationKind.String => HasStringRules(attributes), + PropertyValidationKind.Numeric or PropertyValidationKind.Comparable => Exists( + attributes.Range, + static range => + range.Exists + && !string.IsNullOrEmpty(range.MinimumExpression) + && !string.IsNullOrEmpty(range.MaximumExpression) + ), + PropertyValidationKind.Collection => ( + property.LengthAccessor.IsSupported + && ( + Exists(attributes.Length, static length => length.Exists) + || Exists(attributes.MinLength, static length => length.Exists && length.Length > 0) + || Exists(attributes.MaxLength, static length => length.Exists && length.Length >= 0) + ) + ) + || RejectsNullItems(property) + || RejectsDefaultArray(property), + PropertyValidationKind.Enum => IsEnumValidationEmitted(property, validateEnumValues), + PropertyValidationKind.Complex or PropertyValidationKind.Unsupported => false, + _ => false, + }; + } + + static bool HasStringRules(ValidationAttributes attributes) => + Exists(attributes.EmailAddress, static email => email.Exists) + || Exists(attributes.RegularExpression, static regex => regex.Exists) + || Exists(attributes.Url, static url => url.Exists) + || Exists(attributes.Phone, static phone => phone.Exists) + || Exists(attributes.CreditCard, static card => card.Exists) + || Exists(attributes.Base64String, static base64 => base64.Exists) + || Exists(attributes.Length, static length => length.Exists) + || Exists(attributes.StringLength, static length => length.Exists) + || Exists(attributes.MinLength, static length => length.Exists && length.Length > 0) + || Exists(attributes.MaxLength, static length => length.Exists && length.Length >= 0); + + static bool Exists(GeneratorResult result, Func exists) + where T : struct => result.ShouldProcess && exists(result.Value); + + /// Whether the member recurses into another schema (nested, element, or dispatched case). + static bool HasNestedValidation(ZodPropertyDescriptor property) => + ( + property.NestedSchemaType is not null + && property.ValidationKind is PropertyValidationKind.Complex or PropertyValidationKind.Collection + ) + || property.DispatchKind != DispatchKind.None; + + static IEnumerable GetValidatedMembers(ZodSchemaDescriptor schema) + { + foreach (var property in schema.Properties) + { + if (property.ShouldProcess && HasMemberRules(property.Value, schema.ValidateEnumValues)) + yield return property.Value; + } + } + + /// Whether the synchronous Validate cannot run: the type has async-only rules (defect G23). + static bool HasAsyncOnlyRules(ZodSchemaDescriptor schema) => + schema.ImplementsAsyncValidatableObject + || GetValidatedMembers(schema).Any(static property => property.AsyncValidationAttributes.Count > 0); + + static bool HasTypeValidation(ZodSchemaDescriptor schema) => + schema.TypeRules.Count > 0 + || (schema.ImplementsValidatableObject && !schema.ImplementsAsyncValidatableObject) + || schema.RefinementHook.Value.IsImplemented; + + static void GenerateValidateMethods( + CodeWriter writer, + SchemaGenerationOutputContext outputContext, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var schema = outputContext.ZodSchema; + var targetType = schema.TargetType.AsTypeReference(); + var resultType = TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType).AsTypeReference(); + var resultTaskReference = TypeLibraryGenerator + .ValueTask.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)) + .AsTypeReference(); + var serviceProviderReference = new TypeIdentity(typeof(IServiceProvider)).AsTypeReference(); + var requiresAsync = outputContext.RequiresAsync(schema); + + writer.XmlSummary( + "Whether some rules only run asynchronously (a custom asynchronous method, an AsyncValidationAttribute, an", + "IAsyncValidatableObject target, or a nested schema that has them), computed when the schema is generated." + ); + writer.Field( + new FieldDeclarationOptions("RequiresAsyncValidation", PurviewTypeLibrary.System.Boolean) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsConst = true, + Initializer = requiresAsync ? "true" : "false", + } + ); + writer.NewLine(); + + writer.XmlSummary("Validates an instance of the target type."); + writer.XmlParam("value", "The value to validate."); + writer.XmlReturn("The validation result."); + + // CA1849 would report every call to this method from async code, because ValidateAsync exists. ZodSharp's + // analyzer suppresses that for this method, so the remark states which call the type actually needs. + if (requiresAsync) + { + writer.XmlRemarks( + "Some rules in this graph only run asynchronously (RequiresAsyncValidation is true). Call ValidateAsync, which", + "runs them; a synchronous call cannot." + ); + } + else + { + writer.XmlRemarks( + "Every rule in this graph is synchronous (RequiresAsyncValidation is false), so this call never blocks,", + "and CA1849 is suppressed for it. Call ValidateAsync only when you need the asynchronous pipeline." + ); + } + + writer.MethodExpression( + "Validate", + resultType, + TypeDeclarationAccessibility.Public, + static expression => expression.Write("ValidateCore(value, 0)"), + declaration => declaration with { IsStatic = true, Parameters = [new("value", targetType)] } + ); + writer.NewLine(); + + writer.XmlSummary( + "Validates an instance of the target type at a nesting depth; nested schemas call this so a cyclic", + "graph fails with InvalidOperationException at ZodValidationDepth.MaxDepth instead of overflowing the stack." + ); + writer.XmlParam("value", "The value to validate."); + writer.XmlParam("depth", "The nesting depth of the value (the root is 0)."); + writer.XmlReturn("The validation result."); + writer.Line( + "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]" + ); + using (writer.OpenBlockScope($"public static {resultType} ValidateCore({targetType} value, int depth)")) + { + WriteValidatePrologue(writer, schema, resultType); + + if (HasAsyncOnlyRules(schema)) + { + // Defect G23/Change 8: async-only rules must not be skipped silently by a synchronous caller. + writer.MethodCall($"{GeneratedValidation}.ThrowAsyncValidationRequired", $"typeof({targetType})"); + } + + writer.Assignment(ErrorsListReference(writer), "errors", "null"); + + foreach (var property in GetValidatedMembers(schema)) + writer.MethodCall(CodeGenHelpers.GetMemberMethodName(property.Name), MemberArguments(property, "true")); + + if (HasTypeValidation(schema)) + writer.MethodCall("ValidateType", "value", "ref errors", "services: null"); + + writer.Return($"{GeneratedValidation}.ToResult(value, errors)"); + } + + writer.NewLine(); + + var resultTaskType = $"global::System.Threading.Tasks.ValueTask<{resultType}>"; + var customKind = schema.CustomValidationMethod.Value.InvocationKind; + var adapterParameter = + customKind == CustomValidationInvocationKind.DefinedOnSchemaValidator + ? $", {schema.SchemaType.Name}Validator? validator" + : string.Empty; + + writer.XmlSummary( + "Validates an instance of the target type asynchronously: nested schemas are awaited, and asynchronous and", + "service-aware rules run. Completes synchronously when no rule in the graph is asynchronous." + ); + writer.XmlParam("value", "The value to validate."); + writer.XmlParam("services", "The caller's services, for service-aware rules."); + writer.XmlParam("cancellationToken", "The cancellation token."); + writer.XmlReturn("The validation result."); + writer.MethodExpression( + "ValidateAsync", + resultTaskReference, + TypeDeclarationAccessibility.Public, + static expression => expression.Write("ValidateCoreAsync(value, services, 0, cancellationToken)"), + declaration => + declaration with + { + IsStatic = true, + Parameters = + [ + new("value", targetType), + new("services", serviceProviderReference.Nullable(writer)) { DefaultValue = "null" }, + new("cancellationToken", TypeLibraryGenerator.CancellationToken.AsTypeReference()) + { + DefaultValue = "default", + }, + ], + } + ); + writer.NewLine(); + + writer.XmlSummary("Validates an instance asynchronously at a nesting depth; see ValidateCore."); + writer.XmlParam("value", "The value to validate."); + writer.XmlParam("services", "The caller's services, for service-aware rules."); + writer.XmlParam("depth", "The nesting depth of the value (the root is 0)."); + writer.XmlParam("cancellationToken", "The cancellation token."); + writer.XmlReturn("The validation result."); + writer.Line( + "[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]" + ); + + if (!requiresAsync) + { + writer.MethodExpression( + "ValidateCoreAsync", + resultTaskReference, + TypeDeclarationAccessibility.Public, + static expression => expression.Write("new(ValidateCore(value, depth))"), + declaration => + declaration with + { + IsStatic = true, + Parameters = + [ + new("value", targetType), + new("services", serviceProviderReference.Nullable(writer)), + new("depth", PurviewTypeLibrary.System.Int32), + new("cancellationToken", TypeLibraryGenerator.CancellationToken.AsTypeReference()), + ], + } + ); + writer.NewLine(); + } + else + { + if (adapterParameter.Length > 0) + { + writer.MethodExpression( + "ValidateCoreAsync", + resultTaskReference, + TypeDeclarationAccessibility.Public, + static expression => + expression.Write("ValidateCoreAsync(value, services, depth, cancellationToken, null)"), + declaration => + declaration with + { + IsStatic = true, + Parameters = + [ + new("value", targetType), + new("services", serviceProviderReference.Nullable(writer)), + new("depth", PurviewTypeLibrary.System.Int32), + new("cancellationToken", TypeLibraryGenerator.CancellationToken.AsTypeReference()), + ], + } + ); + writer.NewLine(); + } + + var accessibility = adapterParameter.Length > 0 ? "internal" : "public"; + using ( + writer.OpenBlockScope( + $"{accessibility} static async {resultTaskType} ValidateCoreAsync({targetType} value, {ServiceProviderType}? services, int depth, {CancellationTokenType} cancellationToken{adapterParameter})" + ) + ) + { + WriteValidatePrologue(writer, schema, resultType); + writer.MethodCall("cancellationToken.ThrowIfCancellationRequested"); + writer.Assignment(ErrorsListReference(writer), "errors", "null"); + + foreach (var property in GetValidatedMembers(schema)) + { + var nestedAsync = outputContext.NestedRequiresAsync(property); + writer.MethodCall( + CodeGenHelpers.GetMemberMethodName(property.Name), + MemberArguments(property, nestedAsync ? "false" : "true") + ); + + if (nestedAsync || property.AsyncValidationAttributes.Count > 0) + { + writer.Assignment( + "errors", + $"await {CodeGenHelpers.GetMemberAsyncMethodName(property.Name)}(value, errors, services, depth, {(nestedAsync ? "true" : "false")}, cancellationToken).ConfigureAwait(false)" + ); + } + } + + if (HasTypeValidation(schema)) + writer.MethodCall("ValidateType", "value", "ref errors", "services"); + + if (schema.ImplementsAsyncValidatableObject) + WriteAsyncValidatableObject(writer, schema); + + if (schema.CustomValidationMethod.Value.HasCustomValidation) + { + writer.Assignment( + "var", + "customResult", + $"await {CustomMethodCall(schema, "value")}.ConfigureAwait(false)" + ); + writer.Assignment("errors", $"{GeneratedValidation}.WithResult(errors, customResult)"); + } + + writer.Return($"{GeneratedValidation}.ToResult(value, errors)"); + } + + writer.NewLine(); + } + + if (HasTypeValidation(schema)) + GenerateValidateTypeMethod(writer, schema); + } + + static void WriteValidatePrologue(CodeWriter writer, ZodSchemaDescriptor schema, TypeReference resultType) + { + if (schema.TargetCanBeNull) + { + writer.IfBlock("value is null", ifBody => + ifBody.Return( + $"{resultType}.Failure({GeneratedValidation}.ForMember(global::ZodSharp.Core.ZodIssues.NullValue(global::ZodSharp.Core.ZodTypeNames.Object, EmptyPath), {TypeMemberFieldName}))" + ) + ); + writer.NewLine(); + } + + // Defect G24/Change 5: the same depth limit Microsoft.Extensions.Validation enforces. + writer.MethodCall( + "global::ZodSharp.Core.ZodValidationDepth.ThrowIfExceeded", + "depth", + $"typeof({schema.TargetType.AsTypeReference()})" + ); + } + + /// + /// The custom async method call: a static method on the model, or an instance method on the adapter + /// (the calling adapter when there is one, otherwise a cached instance). A service-aware method is handed + /// the caller's services and fails clearly when there are none. + /// + static string CustomMethodCall(ZodSchemaDescriptor schema, string valueExpression) + { + var custom = schema.CustomValidationMethod.Value; + var services = custom.RequiresServices + ? $"services ?? throw new global::System.InvalidOperationException(\"'{GetDeclaringTypeName(schema)}' has a service-aware validation method that needs an IServiceProvider. Call ValidateAsync with the caller's services.\"), " + : string.Empty; + var receiver = + custom.InvocationKind == CustomValidationInvocationKind.DefinedOnSchemaValidator + ? $"(validator ?? ({CustomValidatorFieldName} ??= new()))" + : schema.TargetType.AsTypeReference().ToString(); + + // A method on the adapter may be private, so the adapter exposes it through a generated internal bridge. + var method = + custom.InvocationKind == CustomValidationInvocationKind.DefinedOnSchemaValidator + ? CustomValidationBridgeName + : custom.MethodName; + + return $"{receiver}.{method}({valueExpression}, {services}cancellationToken)"; + } + + static string[] MemberArguments(ZodPropertyDescriptor property, string recurse) => + HasNestedValidation(property) ? ["value", "ref errors", "depth", recurse] : ["value", "ref errors"]; + + static void WriteAsyncValidatableObject(CodeWriter writer, ZodSchemaDescriptor schema) + { + // Microsoft.Extensions.Validation / DataAnnotations order: members, then type attributes, then the + // validatable object, which runs only when everything before it passed. + using (writer.IfBlockScope("errors is null")) + { + writer.Assignment( + "var", + "validationContext", + $"{GeneratedValidation}.CreateValidationContext(value, {schema.TargetType.Name.StringLiteral()}, services)" + ); + using ( + writer.OpenBlockScope( + $"await foreach (var validationResult in global::System.Threading.Tasks.TaskAsyncEnumerableExtensions.ConfigureAwait(((global::System.ComponentModel.DataAnnotations.IAsyncValidatableObject)value).ValidateAsync(validationContext, cancellationToken), false))" + ) + ) + { + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithDataAnnotationsResult(errors, validationResult, EmptyPath, {TypeMemberFieldName})" + ); + } + } + } + + /// + /// Emits ValidateType: the type-level rules, IValidatableObject (only when nothing before it + /// failed, as in DataAnnotations) and the OnZodValidate refinement, shared by the sync and async paths. + /// + static void GenerateValidateTypeMethod(CodeWriter writer, ZodSchemaDescriptor schema) + { + var targetType = schema.TargetType.AsTypeReference(); + using ( + writer.OpenBlockScope( + $"static void ValidateType({targetType} value, ref {ErrorsListType}? errors, {ServiceProviderType}? services)" + ) + ) + { + GenerateTypeRuleValidations(writer, schema); + + if (schema.ImplementsValidatableObject && !schema.ImplementsAsyncValidatableObject) + { + using (writer.IfBlockScope("errors is null")) + { + writer.Assignment( + "var", + "validationContext", + $"{GeneratedValidation}.CreateValidationContext(value, {schema.TargetType.Name.StringLiteral()}, services)" + ); + writer.Foreach( + "var validationResult in ((global::System.ComponentModel.DataAnnotations.IValidatableObject)value).Validate(validationContext)", + body => + body.MethodCall( + $"{GeneratedValidation}.AddDataAnnotationsResult", + "ref errors", + "validationResult", + "EmptyPath", + TypeMemberFieldName + ) + ); + } + } + + GenerateZodRefinement(schema, writer); + } + + writer.NewLine(); + } + + static void GenerateZodRefinement(ZodSchemaDescriptor schema, CodeWriter method) + { + var hook = schema.RefinementHook.Value; + if (!hook.IsImplemented) + return; + + // The hook runs after the attribute and type rules, so its issues merge into the same error set + // the generated Validate returns. Nothing is emitted when the target supplies no body, so a type + // that only leaves the generated declaration in place stays on the zero-allocation path. + method.Assignment( + $"global::{TypeLibraryGenerator.ZodSharpSchemasNamespace}.RefineCtx<{schema.TargetType.AsTypeReference()}>", + "refineContext", + "new(value, EmptyPath)" + ); + method.MethodCall( + $"{schema.TargetType.AsTypeReference()}.{TypeLibraryGenerator.ZodRefinementHookInvokerName}", + "value", + "refineContext" + ); + using (method.IfBlockScope("refineContext.HasIssues")) + { + method.Foreach("var refinementIssue in refineContext.Issues", foreachBody => + foreachBody.MethodCall($"{GeneratedValidation}.Add", "ref errors", "refinementIssue") + ); + } + } + + /// + /// Emits one method per validated member (Change 1), ValidateMember/ValidateMemberAsync and + /// IsValidatableMember (Change 2), and the per-member async methods. + /// + static void GenerateMemberValidationMethods( + CodeWriter writer, + SchemaGenerationOutputContext outputContext, + CancellationToken cancellationToken + ) + { + var schema = outputContext.ZodSchema; + var targetType = schema.TargetType.AsTypeReference(); + var resultType = TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType).AsTypeReference(); + var resultTaskType = $"global::System.Threading.Tasks.ValueTask<{resultType}>"; + var resultTaskReference = TypeLibraryGenerator + .ValueTask.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)) + .AsTypeReference(); + var members = GetValidatedMembers(schema).ToList(); + var asyncMembers = schema.CustomValidationMethod.Value.HasCustomValidation + ? schema.CustomValidationMethod.Value.AsyncMembers + : new([]); + + foreach (var property in members) + { + cancellationToken.ThrowIfCancellationRequested(); + GenerateMemberMethod(writer, schema, property); + + var nestedAsync = outputContext.NestedRequiresAsync(property); + if (nestedAsync || property.AsyncValidationAttributes.Count > 0) + GenerateMemberAsyncMethod(writer, outputContext, property); + } + + writer.XmlSummary( + "Gets whether a member has rules that ValidateMember runs. Matching is ordinal, on the C# member name." + ); + writer.XmlParam("memberName", "The C# member name."); + writer.XmlReturn("True when the member is individually validatable."); + writer.Line( + members.Count == 0 + ? "public static bool IsValidatableMember(string memberName) => false;" + : $"public static bool IsValidatableMember(string memberName) => memberName is {string.Join(" or ", members.Select(static member => member.Name.StringLiteral()))};" + ); + writer.NewLine(); + + writer.XmlSummary( + "Validates one member of the value: the member's own rules (including a collection's null-element", + "check and its Compare rule). Nested schemas, type-level rules, IValidatableObject, the", + "refinement hook and the custom asynchronous method are not run, so every error belongs to the member. An unknown", + "member succeeds." + ); + writer.XmlParam("value", "The containing object."); + writer.XmlParam("memberName", "The C# member name."); + writer.XmlReturn("The member's errors, with paths relative to the value."); + using ( + writer.OpenBlockScope($"public static {resultType} ValidateMember({targetType} value, string memberName)") + ) + { + if (schema.TargetCanBeNull) + { + writer.IfBlock("value is null", ifBody => + ifBody.Return( + $"{resultType}.Failure({GeneratedValidation}.ForMember(global::ZodSharp.Core.ZodIssues.NullValue(global::ZodSharp.Core.ZodTypeNames.Object, EmptyPath), {TypeMemberFieldName}))" + ) + ); + } + + writer.Assignment(ErrorsListReference(writer), "errors", "null"); + if (members.Count > 0) + { + using (writer.OpenBlockScope("switch (memberName)")) + { + foreach (var property in members) + { + writer.Line($"case {property.Name.StringLiteral()}:"); + using (writer.OpenBlockScope()) + { + if (property.AsyncValidationAttributes.Count > 0) + writer.MethodCall( + $"{GeneratedValidation}.ThrowAsyncValidationRequired", + $"typeof({targetType})" + ); + + writer.MethodCall( + CodeGenHelpers.GetMemberMethodName(property.Name), + HasNestedValidation(property) + ? ["value", "ref errors", "0", "false"] + : ["value", "ref errors"] + ); + writer.Line("break;"); + } + } + } + } + + writer.Return($"{GeneratedValidation}.ToResult(value, errors)"); + } + + writer.NewLine(); + + var hasAsyncMemberWork = + members.Any(static member => member.AsyncValidationAttributes.Count > 0) || asyncMembers.Count > 0; + + writer.XmlSummary( + "Validates one member of the value asynchronously: its own rules, its AsyncValidationAttributes, and the", + "custom asynchronous method when [ZodAsyncMember] lists the member (only that member's errors are kept)." + ); + writer.XmlParam("value", "The containing object."); + writer.XmlParam("memberName", "The C# member name."); + writer.XmlParam("services", "The caller's services, for service-aware rules."); + writer.XmlParam("cancellationToken", "The cancellation token."); + writer.XmlReturn("The member's errors, with paths relative to the value."); + + if (!hasAsyncMemberWork) + { + writer.MethodExpression( + "ValidateMemberAsync", + resultTaskReference, + TypeDeclarationAccessibility.Public, + static expression => expression.Write("new(ValidateMember(value, memberName))"), + declaration => + declaration with + { + IsStatic = true, + Parameters = + [ + new("value", targetType), + new("memberName", PurviewTypeLibrary.System.String), + new( + "services", + new TypeIdentity(typeof(IServiceProvider)).AsTypeReference().Nullable(writer) + ) + { + DefaultValue = "null", + }, + new("cancellationToken", TypeLibraryGenerator.CancellationToken.AsTypeReference()) + { + DefaultValue = "default", + }, + ], + } + ); + writer.NewLine(); + return; + } + + var usesAdapter = + schema.CustomValidationMethod.Value.InvocationKind + == CustomValidationInvocationKind.DefinedOnSchemaValidator; + + using ( + writer.OpenBlockScope( + $"public static async {resultTaskType} ValidateMemberAsync({targetType} value, string memberName, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default)" + ) + ) + { + if (usesAdapter) + writer.Assignment($"{schema.SchemaType.Name}Validator? validator", "null"); + + HashSet asyncMemberNames = new(asyncMembers, StringComparer.Ordinal); + + writer.Assignment(ErrorsListReference(writer), "errors", "null"); + using (writer.OpenBlockScope("switch (memberName)")) + { + foreach (var property in members) + { + var hasAttributes = property.AsyncValidationAttributes.Count > 0; + var runsCustom = asyncMemberNames.Contains(property.Name); + if (!hasAttributes && !runsCustom) + continue; + + writer.Line($"case {property.Name.StringLiteral()}:"); + using (writer.OpenBlockScope()) + { + writer.MethodCall( + CodeGenHelpers.GetMemberMethodName(property.Name), + HasNestedValidation(property) + ? ["value", "ref errors", "0", "false"] + : ["value", "ref errors"] + ); + + if (hasAttributes) + { + writer.Assignment( + "errors", + $"await {CodeGenHelpers.GetMemberAsyncMethodName(property.Name)}(value, errors, services, 0, false, cancellationToken).ConfigureAwait(false)" + ); + } + + if (runsCustom) + WriteMemberCustomCall(writer, schema, property.Name); + + writer.Line("break;"); + } + } + + // A [ZodAsyncMember] name with no rules of its own still runs the custom method. + foreach (var name in asyncMemberNames.OrderBy(static name => name, StringComparer.Ordinal)) + { + if (members.Any(member => member.Name == name)) + continue; + + writer.Line($"case {name.StringLiteral()}:"); + using (writer.OpenBlockScope()) + { + WriteMemberCustomCall(writer, schema, name); + writer.Line("break;"); + } + } + + writer.Line("default:"); + using (writer.OpenBlockScope()) + { + writer.Return("ValidateMember(value, memberName)"); + } + } + + writer.Return($"{GeneratedValidation}.ToResult(value, errors)"); + } + + writer.NewLine(); + } + + static void WriteMemberCustomCall(CodeWriter writer, ZodSchemaDescriptor schema, string memberName) + { + writer.Assignment("var", "customResult", $"await {CustomMethodCall(schema, "value")}.ConfigureAwait(false)"); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithMemberErrors(errors, customResult, {memberName.StringLiteral()})" + ); + } + + /// + /// Emits ValidateMember_X: the member's own rules and, when recurse is true, its nested + /// schema. ValidateMember passes false so field-level validation never re-attributes a nested + /// type's errors to the changed field. + /// + static void GenerateMemberMethod(CodeWriter writer, ZodSchemaDescriptor schema, ZodPropertyDescriptor property) + { + var targetType = schema.TargetType.AsTypeReference(); + var nestedParameters = HasNestedValidation(property) ? ", int depth, bool recurse" : string.Empty; + + using ( + writer.OpenBlockScope( + $"static void {CodeGenHelpers.GetMemberMethodName(property.Name)}({targetType} value, ref {ErrorsListType}? errors{nestedParameters})" + ) + ) + { + GeneratePropertyValidation(writer, property, schema.ValidateEnumValues); + } + + writer.NewLine(); + } + + static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor property, bool validateEnumValues) + { + var propertyName = property.Name; + var attributes = property.ValidationAttributes; + CodeWriter.BlockScope? block = null; + + // The enum rule owns the property's value set: the values a [DeniedValues] attribute lists are + // absorbed into the rule's disallowed set, so the standalone denied-values validation is skipped. + var emitEnumRule = IsEnumValidationEmitted(property, validateEnumValues); + + // A rule that rejects an absent value (IRequiredRule) stands in for [Required]: it is emitted outside + // the non-null guard, so a missing value fails instead of being skipped. + var hasRequiredRule = TryGetRequiredRule(property, out var requiredRule); + + if ( + (property.CanBeNull || RejectsDefaultArray(property)) + && attributes.Required.ShouldProcess + && attributes.Required.Value.Exists + ) + { + var required = attributes.Required.Value; + + // RequiredAttribute semantics (defect G1): a string is missing when it is null, empty or + // whitespace-only, unless AllowEmptyStrings is set, in which case only null is missing. + var rejectsBlankStrings = + property.PropertyType.SpecialType == SpecialType.System_String && !required.AllowEmptyStrings; + var comparison = rejectsBlankStrings + ? $"string.IsNullOrWhiteSpace(value.{propertyName})" + : MissingValueCondition(property); + + // The shared missing-field identity (code missing_field, category invalid_type, the runtime + // "{displayName} is required" template); an attribute message replaces only the text. + var missing = + $"global::ZodSharp.Core.ZodIssues.MissingField({MemberOf(property)}.DisplayName, {PathOf(property)})"; + var errorExpression = HasConfiguredMessage(required.ValidationAttribute) + ? ForMemberMessage( + missing, + MemberOf(property), + BuildMessageTemplate(required.ValidationAttribute, string.Empty) + ) + : MemberError(missing, MemberOf(property)); + + using (writer.IfBlockScope(comparison)) + WriteAddError(writer, errorExpression); + + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } + else if (hasRequiredRule) + { + GenerateRequiredRuleValidation(writer, property, requiredRule); + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } + else if (!property.IsDeclaredNullable && (property.CanBeNull || RejectsDefaultArray(property))) + { + // A non-nullable reference type rejects null — the exported JSON Schema marks it required — so + // the rules below run only for a present value. This keeps null acceptance consistent with the + // schema and makes length/count access null-safe. + using (writer.IfBlockScope(MissingValueCondition(property))) + { + WriteAddError( + writer, + MemberError( + $"global::ZodSharp.Core.ZodIssues.NullValue({ExpectedTypeName(property)}, {PathOf(property)})", + MemberOf(property) + ) + ); + } + + block = writer.ElseScope(); + GenerateValueSetValidations(writer, property, emitEnumRule); + } + else + { + GenerateValueSetValidations(writer, property, emitEnumRule); + if (property.IsDeclaredNullable) + block = writer.IfBlockScope( + RejectsDefaultArray(property) + ? $"!({MissingValueCondition(property)})" + : $"value.{propertyName} is not null" + ); + } + + GenerateTypeSpecificValidations(writer, property, emitEnumRule, skipRequiredRule: hasRequiredRule); + block?.Dispose(); + + GenerateCompareValidation(writer, property); + } + + static string ForMemberMessage(string errorExpression, string memberExpression, MessageTemplate message) => + message.OrdinalNamesExpression is null + ? $"{GeneratedValidation}.ForMember({errorExpression}, {memberExpression}, messageFormat: {message.FormatExpression})" + : $"{GeneratedValidation}.ForMember({errorExpression}, {memberExpression}, messageFormat: {message.FormatExpression}, ordinalNames: {message.OrdinalNamesExpression})"; + + /// The Zod type name of the member's type, as a runtime expression. + static string ExpectedTypeName(ZodPropertyDescriptor property) => + property.ValidationKind switch + { + PropertyValidationKind.String => "global::ZodSharp.Core.ZodTypeNames.String", + PropertyValidationKind.Collection => property.IsMap + ? "global::ZodSharp.Core.ZodTypeNames.Record" + : "global::ZodSharp.Core.ZodTypeNames.Array", + PropertyValidationKind.Complex => "global::ZodSharp.Core.ZodTypeNames.Object", + PropertyValidationKind.Numeric + or PropertyValidationKind.Comparable + or PropertyValidationKind.Enum + or PropertyValidationKind.Unsupported => + $"global::ZodSharp.Core.ZodTypeNames.Of(typeof({property.PropertyType.AsTypeReference()}))", + _ => "global::ZodSharp.Core.ZodTypeNames.Unknown", + }; + + /// + /// Finds the rule that rejects an absent value (IRequiredRule) among the property's custom rules, + /// when the property can be null. + /// + /// The property to inspect. + /// The required rule, when one is bound to the property. + /// when a required rule is bound to a nullable property. + static bool TryGetRequiredRule(ZodPropertyDescriptor property, out CustomRuleDescriptor requiredRule) + { + requiredRule = default; + + if (!property.CanBeNull && !RejectsDefaultArray(property)) + return false; + + foreach (var rule in property.CustomRules) + { + if (!rule.IsRequired) + continue; + + requiredRule = rule; + return true; + } + + return false; + } + + static void GenerateTypeSpecificValidations( + CodeWriter writer, + ZodPropertyDescriptor property, + bool emitEnumRule, + bool skipRequiredRule + ) + { + switch (property.ValidationKind) + { + case PropertyValidationKind.String: + GenerateStringValidations(writer, property); + break; + case PropertyValidationKind.Numeric: + case PropertyValidationKind.Comparable: + GenerateRangeValidations(writer, property); + break; + case PropertyValidationKind.Collection: + GenerateCollectionValidations(writer, property, recurse: HasNestedValidation(property)); + break; + case PropertyValidationKind.Complex: + GenerateComplexTypeValidation(writer, property); + break; + case PropertyValidationKind.Enum: + if (emitEnumRule) + GenerateEnumValidation(writer, property); + break; + case PropertyValidationKind.Unsupported: + break; + default: + break; + } + + if (property.DispatchKind != DispatchKind.None) + GenerateDispatchValidation(writer, property); + + GenerateCustomRuleValidations(writer, property, skipRequiredRule); + } + + static void GenerateCompareValidation(CodeWriter writer, ZodPropertyDescriptor property) + { + var compareAttribute = property.ValidationAttributes.Compare; + if (!compareAttribute.ShouldProcess || !compareAttribute.Value.Exists) + return; + + var compare = compareAttribute.Value; + var otherDisplayName = compare.OtherPropertyDisplayName ?? compare.OtherProperty; + + // CompareAttribute formats a custom message with (name, OtherPropertyDisplayName ?? OtherProperty). + var message = BuildMessageTemplate( + compare.ValidationAttribute, + "Field '{displayName}' must match '{otherDisplayName}'.", + "otherDisplayName" + ); + + writer.IfBlock( + $"!global::System.Collections.Generic.EqualityComparer.Default.Equals(value.{property.Name}, value.{compare.OtherProperty})", + ifBody => + WriteAddError( + ifBody, + ErrorExpression( + "global::ZodSharp.Rules.CompareRule.ErrorCode", + message, + PathOf(property), + MemberOf(property), + category: Category("InvalidValue"), + valuesExpression: ValuesExpression( + ("otherProperty", compare.OtherProperty.StringLiteral()), + ("otherDisplayName", otherDisplayName.StringLiteral()) + ) + ) + ) + ); + } + + static void GenerateComplexTypeValidation(CodeWriter writer, ZodPropertyDescriptor property) + { + if (property.NestedSchemaType is null) + return; + + var propertyName = property.Name; + var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); + var nestedResultName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Result"); + + using (writer.IfBlockScope("recurse")) + { + if (property.IsNullableValueType) + { + // A Nullable property is validated through its unwrapped value; the nested schema is declared + // for T, not Nullable. + using (writer.IfBlockScope($"value.{propertyName} is {{ }} {propertyValueName}")) + WriteNestedValidation(writer, property, propertyValueName, nestedResultName); + + return; + } + + writer.Assignment("var", propertyValueName, $"value.{propertyName}"); + if (property.IsDeclaredNullable) + { + using (writer.IfBlockScope($"{propertyValueName} is not null")) + WriteNestedValidation(writer, property, propertyValueName, nestedResultName); + } + else + { + WriteNestedValidation(writer, property, propertyValueName, nestedResultName); + } + } + } + + static void WriteNestedValidation( + CodeWriter writer, + ZodPropertyDescriptor property, + string valueName, + string resultName + ) + { + writer.Assignment("var", resultName, NestedValidateCall(property, valueName)); + using (writer.IfBlockScope($"!{resultName}.IsSuccess")) + { + if (property.NestedIsScalarValueObject) + writer.MethodCall( + $"{GeneratedValidation}.AddNested", + "ref errors", + resultName, + PathOf(property), + MemberOf(property) + ); + else + writer.MethodCall($"{GeneratedValidation}.AddNested", "ref errors", resultName, PathOf(property)); + } + } + + /// + /// Emits the dispatch for a member typed as a C# 15 union (on the active case) or a closed class (on the + /// derived type): the value's runtime type selects the case schema, and the path does not include the + /// union's Value (Change 7). + /// + static void GenerateDispatchValidation(CodeWriter writer, ZodPropertyDescriptor property) + { + var dispatched = DispatchValueExpression(property); + using (writer.IfBlockScope("recurse")) + { + using (writer.OpenBlockScope($"switch ({dispatched})")) + { + for (var i = 0; i < property.DispatchCases.Count; i++) + { + var dispatchCase = property.DispatchCases[i]; + var caseName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"Case{i}"); + writer.Line($"case {dispatchCase.CaseType.AsTypeReference()} {caseName}:"); + using (writer.OpenBlockScope()) + { + var resultName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"Case{i}Result"); + writer.Assignment( + "var", + resultName, + NestedValidateCall(dispatchCase.SchemaType, dispatchCase.IsModern, caseName) + ); + writer.MethodCall( + $"{GeneratedValidation}.AddNested", + "ref errors", + resultName, + PathOf(property) + ); + writer.Line("break;"); + } + } + } + } + } + + /// + /// The expression dispatched on: a union's active case (Value), or the member value itself for a + /// closed hierarchy. + /// + static string DispatchValueExpression(ZodPropertyDescriptor property) => + property.DispatchKind == DispatchKind.Union + ? property.IsNullableValueType + ? $"value.{property.Name}?.Value" + : $"value.{property.Name}.Value" + : $"value.{property.Name}"; + + /// + /// Emits ValidateMemberAsync_X: a member's async attributes and, when recurse is true, its + /// nested schemas awaited through their own ValidateCoreAsync (defect G11), with the caller's + /// services threaded through (Change 9). + /// + static void GenerateMemberAsyncMethod( + CodeWriter writer, + SchemaGenerationOutputContext outputContext, + ZodPropertyDescriptor property + ) + { + var schema = outputContext.ZodSchema; + var targetType = schema.TargetType.AsTypeReference(); + var nestedAsync = outputContext.NestedRequiresAsync(property); + + using ( + writer.OpenBlockScope( + $"static async global::System.Threading.Tasks.ValueTask<{ErrorsListType}?> {CodeGenHelpers.GetMemberAsyncMethodName(property.Name)}({targetType} value, {ErrorsListType}? errors, {ServiceProviderType}? services, int depth, bool recurse, {CancellationTokenType} cancellationToken)" + ) + ) + { + if (property.AsyncValidationAttributes.Count > 0) + { + writer.Assignment( + "var", + "validationContext", + $"{GeneratedValidation}.CreateValidationContext(value, {MemberOf(property)}.DisplayName, services, {property.Name.StringLiteral()})" + ); + + for (var i = 0; i < property.AsyncValidationAttributes.Count; i++) + { + var resultName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"AsyncResult{i}"); + writer.Assignment( + "var", + resultName, + $"await {CodeGenHelpers.GetAsyncAttributeFieldName(property.Name, i)}.GetValidationResultAsync(value.{property.Name}, validationContext, cancellationToken).ConfigureAwait(false)" + ); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithDataAnnotationsResult(errors, {resultName}, {PathOf(property)}, {MemberOf(property)})" + ); + } + } + + if (nestedAsync) + { + using (writer.IfBlockScope("recurse")) + { + if (property.DispatchKind != DispatchKind.None) + WriteAsyncDispatch(writer, outputContext, property); + else if (property.ValidationKind == PropertyValidationKind.Collection) + WriteAsyncCollection(writer, property); + else + WriteAsyncComplex(writer, property); + } + } + + writer.Return("errors"); + } + + writer.NewLine(); + } + + static string NestedValidateAsyncCall(TypeIdentity schemaType, string valueExpression) => + $"await {schemaType.StaticMember($"ValidateCoreAsync({valueExpression}, services, depth + 1, cancellationToken)")}.ConfigureAwait(false)"; + + static void WriteAsyncComplex(CodeWriter writer, ZodPropertyDescriptor property) + { + var valueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Value"); + var resultName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Result"); + var schemaType = property.NestedSchemaType!.Value; + + void WriteCall(CodeWriter body) + { + body.Assignment("var", resultName, NestedValidateAsyncCall(schemaType, valueName)); + var owner = property.NestedIsScalarValueObject ? $", {MemberOf(property)}" : string.Empty; + body.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)}{owner})" + ); + } + + if (property.IsNullableValueType) + { + writer.IfBlock($"value.{property.Name} is {{ }} {valueName}", WriteCall); + return; + } + + writer.Assignment("var", valueName, $"value.{property.Name}"); + if (property.CanBeNull) + writer.IfBlock($"{valueName} is not null", WriteCall); + else + WriteCall(writer); + } + + static void WriteAsyncCollection(CodeWriter writer, ZodPropertyDescriptor property) + { + var collectionName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Collection"); + var itemName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Item"); + var resultName = CodeGenHelpers.GetLocalIdentifier(property.Name, "ItemResult"); + var schemaType = property.NestedSchemaType!.Value; + var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemName}.Value" : itemName; + + using (CollectionValueScope(writer, property, collectionName)) + { + if (property.IsMap) + { + var entryName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Entry"); + using (writer.ForeachScope($"var {entryName} in {collectionName}")) + { + writer.Assignment("var", itemName, $"{entryName}.Value"); + if (property.ElementTypeCanBeNull) + writer.IfBlock($"{itemName} is null", body => body.Line("continue;")); + + writer.Assignment("var", resultName, NestedValidateAsyncCall(schemaType, itemExpression)); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)}.Append({ZodPathSegmentType}.Key({entryName}.Key)))" + ); + } + + return; + } + + var indexName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Index"); + writer.Assignment("var", indexName, "0"); + using (writer.ForeachScope($"var {itemName} in {collectionName}")) + { + if (property.ElementTypeCanBeNull) + { + writer.IfBlock($"{itemName} is null", body => + { + body.Line($"{indexName}++;"); + body.Line("continue;"); + }); + } + + writer.Assignment("var", resultName, NestedValidateAsyncCall(schemaType, itemExpression)); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)}.Append({ZodPathSegmentType}.At({indexName})))" + ); + writer.Line($"{indexName}++;"); + } + } + } + + static void WriteAsyncDispatch( + CodeWriter writer, + SchemaGenerationOutputContext outputContext, + ZodPropertyDescriptor property + ) + { + using (writer.OpenBlockScope($"switch ({DispatchValueExpression(property)})")) + { + for (var i = 0; i < property.DispatchCases.Count; i++) + { + var dispatchCase = property.DispatchCases[i]; + var caseIsAsync = + dispatchCase.IsModern && outputContext.AsyncIndex.RequiresAsync(dispatchCase.SchemaType); + + var caseName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"Case{i}"); + writer.Line($"case {dispatchCase.CaseType.AsTypeReference()} {caseName}:"); + using (writer.OpenBlockScope()) + { + var resultName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"Case{i}Result"); + writer.Assignment( + "var", + resultName, + caseIsAsync + ? NestedValidateAsyncCall(dispatchCase.SchemaType, caseName) + : NestedValidateCall(dispatchCase.SchemaType, dispatchCase.IsModern, caseName) + ); + writer.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)})" + ); + writer.Line("break;"); + } + } + } + } +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs index c22a089f..45cfa6ee 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs @@ -58,23 +58,14 @@ out var displayValues var propertyName = property.Name; var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); - var displayName = property.DisplayName; - var messageExpression = BuildErrorMessageExpression( + var message = BuildMessageTemplate( allowedValues.Value.ValidationAttribute, - "Field '{0}' must be one of the following values: {1}.", - displayName.StringLiteral(), - displayValues.StringLiteral() + "Field '{displayName}' must be one of the following values: {options}.", + "options" ); - writer.IfBlock( - $"!({comparisonExpression.Replace("propertyValue", propertyValueName)})", - ifBody => - WriteValidationError( - ifBody, - "invalid_value", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName) - ) + writer.IfBlock($"!({comparisonExpression.Replace("propertyValue", propertyValueName)})", ifBody => + WriteAddError(ifBody, ValueSetError(property, message, displayValues)) ); writer.NewLine(); @@ -101,28 +92,34 @@ out var displayValues var propertyName = property.Name; var propertyValueName = CodeGenHelpers.GetLocalIdentifier(propertyName, "Value"); - var displayName = property.DisplayName; - var messageExpression = BuildErrorMessageExpression( + var message = BuildMessageTemplate( deniedValues.Value.ValidationAttribute, - "Field '{0}' contains a denied value. Disallowed values: {1}.", - displayName.StringLiteral(), - displayValues.StringLiteral() + "Field '{displayName}' contains a denied value. Disallowed values: {options}.", + "options" ); - writer.IfBlock( - $"{comparisonExpression.Replace("propertyValue", propertyValueName)}", - ifBody => - WriteValidationError( - ifBody, - "invalid_value", - messageExpression, - CodeGenHelpers.GetPathFieldName(propertyName) - ) + writer.IfBlock($"{comparisonExpression.Replace("propertyValue", propertyValueName)}", ifBody => + WriteAddError(ifBody, ValueSetError(property, message, displayValues)) ); writer.NewLine(); } + /// + /// The invalid_value error for an allowed/denied value set, carrying the listed values as the + /// options parameter and the value's Zod type as its origin. + /// + static string ValueSetError(ZodPropertyDescriptor property, MessageTemplate message, string displayValues) => + ErrorExpression( + "\"invalid_value\"", + message, + PathOf(property), + MemberOf(property), + origin: $"global::ZodSharp.Core.ZodTypeNames.Of(typeof({property.PropertyType.AsTypeReference()}))", + category: Category("InvalidValue"), + valuesExpression: ValuesExpression(("options", displayValues.StringLiteral())) + ); + static bool TryBuildValueSetComparison( CodeWriter writer, ZodPropertyDescriptor property, @@ -173,15 +170,36 @@ static string BuildValueListDisplay(ImmutableArray values) } parts.Add( - value.Value is string text - ? text - : Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? string.Empty + value.Value is string text ? text + : TryGetEnumMemberName(value, out var memberName) ? memberName + : Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? string.Empty ); } return string.Join(", ", parts); } + /// + /// Resolves the member name of an enum constant, so a value set lists Active rather than 1. + /// + static bool TryGetEnumMemberName(TypedConstant constant, out string memberName) + { + memberName = string.Empty; + if (constant.Type is not INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType || constant.Value is null) + return false; + + foreach (var member in enumType.GetMembers()) + { + if (member is IFieldSymbol { HasConstantValue: true } field && Equals(field.ConstantValue, constant.Value)) + { + memberName = field.Name; + return true; + } + } + + return false; + } + static bool TryBuildTypedConstantExpression( ZodPropertyDescriptor property, TypedConstant constant, diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.cs b/src/src/SourceGenerators/ZodSchemaGenerator.cs index eeac98b4..d5726372 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; namespace ZodSharp.SourceGenerators; @@ -17,56 +18,72 @@ public sealed partial class ZodSchemaGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - context - .RegisterEmbeddedAttribute() - .RegisterPostInitializationOutput(static ctx => - { - foreach (var (hintName, sourceText) in AttributeGenHelper.GenerateMarkers()) - ctx.AddSource($"{hintName}.g.cs", sourceText); - }); - + // No post-initialization output: [ZodSchema] is defined once in Purview.ZodSharp. A copy generated into every + // compilation gave each project in a solution an identical type and XML documentation ID, which crashed the + // ASP.NET Core OpenAPI XML-comment generator ("An item with the same key has already been added"). var generationValueProviders = SourceGenLibrary.GetGeneratorValueProviders(context); - context.RegisterSourceOutput( - generationValueProviders, - static (spc, model) => + context.RegisterSourceOutput(generationValueProviders, static (spc, model) => + { + if (model.Context.Settings.IsSourceGeneratorDisabled) + return; + + if (!model.Context.Capabilities.HasRequiredAttribute) + return; + + List processed = []; + foreach (var schema in model.ZodSchemas) { - if (model.Context.Settings.IsSourceGeneratorDisabled) - return; + if (schema.ShouldProcess) + processed.Add(schema.Value); + } - if (!model.Context.Capabilities.HasRequiredAttribute) - return; + // Which schemas need asynchronous validation is a property of the whole graph (a nested schema + // can make its parent asynchronous), so it is decided once per output from the collected schemas. + AsyncSchemaIndex asyncIndex = new(processed); - foreach (var schema in model.ZodSchemas) - { - if (!schema.ShouldProcess) - continue; + foreach (var schema in model.ZodSchemas) + { + if (!schema.ShouldProcess) + continue; - try - { - SchemaGenerationOutputContext outputContext = new(model.Context, schema.Value); - BuildSchema(outputContext, spc, schema.Value.IsPrimary); - } - // CodeWriterScopeValidationException was caught here and rethrown, to fail loudly on a - // generator bug. It reached the consumer as CS8785 instead, which discards every file this - // generator produced for the compilation and buries the real cause under the 'type or - // namespace not found' errors that follow (PSGFR44). It now takes the same path as any - // other failure: the exception message lists every open scope, so the diagnostic is no less - // loud, and the rest of the generated output still arrives. - catch (Exception ex) - { - var diagnostic = ReportableDiagnostic.Create( - DiagnosticLibrary.UnhandledException, - true, - schema.Value.TargetType.Name, - ex.Message - ); + try + { + SchemaGenerationOutputContext outputContext = new(model.Context, schema.Value, asyncIndex); + BuildSchema(outputContext, spc, schema.Value.IsPrimary); + } + // CodeWriterScopeValidationException was caught here and rethrown, to fail loudly on a + // generator bug. It reached the consumer as CS8785 instead, which discards every file this + // generator produced for the compilation and buries the real cause under the 'type or + // namespace not found' errors that follow (PSGFR44). It now takes the same path as any + // other failure: the exception message lists every open scope, so the diagnostic is no less + // loud, and the rest of the generated output still arrives. + catch (Exception ex) + { + var diagnostic = ReportableDiagnostic.Create( + DiagnosticLibrary.UnhandledException, + true, + schema.Value.TargetType.Name, + ex.Message + ); - spc.ReportDiagnostic(diagnostic.ToDiagnostic()); - } + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); } } - ); + + try + { + GenerateAssemblyRegistration(spc, model, processed); + } + catch (Exception ex) + { + spc.ReportDiagnostic( + ReportableDiagnostic + .Create(DiagnosticLibrary.UnhandledException, true, "assembly registration", ex.Message) + .ToDiagnostic() + ); + } + }); context.RegisterSourceOutput( generationValueProviders.Combine(GetRuleAttributeProvider(context).Collect()), diff --git a/src/src/SourceGenerators/ZodSchemaRazorAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaRazorAnalyzer.cs new file mode 100644 index 00000000..4e1414a3 --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaRazorAnalyzer.cs @@ -0,0 +1,61 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Models; + +namespace ZodSharp.SourceGenerators; + +/// +/// Reports ZODSGEN051 for a [ZodSchema] type declared in a Razor component. Razor compiles components +/// with its own source generator, and generators cannot see each other's output, so the ZodSharp generator +/// never sees the type. The type only exists in generated code, so — unlike — +/// this analyzer analyzes generated code. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ZodSchemaRazorAnalyzer : DiagnosticAnalyzer +{ + public override System.Collections.Immutable.ImmutableArray SupportedDiagnostics { get; } = + [DiagnosticLibrary.ZodSchemaInRazorFile]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis( + GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics + ); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + static void AnalyzeNamedType(SymbolAnalysisContext context) + { + if (context.Symbol is not INamedTypeSymbol type) + return; + + foreach (var location in type.Locations) + { + if (!location.IsInSource || !IsRazorFile(location.SourceTree?.FilePath)) + continue; + + if (!ZodSchemaAttributeData.FromAttributeData(type, out _).Exists) + return; + + context.ReportDiagnostic(Diagnostic.Create(DiagnosticLibrary.ZodSchemaInRazorFile, location, type.Name)); + return; + } + } + + /// + /// Whether a syntax tree comes from a Razor file: the .razor file itself (design time) or the Razor + /// generator's output (Pages_Counter_razor.g.cs, Counter.razor.g.cs). + /// + internal static bool IsRazorFile(string? path) => + path is not null + && ( + path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".razor.g.cs", StringComparison.OrdinalIgnoreCase) + || path.EndsWith("_razor.g.cs", StringComparison.OrdinalIgnoreCase) + ); +} diff --git a/src/src/SourceGenerators/ZodSyncValidationSuppressor.cs b/src/src/SourceGenerators/ZodSyncValidationSuppressor.cs new file mode 100644 index 00000000..f4fc7506 --- /dev/null +++ b/src/src/SourceGenerators/ZodSyncValidationSuppressor.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace ZodSharp.SourceGenerators; + +/// +/// Suppresses CA1849 ("call async methods when in an async method") for ZodSharp's synchronous validation entry +/// points, Validate and ValidateMember. +/// +/// +/// CA1849 reports every Validate call inside a Task-returning method because a ValidateAsync that +/// takes a superset of the parameters exists on the same type. That pairing is deliberate: the synchronous +/// pipeline is the cheap path (no ValueTask, no state machine) and every fluent schema completes it +/// synchronously. ValidateAsync earns its keep only when the type's rules are asynchronous, which +/// RequiresAsyncValidation and ZODSGEN048 already say, and a synchronous call on such a type throws +/// rather than silently skipping them. +/// +/// The diagnostic is reported at the call site, so no attribute on the declaring method can suppress it; a +/// is the only mechanism a package can ship. It is deliberately narrow — an +/// unrelated library's Validate/ValidateAsync pair, and every genuine blocking call such as +/// Task.Wait or Thread.Sleep, still gets the warning. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ZodSyncValidationSuppressor : DiagnosticSuppressor +{ + /// The suppression id, visible on the suppressed diagnostic's tooltip. + public const string SuppressionId = "ZODSUP001"; + + /// The only rule this suppressor covers. + public const string SuppressedDiagnosticId = "CA1849"; + + const string ValidateName = "Validate"; + const string ValidateMemberName = "ValidateMember"; + const string ValidateCoreName = "ValidateCore"; + + /// The assembly a schema type has to come from, or from whose contract it has to be reached. + public const string CoreAssemblyName = "Purview.ZodSharp"; + + static readonly SuppressionDescriptor Rule = new( + SuppressionId, + SuppressedDiagnosticId, + "Validate runs the whole synchronous rule set without blocking; ValidateAsync is the asynchronous path and is only required when the type's rules are asynchronous." + ); + + /// + public override ImmutableArray SupportedSuppressions { get; } = [Rule]; + + /// + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (var diagnostic in context.ReportedDiagnostics) + { + if (!IsSuppressedRule(diagnostic) || diagnostic.Location.SourceTree is not { } tree) + continue; + + var root = tree.GetRoot(context.CancellationToken); + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + + // The diagnostic is reported on the invoked expression itself. Anything else — CA1849 also reports + // blocking members such as Task.Wait() or Task.Result — is left alone; walking up to an enclosing + // invocation would attribute it to the wrong call. + if (node is not InvocationExpressionSyntax invocation) + continue; + + var operation = context.GetSemanticModel(tree).GetOperation(invocation, context.CancellationToken); + if (operation is IInvocationOperation { TargetMethod: { } target } && IsZodSynchronousValidation(target)) + context.ReportSuppression(Suppression.Create(Rule, diagnostic)); + } + } + + static bool IsSuppressedRule(Diagnostic diagnostic) => + string.Equals(diagnostic.Id, SuppressedDiagnosticId, StringComparison.Ordinal); + + /// + /// Whether the invoked method is one of ZodSharp's synchronous validators. Schema types declared in the package + /// are recognised by their assembly; generated schemas live in the consuming assembly, so a static schema is + /// recognised by the ValidateCore helper it pairs with and a generated validator adapter by the + /// ZodSharp contract it implements. + /// + static bool IsZodSynchronousValidation(IMethodSymbol method) + { + var definition = method.ReducedFrom ?? method; + if (definition.Name is not (ValidateName or ValidateMemberName)) + return false; + + var type = definition.ContainingType; + if (type is null) + return false; + + if (IsZodSharpAssembly(type.ContainingAssembly)) + return true; + + // The checks are an ordered chain of early returns; a conditional expression would obscure it. +#pragma warning disable IDE0046 // Convert to conditional expression + if (HasMethod(type, ValidateCoreName, 2)) + return true; +#pragma warning restore IDE0046 + + return type.AllInterfaces.Any(static candidate => IsZodSharpAssembly(candidate.ContainingAssembly)); + } + + static bool HasMethod(INamedTypeSymbol type, string name, int parameterCount) => + type.GetMembers(name).OfType().Any(method => method.Parameters.Length == parameterCount); + + static bool IsZodSharpAssembly(IAssemblySymbol? assembly) => + assembly is not null + && ( + string.Equals(assembly.Name, CoreAssemblyName, StringComparison.Ordinal) + || assembly.Name.StartsWith(CoreAssemblyName + ".", StringComparison.Ordinal) + ); +} diff --git a/src/src/SystemTextJson/CompatibilitySuppressions.xml b/src/src/SystemTextJson/CompatibilitySuppressions.xml new file mode 100644 index 00000000..3301894e --- /dev/null +++ b/src/src/SystemTextJson/CompatibilitySuppressions.xml @@ -0,0 +1,12 @@ + + + + + PKV006 + net8.0 + + + PKV006 + net9.0 + + diff --git a/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs new file mode 100644 index 00000000..02eb5167 --- /dev/null +++ b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs @@ -0,0 +1,234 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// Turns System.Text.Json deserialization failures into s. +/// +/// +/// +/// Every failure uses the one code and the category +/// . The location is kept: the serializer's JSONPath becomes the +/// error's and the line and byte position become the line and +/// position parameters. +/// +/// +/// The message is fixed and generic. The serializer's own message names CLR types and can quote the input, so +/// it is never passed to a client; the expected type is described with Zod's type names instead. +/// +/// +/// A converter that rejects its value with ZodSharp errors - a whose inner exceptions +/// contain a , as a strict value object converter throws - is not a failure to read JSON: +/// its errors are the result, located under the member that held the value (see ). +/// +/// +static class JsonDeserializationErrors +{ + /// The message format of every deserialization failure. + public const string MessageFormat = "The JSON value could not be converted to {expected}."; + + /// The parameter holding the (zero-based) line number reported by the serializer. + public const string LineParameter = "line"; + + /// The parameter holding the (zero-based) byte position in the line reported by the serializer. + public const string PositionParameter = "position"; + + /// + /// Maps an exception thrown while deserializing to validation errors. + /// + /// The exception. + /// The root contract, used to describe the expected type at the failing path. + /// The errors, when the exception is a deserialization failure. + /// when the exception is a deserialization failure. + public static bool TryMap(Exception exception, JsonTypeInfo? typeInfo, out ImmutableArray errors) + { + switch (exception) + { + // A validating converter failed: its errors are the result, located under the converter's path. + case ZodJsonValidationException validation: + errors = validation.Errors; + return true; + + // A value inside a validating converter's value failed its own validation (a strict value object + // converter wrapping a ZodException): its errors, under the full location. + case ZodJsonReadException read when FindZodException(read.InnerException) is { } nested: + errors = ZodJsonExceptions.Reroot(nested.Errors, read.FullPath, typeInfo); + return true; + + // A validating converter could not read its value: the location joins the converted value's path + // with the position inside it. + case ZodJsonReadException read: + errors = [Create(read.FullPath, typeInfo, read.LineNumber, read.BytePositionInLine)]; + return true; + + // A converter rejected its value with ZodSharp errors (a strict value object converter throws + // JsonException(inner: ZodException)): those errors, under the member that held the value. + case JsonException when ZodJsonExceptions.TryGetErrors(exception, typeInfo, out errors): + return true; + + case JsonException json: + errors = [FromJsonException(json, typeInfo)]; + return true; + + // System.Text.Json throws these for types or metadata it cannot handle. The client cannot fix them, + // but they are still a failure to read the body rather than an unhandled server error. + case NotSupportedException: + case InvalidOperationException and not ObjectDisposedException: + errors = [Create(ZodPath.Root, typeInfo, line: null, position: null)]; + return true; + + default: + errors = default; + return false; + } + } + + /// + /// The error reported when the JSON deserialized to . + /// + /// The root contract. + /// The error. + public static ValidationError NullValue(JsonTypeInfo? typeInfo) => + Create(ZodPath.Root, typeInfo, line: null, position: null, received: "null"); + + static ZodException? FindZodException(Exception? exception) + { + for (var depth = 0; exception is not null && depth < 16; depth++, exception = exception.InnerException) + { + if (exception is ZodException zodException) + return zodException; + } + + return null; + } + + static ValidationError FromJsonException(JsonException exception, JsonTypeInfo? typeInfo) => + Create(ZodPath.ParseJsonPath(exception.Path), typeInfo, exception.LineNumber, exception.BytePositionInLine); + + static ValidationError Create( + ZodPath path, + JsonTypeInfo? typeInfo, + long? line, + long? position, + string? received = null + ) + { + var expected = typeInfo is null ? JsonKinds.Unknown : DescribeExpected(typeInfo, path); + + Dictionary parameters = new(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = expected, + }; + if (received is not null) + parameters[ZodIssueParameters.Received] = received; + if (line is { } lineNumber) + parameters[LineParameter] = lineNumber; + if (position is { } bytePosition) + parameters[PositionParameter] = bytePosition; + + return ValidationError.Create( + SystemTextJsonExtensions.InvalidJsonCode, + ZodMessageFormat.Format(MessageFormat, parameters, CultureInfo.InvariantCulture), + path, + parameters, + category: ZodIssueCategories.InvalidType + ) with + { + MessageFormat = MessageFormat, + }; + } + + /// + /// Walks through the contract to name the JSON expected where reading failed. + /// + static string DescribeExpected(JsonTypeInfo root, ZodPath path) + { + try + { + var current = root; + foreach (var segment in path.Segments) + { + var next = Step(current, segment); + if (next is null) + return JsonKinds.Unknown; + + current = next; + } + + return JsonKinds.OfContract(current) ?? JsonKinds.Unknown; + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or ArgumentException) + { + // The resolver does not know a member type (for example a source-generated context that omits + // it). Describing the expected type is best effort; the error itself is still reported. + return JsonKinds.Unknown; + } + } + + static JsonTypeInfo? Step(JsonTypeInfo current, ZodPathSegment segment) + { + if (current.Kind == JsonTypeInfoKind.Object) + { + if (segment.Kind != ZodPathSegmentKind.Member) + return null; + + var comparison = current.Options.PropertyNameCaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + foreach (var property in current.Properties) + { + if (string.Equals(property.Name, segment.Name, comparison)) + return current.Options.GetTypeInfo(property.PropertyType); + } + + return null; + } + + if (current.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary) + { + var expectedSegment = + current.Kind == JsonTypeInfoKind.Enumerable ? ZodPathSegmentKind.Index : ZodPathSegmentKind.Key; + // A conditional expression here would nest the segment check inside the element lookup. +#pragma warning disable IDE0046 // Convert to conditional expression + if (segment.Kind != expectedSegment && segment.Kind != ZodPathSegmentKind.Member) + return null; +#pragma warning restore IDE0046 + + return ElementTypeOf(current) is { } element ? current.Options.GetTypeInfo(element) : null; + } + + // A scalar has no members. + return null; + } + + /// + /// The element (or dictionary value) type of a collection contract, as the contract exposes it; for a contract + /// that does not set it, it is read from an array's element type or the collection's generic arguments (the last + /// one, so a dictionary yields its value type). Neither needs unreferenced metadata. + /// + static Type? ElementTypeOf(JsonTypeInfo collection) + { + if (collection.ElementType is { } element) + return element; + + var type = collection.Type; + if (type.IsArray) + return type.GetElementType(); + + if (!type.IsGenericType) + return null; + + var arguments = type.GetGenericArguments(); + return collection.Kind == JsonTypeInfoKind.Dictionary + ? arguments.Length == 2 + ? arguments[1] + : null + : arguments.Length == 1 + ? arguments[0] + : null; + } +} diff --git a/src/src/SystemTextJson/Json/JsonKinds.cs b/src/src/SystemTextJson/Json/JsonKinds.cs new file mode 100644 index 00000000..53825ee7 --- /dev/null +++ b/src/src/SystemTextJson/Json/JsonKinds.cs @@ -0,0 +1,114 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace ZodSharp.Json; + +/// +/// Names JSON values and CLR types with Zod's type names (string, number, boolean, +/// object, array, null), so messages describe the JSON a client sent rather than the +/// CLR or type that happened to hold it. Only the kind is ever reported, never the +/// value, so no input is echoed into a message. +/// +static class JsonKinds +{ + /// The name used when the expected type cannot be determined. + public const string Unknown = "the expected type"; + + /// + /// The Zod name of a value held as a or as a plain CLR value. + /// + /// The value. + /// The Zod type name. + public static string OfValue(object? value) => + value switch + { + null => "null", + JsonElement element => OfValueKind(element.ValueKind), + string or char or Guid or DateTime or DateTimeOffset => "string", + bool => "boolean", + byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal => "number", + IDictionary => "object", + IEnumerable => "array", + _ => "object", + }; + + /// + /// The Zod name of a . + /// + /// The value kind. + /// The Zod type name. + public static string OfValueKind(JsonValueKind kind) => + kind switch + { + JsonValueKind.Object => "object", + JsonValueKind.Array => "array", + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True => "boolean", + JsonValueKind.False => "boolean", + JsonValueKind.Null => "null", + JsonValueKind.Undefined => "undefined", + _ => "undefined", + }; + + /// + /// The Zod name of the JSON a contract reads, or when it cannot be named. + /// + /// The contract. + /// The Zod type name, or . + public static string? OfContract(JsonTypeInfo typeInfo) => + typeInfo.Kind switch + { + JsonTypeInfoKind.Object => "object", + JsonTypeInfoKind.Dictionary => "object", + JsonTypeInfoKind.Enumerable => "array", + JsonTypeInfoKind.None => OfScalarType(typeInfo.Type), + _ => null, + }; + + /// + /// The Zod name of a scalar CLR type, or when it cannot be named. + /// + /// The type. + /// The Zod type name, or . + public static string? OfScalarType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + + return type.IsEnum ? "enum" : ScalarNames.GetValueOrDefault(type); + } + + static readonly Dictionary ScalarNames = new() + { + [typeof(bool)] = "boolean", + [typeof(byte)] = "number", + [typeof(sbyte)] = "number", + [typeof(short)] = "number", + [typeof(ushort)] = "number", + [typeof(int)] = "number", + [typeof(uint)] = "number", + [typeof(long)] = "number", + [typeof(ulong)] = "number", + [typeof(float)] = "number", + [typeof(double)] = "number", + [typeof(decimal)] = "number", + [typeof(Half)] = "number", + [typeof(Int128)] = "number", + [typeof(UInt128)] = "number", + [typeof(string)] = "string", + [typeof(char)] = "string", + [typeof(Guid)] = "string", + [typeof(DateTime)] = "string", + [typeof(DateTimeOffset)] = "string", + [typeof(DateOnly)] = "string", + [typeof(TimeOnly)] = "string", + [typeof(TimeSpan)] = "string", + [typeof(Uri)] = "string", + [typeof(Version)] = "string", + // Binary data is written as a base64 string. + [typeof(byte[])] = "string", + [typeof(Memory)] = "string", + [typeof(ReadOnlyMemory)] = "string", + }; +} diff --git a/src/src/SystemTextJson/Json/ZodJsonConverter.cs b/src/src/SystemTextJson/Json/ZodJsonConverter.cs index 485d50ae..0062665e 100644 --- a/src/src/SystemTextJson/Json/ZodJsonConverter.cs +++ b/src/src/SystemTextJson/Json/ZodJsonConverter.cs @@ -34,17 +34,30 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial { // Buffer the current token so we can re-read it after extracting the value. using var document = JsonDocument.ParseValue(ref reader); - var deserialized = - JsonSerializer.Deserialize(document.RootElement, TypeInfoFor(options)) - ?? throw new JsonException("Failed to deserialize JSON"); - var result = schema.Validate(deserialized); - if (!result.IsSuccess) + T deserialized; + try { - var errorMessages = string.Join(", ", result.Errors.Select(e => e.Message)); - throw new JsonException($"Validation failed: {errorMessages}"); + deserialized = + JsonSerializer.Deserialize(document.RootElement, TypeInfoFor(options)) + ?? throw new JsonException("Failed to deserialize JSON"); } + catch (ZodJsonValidationException nested) + { + // A validating converter for a nested value was located within this element only. Its errors + // already carry that relative path; a fresh exception lets the outer serializer add this value's. + throw new ZodJsonValidationException(nested.Errors); + } + catch (JsonException unreadable) + { + // The inner serializer's path is relative to this element; the serializer would keep it as it is + // and drop this value's own path. Record it, and let the serializer add this value's path. + throw new ZodJsonReadException(ZodJsonReadException.RelativePathOf(unreadable), unreadable); + } + var result = schema.Validate(deserialized); - return result.Value; + // The serializer adds the converted value's path to the exception as it leaves the converter, so the + // errors are reported under it (see ZodJsonValidationException.Errors). + return result.IsSuccess ? result.Value : throw new ZodJsonValidationException(result.Errors); } public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) @@ -57,10 +70,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions var result = schema.Validate(value); if (!result.IsSuccess) - { - var errorMessages = string.Join(", ", result.Errors.Select(e => e.Message)); - throw new JsonException($"Validation failed: {errorMessages}"); - } + throw new ZodJsonValidationException(result.Errors); JsonSerializer.Serialize(writer, result.Value, TypeInfoFor(options)); } diff --git a/src/src/SystemTextJson/Json/ZodJsonReadException.cs b/src/src/SystemTextJson/Json/ZodJsonReadException.cs new file mode 100644 index 00000000..2aa6a226 --- /dev/null +++ b/src/src/SystemTextJson/Json/ZodJsonReadException.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// Thrown by a validating converter when the value it buffered cannot be read into the target type. It records +/// where inside that value reading failed, because the serializer only knows the converted value's own path. +/// +/// +/// The converter reads its value through a second serializer call over a buffered element, whose +/// is relative to that element ($.Age). Left as it is, the serializer keeps +/// that relative path and the error is reported at Age instead of Members[1].Age. This exception +/// leaves for the serializer to fill in with the converted value's path, and +/// joins the two. +/// +sealed class ZodJsonReadException : JsonException +{ + const string DefaultMessage = "The JSON value could not be converted."; + + public ZodJsonReadException() + : base(DefaultMessage) { } + + public ZodJsonReadException(string? message) + : base(message) { } + + public ZodJsonReadException(string? message, Exception? innerException) + : base(message, innerException) { } + + public ZodJsonReadException(ZodPath relativePath, JsonException innerException) + : base(DefaultMessage, innerException) + { + RelativePath = relativePath; + } + + /// Gets where reading failed, relative to the converted value. + public ZodPath RelativePath { get; } + + /// Gets the full location: the serializer's path for the converted value, then . + public ZodPath FullPath => RelativePath.Prepend(ZodPath.ParseJsonPath(Path)); + + /// + /// The location of relative to the element it was read from, including any + /// location a nested validating converter recorded. + /// + public static ZodPath RelativePathOf(JsonException exception) => + exception is ZodJsonReadException nested ? nested.FullPath : ZodPath.ParseJsonPath(exception.Path); +} diff --git a/src/src/SystemTextJson/Json/ZodJsonValidationException.cs b/src/src/SystemTextJson/Json/ZodJsonValidationException.cs new file mode 100644 index 00000000..2c315163 --- /dev/null +++ b/src/src/SystemTextJson/Json/ZodJsonValidationException.cs @@ -0,0 +1,86 @@ +using System.Collections.Immutable; +using System.Text.Json; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// The thrown by a validating converter (see +/// ) when the value it read or is writing +/// fails its schema. It carries the full s rather than a flattened message. +/// +/// +/// System.Text.Json fills in as the exception leaves the converter, so +/// is located under the converted value's path: a nested Child property whose +/// Name failed reports Child.Name. DeserializeAndValidate unwraps the exception into +/// those errors. +/// +public sealed class ZodJsonValidationException : JsonException +{ + const string DefaultMessage = "Validation failed."; + + /// + /// Initializes a new instance with no errors. + /// + public ZodJsonValidationException() + : this(DefaultMessage) { } + + /// + /// Initializes a new instance with a message and no errors. + /// + /// The message. + public ZodJsonValidationException(string? message) + : base(message) + { + Errors = []; + } + + /// + /// Initializes a new instance with a message, an inner exception and no errors. + /// + /// The message. + /// The inner exception. + public ZodJsonValidationException(string? message, Exception? innerException) + : base(message, innerException) + { + Errors = []; + } + + /// + /// Initializes a new instance carrying validation errors, with paths relative to the converted value. + /// + /// The validation errors. + public ZodJsonValidationException(IEnumerable errors) + : this([.. errors ?? throw new ArgumentNullException(nameof(errors))]) { } + + ZodJsonValidationException(ImmutableArray errors) + : base(BuildMessage(errors)) + { + Errors = errors; + } + + /// + /// Gets the validation errors, located under when the serializer has + /// supplied it. + /// + public ImmutableArray Errors + { + get + { + var prefix = ZodPath.ParseJsonPath(Path); + if (prefix.IsRoot) + return field; + + var builder = ImmutableArray.CreateBuilder(field.Length); + foreach (var error in field) + builder.Add(error.WithPathPrefix(prefix)); + + return builder.MoveToImmutable(); + } + } + + static string BuildMessage(ImmutableArray errors) => + errors.IsDefaultOrEmpty + ? DefaultMessage + : "Validation failed: " + string.Join(", ", errors.Select(static e => e.Message)); +} diff --git a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs index c4903018..9ef5dce1 100644 --- a/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs +++ b/src/src/SystemTextJson/JsonSchema/SystemTextJson/FromJsonSchemaParser.cs @@ -428,6 +428,98 @@ static IZodSchema CreateLiteralFromJsonElement(JsonElement eleme }; } + // ========== Errors ========== + + const string InvalidTypeFormat = "Expected {expected}, but got {received}"; + const string InvalidLiteralFormat = "Expected literal value '{expected}', but got {received}"; + const string TooSmallArrayFormat = "Array must have at least {minimum} elements, but got {count}"; + const string TooBigArrayFormat = "Array must have at most {maximum} elements, but got {count}"; + + /// + /// An invalid_type error naming the expected and received JSON kinds with Zod's type names. Only the + /// kind of is reported, never the value. + /// + static ValidationError InvalidType(string expected, object? value) => + CreateError( + "invalid_type", + InvalidTypeFormat, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = expected, + [ZodIssueParameters.Received] = Json.JsonKinds.OfValue(value), + }, + ZodIssueCategories.InvalidType + ); + + /// + /// An invalid_literal error. The expected literal comes from the schema; only the kind of the + /// received value is reported, never the value. + /// + static ValidationError InvalidLiteral(T expected, object? value) => + CreateError( + "invalid_literal", + InvalidLiteralFormat, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = string.Create(CultureInfo.InvariantCulture, $"{expected}"), + [ZodIssueParameters.Received] = Json.JsonKinds.OfValue(value), + }, + ZodIssueCategories.InvalidValue + ); + + /// + /// The minItems/maxItems failure for , shaped exactly like a runtime + /// ZodArray length failure (code, origin array, bounds, inclusivity, "elements" wording), or + /// when the length is acceptable. + /// + static ValidationError? ArrayLengthFailure(int count, int? minItems, int? maxItems) + { + var tooSmall = minItems is { } min && count < min; + if (!tooSmall && !(maxItems is { } max && count > max)) + return null; + + Dictionary parameters = new(StringComparer.Ordinal) { [ZodIssueParameters.Count] = count }; + if (minItems is { } minimum) + parameters["minimum"] = minimum; + if (maxItems is { } maximum) + parameters["maximum"] = maximum; + + var format = tooSmall ? TooSmallArrayFormat : TooBigArrayFormat; + ValidationError error = new( + tooSmall ? "too_small" : "too_big", + ZodMessageFormat.Format(format, parameters, CultureInfo.InvariantCulture), + path: null, + parameters: new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.Count] = count }, + origin: "array", + minimum: minItems, + maximum: maxItems, + inclusive: true, + category: tooSmall ? ZodIssueCategories.TooSmall : ZodIssueCategories.TooBig + ); + + return error with + { + MessageFormat = format, + }; + } + + static ValidationError CreateError( + string code, + string format, + Dictionary parameters, + string category + ) => + new ValidationError( + code, + ZodMessageFormat.Format(format, parameters, CultureInfo.InvariantCulture), + path: null, + parameters, + category: category + ) with + { + MessageFormat = format, + }; + // ========== Wrapper Classes ========== sealed class StringSchemaWrapper(ZodString inner) : IZodSchema @@ -443,13 +535,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected string, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("string", value)); } } @@ -484,13 +570,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected number, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("number", value)); } var result = inner.Validate(num); @@ -525,13 +605,7 @@ value is JsonElement element } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected boolean, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("boolean", value)); } var result = _inner.Validate(b); @@ -552,9 +626,7 @@ public ValidationResult Validate(object value) { if (value != null && !(value is JsonElement element && element.ValueKind == JsonValueKind.Null)) { - return ValidationResult.Failure( - new ValidationError("invalid_type", $"Expected null, but got {value.GetType().Name}", []) - ); + return ValidationResult.Failure(InvalidType("null", value)); } // If matches, return success with null @@ -587,13 +659,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected object, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("object", value)); } var result = inner.Validate(dict); @@ -630,13 +696,7 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected object, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("object", value)); } Dictionary validated = []; @@ -648,24 +708,7 @@ public ValidationResult Validate(object value) if (!result.IsSuccess) { foreach (var error in result.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = key; - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add( - ValidationError.Create( - error.Code, - error.Message, - System.Collections.Immutable.ImmutableArray.Create(path), - error.Parameters, - error.Origin, - error.Minimum, - error.Maximum, - error.Inclusive, - error.Category - ) - ); - } + errors.Add(error.WithPathPrefix(ZodPathSegment.Key(key))); } else { @@ -701,29 +744,12 @@ public ValidationResult Validate(object value) } else { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected array, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(InvalidType("array", value)); } - // Check length constraints - if (minItems.HasValue && items.Count < minItems.Value) - { - return ValidationResult.Failure( - new ValidationError("too_small", $"Array must contain at least {minItems.Value} items", []) - ); - } - - if (maxItems.HasValue && items.Count > maxItems.Value) - { - return ValidationResult.Failure( - new ValidationError("too_big", $"Array must contain at most {maxItems.Value} items", []) - ); - } + // Length is checked before any element, exactly as ZodArray does. + if (ArrayLengthFailure(items.Count, minItems, maxItems) is { } lengthFailure) + return ValidationResult.Failure(lengthFailure); // Validate each item List errors = []; @@ -739,12 +765,7 @@ public ValidationResult Validate(object value) else { foreach (var error in itemResult.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = i.ToString(CultureInfo.InvariantCulture); - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add(new ValidationError(error.Code, error.Message, path, error.Parameters)); - } + errors.Add(error.WithPathPrefix(ZodPathSegment.At(i))); } } @@ -832,9 +853,7 @@ JsonValueKind.Number when typeof(T) == typeof(double) => element.GetDouble(), if (!matches) { - return ValidationResult.Failure( - new ValidationError("invalid_literal", $"Expected literal value '{_value}', but got '{value}'", []) - ); + return ValidationResult.Failure(InvalidLiteral(_value, value)); } // If matches, return success with the typed value diff --git a/src/src/SystemTextJson/Sdk/README.md b/src/src/SystemTextJson/Sdk/README.md index d179c8e7..5f1e2470 100644 --- a/src/src/SystemTextJson/Sdk/README.md +++ b/src/src/SystemTextJson/Sdk/README.md @@ -35,6 +35,26 @@ await using var stream = File.OpenRead("user.json"); var result = await userSchema.DeserializeAndValidateAsync(stream); ``` +In a trimmed, Native AOT or Blazor WebAssembly app, pass the contract from a source-generated `JsonSerializerContext` +(the `JsonTypeInfo` overloads); the overloads above resolve it reflectively and are annotated so the build warns. + +```csharp +var result = schema.DeserializeAndValidate(json, AppJsonContext.Default.User); +``` + +Service-aware rules (an async uniqueness check that resolves a repository) run when a generated validator is given the +caller's services: + +```csharp +var result = await new UserSchemaValidator().DeserializeAndValidateAsync( + stream, AppJsonContext.Default.User, services, cancellationToken); +var written = await new UserSchemaValidator().ValidateAndSerializeAsync( + user, output, AppJsonContext.Default.User, services, cancellationToken); +``` + +For `HttpClient` request and response bodies, see +[Purview.ZodSharp.Http](https://www.nuget.org/packages/Purview.ZodSharp.Http), which builds on these. + ## Validating converter ```csharp @@ -43,6 +63,19 @@ var options = new JsonSerializerOptions { Converters = { converter } }; var value = JsonSerializer.Deserialize(json, options); ``` +## Errors + +A failure to read the JSON (malformed text, a `null` document, a value the serializer cannot convert, or a +`NotSupportedException`/`InvalidOperationException` thrown while deserializing) is reported as one error with code +`invalid_json` (`SystemTextJsonExtensions.InvalidJsonCode`), category `invalid_type` and the fixed message +`"The JSON value could not be converted to {expected}."`. The serializer's path becomes the error's `FullPath` +(`$.items[1].age` becomes `items[1].age`) and its line and byte position become the `line` and `position` +parameters. The serializer's own message is never surfaced, because it names CLR types and can quote the input. + +The validating converter throws `ZodJsonValidationException` (namespace `ZodSharp.Json`, a `JsonException`) carrying the +schema's full `Errors`, located under the converter's JSON path; its message still starts with `"Validation failed: "`, +so existing `catch (JsonException)` blocks keep working. `DeserializeAndValidate` unwraps it into the result. + ## UTC DateTimeOffset contract The default `PreserveOffset` schema is exact and needs no converter. When the generated JSON Schema uses diff --git a/src/src/SystemTextJson/SystemTextJson.csproj b/src/src/SystemTextJson/SystemTextJson.csproj index ff786ece..955c06a0 100644 --- a/src/src/SystemTextJson/SystemTextJson.csproj +++ b/src/src/SystemTextJson/SystemTextJson.csproj @@ -1,5 +1,8 @@  + + true true false ZodSharp diff --git a/src/src/SystemTextJson/SystemTextJsonExtensions.cs b/src/src/SystemTextJson/SystemTextJsonExtensions.cs index 61b509cb..2afa40de 100644 --- a/src/src/SystemTextJson/SystemTextJsonExtensions.cs +++ b/src/src/SystemTextJson/SystemTextJsonExtensions.cs @@ -22,7 +22,14 @@ public static class SystemTextJsonExtensions "Resolves the contract for T reflectively. Use the JsonTypeInfo overload in a trimmed or Native " + "AOT application, passing the type info from a source-generated JsonSerializerContext."; - static readonly string[] EmptyPath = []; + /// + /// The code of every failure to read JSON: malformed JSON, a value that cannot be converted to the target + /// type, a document, or a type the serializer cannot handle. The error's + /// is , its + /// is the location the serializer reported, and its line and + /// position parameters hold the serializer's (zero-based) line number and byte position in the line. + /// + public const string InvalidJsonCode = "invalid_json"; /// /// Deserializes JSON and validates it using a Zod schema, using a supplied contract. @@ -48,22 +55,19 @@ JsonTypeInfo jsonTypeInfo ArgumentNullException.ThrowIfNull(json); ArgumentNullException.ThrowIfNull(jsonTypeInfo); + T? deserialized; try { - var deserialized = JsonSerializer.Deserialize(json, jsonTypeInfo); - - return deserialized == null - ? ValidationResult.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : schema.Validate(deserialized); + deserialized = JsonSerializer.Deserialize(json, jsonTypeInfo); } - catch (JsonException ex) + catch (Exception ex) when (JsonDeserializationErrors.TryMap(ex, jsonTypeInfo, out var errors)) { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); + return ValidationResult.Failure(errors); } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(jsonTypeInfo)) + : schema.Validate(deserialized); } /// @@ -92,24 +96,67 @@ public static async ValueTask> DeserializeAndValidateAsync.Failure(errors); + } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(jsonTypeInfo)) + : await schema.ValidateAsync(deserialized, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deserializes JSON from a stream and validates it with a validator that has access to the caller's services, so + /// service-aware rules (for example an OnZodValidateAsync hook that resolves a repository) run (async). + /// + /// The validated type. + /// The validator, typically a generated {TypeName}SchemaValidator. + /// The stream to read JSON from. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// The services service-aware rules resolve from. + /// Cancels the read and the validation. + /// The validated value, or the validation errors. + /// + /// Safe under trimming and Native AOT: nothing here resolves a contract reflectively. + /// + public static async ValueTask> DeserializeAndValidateAsync( + this IZodSchemaValidator validator, + Stream jsonStream, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(validator); + ArgumentNullException.ThrowIfNull(jsonStream); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); - return deserialized == null - ? ValidationResult.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : await schema.ValidateAsync(deserialized, cancellationToken).ConfigureAwait(false); + T? deserialized; + try + { + deserialized = await JsonSerializer + .DeserializeAsync(jsonStream, jsonTypeInfo, cancellationToken) + .ConfigureAwait(false); } - catch (JsonException ex) + catch (Exception ex) when (JsonDeserializationErrors.TryMap(ex, jsonTypeInfo, out var errors)) { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); + return ValidationResult.Failure(errors); } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(jsonTypeInfo)) + : await validator.ValidateAsync(deserialized, services, cancellationToken).ConfigureAwait(false); } /// @@ -181,6 +228,48 @@ await JsonSerializer return ValidationResult.Success(string.Empty); } + /// + /// Validates a value with a validator that has access to the caller's services, so service-aware rules run, and + /// serializes it to a stream, using a supplied contract (async). + /// + /// The validated type. + /// The validator, typically a generated {TypeName}SchemaValidator. + /// The value to validate and serialize. + /// The stream to write to. + /// + /// The contract for , typically from a source-generated + /// . + /// + /// The services service-aware rules resolve from. + /// Cancels the validation and the write. + /// An empty success, or the validation errors. + /// + /// Safe under trimming and Native AOT: nothing here resolves a contract reflectively. + /// + public static async ValueTask> ValidateAndSerializeAsync( + this IZodSchemaValidator validator, + T value, + Stream output, + JsonTypeInfo jsonTypeInfo, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(validator); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + + var result = await validator.ValidateAsync(value, services, cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess) + return ValidationResult.Failure(result.Errors); + + await JsonSerializer + .SerializeAsync(output, result.Value, jsonTypeInfo, cancellationToken) + .ConfigureAwait(false); + + return ValidationResult.Success(string.Empty); + } + /// /// Deserializes JSON and validates it using a Zod schema. /// @@ -202,21 +291,19 @@ public static ValidationResult DeserializeAndValidate( if (json == null) throw new ArgumentNullException(nameof(json)); + T? deserialized; try { - var deserialized = JsonSerializer.Deserialize(json, options); - return deserialized == null - ? ValidationResult.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : schema.Validate(deserialized); + deserialized = JsonSerializer.Deserialize(json, options); } - catch (JsonException ex) + catch (Exception ex) when (JsonDeserializationErrors.TryMap(ex, TryGetTypeInfo(options), out var errors)) { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); + return ValidationResult.Failure(errors); } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(TryGetTypeInfo(options))) + : schema.Validate(deserialized); } /// @@ -239,9 +326,9 @@ public static async ValueTask> DeserializeAndValidateAsync> DeserializeAndValidateAsync(jsonStream, options, cancellationToken); #endif - return deserialized == null - ? ValidationResult.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : await schema.ValidateAsync(deserialized, cancellationToken); } - catch (JsonException ex) + catch (Exception ex) when (JsonDeserializationErrors.TryMap(ex, TryGetTypeInfo(options), out var errors)) { - return ValidationResult.Failure( - new ValidationError("json_error", $"JSON parsing error: {ex.Message}", EmptyPath) - ); + return ValidationResult.Failure(errors); } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(TryGetTypeInfo(options))) + : await schema.ValidateAsync(deserialized, cancellationToken); } /// @@ -318,9 +402,108 @@ public static async ValueTask> ValidateAndSerializeAsyn return ValidationResult.Success(string.Empty); } + // The overloads below take no options at all. They exist so a project that references both JSON packages can + // call the argument-light forms: Purview.ZodSharp.NewtonsoftJson declares same-named extensions in the same + // namespace whose settings parameter is optional too, so without these the calls are ambiguous (CS0121). + // An overload that needs no default argument beats one that does, so these win and resolve to + // System.Text.Json; a call that passes Newtonsoft settings still resolves to Newtonsoft. + + /// + /// Deserializes JSON with the default and validates it using a Zod schema. + /// + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + public static ValidationResult DeserializeAndValidate(this IZodSchema schema, string json) => + DeserializeAndValidate(schema, json, options: null); + + /// + /// Deserializes JSON from a stream with the default and validates it using + /// a Zod schema (async). + /// + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + public static ValueTask> DeserializeAndValidateAsync( + this IZodSchema schema, + Stream jsonStream + ) => DeserializeAndValidateAsync(schema, jsonStream, options: null, CancellationToken.None); + + /// + /// Deserializes JSON from a stream with the default and validates it using + /// a Zod schema (async). + /// + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + public static ValueTask> DeserializeAndValidateAsync( + this IZodSchema schema, + Stream jsonStream, + CancellationToken cancellationToken + ) => DeserializeAndValidateAsync(schema, jsonStream, options: null, cancellationToken); + + /// + /// Validates a value and serializes it to a JSON string with the default . + /// + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + public static ValidationResult ValidateAndSerialize(this IZodSchema schema, T value) => + ValidateAndSerialize(schema, value, options: null); + + /// + /// Validates a value and serializes it to a stream with the default (async). + /// + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + public static ValueTask> ValidateAndSerializeAsync( + this IZodSchema schema, + T value, + Stream output + ) => ValidateAndSerializeAsync(schema, value, output, options: null, CancellationToken.None); + + /// + /// Validates a value and serializes it to a stream with the default (async). + /// + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + public static ValueTask> ValidateAndSerializeAsync( + this IZodSchema schema, + T value, + Stream output, + CancellationToken cancellationToken + ) => ValidateAndSerializeAsync(schema, value, output, options: null, cancellationToken); + /// /// Creates a custom JsonConverter that validates using a Zod schema. /// public static JsonConverter CreateValidatingConverter(this IZodSchema schema) => schema == null ? throw new ArgumentNullException(nameof(schema)) : new ZodJsonConverter(schema); + + /// + /// Resolves the contract for to describe a failure, or + /// when the options cannot supply one. + /// + [RequiresUnreferencedCode(JsonSerializerRequirement)] + [RequiresDynamicCode(JsonSerializerRequirement)] + static JsonTypeInfo? TryGetTypeInfo(JsonSerializerOptions? options) + { + try + { + var effective = options ?? JsonSerializerOptions.Default; + effective.MakeReadOnly(populateMissingResolver: true); + +#if NET11_0_OR_GREATER + return effective.GetTypeInfo(); +#else + return effective.GetTypeInfo(typeof(T)) as JsonTypeInfo; +#endif + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or ArgumentException) + { + return null; + } + } } diff --git a/src/src/ZodSharp/CollectionBehaviorAttribute.cs b/src/src/ZodSharp/CollectionBehaviorAttribute.cs index 2661a834..0426aef0 100644 --- a/src/src/ZodSharp/CollectionBehaviorAttribute.cs +++ b/src/src/ZodSharp/CollectionBehaviorAttribute.cs @@ -5,7 +5,7 @@ namespace ZodSharp; /// /// /// -/// The only setting today is , which controls whether a collection may contain +/// controls whether a collection may contain /// elements (or a dictionary a value). Null entries are /// rejected by default. Set AllowNulls = true to accept them, or AllowNulls = false to reject /// them even when the element type is explicitly nullable (T?) or a wider default allows them. @@ -34,4 +34,12 @@ public sealed class CollectionBehaviorAttribute : Attribute /// the named argument rather than a null value. /// public bool AllowNulls { get; set; } + + /// Gets or sets how an uninitialized immutable array is validated. + /// + /// Overrides the assembly's + /// and the ZodSharpDefaultImmutableArrayBehavior MSBuild property. Unset options defer to the + /// next level; the built-in default is . + /// + public DefaultImmutableArrayBehavior DefaultImmutableArrayBehavior { get; set; } } diff --git a/src/src/ZodSharp/CompatibilitySuppressions.xml b/src/src/ZodSharp/CompatibilitySuppressions.xml new file mode 100644 index 00000000..3301894e --- /dev/null +++ b/src/src/ZodSharp/CompatibilitySuppressions.xml @@ -0,0 +1,12 @@ + + + + + PKV006 + net8.0 + + + PKV006 + net9.0 + + diff --git a/src/src/ZodSharp/Core/ErrorBound.cs b/src/src/ZodSharp/Core/ErrorBound.cs new file mode 100644 index 00000000..5ff5799a --- /dev/null +++ b/src/src/ZodSharp/Core/ErrorBound.cs @@ -0,0 +1,26 @@ +namespace ZodSharp.Core; + +/// +/// A bound reported by a size or range issue. Unlike the integer and +/// , a bound can carry any comparable value (a , +/// , and so on) and records whether it is inclusive, so +/// > and >= can be told apart. +/// +/// The bound value. +/// Whether a value equal to the bound satisfies it. +public readonly record struct ErrorBound(object Value, bool Inclusive) +{ + /// + /// Creates an inclusive bound. + /// + /// The bound value. + /// The bound. + public static ErrorBound Including(object value) => new(value, true); + + /// + /// Creates an exclusive bound. + /// + /// The bound value. + /// The bound. + public static ErrorBound Excluding(object value) => new(value, false); +} diff --git a/src/src/ZodSharp/Core/ErrorType.cs b/src/src/ZodSharp/Core/ErrorType.cs index 4eb10576..ca73f0ec 100644 --- a/src/src/ZodSharp/Core/ErrorType.cs +++ b/src/src/ZodSharp/Core/ErrorType.cs @@ -64,6 +64,13 @@ public sealed record ErrorType( IReadOnlyList? Parameters = null ) { + /// + /// Gets the default of errors created for this type (for example + /// string or number). The generated Create* helper uses it when the caller passes no + /// origin. + /// + public string? Origin { get; init; } + /// /// Creates an error type parameter with the specified name and type. /// diff --git a/src/src/ZodSharp/Core/ErrorTypeAttribute.cs b/src/src/ZodSharp/Core/ErrorTypeAttribute.cs new file mode 100644 index 00000000..72da238a --- /dev/null +++ b/src/src/ZodSharp/Core/ErrorTypeAttribute.cs @@ -0,0 +1,13 @@ +namespace ZodSharp.Core; + +/// +/// Attribute to mark a static ErrorType field for strongly-typed helper generation. +/// When applied to a static ErrorType field in a partial class, Create/Throw helpers are generated at compile time. +/// +/// +/// Defined once, here, rather than generated into every consuming compilation: a generated copy gave every project in +/// a solution an identical type and XML documentation ID, which crashed tools that index documentation across a +/// project and its references (the ASP.NET Core OpenAPI XML-comment generator). +/// +[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] +public sealed class ErrorTypeAttribute : Attribute { } diff --git a/src/src/ZodSharp/Core/ErrorTypeParameters.cs b/src/src/ZodSharp/Core/ErrorTypeParameters.cs index d6700262..923d2650 100644 --- a/src/src/ZodSharp/Core/ErrorTypeParameters.cs +++ b/src/src/ZodSharp/Core/ErrorTypeParameters.cs @@ -14,7 +14,10 @@ namespace ZodSharp.Core; /// the response — so whatever you put here is returned to whoever made the request. That is the intended /// behaviour for a value the message names, such as an identifier the caller supplied. Do not put internal /// context in a parameter: connection details, configuration values, another user's data, or a whole -/// record fetched while validating. Only issues and traceId are reserved and withheld. +/// record fetched while validating. Only invariant scalar values (strings, numbers, booleans, dates and arrays of +/// them) are serialized, and a parameter named like a reserved ProblemDetails member (type, title, +/// status, detail, instance, errors, issues, traceId) is renamed +/// param_{name} rather than overwriting it. /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/src/ZodSharp/Core/IZodFailureDescriber.cs b/src/src/ZodSharp/Core/IZodFailureDescriber.cs new file mode 100644 index 00000000..1fab9a87 --- /dev/null +++ b/src/src/ZodSharp/Core/IZodFailureDescriber.cs @@ -0,0 +1,50 @@ +namespace ZodSharp.Core; + +/// +/// The value-dependent part of a rule failure: the Zod issue code for this failure and any parameters +/// that depend on the failing value (for example the of a length rule). +/// +/// The Zod issue code, overriding ; keeps it. +/// Value-dependent parameters, merged over . +public readonly record struct ZodRuleFailure( + string? Category = null, + IReadOnlyDictionary? Parameters = null +) +{ + /// + /// Gets the error code for this failure, overriding the rule's own code when the failure's direction + /// decides it (for example below a range and + /// above it); keeps the rule's code. It replaces only + /// the rule's own code: a code supplied explicitly by the caller (an attribute's Code) still wins. + /// + public string? Code { get; init; } +} + +/// +/// Implemented by a rule whose failure depends on the failing value, such as a range rule whose Zod issue code is +/// or by direction. +/// consults it when building the error. +/// +/// The validated type. +public interface IZodFailureDescriber +{ + /// + /// Describes the failure of . Only called after the rule rejected the value. + /// + /// The value that failed. + /// The value-dependent failure description. + ZodRuleFailure DescribeFailure(in T value); +} + +/// +/// The span counterpart of for . +/// +public interface IZodStringFailureDescriber +{ + /// + /// Describes the failure of . Only called after the rule rejected the value. + /// + /// The value that failed. + /// The value-dependent failure description. + ZodRuleFailure DescribeFailure(ReadOnlySpan value); +} diff --git a/src/src/ZodSharp/Core/IZodJsonPathMapper.cs b/src/src/ZodSharp/Core/IZodJsonPathMapper.cs new file mode 100644 index 00000000..b5338ec5 --- /dev/null +++ b/src/src/ZodSharp/Core/IZodJsonPathMapper.cs @@ -0,0 +1,22 @@ +namespace ZodSharp.Core; + +/// +/// Implemented by validators that can rename the member segments of an error path to their JSON names without +/// reflection, using the names recorded at compile time ([JsonPropertyName]) and, for members without +/// one, a naming policy. Generated validators implement it, so a host that renders JSON-named paths +/// (PathNaming = Json) can do so under trimming. +/// +public interface IZodJsonPathMapper +{ + /// + /// Returns with every member segment renamed to its JSON name. Index and key + /// segments are unchanged. + /// + /// The error path, relative to the validated type. + /// + /// The naming policy applied to members without an explicit JSON name (for example + /// JsonNamingPolicy.CamelCase.ConvertName), or to keep their C# names. + /// + /// The renamed path. + ZodPath ToJsonNamedPath(ZodPath path, Func? namingPolicy = null); +} diff --git a/src/src/ZodSharp/Core/IZodMemberValidator.cs b/src/src/ZodSharp/Core/IZodMemberValidator.cs new file mode 100644 index 00000000..1c072c2e --- /dev/null +++ b/src/src/ZodSharp/Core/IZodMemberValidator.cs @@ -0,0 +1,63 @@ +namespace ZodSharp.Core; + +/// +/// Implemented by validators that can validate a single member in isolation — the member's own rules only, +/// without recursing into a nested schema and without type-level rules or refinements. Hosts use it for +/// field-level validation (a Blazor field change), where every error produced is attributed to the field +/// that changed. +/// +/// The validated type. +public interface IZodMemberValidator +{ + /// + /// Gets whether has rules that runs. + /// + /// The C# member name. + /// when the member is individually validatable. + bool IsValidatableMember(string memberName); + + /// + /// Validates one member of . An unknown member succeeds. + /// + /// The containing object. + /// The C# member name. + /// The member's errors, with paths relative to . + ValidationResult ValidateMember(T value, string memberName); + + /// + /// Validates one member of asynchronously, including the member's async and + /// service-aware rules. + /// + /// The containing object. + /// The C# member name. + /// The caller's services, if any. + /// A token that cancels the validation. + /// The member's errors, with paths relative to . + ValueTask> ValidateMemberAsync( + T value, + string memberName, + IServiceProvider? services = null, + CancellationToken cancellationToken = default + ) => new(ValidateMember(value, memberName)); +} + +/// +/// Implemented by validators that can walk a value along an error path to the object that owns the failing +/// member, without reflection. Blazor needs this to attach a message to the right field +/// (FieldIdentifier(container, member)). +/// +/// The validated type. +public interface IZodMemberResolver +{ + /// + /// Resolves the object that owns the last member of . A path that ends in an index or + /// key resolves to the owner of the collection member. A path that cannot be followed resolves to the + /// nearest object reached. + /// + /// The root value. + /// The error path, relative to . + /// The owning object. + /// The member name on ; empty for a model-level error. + /// when the full path was followed. + bool TryResolveMember(T value, ZodPath path, out object? container, out string memberName); +} diff --git a/src/src/ZodSharp/Core/IZodMessageFormatter.cs b/src/src/ZodSharp/Core/IZodMessageFormatter.cs new file mode 100644 index 00000000..d165f6ba --- /dev/null +++ b/src/src/ZodSharp/Core/IZodMessageFormatter.cs @@ -0,0 +1,54 @@ +using System.Globalization; + +namespace ZodSharp.Core; + +/// +/// Renders the client-facing message of a at a host boundary (ProblemDetails, +/// Microsoft.Extensions.Validation, Blazor, options failures). This is the localization hook: validation itself +/// stays culture-independent, and a host re-renders each message in the request culture. +/// +/// +/// +/// Implementations must never throw and must fall back to when they have +/// nothing better, so a missing resource can never turn a validation failure into an exception. +/// +/// +/// , , paths and parameter values are +/// invariant data and are never localized; only the message is. +/// +/// +public interface IZodMessageFormatter +{ + /// + /// Returns the message for in . + /// + /// The error. + /// The culture to render in, normally . + /// The message. + string Format(in ValidationError validationError, CultureInfo culture); +} + +/// +/// The default : re-formats in the +/// requested culture when every placeholder resolves, and otherwise returns +/// unchanged. It has no dependency on Microsoft.Extensions.Localization. +/// +public sealed class DefaultZodMessageFormatter : IZodMessageFormatter +{ + /// + /// Gets the shared instance. + /// + public static DefaultZodMessageFormatter Instance { get; } = new(); + + /// + public string Format(in ValidationError validationError, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture); + + return + validationError.MessageFormat is { Length: > 0 } format + && ZodMessageFormat.TryFormatComplete(format, validationError, out var message, culture) + ? message + : validationError.Message ?? string.Empty; + } +} diff --git a/src/src/ZodSharp/Core/IZodRule.cs b/src/src/ZodSharp/Core/IZodRule.cs index d98b9631..7f42e9dc 100644 --- a/src/src/ZodSharp/Core/IZodRule.cs +++ b/src/src/ZodSharp/Core/IZodRule.cs @@ -18,6 +18,12 @@ namespace ZodSharp.Core; /// because the value would be supplied by the generated validation and then never read. See /// for the attribute-side counterpart. /// +/// +/// The structured members (, , , +/// , ) are copied onto the reported +/// by , so runtime schemas and generated validators +/// report the same structure for the same rule. +/// /// public interface IZodRule { @@ -34,4 +40,32 @@ public interface IZodRule /// to fall back to the attribute-mapped origin. /// string? Origin { get; } + + /// + /// Gets the Zod issue code reported as (see + /// ), or when the rule has no Zod equivalent. + /// + string? Category => null; + + /// + /// Gets the lower bound the rule enforces, if any. + /// + ErrorBound? Lower => null; + + /// + /// Gets the upper bound the rule enforces, if any. + /// + ErrorBound? Upper => null; + + /// + /// Gets the unformatted message template, with named placeholders, that a host may re-format (for example + /// to localize it); when the message is not template-based. + /// + string? MessageFormat => null; + + /// + /// Gets the named values reported in , used to format + /// . + /// + IReadOnlyDictionary? Parameters => null; } diff --git a/src/src/ZodSharp/Core/IZodSchemaFactory.cs b/src/src/ZodSharp/Core/IZodSchemaFactory.cs index b8109447..1e1860fb 100644 --- a/src/src/ZodSharp/Core/IZodSchemaFactory.cs +++ b/src/src/ZodSharp/Core/IZodSchemaFactory.cs @@ -25,4 +25,18 @@ public interface IZodSchemaFactory /// True if a validator is registered for . bool IsRegistered(); + + /// + /// Resolves a type-erased view of the validator registered for , for hosts that only + /// know the type at runtime. Registrations made through , or with a validator that + /// implements itself (generated validators do), are resolvable. + /// + /// The validated type. + /// The validator, when one is registered. + /// when a validator is registered. + bool TryGetUntypedValidator(Type type, out IZodUntypedValidator? validator) + { + validator = null; + return false; + } } diff --git a/src/src/ZodSharp/Core/IZodSchemaValidator.cs b/src/src/ZodSharp/Core/IZodSchemaValidator.cs index 0eb19dac..58cd6b10 100644 --- a/src/src/ZodSharp/Core/IZodSchemaValidator.cs +++ b/src/src/ZodSharp/Core/IZodSchemaValidator.cs @@ -32,4 +32,24 @@ public interface IZodSchemaValidator : IZodSchemaValidator, IZodSchema /// /// A task that completes with a validation result. new ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default); + + /// + /// Validates the input value asynchronously with access to the caller's services, so service-aware rules + /// (uniqueness checks, lookups) can run. Hosts pass the request or component service provider. + /// + /// The value to validate. + /// The caller's services, or when none are available. + /// A token that cancels the validation. + /// A task that completes with a validation result. + ValueTask> ValidateAsync( + T value, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => ValidateAsync(value, cancellationToken); + + /// + /// Gets whether some rules only run asynchronously, so the synchronous cannot give a + /// complete answer. Hosts that can only validate synchronously throw rather than silently skip them. + /// + bool RequiresAsyncValidation => false; } diff --git a/src/src/ZodSharp/Core/IZodUntypedValidator.cs b/src/src/ZodSharp/Core/IZodUntypedValidator.cs new file mode 100644 index 00000000..db5af8df --- /dev/null +++ b/src/src/ZodSharp/Core/IZodUntypedValidator.cs @@ -0,0 +1,158 @@ +using System.Collections.Immutable; + +namespace ZodSharp.Core; + +/// +/// A type-erased view of a registered validator, so hosts that only know a at runtime +/// (Microsoft.Extensions.Validation resolvers, Blazor, endpoint filters) can validate without +/// MakeGenericType or other reflection. Obtain one from +/// . +/// +public interface IZodUntypedValidator +{ + /// + /// Gets the validated type. + /// + Type ValidatedType { get; } + + /// + /// Gets whether some rules only run asynchronously. + /// + bool RequiresAsyncValidation { get; } + + /// + /// Validates synchronously. + /// + /// The value; must be assignable to or . + /// The errors; empty when valid. + ImmutableArray Validate(object? value); + + /// + /// Validates asynchronously. + /// + /// The value; must be assignable to or . + /// The caller's services, if any. + /// A token that cancels the validation. + /// The errors; empty when valid. + ValueTask> ValidateAsync( + object? value, + IServiceProvider? services, + CancellationToken cancellationToken + ); + + /// + /// Gets whether can be validated on its own. + /// + /// The C# member name. + /// when supported. + bool IsValidatableMember(string memberName); + + /// + /// Validates one member of asynchronously; see . + /// + /// The containing object. + /// The C# member name. + /// The caller's services, if any. + /// A token that cancels the validation. + /// The member's errors; empty when valid or unsupported. + ValueTask> ValidateMemberAsync( + object value, + string memberName, + IServiceProvider? services, + CancellationToken cancellationToken + ); + + /// + /// Resolves the object that owns the last member of ; see + /// . + /// + /// The root value. + /// The error path. + /// The owning object. + /// The owning member name. + /// when resolution is supported and the full path was followed. + bool TryResolveMember(object value, ZodPath path, out object? container, out string memberName); +} + +/// +/// Adapts an to , delegating member +/// validation and member resolution when the validator supports them. +/// +/// The validated type. +/// The typed validator. +public sealed class ZodUntypedValidator(IZodSchemaValidator validator) + : IZodUntypedValidator, + JsonSchema.IJsonSchemaInnerSchema +{ + /// + /// Gets the wrapped validator. + /// + public IZodSchemaValidator Inner { get; } = validator ?? throw new ArgumentNullException(nameof(validator)); + + // Lets the JSON Schema exporter see the validator this view adapts. + object JsonSchema.IJsonSchemaInnerSchema.InnerSchema => Inner; + + /// + public Type ValidatedType => typeof(T); + + /// + public bool RequiresAsyncValidation => Inner.RequiresAsyncValidation; + + /// + public ImmutableArray Validate(object? value) + { + var result = Inner.Validate(Cast(value)); + return result.IsSuccess ? [] : result.Errors; + } + + /// + public async ValueTask> ValidateAsync( + object? value, + IServiceProvider? services, + CancellationToken cancellationToken + ) + { + var pending = Inner.ValidateAsync(Cast(value), services, cancellationToken); + var result = pending.IsCompletedSuccessfully ? pending.Result : await pending.ConfigureAwait(false); + return result.IsSuccess ? [] : result.Errors; + } + + /// + public bool IsValidatableMember(string memberName) => + Inner is IZodMemberValidator members && members.IsValidatableMember(memberName); + + /// + public async ValueTask> ValidateMemberAsync( + object value, + string memberName, + IServiceProvider? services, + CancellationToken cancellationToken + ) + { + if (Inner is not IZodMemberValidator members) + return []; + + var pending = members.ValidateMemberAsync(Cast(value), memberName, services, cancellationToken); + var result = pending.IsCompletedSuccessfully ? pending.Result : await pending.ConfigureAwait(false); + return result.IsSuccess ? [] : result.Errors; + } + + /// + public bool TryResolveMember(object value, ZodPath path, out object? container, out string memberName) + { + if (Inner is IZodMemberResolver resolver) + return resolver.TryResolveMember(Cast(value), path, out container, out memberName); + + container = value; + memberName = string.Empty; + return false; + } + + static T Cast(object? value) => + value is T typed ? typed + : value is null ? default! + : throw new ArgumentException( + $"Value of type '{value.GetType().FullName}' cannot be validated as '{typeof(T).FullName}'.", + nameof(value) + ); +} diff --git a/src/src/ZodSharp/Core/ValidationError.cs b/src/src/ZodSharp/Core/ValidationError.cs index 0472d96f..f75afa0f 100644 --- a/src/src/ZodSharp/Core/ValidationError.cs +++ b/src/src/ZodSharp/Core/ValidationError.cs @@ -8,6 +8,9 @@ namespace ZodSharp.Core; /// public readonly record struct ValidationError { + readonly ErrorBound? _lower; + readonly ErrorBound? _upper; + /// /// The error code (e.g., "invalid_type", "too_small", "too_big") /// @@ -15,24 +18,41 @@ public readonly record struct ValidationError /// /// The optional category the error belongs to. A broad grouping that can span many specific - /// codes (e.g. code "invalid_tenant_id" and category "invalid_value"). + /// codes (e.g. code "invalid_tenant_id" and category "invalid_value"). Built-in errors use the Zod issue + /// code here (see ), so a client can treat ZodSharp and Zod issues alike. /// - public string? Category { get; } + public string? Category { get; init; } /// /// The error message /// - public string Message { get; } + public string Message { get; init; } /// - /// The path to the field that failed validation (e.g., ["user", "email"]) + /// The unformatted template that produced , using named placeholders resolved from + /// and the built-in fields (see ). Hosts use it to + /// re-format the message in the request culture; when the message was supplied + /// literally. /// - public ImmutableArray Path { get; } + public string? MessageFormat { get; init; } /// - /// The origin category for structured issues (e.g. "string", "array", "collection"). + /// The path to the field that failed validation (e.g., ["user", "email"]). Index segments are rendered as + /// their digits (["items", "0", "name"]), matching Zod. Prefer , which keeps the + /// segment kinds. /// - public string? Origin { get; } + public ImmutableArray Path => FullPath.LegacySegments; + + /// + /// The typed path to the field that failed validation. This is the authoritative location; render it with + /// rather than joining . + /// + public ZodPath FullPath { get; init; } + + /// + /// The origin category for structured issues: the Zod type of the failing value (e.g. "string", "number", "array", "record"). + /// + public string? Origin { get; init; } /// /// The inclusive minimum bound for size-based validation issues. @@ -49,11 +69,31 @@ public readonly record struct ValidationError /// public bool? Inclusive { get; } + /// + /// The lower bound of a size or range issue, of any comparable type and with its own inclusivity. Falls + /// back to when only an integer bound was supplied. + /// + public ErrorBound? Lower + { + get => _lower ?? (Minimum is { } minimum ? new ErrorBound(minimum, Inclusive ?? true) : null); + init => _lower = value; + } + + /// + /// The upper bound of a size or range issue, of any comparable type and with its own inclusivity. Falls + /// back to when only an integer bound was supplied. + /// + public ErrorBound? Upper + { + get => _upper ?? (Maximum is { } maximum ? new ErrorBound(maximum, Inclusive ?? true) : null); + init => _upper = value; + } + /// /// Additional error parameters. Typed container that also exposes the raw values through /// and IReadOnlyDictionary<string, object?>. /// - public ErrorTypeParameters? Parameters { get; } + public ErrorTypeParameters? Parameters { get; init; } /// /// Initializes a new instance of the ValidationError struct. @@ -78,17 +118,7 @@ public ValidationError( bool? inclusive = null, string? category = null ) - { - Code = code; - Category = category; - Message = message; - Path = path is null ? [] : ImmutableArray.Create(path); - Parameters = Wrap(parameters); - Origin = origin; - Minimum = minimum; - Maximum = maximum; - Inclusive = inclusive; - } + : this(code, message, ZodPath.FromLegacy(path), parameters, origin, minimum, maximum, inclusive, category) { } /// /// Creates a validation error using a precomputed immutable path. @@ -112,12 +142,36 @@ public static ValidationError Create( int? maximum = null, bool? inclusive = null, string? category = null - ) => new(code, message, path.IsDefault ? [] : path, parameters, origin, minimum, maximum, inclusive, category); + ) => new(code, message, ZodPath.FromLegacy(path), parameters, origin, minimum, maximum, inclusive, category); + + /// + /// Creates a validation error at a typed path. + /// + /// The error code. + /// The error message. + /// The typed path. + /// Additional error parameters. + /// The origin category for structured issues. + /// The inclusive minimum bound for structured size issues. + /// The inclusive maximum bound for structured size issues. + /// Whether the structured bound is inclusive. + /// The optional category the error belongs to. + public static ValidationError Create( + string code, + string message, + ZodPath path, + IReadOnlyDictionary? parameters = null, + string? origin = null, + int? minimum = null, + int? maximum = null, + bool? inclusive = null, + string? category = null + ) => new(code, message, path, parameters, origin, minimum, maximum, inclusive, category); ValidationError( string code, string message, - ImmutableArray path, + ZodPath path, IReadOnlyDictionary? parameters, string? origin, int? minimum, @@ -129,7 +183,7 @@ public static ValidationError Create( Code = code; Category = category; Message = message; - Path = path; + FullPath = path; Parameters = Wrap(parameters); Origin = origin; Minimum = minimum; @@ -137,6 +191,82 @@ public static ValidationError Create( Inclusive = inclusive; } + /// + /// Returns a copy of this error located under , keeping every other field. This + /// is the only supported way to re-path an error. + /// + /// The outer segment. + /// The re-pathed error. + public ValidationError WithPathPrefix(ZodPathSegment segment) => this with { FullPath = FullPath.Prepend(segment) }; + + /// + /// Returns a copy of this error located under , keeping every other field. + /// + /// The outer path. + /// The re-pathed error. + public ValidationError WithPathPrefix(ZodPath prefix) => + prefix.IsRoot ? this : this with { FullPath = FullPath.Prepend(prefix) }; + + /// + /// Returns a copy of this error with the supplied parameters, keeping every other field. + /// + /// The parameters. + /// The updated error. + public ValidationError WithParameters(IReadOnlyDictionary? parameters) => + this with + { + Parameters = Wrap(parameters), + }; + + /// + public bool Equals(ValidationError other) => + string.Equals(Code, other.Code, StringComparison.Ordinal) + && string.Equals(Category, other.Category, StringComparison.Ordinal) + && string.Equals(Message, other.Message, StringComparison.Ordinal) + && string.Equals(MessageFormat, other.MessageFormat, StringComparison.Ordinal) + && string.Equals(Origin, other.Origin, StringComparison.Ordinal) + && FullPath.Equals(other.FullPath) + && Minimum == other.Minimum + && Maximum == other.Maximum + && Inclusive == other.Inclusive + && Nullable.Equals(_lower, other._lower) + && Nullable.Equals(_upper, other._upper) + && ParametersEqual(Parameters, other.Parameters); + + /// + public override int GetHashCode() => + HashCode.Combine( + Code, + Category, + Message, + FullPath, + Origin, + HashCode.Combine(Minimum, Maximum, Inclusive, _lower, _upper), + Parameters?.Count ?? 0 + ); + + static bool ParametersEqual(ErrorTypeParameters? left, ErrorTypeParameters? right) + { + if (ReferenceEquals(left, right)) + return true; + + var leftCount = left?.Count ?? 0; + var rightCount = right?.Count ?? 0; + if (leftCount != rightCount) + return false; + + if (leftCount == 0) + return true; + + foreach (var pair in left!) + { + if (!right!.TryGetValue(pair.Key, out var value) || !Equals(pair.Value, value)) + return false; + } + + return true; + } + static ErrorTypeParameters? Wrap(IReadOnlyDictionary? parameters) => parameters is ErrorTypeParameters typed ? typed : parameters is null ? null diff --git a/src/src/ZodSharp/Core/ValidationResult.cs b/src/src/ZodSharp/Core/ValidationResult.cs index b320ae18..d3f248eb 100644 --- a/src/src/ZodSharp/Core/ValidationResult.cs +++ b/src/src/ZodSharp/Core/ValidationResult.cs @@ -45,7 +45,9 @@ public static ValidationResult Merge(ValidationResult lhs, ValidationResul { var isSuccess = lhs.IsSuccess && rhs.IsSuccess; var value = lhs.Value ?? rhs.Value ?? default; - var errors = lhs.Errors.AddRange(rhs.Errors); + // A default(ValidationResult) carries a default array; treat it as having no errors. + var lhsErrors = lhs.Errors.IsDefault ? [] : lhs.Errors; + var errors = rhs.Errors.IsDefaultOrEmpty ? lhsErrors : lhsErrors.AddRange(rhs.Errors); return new(isSuccess, value, errors); } diff --git a/src/src/ZodSharp/Core/ZodErrorOptions.cs b/src/src/ZodSharp/Core/ZodErrorOptions.cs new file mode 100644 index 00000000..2fbdfcaa --- /dev/null +++ b/src/src/ZodSharp/Core/ZodErrorOptions.cs @@ -0,0 +1,23 @@ +namespace ZodSharp.Core; + +/// +/// Process-wide options for the errors built-in schemas and rules report. +/// +public static class ZodErrorOptions +{ + /// + /// Gets or sets whether built-in errors carry the raw input in their + /// parameter. Defaults to : errors leave the process (ProblemDetails, logs, UI), and + /// the input may be a card number, a token or another secret, so by default received holds only the + /// input's type name (for example "string"). Built-in default messages never contain the input, + /// whatever this is set to. + /// + /// + /// Intended for local development and diagnostics only; do not enable it in production. + /// + public static bool IncludeReceivedValues + { + get => Volatile.Read(ref field); + set => Volatile.Write(ref field, value); + } +} diff --git a/src/src/ZodSharp/Core/ZodException.cs b/src/src/ZodSharp/Core/ZodException.cs index a1cf69dc..d1f890e5 100644 --- a/src/src/ZodSharp/Core/ZodException.cs +++ b/src/src/ZodSharp/Core/ZodException.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text; namespace ZodSharp.Core; @@ -17,11 +18,46 @@ public class ZodException : Exception /// /// The validation errors public ZodException(ImmutableArray errors) - : base($"Validation failed with {(errors.IsDefault ? 0 : errors.Length)} error(s)") + : base(BuildMessage(errors)) { Errors = errors.IsDefault ? [] : errors; } + const int MaxIssuesInMessage = 5; + + static string BuildMessage(ImmutableArray errors) + { + var count = errors.IsDefault ? 0 : errors.Length; + if (count == 0) + return "Validation failed with 0 error(s)"; + + StringBuilder builder = new(); + builder.Append("Validation failed with ").Append(count).Append(" error(s): "); + for (var i = 0; i < Math.Min(count, MaxIssuesInMessage); i++) + { + if (i > 0) + builder.Append("; "); + AppendIssue(builder, errors[i]); + } + + if (count > MaxIssuesInMessage) + builder.Append("; and ").Append(count - MaxIssuesInMessage).Append(" more"); + + return builder.ToString(); + } + + static void AppendIssue(StringBuilder builder, in ValidationError error) + { + var path = error.FullPath.ToDottedString(); + builder + .Append(path.Length == 0 ? "(root)" : path) + .Append(": ") + .Append(error.Message) + .Append(" (") + .Append(error.Code) + .Append(')'); + } + /// /// Initializes a new instance of the ZodException class. /// @@ -38,9 +74,14 @@ public override string ToString() if (Errors.IsDefaultOrEmpty) return base.ToString(); - var errorMessages = Errors.Select(static e => $"{string.Join(".", e.Path)}: {e.Message} ({e.Code})"); + StringBuilder builder = new(Message); + foreach (var error in Errors) + { + builder.Append('\n'); + AppendIssue(builder, error); + } - return $"{Message}\n{string.Join("\n", errorMessages)}"; + return builder.ToString(); } /// diff --git a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs new file mode 100644 index 00000000..d4eeb646 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs @@ -0,0 +1,770 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using DataAnnotationsResult = System.ComponentModel.DataAnnotations.ValidationResult; + +namespace ZodSharp.Core; + +/// +/// Identifies a member validated by generated code: the declaring type, the C# member name, the display +/// name used in messages and the canonical path. Generated schemas create one per validated member. +/// +/// +/// The display name is resolved on every read when it comes from a resource +/// ([Display(Name = ..., ResourceType = ...)]), so a message follows the current UI culture. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class ZodGeneratedMember +{ + readonly Func? _displayNameResolver; + + /// + /// Initializes a new instance. + /// + /// The full name of the declaring type. + /// The C# member name; empty for the type itself. + /// The display name, or the fallback when returns nothing. + /// The canonical path of the member. + /// Resolves a localized display name at runtime, when one is declared. + public ZodGeneratedMember( + string declaringType, + string member, + string displayName, + ZodPath path, + Func? displayNameResolver = null + ) + { + DeclaringType = declaringType ?? throw new ArgumentNullException(nameof(declaringType)); + Member = member ?? throw new ArgumentNullException(nameof(member)); + DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName)); + Path = path; + _displayNameResolver = displayNameResolver; + } + + /// Gets the full name of the declaring type. + public string DeclaringType { get; } + + /// Gets the C# member name; empty for the type itself. + public string Member { get; } + + /// Gets the canonical path of the member. + public ZodPath Path { get; } + + /// Gets the display name used in messages. + public string DisplayName + { + get + { + if (_displayNameResolver is null) + return field; + + var resolved = _displayNameResolver(); + return string.IsNullOrEmpty(resolved) ? field : resolved; + } + } +} + +/// +/// Helpers called by source-generated validators. Not intended to be called directly. +/// +/// +/// Every helper runs only on the failure path (or when walking a path after a failure), so the success path +/// of a generated validator stays allocation-free. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +[SuppressMessage( + "Design", + "CA1002:Do not expose generic lists", + Justification = "Generated validators collect errors in a lazily created List; these helpers operate on it directly to stay allocation-free on success." +)] +[SuppressMessage( + "Design", + "CA1045:Do not pass types by reference", + Justification = "Generated validators pass the lazily created error list by reference so the success path allocates nothing." +)] +public static class ZodGeneratedValidation +{ + /// + /// The code reported for a failed DataAnnotations input (IValidatableObject, + /// AsyncValidationAttribute) that carries no code of its own. + /// + public const string DataAnnotationsErrorCode = "validation_failed"; + + /// + /// Creates an error whose message is produced at runtime from , so the + /// error keeps the template () and the values that fill it + /// (). + /// + /// The error code. + /// + /// The message template. Named placeholders resolve from the parameters and the error's built-in fields; + /// positional placeholders are first renamed through . + /// + /// The error path. + /// The member that failed. + /// The structured origin. + /// The Zod issue category. + /// The lower bound of a size or range issue. + /// The upper bound of a size or range issue. + /// + /// The names positional placeholders bind to, in DataAnnotations argument order ({0} is always the + /// display name). + /// + /// Additional named values (rule arguments). + /// The error. + public static ValidationError Error( + string code, + string messageFormat, + ZodPath path, + ZodGeneratedMember member, + string? origin = null, + string? category = null, + ErrorBound? lower = null, + ErrorBound? upper = null, + string[]? ordinalNames = null, + KeyValuePair[]? values = null + ) + { + ArgumentNullException.ThrowIfNull(member); + + var named = ToNamedFormat(messageFormat ?? string.Empty, ordinalNames); + var parameters = CreateParameters(member, null, values); + var minimum = lower is { Value: int lowerValue } ? lowerValue : (int?)null; + var maximum = upper is { Value: int upperValue } ? upperValue : (int?)null; + var inclusive = lower?.Inclusive ?? upper?.Inclusive; + + var error = ValidationError.Create( + code, + string.Empty, + path, + parameters, + origin, + minimum, + maximum, + inclusive, + category + ) with + { + Lower = lower, + Upper = upper, + MessageFormat = named, + }; + + return error with + { + Message = ZodMessageFormat.Format(named, in error, System.Globalization.CultureInfo.InvariantCulture), + }; + } + + /// + /// Adds the member's parameters (displayName, member, declaringType) to an error built + /// elsewhere (a shared factory or ), and optionally + /// replaces its message with a template such as an attribute's ErrorMessage. + /// + /// The error. + /// The member that failed. + /// The category used when the error has none. + /// A message template that replaces the error's message. + /// The names positional placeholders in bind to. + /// The error. + public static ValidationError ForMember( + in ValidationError error, + ZodGeneratedMember member, + string? fallbackCategory = null, + string? messageFormat = null, + string[]? ordinalNames = null + ) + { + ArgumentNullException.ThrowIfNull(member); + + var result = error.WithParameters(CreateParameters(member, error.Parameters, null)) with + { + Category = error.Category ?? fallbackCategory, + }; + + if (messageFormat is null) + return result; + + var named = ToNamedFormat(messageFormat, ordinalNames); + result = result with { MessageFormat = named }; + return result with + { + Message = ZodMessageFormat.Format(named, in result, System.Globalization.CultureInfo.InvariantCulture), + }; + } + + /// + /// Renames positional placeholders ({0}, {1,5:N2}) to the named placeholders in + /// , so a DataAnnotations-style message becomes a named template a host can + /// re-format. Escaped braces, named placeholders and positions without a name are left as they are. Never + /// throws. + /// + /// The message format. + /// The name for each position. + /// The named template. + public static string ToNamedFormat(string format, string[]? ordinalNames) + { + if (string.IsNullOrEmpty(format) || ordinalNames is null || ordinalNames.Length == 0) + return format ?? string.Empty; + + if (format.IndexOf('{', StringComparison.Ordinal) < 0) + return format; + + StringBuilder builder = new(format.Length + 16); + var i = 0; + while (i < format.Length) + { + var c = format[i]; + if (c != '{') + { + builder.Append(c); + i++; + continue; + } + + if (i + 1 < format.Length && format[i + 1] == '{') + { + builder.Append("{{"); + i += 2; + continue; + } + + var start = i + 1; + var end = start; + while (end < format.Length && char.IsAsciiDigit(format[end])) + end++; + + if ( + end > start + && end < format.Length + && format[end] is '}' or ',' or ':' + && int.TryParse( + format.AsSpan(start, end - start), + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var position + ) + && position < ordinalNames.Length + ) + { + builder.Append('{').Append(ordinalNames[position]); + i = end; + continue; + } + + builder.Append(c); + i++; + } + + return builder.ToString(); + } + + /// + /// Adds to , creating the list on first use. + /// + /// The error list. + /// The error. + public static void Add(ref List? errors, in ValidationError error) => (errors ??= []).Add(error); + + /// + /// Adds the errors of a nested result under . + /// + /// The nested type. + /// The error list. + /// The nested result. + /// The path the nested errors are placed under. + public static void AddNested(ref List? errors, in ValidationResult nested, ZodPath prefix) + { + if (nested.IsSuccess || nested.Errors.IsDefaultOrEmpty) + return; + + errors ??= []; + foreach (var error in nested.Errors) + errors.Add(error.WithPathPrefix(prefix)); + } + + /// + /// Adds the errors of a nested scalar value object's result under , naming them after + /// : the displayName parameter becomes the owner's display name, and a message + /// produced from the error's is re-formatted with it. A scalar's own + /// member (Value) is an implementation detail, so "Field 'Value' is required" reads + /// "Field 'Email' is required". A message the template does not reproduce (a rule's own text, an attribute + /// message without a placeholder) is kept as it is. + /// + /// The nested type. + /// The error list. + /// The nested result. + /// The path the nested errors are placed under. + /// The member that holds the scalar. + public static void AddNested( + ref List? errors, + in ValidationResult nested, + ZodPath prefix, + ZodGeneratedMember owner + ) + { + ArgumentNullException.ThrowIfNull(owner); + + if (nested.IsSuccess || nested.Errors.IsDefaultOrEmpty) + return; + + errors ??= []; + var displayName = owner.DisplayName; + foreach (var error in nested.Errors) + errors.Add(NameAfterOwner(error, displayName).WithPathPrefix(prefix)); + } + + /// + /// Adds the errors of a nested scalar value object's result under , naming them after + /// , and returns the list; see + /// . + /// + /// The nested type. + /// The error list. + /// The nested result. + /// The path the nested errors are placed under. + /// The member that holds the scalar. + /// The error list. + public static List? WithNested( + List? errors, + ValidationResult nested, + ZodPath prefix, + ZodGeneratedMember owner + ) + { + AddNested(ref errors, nested, prefix, owner); + return errors; + } + + /// + /// Completes a nested TryResolveMember step so the resolved container is never a value type. A struct + /// member's errors resolve to the struct, and a boxed copy of it cannot own a field (Blazor's + /// FieldIdentifier rejects a value-type model), so they are attributed to the nearest reference-type + /// owner: and its member . + /// + /// What the nested step returned. + /// The object that holds the nested value. + /// The member of that holds the nested value. + /// The container the nested step resolved. + /// The member name the nested step resolved. + /// . + public static bool ResolveToReferenceOwner( + bool resolved, + object owner, + string ownerMember, + ref object? container, + ref string memberName + ) + { + if (container is not null && container.GetType().IsValueType) + { + container = owner; + memberName = ownerMember; + } + + return resolved; + } + + internal static ValidationError NameAfterOwner(in ValidationError error, string displayName) + { + var parameters = error.Parameters; + if ( + parameters is null + || !parameters.TryGetValue(ZodIssueParameters.DisplayName, out var current) + || current is not string currentName + || string.Equals(currentName, displayName, StringComparison.Ordinal) + ) + return error; + + Dictionary renamed = new(parameters.Count, StringComparer.Ordinal); + foreach (var pair in parameters) + renamed[pair.Key] = pair.Value; + renamed[ZodIssueParameters.DisplayName] = displayName; + + var result = error.WithParameters(renamed); + if ( + error.MessageFormat is { Length: > 0 } format + && string.Equals( + ZodMessageFormat.Format(format, in error, System.Globalization.CultureInfo.InvariantCulture), + error.Message, + StringComparison.Ordinal + ) + ) + { + result = result with + { + Message = ZodMessageFormat.Format(format, in result, System.Globalization.CultureInfo.InvariantCulture), + }; + } + + return result; + } + + /// + /// Adds the errors of a nested result under , returning the list. Used from async + /// methods, which cannot take parameters. + /// + /// The nested type. + /// The error list. + /// The nested result. + /// The path the nested errors are placed under. + /// The error list. + public static List? WithNested( + List? errors, + ValidationResult nested, + ZodPath prefix + ) + { + AddNested(ref errors, nested, prefix); + return errors; + } + + /// + /// Adds a DataAnnotations result. An object-level result (IValidatableObject, reported at the root) + /// is placed at each of its MemberNames, parsed as dotted paths; a member-level result is placed at + /// . + /// + /// The error list. + /// The DataAnnotations result; or success adds nothing. + /// The path of the member that produced the result, or the root for object-level results. + /// The member that failed. + public static void AddDataAnnotationsResult( + ref List? errors, + DataAnnotationsResult? result, + ZodPath path, + ZodGeneratedMember member + ) + { + if (result is null || ReferenceEquals(result, DataAnnotationsResult.Success)) + return; + + ArgumentNullException.ThrowIfNull(member); + + var message = string.IsNullOrEmpty(result.ErrorMessage) ? "Validation failed" : result.ErrorMessage; + if (path.IsRoot) + { + var added = false; + foreach (var memberName in result.MemberNames) + { + if (string.IsNullOrEmpty(memberName)) + continue; + + Add(ref errors, CreateDataAnnotationsError(message, ZodPath.ParseDotted(memberName), member)); + added = true; + } + + if (added) + return; + } + + Add(ref errors, CreateDataAnnotationsError(message, path, member)); + } + + /// + /// Adds a DataAnnotations result, returning the list; see + /// . + /// + /// The error list. + /// The DataAnnotations result. + /// The member path, or the root. + /// The member that failed. + /// The error list. + public static List? WithDataAnnotationsResult( + List? errors, + DataAnnotationsResult? result, + ZodPath path, + ZodGeneratedMember member + ) + { + AddDataAnnotationsResult(ref errors, result, path, member); + return errors; + } + + /// + /// Creates the DataAnnotations ValidationContext handed to IValidatableObject, + /// IAsyncValidatableObject and AsyncValidationAttribute inputs. The display name is set + /// explicitly, so the context never reflects over the instance to find one (trim-safe). + /// + /// The object being validated. + /// The display name of the object or member. + /// The caller's services, if any. + /// The member being validated, or for the object itself. + /// The context. + public static System.ComponentModel.DataAnnotations.ValidationContext CreateValidationContext( + object instance, + string displayName, + IServiceProvider? services, + string? memberName = null + ) + { + System.ComponentModel.DataAnnotations.ValidationContext context = new(instance, displayName, services, null); + if (memberName is not null) + context.MemberName = memberName; + + return context; + } + + /// + /// Merges a custom validation result into the error list. + /// + /// The validated type. + /// The error list. + /// The custom result. + /// The error list. + public static List? WithResult(List? errors, ValidationResult result) + { + if (result.IsSuccess || result.Errors.IsDefaultOrEmpty) + return errors; + + errors ??= []; + errors.AddRange(result.Errors); + return errors; + } + + /// + /// Keeps only the errors whose path starts with . + /// + /// The validated type. + /// The error list. + /// The result to filter. + /// The member name. + /// The error list. + public static List? WithMemberErrors( + List? errors, + ValidationResult result, + string member + ) + { + if (result.IsSuccess || result.Errors.IsDefaultOrEmpty) + return errors; + + foreach (var error in result.Errors) + { + var path = error.FullPath; + if ( + !path.IsRoot + && path[0].Kind == ZodPathSegmentKind.Member + && string.Equals(path[0].Name, member, StringComparison.Ordinal) + ) + { + (errors ??= []).Add(error); + } + } + + return errors; + } + + /// + /// Builds the final result from the collected errors. + /// + /// The validated type. + /// The validated value. + /// The collected errors. + /// The result. + public static ValidationResult ToResult(T value, List? errors) => + errors is null ? ValidationResult.Success(value) : ValidationResult.Failure(errors); + + /// + /// Projects a pending result onto its errors (empty when valid), completing synchronously when the + /// validation did. + /// + /// The validated type. + /// The pending result. + /// The errors. + public static ValueTask> ToErrorsAsync(ValueTask> pending) + { + return pending.IsCompletedSuccessfully ? new(ToErrors(pending.Result)) : AwaitErrors(pending); + + static async ValueTask> AwaitErrors(ValueTask> pending) => + ToErrors(await pending.ConfigureAwait(false)); + } + + static ImmutableArray ToErrors(in ValidationResult result) => + result.IsSuccess || result.Errors.IsDefault ? [] : result.Errors; + + /// + /// Resolves the element of addressed by an index segment, without + /// reflection. Lists and arrays are indexed directly; other sequences are enumerated (failure path only). + /// + /// The element type. + /// The collection. + /// The index segment. + /// The element. + /// when the element exists. + public static bool TryGetElement( + IEnumerable? collection, + ZodPathSegment segment, + [MaybeNullWhen(false)] out TElement element + ) + { + element = default; + if (collection is null || segment.Kind != ZodPathSegmentKind.Index) + return false; + + var index = segment.Index; + switch (collection) + { + case IList list: + if (index >= list.Count) + return false; + element = list[index]; + return true; + case IReadOnlyList readOnlyList: + if (index >= readOnlyList.Count) + return false; + element = readOnlyList[index]; + return true; + default: + var position = 0; + foreach (var item in collection) + { + if (position++ == index) + { + element = item; + return true; + } + } + + return false; + } + } + + /// + /// Resolves the value of the map entry addressed by a key segment, comparing the key's invariant + /// rendering (the same rendering records). + /// + /// The key type. + /// The value type. + /// The map. + /// The key segment. + /// The entry value. + /// when the entry exists. + public static bool TryGetEntryValue( + IEnumerable>? map, + ZodPathSegment segment, + [MaybeNullWhen(false)] out TValue value + ) + { + value = default; + if (map is null || segment.Kind != ZodPathSegmentKind.Key) + return false; + + foreach (var entry in map) + { + if (string.Equals(ZodPathSegment.FormatKey(entry.Key), segment.Name, StringComparison.Ordinal)) + { + value = entry.Value; + return true; + } + } + + return false; + } + + /// + /// Renames the member segments of from onwards with + /// , appending them to . Used by generated + /// JSON-name mapping once a member has no generated schema to delegate to. + /// + /// The path. + /// The first segment to append. + /// The naming policy, or to keep names. + /// The segments being built. + public static void AppendRemaining( + ZodPath path, + int offset, + Func? namingPolicy, + ImmutableArray.Builder segments + ) + { + ArgumentNullException.ThrowIfNull(segments); + + for (var i = offset; i < path.Length; i++) + { + var segment = path[i]; + segments.Add( + segment.Kind == ZodPathSegmentKind.Member && namingPolicy is not null + ? ZodPathSegment.Member(namingPolicy(segment.Name!)) + : segment + ); + } + } + + /// + /// Renders an error as an options validation failure: {path}: {message} ({code}), or + /// {message} ({code}) for an error at the root. + /// + /// The error. + /// The failure text. + public static string FormatOptionsFailure(in ValidationError error) + { + var path = error.FullPath.ToDottedString(); + return path.Length == 0 ? $"{error.Message} ({error.Code})" : $"{path}: {error.Message} ({error.Code})"; + } + + /// + /// Throws because has rules that only run asynchronously. + /// + /// The validated type. + /// Always. + [DoesNotReturn] + public static void ThrowAsyncValidationRequired(Type type) => + throw new InvalidOperationException( + $"'{type?.FullName}' has validation rules that only run asynchronously (an AsyncValidationAttribute or IAsyncValidatableObject). Call ValidateAsync instead of Validate." + ); + + /// + /// Throws because the service-aware validation of was called without services. + /// + /// The validated type. + /// Always. + [DoesNotReturn] + public static void ThrowServicesRequired(Type type) => + throw new InvalidOperationException( + $"'{type?.FullName}' has a service-aware validation method that needs an IServiceProvider. Call ValidateAsync with the caller's services." + ); + + static ValidationError CreateDataAnnotationsError(string message, ZodPath path, ZodGeneratedMember member) + { + var error = ValidationError.Create( + DataAnnotationsErrorCode, + message, + path, + CreateParameters(member, null, null), + category: ZodIssueCategories.Custom + ); + return error; + } + + static Dictionary CreateParameters( + ZodGeneratedMember member, + ErrorTypeParameters? existing, + KeyValuePair[]? values + ) + { + Dictionary parameters = new( + 3 + (existing?.Count ?? 0) + (values?.Length ?? 0), + StringComparer.Ordinal + ); + + if (existing is not null) + { + foreach (var pair in existing) + parameters[pair.Key] = pair.Value; + } + + if (values is not null) + { + foreach (var pair in values) + parameters[pair.Key] = pair.Value; + } + + parameters[ZodIssueParameters.DisplayName] = member.DisplayName; + parameters[ZodIssueParameters.DeclaringType] = member.DeclaringType; + if (member.Member.Length > 0) + parameters[ZodIssueParameters.Member] = member.Member; + + return parameters; + } +} diff --git a/src/src/ZodSharp/Core/ZodIssueCategories.cs b/src/src/ZodSharp/Core/ZodIssueCategories.cs new file mode 100644 index 00000000..92735e62 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodIssueCategories.cs @@ -0,0 +1,105 @@ +namespace ZodSharp.Core; + +/// +/// The Zod (v4) issue codes, used as the of every built-in error so a +/// client can handle ZodSharp and Zod issues the same way, while keeps +/// ZodSharp's more specific identity. +/// +public static class ZodIssueCategories +{ + /// The value has the wrong type (including a missing or null value). + public const string InvalidType = "invalid_type"; + + /// The value is below a size or range bound. + public const string TooSmall = "too_small"; + + /// The value is above a size or range bound. + public const string TooBig = "too_big"; + + /// A string does not match a format (email, uuid, regex, prefix, …). + public const string InvalidFormat = "invalid_format"; + + /// A number is not a multiple of the required step. + public const string NotMultipleOf = "not_multiple_of"; + + /// An object contains keys its schema does not declare. + public const string UnrecognizedKeys = "unrecognized_keys"; + + /// No option of a union matched. + public const string InvalidUnion = "invalid_union"; + + /// A record key is invalid. + public const string InvalidKey = "invalid_key"; + + /// A map or set element is invalid. + public const string InvalidElement = "invalid_element"; + + /// The value is not one of the allowed values (enums, literals, allowed/denied lists). + public const string InvalidValue = "invalid_value"; + + /// A refinement or custom rule failed. + public const string Custom = "custom"; +} + +/// +/// The well-known names used by built-in errors. +/// +public static class ZodIssueParameters +{ + /// + /// The per-option issues of a failed union, as + /// ImmutableArray<ImmutableArray<ValidationError>>, with paths relative to the union. + /// + public const string UnionErrors = "unionErrors"; + + /// The allowed values or union options. + public const string Options = "options"; + + /// The received value or, by default, only its type name. + public const string Received = "received"; + + /// The expected type name. + public const string Expected = "expected"; + + /// The unrecognized keys of an object. + public const string Keys = "keys"; + + /// The discriminator property of a discriminated union. + public const string Discriminator = "discriminator"; + + /// The display name of the member that failed. + public const string DisplayName = "displayName"; + + /// The full name of the type that declares the member that failed. + public const string DeclaringType = "declaringType"; + + /// The member name that failed. + public const string Member = "member"; + + /// The count (length, number of elements) that failed a size bound. + public const string Count = "count"; + + /// The string format a format rule enforces (Zod's format: email, uuid, …). + public const string Format = "format"; + + /// The divisor of a issue. + public const string Divisor = "divisor"; + + /// The regular expression a string must match. + public const string Pattern = "pattern"; + + /// The prefix a string must start with. + public const string Prefix = "prefix"; + + /// The suffix a string must end with. + public const string Suffix = "suffix"; + + /// The substring a string must contain. + public const string Includes = "includes"; + + /// The required version (for example of a UUID), as an integer. + public const string Version = "version"; + + /// A single unrecognized key (per-key issues). + public const string Key = "key"; +} diff --git a/src/src/ZodSharp/Core/ZodIssueCodes.cs b/src/src/ZodSharp/Core/ZodIssueCodes.cs new file mode 100644 index 00000000..8635f9c6 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodIssueCodes.cs @@ -0,0 +1,52 @@ +namespace ZodSharp.Core; + +/// +/// The values reported by the built-in schemas. The same failure reports the +/// same code whichever producer reports it (runtime schema, generated validator, JSON Schema import); the +/// matching Zod issue code is reported as (see +/// ). Rules declare their own codes as ErrorCode constants. +/// +public static class ZodIssueCodes +{ + /// The value has the wrong type, or is where a value is required. + public const string InvalidType = "invalid_type"; + + /// A required field (or member) is missing. Category . + public const string MissingField = "missing_field"; + + /// A refinement (Refine, SuperRefine, a custom validation method) failed. + public const string RefinementFailed = "refinement_failed"; + + /// No option of a union matched. + public const string InvalidUnion = "invalid_union"; + + /// The discriminator property of a discriminated union is absent. + public const string MissingDiscriminator = "missing_discriminator"; + + /// The discriminator value does not select any option of a discriminated union. + public const string InvalidDiscriminator = "invalid_discriminator"; + + /// The value is not one of an enum's allowed values. + public const string InvalidEnumValue = "invalid_enum_value"; + + /// The value does not equal a literal. + public const string InvalidLiteral = "invalid_literal"; + + /// A tuple has the wrong number of elements. + public const string InvalidTupleLength = "invalid_tuple_length"; + + /// An object contains keys its schema does not declare (one aggregated issue). + public const string UnrecognizedKeys = "unrecognized_keys"; + + /// An object contains a key its schema does not declare (one issue per key, opt-in compatibility mode). + public const string UnrecognizedKey = "unrecognized_key"; + + /// A size or range bound was not reached. + public const string TooSmall = "too_small"; + + /// A size or range bound was exceeded. + public const string TooBig = "too_big"; + + /// A transform threw. + public const string TransformError = "transform_error"; +} diff --git a/src/src/ZodSharp/Core/ZodIssues.cs b/src/src/ZodSharp/Core/ZodIssues.cs new file mode 100644 index 00000000..8942b265 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodIssues.cs @@ -0,0 +1,228 @@ +using System.Collections.Immutable; +using System.Globalization; + +namespace ZodSharp.Core; + +/// +/// Builds the errors the built-in schemas report for structural failures (null and wrong-type values, missing +/// fields, refinements, unions, array sizes), with the code, Zod category, parameters, message template and +/// formatted message every producer shares. Generated validators and the JSON integrations use these too, so the +/// same failure produces an identical whichever producer reports it. +/// +public static class ZodIssues +{ + /// + /// Creates the error for a value. + /// + /// The expected Zod type name (see ). + /// The error path. + /// The optional origin. + /// The error: "Expected {expected}, received null". + public static ValidationError NullValue(string expected, ZodPath path = default, string? origin = null) => + Formatted( + ZodIssueCodes.InvalidType, + ZodIssueCategories.InvalidType, + ZodMessages.NullValue, + path, + origin, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = expected, + [ZodIssueParameters.Received] = ZodTypeNames.Null, + } + ); + + /// + /// Creates the error for a value of the wrong type. Only the received + /// value's Zod type name is reported, never the value. + /// + /// The expected Zod type name (see ). + /// The received value. + /// The error path. + /// The optional origin. + /// The error: "Expected {expected}, received {received}". + public static ValidationError InvalidType( + string expected, + object? received, + ZodPath path = default, + string? origin = null + ) => + received is null + ? NullValue(expected, path, origin) + : Formatted( + ZodIssueCodes.InvalidType, + ZodIssueCategories.InvalidType, + ZodMessages.InvalidType, + path, + origin, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = expected, + [ZodIssueParameters.Received] = ZodTypeNames.OfValue(received), + } + ); + + /// + /// Creates the error for an absent required field. + /// + /// The field's display name (the key or member name when there is no other). + /// The error path (normally the field's own path). + /// The error: "{displayName} is required". + public static ValidationError MissingField(string displayName, ZodPath path) => + Formatted( + ZodIssueCodes.MissingField, + ZodIssueCategories.InvalidType, + ZodMessages.Required, + path, + null, + new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.DisplayName] = displayName } + ); + + /// + /// Creates the error for a failed refinement. + /// + /// The refinement's message; uses . + /// The error path. + /// The error, with category . + public static ValidationError RefinementFailed(string? message = null, ZodPath path = default) => + ValidationError.Create( + ZodIssueCodes.RefinementFailed, + message ?? ZodMessages.RefinementFailed, + path, + category: ZodIssueCategories.Custom + ) with + { + MessageFormat = message is null ? ZodMessages.RefinementFailed : null, + }; + + /// + /// Creates the error carrying each option's issues. + /// + /// The issues of each option, in declaration order, with paths relative to the union. + /// The option type names shown in the message, or for none. + /// The error path. + /// The error, with the per-option issues under . + public static ValidationError InvalidUnion( + ImmutableArray> unionErrors, + IReadOnlyList? options = null, + ZodPath path = default + ) + { + Dictionary parameters = new(StringComparer.Ordinal) + { + [ZodIssueParameters.UnionErrors] = unionErrors.IsDefault ? [] : unionErrors, + }; + + if (options is { Count: > 0 }) + parameters[ZodIssueParameters.Options] = ImmutableArray.CreateRange(options); + + return Formatted( + ZodIssueCodes.InvalidUnion, + ZodIssueCategories.InvalidUnion, + options is { Count: > 0 } ? ZodMessages.InvalidUnionOf : ZodMessages.InvalidUnion, + path, + null, + parameters + ); + } + + /// + /// Creates the size error for a collection of elements, or + /// when the count satisfies both bounds. Equal bounds report the exact-length message. + /// + /// The element count. + /// The inclusive minimum, if any. + /// The inclusive maximum, if any. + /// A custom message for a count below . + /// A custom message for a count above . + /// The error path. + /// The origin; "array" by default. + /// The error, or . + public static ValidationError? CollectionSize( + int count, + int? minimum, + int? maximum, + string? tooSmallMessage = null, + string? tooBigMessage = null, + ZodPath path = default, + string origin = "array" + ) + { + var tooSmall = minimum is { } min && count < min; + var tooBig = !tooSmall && maximum is { } max && count > max; + if (!tooSmall && !tooBig) + return null; + + var exact = minimum is not null && minimum == maximum; + var template = + exact ? ZodMessages.ArrayExactLength + : tooSmall ? ZodMessages.ArrayTooSmall + : ZodMessages.ArrayTooBig; + // A custom message is formatted with the same never-throw engine, so it may use the same placeholders. + var format = (tooSmall ? tooSmallMessage : tooBigMessage) ?? template; + + var error = ValidationError.Create( + tooSmall ? ZodIssueCodes.TooSmall : ZodIssueCodes.TooBig, + string.Empty, + path, + new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.Count] = count }, + origin, + minimum, + maximum, + inclusive: true, + category: tooSmall ? ZodIssueCategories.TooSmall : ZodIssueCategories.TooBig + ) with + { + MessageFormat = format, + }; + + return error with + { + Message = ZodMessageFormat.Format(format, error, CultureInfo.InvariantCulture), + }; + } + + /// + /// Builds an error whose message is formatted (invariantly) against the error's + /// own parameters and fields, reporting the template as . + /// + /// The error code. + /// The Zod issue code. + /// The message template. + /// The error path. + /// The origin. + /// The parameters. + /// The error. + public static ValidationError Formatted( + string code, + string? category, + string template, + ZodPath path, + string? origin, + IReadOnlyDictionary? parameters + ) + { + var error = ValidationError.Create(code, string.Empty, path, parameters, origin, category: category) with + { + MessageFormat = template, + }; + + return error with + { + Message = ZodMessageFormat.Format(template, error, CultureInfo.InvariantCulture), + }; + } + + /// + /// Gets the per-option issues of an error (see + /// ), or an empty array when the error carries none. + /// + /// The error. + /// The issues of each option, with paths relative to the union. + public static ImmutableArray> GetUnionErrors(this ValidationError error) => + error.Parameters is { } parameters + && parameters.TryGetValue(ZodIssueParameters.UnionErrors, out var value) + && value is ImmutableArray> unionErrors + ? unionErrors + : []; +} diff --git a/src/src/ZodSharp/Core/ZodJsonExceptions.cs b/src/src/ZodSharp/Core/ZodJsonExceptions.cs new file mode 100644 index 00000000..b513ac5a --- /dev/null +++ b/src/src/ZodSharp/Core/ZodJsonExceptions.cs @@ -0,0 +1,257 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace ZodSharp.Core; + +/// +/// Recovers the ZodSharp errors of a value that failed validation while System.Text.Json was reading it, located +/// under the member of the enclosing document that held it. +/// +/// +/// +/// A converter cannot know where in the document it is reading, so a thrown while +/// creating a value object carries only the value object's own path (Value). A converter that wraps it in a +/// (new JsonException(message, zodException)) lets the serializer record that +/// location in ($.customerEmail); these helpers join the two, so the error +/// reads CustomerEmail.Value, exactly as validating the deserialized model would report it. +/// +/// +/// The serializer's path uses JSON names. Given the root contract, every member segment is mapped back to its member +/// name through the contract's metadata (no reflection beyond what the contract already holds), collection indexes +/// are kept and dictionary entries become key segments. Without a root contract, or past a point the contract cannot +/// describe, the JSON names are kept. +/// +/// +/// An error located at the value object's own member (one member segment, displayed under that member's name) is +/// named after the member that holds the value object, as nested validation names a scalar value object's errors: +/// "Field 'Value' ..." reads "Field 'CustomerEmail' ...". +/// +/// +public static class ZodJsonExceptions +{ + const int MaxDepth = 16; + + /// + /// Finds a in or its inner exceptions whose own inner + /// exceptions contain a , and returns that exception's errors located under the + /// . + /// + /// The exception, for example a BadHttpRequestException from body binding. + /// + /// The contract of the deserialized root type, used to map JSON names back to member names; when + /// , the JSON names are kept. + /// + /// The re-rooted errors. + /// when such a chain was found. + public static bool TryGetErrors( + Exception exception, + JsonTypeInfo? rootTypeInfo, + out ImmutableArray errors + ) + { + ArgumentNullException.ThrowIfNull(exception); + + if (!TryFind(exception, out var jsonPath, out var zodException)) + { + errors = default; + return false; + } + + errors = Reroot(zodException.Errors, ZodPath.ParseJsonPath(jsonPath), rootTypeInfo); + return true; + } + + /// + /// Like , but returns a carrying the re-rooted errors, for + /// code that handles a bare already. + /// + /// The exception. + /// The contract of the deserialized root type, or . + /// The exception carrying the re-rooted errors. + /// when such a chain was found. + public static bool TryReroot( + Exception exception, + JsonTypeInfo? rootTypeInfo, + [NotNullWhen(true)] out ZodException? zodException + ) + { + zodException = TryGetErrors(exception, rootTypeInfo, out var errors) ? new ZodException(errors) : null; + return zodException is not null; + } + + /// + /// Locates , whose paths are relative to a value read at , + /// under that location. Every other field of each error (code, category, origin, bounds, parameters, union + /// errors) is kept. + /// + /// The errors, relative to the value. + /// Where the value was read, in JSON names (see ). + /// The contract of the deserialized root type, or . + /// The re-rooted errors. + public static ImmutableArray Reroot( + ImmutableArray errors, + ZodPath jsonPath, + JsonTypeInfo? rootTypeInfo + ) + { + if (errors.IsDefaultOrEmpty) + return []; + + var prefix = ToMemberPath(jsonPath, rootTypeInfo); + if (prefix.IsRoot) + return errors; + + var owner = LastMemberName(prefix); + var builder = ImmutableArray.CreateBuilder(errors.Length); + foreach (var error in errors) + { + var named = + owner is not null && IsAtOwnMember(error) ? ZodGeneratedValidation.NameAfterOwner(error, owner) : error; + builder.Add(named.WithPathPrefix(prefix)); + } + + return builder.MoveToImmutable(); + } + + /// + /// Maps the member segments of a JSON-named path to member names through . + /// Collection indexes are kept, and the entries of a dictionary become key segments. A segment the contract + /// cannot describe, and every segment after it, is kept as it is. + /// + /// The JSON-named path. + /// The contract of the root type, or to keep the JSON names. + /// The member-named path. + public static ZodPath ToMemberPath(ZodPath jsonPath, JsonTypeInfo? rootTypeInfo) + { + if (rootTypeInfo is null || jsonPath.IsRoot) + return jsonPath; + + var builder = ImmutableArray.CreateBuilder(jsonPath.Length); + var current = rootTypeInfo; + foreach (var segment in jsonPath.Segments) + builder.Add(current is null ? segment : Step(ref current, segment)); + + return ZodPath.From(builder.MoveToImmutable()); + } + + static bool TryFind(Exception? exception, out string? jsonPath, [NotNullWhen(true)] out ZodException? zodException) + { + // The JsonException nearest the ZodException that the serializer gave a path to: an outer wrapper (such as a + // BadHttpRequestException) adds no location, and a converter's own JsonException is the one the serializer fills. + JsonException? carrier = null; + var current = exception; + for (var depth = 0; current is not null && depth < MaxDepth; depth++, current = current.InnerException) + { + if (current is ZodException found) + { + if (carrier is null) + break; + + jsonPath = carrier.Path; + zodException = found; + return true; + } + + if (current is JsonException json && (carrier?.Path is null || json.Path is not null)) + carrier = json; + } + + jsonPath = null; + zodException = null; + return false; + } + + static ZodPathSegment Step(ref JsonTypeInfo? current, ZodPathSegment segment) + { + var info = current!; + current = null; + + if (info.Kind == JsonTypeInfoKind.Object && segment.Kind == ZodPathSegmentKind.Member) + { + var comparison = info.Options.PropertyNameCaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + foreach (var property in info.Properties) + { + if (!string.Equals(property.Name, segment.Name, comparison)) + continue; + + current = Resolve(info.Options, property.PropertyType); + // The contract's attribute provider is the property or field it binds (source-generated contracts + // record it too), so its name is the member name; a custom contract may not set it. + return ZodPathSegment.Member( + property.AttributeProvider is MemberInfo member ? member.Name : property.Name + ); + } + + return segment; + } + + if (info.Kind == JsonTypeInfoKind.Enumerable && segment.Kind == ZodPathSegmentKind.Index) + { + current = Resolve(info.Options, info.ElementType); + return segment; + } + + if ( + info.Kind == JsonTypeInfoKind.Dictionary + && segment.Kind is ZodPathSegmentKind.Member or ZodPathSegmentKind.Key + ) + { + current = Resolve(info.Options, info.ElementType); + return ZodPathSegment.Key(segment.Name ?? string.Empty); + } + + // A scalar (or a value with its own converter) has no members the contract can describe. + return segment; + } + + static JsonTypeInfo? Resolve(JsonSerializerOptions options, Type? type) + { + if (type is null) + return null; + + try + { + return options.TryGetTypeInfo(type, out var typeInfo) ? typeInfo : null; + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or ArgumentException) + { + // The resolver cannot describe the member type; the rest of the path keeps its JSON names. + return null; + } + } + + static string? LastMemberName(ZodPath path) + { + for (var i = path.Length - 1; i >= 0; i--) + { + if (path[i].Kind == ZodPathSegmentKind.Member) + return path[i].Name; + } + + return null; + } + + /// + /// Whether is located at the value object itself or at its own single member and is + /// displayed under that member's name - the shape of a scalar value object's error (Value, "Field 'Value' + /// ..."). A deeper error, or one with its own display name, keeps it. + /// + static bool IsAtOwnMember(in ValidationError error) + { + var path = error.FullPath; + if (path.IsRoot) + return true; + + return path.Length == 1 + && path[0].Kind == ZodPathSegmentKind.Member + && error.Parameters is { } parameters + && parameters.TryGetValue(ZodIssueParameters.DisplayName, out var displayName) + && displayName is string name + && string.Equals(name, path[0].Name, StringComparison.Ordinal); + } +} diff --git a/src/src/ZodSharp/Core/ZodMessageFormat.cs b/src/src/ZodSharp/Core/ZodMessageFormat.cs new file mode 100644 index 00000000..5a317f4e --- /dev/null +++ b/src/src/ZodSharp/Core/ZodMessageFormat.cs @@ -0,0 +1,293 @@ +using System.Globalization; +using System.Text; + +namespace ZodSharp.Core; + +/// +/// Formats validation messages. Unlike it never +/// throws: a message is user-facing text, and a stray brace in a custom message must not turn a validation +/// failure into an exception. +/// +/// +/// +/// Placeholders are {name}, {name:format} or positional {0}. Named placeholders resolve +/// from the supplied parameters first and then, when formatting an error, from its built-in fields: +/// displayName, member, path, code, category, origin, minimum, +/// maximum and inclusive. displayName falls back to the last member of the path, then to +/// "Value", so a template such as resolves for an error at the root. +/// {{ and }} are literal braces. +/// +/// +/// A placeholder that cannot be resolved, and any unbalanced brace, is written out literally. +/// +/// +public static class ZodMessageFormat +{ + /// + /// Formats with named parameters. + /// + /// The message format. + /// The named values. + /// The format provider; the invariant culture when . + /// The formatted message. + public static string Format( + string format, + IReadOnlyDictionary? parameters, + IFormatProvider? provider = null + ) => FormatCore(format, default, parameters, null, provider, out _); + + /// + /// Formats with positional and named values. + /// + /// The message format. + /// The values for {0}, {1}, …. + /// The named values. + /// The format provider; the invariant culture when . + /// The formatted message. + public static string Format( + string format, + ReadOnlySpan positional, + IReadOnlyDictionary? parameters = null, + IFormatProvider? provider = null + ) => FormatCore(format, positional, parameters, null, provider, out _); + + /// + /// Formats against an error's parameters and built-in fields. + /// + /// The message format. + /// The error supplying values. + /// The format provider; the invariant culture when . + /// The formatted message. + public static string Format(string format, in ValidationError error, IFormatProvider? provider = null) + { + var copy = error; + return FormatCore(format, default, error.Parameters, name => TryGetBuiltIn(copy, name), provider, out _); + } + + /// + /// Formats against an error, succeeding only when every placeholder resolves. Use + /// this when replacing an already-good message with a template, so a template is never shown with its + /// placeholders unfilled. + /// + /// The message format. + /// The error supplying values. + /// The formatted message when every placeholder resolved. + /// The format provider; the invariant culture when . + /// when every placeholder resolved. + public static bool TryFormatComplete( + string format, + in ValidationError error, + out string message, + IFormatProvider? provider = null + ) + { + var copy = error; + message = FormatCore( + format, + default, + error.Parameters, + name => TryGetBuiltIn(copy, name), + provider, + out var allResolved + ); + return allResolved; + } + + static (bool Found, object? Value) TryGetBuiltIn(ValidationError error, string name) => + name switch + { + "path" => (true, error.FullPath.ToDottedString()), + "code" => (true, error.Code), + "category" => (error.Category is not null, error.Category), + "origin" => (error.Origin is not null, error.Origin), + "minimum" => (error.Lower is not null, error.Lower?.Value), + "maximum" => (error.Upper is not null, error.Upper?.Value), + "inclusive" => (error.Inclusive is not null, error.Inclusive), + "member" => LastMember(error.FullPath), + "displayName" => LastMember(error.FullPath) is (true, _) found ? found : (true, DefaultDisplayName), + _ => (false, null), + }; + + // The display name of a value the error cannot name (no member segment), as in DataAnnotations' "Value". + const string DefaultDisplayName = "Value"; + + static (bool Found, object? Value) LastMember(ZodPath path) + { + for (var i = path.Length - 1; i >= 0; i--) + { + if (path[i].Kind == ZodPathSegmentKind.Member) + return (true, path[i].Name); + } + + return (false, null); + } + + static string FormatCore( + string format, + ReadOnlySpan positional, + IReadOnlyDictionary? parameters, + Func? fallback, + IFormatProvider? provider, + out bool allResolved + ) + { + allResolved = true; + if (string.IsNullOrEmpty(format) || (format.IndexOf('{') < 0 && format.IndexOf('}') < 0)) + return format ?? string.Empty; + + provider ??= CultureInfo.InvariantCulture; + StringBuilder builder = new(format.Length + 16); + var span = format.AsSpan(); + var i = 0; + while (i < span.Length) + { + var c = span[i]; + if (c == '{') + { + if (i + 1 < span.Length && span[i + 1] == '{') + { + builder.Append('{'); + i += 2; + continue; + } + + var close = span[(i + 1)..].IndexOfAny('{', '}'); + if (close < 0 || span[i + 1 + close] == '{') + { + // Unbalanced: write the brace literally and carry on. + builder.Append('{'); + i++; + continue; + } + + var token = span.Slice(i + 1, close); + var placeholder = span.Slice(i, close + 2); + i += close + 2; + + if (TryResolve(token, positional, parameters, fallback, provider, out var text)) + { + builder.Append(text); + } + else + { + allResolved = false; + builder.Append(placeholder); + } + + continue; + } + + if (c == '}') + { + builder.Append('}'); + i += i + 1 < span.Length && span[i + 1] == '}' ? 2 : 1; + continue; + } + + builder.Append(c); + i++; + } + + return builder.ToString(); + } + + static bool TryResolve( + ReadOnlySpan token, + ReadOnlySpan positional, + IReadOnlyDictionary? parameters, + Func? fallback, + IFormatProvider provider, + out string text + ) + { + text = string.Empty; + + var formatSeparator = token.IndexOf(':'); + var nameAndAlignment = formatSeparator < 0 ? token : token[..formatSeparator]; + var itemFormat = formatSeparator < 0 ? null : token[(formatSeparator + 1)..].ToString(); + + var alignmentSeparator = nameAndAlignment.IndexOf(','); + var nameSpan = (alignmentSeparator < 0 ? nameAndAlignment : nameAndAlignment[..alignmentSeparator]).Trim(); + var alignment = 0; + if ( + alignmentSeparator >= 0 + && !int.TryParse( + nameAndAlignment[(alignmentSeparator + 1)..].Trim(), + NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, + out alignment + ) + ) + { + return false; + } + + if (nameSpan.IsEmpty) + return false; + + object? value; + if (char.IsAsciiDigit(nameSpan[0])) + { + if ( + !int.TryParse(nameSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var index) + || index >= positional.Length + ) + { + return false; + } + + value = positional[index]; + } + else + { + var name = nameSpan.ToString(); + if (parameters is not null && parameters.TryGetValue(name, out var named)) + { + value = named; + } + else if (fallback?.Invoke(name) is (true, var builtIn)) + { + value = builtIn; + } + else + { + return false; + } + } + + text = Render(value, itemFormat, provider); + + if (alignment != 0) + text = alignment > 0 ? text.PadLeft(alignment) : text.PadRight(-alignment); + + return true; + } + + // A sequence (an options or keys list) renders as its elements joined by ", " rather than as the + // collection's type name. + static string Render(object? value, string? itemFormat, IFormatProvider provider) => + value switch + { + null => string.Empty, + string text => text, + IFormattable formattable => formattable.ToString(itemFormat, provider), + System.Collections.IEnumerable sequence => JoinSequence(sequence, itemFormat, provider), + _ => value.ToString() ?? string.Empty, + }; + + static string JoinSequence(System.Collections.IEnumerable sequence, string? itemFormat, IFormatProvider provider) + { + StringBuilder builder = new(); + var first = true; + foreach (var item in sequence) + { + if (!first) + builder.Append(", "); + + first = false; + builder.Append(Render(item, itemFormat, provider)); + } + + return builder.ToString(); + } +} diff --git a/src/src/ZodSharp/Core/ZodMessages.cs b/src/src/ZodSharp/Core/ZodMessages.cs new file mode 100644 index 00000000..7db5ab74 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodMessages.cs @@ -0,0 +1,69 @@ +namespace ZodSharp.Core; + +/// +/// The default message templates of the built-in schemas, with named placeholders resolved by +/// from the error's and built-in fields. +/// Every producer (runtime schemas, generated validators, JSON integrations) uses these so the same failure +/// reads the same everywhere. A template is reported as so a host can +/// re-format (or localize) it. +/// +/// +/// No template contains the input value: messages leave the process. See +/// . +/// +public static class ZodMessages +{ + /// A value where a value of type {expected} is required. + public const string NullValue = "Expected {expected}, received null"; + + /// A value of the wrong type; {received} is the received type name, never the value. + public const string InvalidType = "Expected {expected}, received {received}"; + + /// A missing required field or member. + public const string Required = "{displayName} is required"; + + /// The default message of a failed refinement. + public const string RefinementFailed = "Custom validation failed"; + + /// No union option matched. + public const string InvalidUnion = "Value does not match any of the union options"; + + /// No union option matched; {options} lists the option type names. + public const string InvalidUnionOf = "Value does not match any of the union options ({options})"; + + /// The discriminator property of a discriminated union is absent. + public const string MissingDiscriminator = "Discriminator field '{discriminator}' not found"; + + /// The discriminator value does not select an option; {options} lists the valid values. + public const string InvalidDiscriminator = "Invalid discriminator value. Expected one of: {options}"; + + /// The value is not one of an enum's allowed values. + public const string InvalidEnumValue = "Expected one of: {options}"; + + /// The value does not equal the literal {expected}. + public const string InvalidLiteral = "Expected literal {expected}"; + + /// An object contains keys its schema does not declare. + public const string UnrecognizedKeys = "Unrecognized keys: {keys}"; + + /// An object contains a key its schema does not declare (per-key compatibility mode). + public const string UnrecognizedKey = "Unrecognized key '{key}'"; + + /// An array has fewer elements than its minimum. + public const string ArrayTooSmall = "Array must have at least {minimum} elements, but got {count}"; + + /// An array has more elements than its maximum. + public const string ArrayTooBig = "Array must have at most {maximum} elements, but got {count}"; + + /// An array does not have its exact required length. + public const string ArrayExactLength = "Array must have exactly {minimum} elements, but got {count}"; + + /// An array is empty but must not be. + public const string ArrayNonEmpty = "Array must not be empty"; + + /// A tuple has the wrong number of elements. + public const string TupleLength = "Expected tuple of length {minimum}, but got {count}"; + + /// A transform threw. + public const string TransformFailed = "Transform failed."; +} diff --git a/src/src/ZodSharp/Core/ZodPath.cs b/src/src/ZodSharp/Core/ZodPath.cs new file mode 100644 index 00000000..56dd340f --- /dev/null +++ b/src/src/ZodSharp/Core/ZodPath.cs @@ -0,0 +1,524 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text; + +namespace ZodSharp.Core; + +/// +/// Maps a member name in a to the name rendered for a host, for example a JSON naming +/// policy. Index and key segments are never renamed. +/// +public interface IZodPathNamer +{ + /// + /// Returns the rendered name for . + /// + /// The member name recorded in the path. + /// The name to render. + string GetName(string memberName); +} + +/// +/// Creates instances. +/// +public static class ZodPathNamer +{ + /// + /// Creates a namer from a conversion function, for example JsonNamingPolicy.CamelCase.ConvertName. + /// + /// The conversion applied to each member name. + /// The namer. + public static IZodPathNamer FromFunc(Func convert) + { + ArgumentNullException.ThrowIfNull(convert); + return new FuncNamer(convert); + } + + sealed class FuncNamer(Func convert) : IZodPathNamer + { + public string GetName(string memberName) => convert(memberName); + } +} + +/// +/// The location of a validation issue: an immutable sequence of typed s plus the +/// legacy string projection exposed by . This is the one place paths are +/// rendered, so ProblemDetails keys, Blazor field mapping, log output and options failures always agree. +/// +/// +/// The dotted rendering () matches Microsoft.Extensions.Validation: members are +/// joined with . and indexes are bracketed (Items[0].Name). Keys are bracketed too, and quoted +/// when they would otherwise be ambiguous (Map[en], Map["0"], Map["a.b"]). +/// +public readonly struct ZodPath : IEquatable +{ + readonly ImmutableArray _segments; + + ZodPath(ImmutableArray segments, ImmutableArray legacy) + { + _segments = segments; + LegacySegments = legacy; + } + + /// + /// Gets the empty (root) path. + /// + public static ZodPath Root => default; + + /// + /// Gets the typed segments, outermost first. + /// + public ImmutableArray Segments => _segments.IsDefault ? [] : _segments; + + /// + /// Gets the number of segments. + /// + public int Length => _segments.IsDefault ? 0 : _segments.Length; + + /// + /// Gets whether this is the root path. + /// + public bool IsRoot => Length == 0; + + /// + /// Gets the segment at . + /// + /// The segment position. + public ZodPathSegment this[int index] => Segments[index]; + + /// + /// Gets the legacy string projection: members and keys as-is, indexes as invariant digits. + /// + public ImmutableArray LegacySegments => field.IsDefault ? [] : field; + + /// + /// Creates a path from typed segments. + /// + /// The segments, outermost first. + /// The path. + public static ZodPath From(params ReadOnlySpan segments) => + segments.IsEmpty ? Root : Create(ImmutableArray.Create(segments)); + + /// + /// Creates a path from typed segments. + /// + /// The segments, outermost first. + /// The path. + public static ZodPath From(ImmutableArray segments) => + segments.IsDefaultOrEmpty ? Root : Create(segments); + + /// + /// Creates a single-member path. + /// + /// The member name. + /// The path. + public static ZodPath Member(string name) => Create([ZodPathSegment.Member(name)]); + + /// + /// Classifies legacy string segments. A segment written as [n] or consisting only of ASCII digits is + /// an index; anything else is a member. This keeps paths supplied as plain strings (refinements, existing + /// callers) working. + /// + /// The legacy segments. + /// The path. + public static ZodPath FromLegacy(IEnumerable? segments) + { + if (segments is null) + return Root; + + var builder = ImmutableArray.CreateBuilder(); + foreach (var segment in segments) + builder.Add(ClassifyLegacy(segment)); + + return builder.Count == 0 ? Root : Create(builder.ToImmutable()); + } + + /// + /// Classifies legacy string segments; see . + /// + /// The legacy segments. + /// The path. + public static ZodPath FromLegacy(ImmutableArray segments) + { + if (segments.IsDefaultOrEmpty) + return Root; + + var builder = ImmutableArray.CreateBuilder(segments.Length); + foreach (var segment in segments) + builder.Add(ClassifyLegacy(segment)); + + return Create(builder.MoveToImmutable()); + } + + /// + /// Returns a new path with placed before the existing segments. + /// + /// The outer segment. + /// The new path. + public ZodPath Prepend(ZodPathSegment segment) => + new(Segments.Insert(0, segment), LegacySegments.Insert(0, segment.ToString())); + + /// + /// Returns a new path with placed before the existing segments. + /// + /// The outer path. + /// The new path. + public ZodPath Prepend(ZodPath prefix) => + prefix.IsRoot ? this + : IsRoot ? prefix + : new(prefix.Segments.AddRange(Segments), prefix.LegacySegments.AddRange(LegacySegments)); + + /// + /// Returns a new path with added after the existing segments. + /// + /// The inner segment. + /// The new path. + public ZodPath Append(ZodPathSegment segment) => new(Segments.Add(segment), LegacySegments.Add(segment.ToString())); + + /// + /// Returns a new path with added after the existing segments. + /// + /// The inner path. + /// The new path. + public ZodPath Append(ZodPath suffix) => suffix.Prepend(this); + + /// + /// Renders the path with members joined by . and indexes and keys bracketed: + /// Items[0].Name, Map[en].Value, Map["a.b"]. The root renders as an empty string. + /// + /// Optional member renaming (for example a JSON naming policy). + /// The dotted path. + public string ToDottedString(IZodPathNamer? namer = null) + { + if (IsRoot) + return string.Empty; + + StringBuilder builder = new(); + foreach (var segment in Segments) + { + switch (segment.Kind) + { + case ZodPathSegmentKind.Index: + builder.Append('[').Append(segment.Index.ToString(CultureInfo.InvariantCulture)).Append(']'); + break; + case ZodPathSegmentKind.Key: + AppendBracketedKey(builder, segment.Name!); + break; + case ZodPathSegmentKind.Member: + default: + var name = namer?.GetName(segment.Name!) ?? segment.Name!; + if (IsPlainMemberName(name)) + { + if (builder.Length > 0) + builder.Append('.'); + builder.Append(name); + } + else + { + AppendQuoted(builder, name); + } + + break; + } + } + + return builder.ToString(); + } + + /// + /// Renders the path as an RFC 6901 JSON Pointer (/items/0/name). The root renders as an empty + /// string. + /// + /// Optional member renaming. + /// The JSON Pointer. + public string ToJsonPointer(IZodPathNamer? namer = null) + { + if (IsRoot) + return string.Empty; + + StringBuilder builder = new(); + foreach (var segment in Segments) + { + builder.Append('/'); + var text = segment.Kind switch + { + ZodPathSegmentKind.Index => segment.Index.ToString(CultureInfo.InvariantCulture), + ZodPathSegmentKind.Member => namer?.GetName(segment.Name!) ?? segment.Name!, + ZodPathSegmentKind.Key or _ => segment.Name!, + }; + builder.Append( + text.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal) + ); + } + + return builder.ToString(); + } + + /// + /// Renders the path as a JSONPath expression in the form System.Text.Json reports + /// ($.items[0].name, $['a.b']). + /// + /// Optional member renaming. + /// The JSONPath. + public string ToJsonPath(IZodPathNamer? namer = null) + { + StringBuilder builder = new("$"); + foreach (var segment in Segments) + { + switch (segment.Kind) + { + case ZodPathSegmentKind.Index: + builder.Append('[').Append(segment.Index.ToString(CultureInfo.InvariantCulture)).Append(']'); + break; + case ZodPathSegmentKind.Member: + case ZodPathSegmentKind.Key: + default: + var name = + segment.Kind == ZodPathSegmentKind.Member + ? namer?.GetName(segment.Name!) ?? segment.Name! + : segment.Name!; + if (IsPlainMemberName(name)) + builder.Append('.').Append(name); + else + builder.Append("['").Append(name.Replace("'", "\\'", StringComparison.Ordinal)).Append("']"); + break; + } + } + + return builder.ToString(); + } + + /// + /// Renders the path as a Microsoft.Extensions.Configuration key (Servers:0:Host). + /// + /// The configuration key. + public string ToConfigurationKey() => string.Join(':', LegacySegments); + + /// + /// Returns the path in Zod's issue shape: strings for members and keys, integers for indexes. + /// + /// The Zod path. + public IReadOnlyList ToZodPath() + { + if (IsRoot) + return []; + + var result = new object[Length]; + for (var i = 0; i < result.Length; i++) + { + var segment = Segments[i]; + result[i] = segment.Kind == ZodPathSegmentKind.Index ? segment.Index : segment.Name!; + } + + return result; + } + + /// + /// Parses a JSONPath as reported by System.Text.Json ($.a[0]['b.c']) or Newtonsoft.Json + /// (a[0].b, ['b.c']). Property names become member segments. + /// + /// The JSONPath; or empty yields the root. + /// The parsed path. + public static ZodPath ParseJsonPath(string? jsonPath) + { + if (string.IsNullOrEmpty(jsonPath)) + return Root; + + var span = jsonPath.AsSpan(); + if (span[0] == '$') + span = span[1..]; + + return Parse(span, quotedIsKey: false); + } + + /// + /// Parses the output of (and plain Microsoft.Extensions.Validation + /// keys such as Items[0].Name). A bracketed all-digit value is an index; any other bracketed value is + /// a key. + /// + /// The dotted path; or empty yields the root. + /// The parsed path. + public static ZodPath ParseDotted(string? dotted) => + string.IsNullOrEmpty(dotted) ? Root : Parse(dotted.AsSpan(), quotedIsKey: true); + + /// + public bool Equals(ZodPath other) => Segments.AsSpan().SequenceEqual(other.Segments.AsSpan()); + + /// + public override bool Equals(object? obj) => obj is ZodPath other && Equals(other); + + /// + public override int GetHashCode() + { + HashCode hash = new(); + foreach (var segment in Segments) + hash.Add(segment); + return hash.ToHashCode(); + } + + /// + /// Returns . + /// + /// The dotted path. + public override string ToString() => ToDottedString(); + + /// Equality operator. + public static bool operator ==(ZodPath left, ZodPath right) => left.Equals(right); + + /// Inequality operator. + public static bool operator !=(ZodPath left, ZodPath right) => !left.Equals(right); + + static ZodPath Create(ImmutableArray segments) + { + var legacy = ImmutableArray.CreateBuilder(segments.Length); + foreach (var segment in segments) + legacy.Add(segment.ToString()); + + return new(segments, legacy.MoveToImmutable()); + } + + static ZodPathSegment ClassifyLegacy(string? segment) + { + if (string.IsNullOrEmpty(segment)) + return ZodPathSegment.Member(segment ?? string.Empty); + + var span = segment.AsSpan(); + // The bracket check is one of three ordered parse attempts; a conditional expression would hide it. +#pragma warning disable IDE0046 // Convert to conditional expression + if (span.Length > 2 && span[0] == '[' && span[^1] == ']' && TryParseIndex(span[1..^1], out var bracketed)) + return ZodPathSegment.At(bracketed); +#pragma warning restore IDE0046 + + return TryParseIndex(span, out var index) ? ZodPathSegment.At(index) : ZodPathSegment.Member(segment); + } + + static bool TryParseIndex(ReadOnlySpan span, out int index) + { + index = 0; + if (span.IsEmpty) + return false; + + foreach (var c in span) + { + if (c is < '0' or > '9') + return false; + } + + return int.TryParse(span, NumberStyles.None, CultureInfo.InvariantCulture, out index); + } + + static bool IsPlainMemberName(string name) + { + if (name.Length == 0) + return false; + + foreach (var c in name) + { + if (c is '.' or '[' or ']' or '"' or '\'' || char.IsWhiteSpace(c)) + return false; + } + + return true; + } + + static bool IsPlainKey(string key) + { + if (key.Length == 0) + return false; + + var allDigits = true; + foreach (var c in key) + { + if (!(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')) + return false; + allDigits &= char.IsAsciiDigit(c); + } + + return !allDigits; + } + + static void AppendBracketedKey(StringBuilder builder, string key) + { + // An all-digit key is quoted so it cannot be read back as an index. + if (IsPlainKey(key)) + builder.Append('[').Append(key).Append(']'); + else + AppendQuoted(builder, key); + } + + static void AppendQuoted(StringBuilder builder, string value) + { + builder.Append("[\""); + foreach (var c in value) + { + if (c is '"' or '\\') + builder.Append('\\'); + builder.Append(c); + } + + builder.Append("\"]"); + } + + static ZodPath Parse(ReadOnlySpan span, bool quotedIsKey) + { + var builder = ImmutableArray.CreateBuilder(); + var i = 0; + while (i < span.Length) + { + var c = span[i]; + if (c == '.') + { + i++; + continue; + } + + if (c == '[') + { + i++; + if (i < span.Length && span[i] is '"' or '\'') + { + var quote = span[i++]; + StringBuilder value = new(); + while (i < span.Length && span[i] != quote) + { + if (span[i] == '\\' && i + 1 < span.Length) + i++; + value.Append(span[i++]); + } + + i++; // closing quote + if (i < span.Length && span[i] == ']') + i++; + + builder.Add( + quotedIsKey ? ZodPathSegment.Key(value.ToString()) : ZodPathSegment.Member(value.ToString()) + ); + continue; + } + + var start = i; + while (i < span.Length && span[i] != ']') + i++; + + var content = span[start..i]; + if (i < span.Length) + i++; // closing bracket + + if (TryParseIndex(content, out var index)) + builder.Add(ZodPathSegment.At(index)); + else + builder.Add(ZodPathSegment.Key(content.ToString())); + + continue; + } + + var nameStart = i; + while (i < span.Length && span[i] is not ('.' or '[')) + i++; + + builder.Add(ZodPathSegment.Member(span[nameStart..i].ToString())); + } + + return builder.Count == 0 ? Root : Create(builder.ToImmutable()); + } +} diff --git a/src/src/ZodSharp/Core/ZodPathNaming.cs b/src/src/ZodSharp/Core/ZodPathNaming.cs new file mode 100644 index 00000000..eede5f5d --- /dev/null +++ b/src/src/ZodSharp/Core/ZodPathNaming.cs @@ -0,0 +1,19 @@ +namespace ZodSharp.Core; + +/// +/// Selects how the member segments of a are named when a host renders error keys. +/// +public enum ZodPathNaming +{ + /// + /// The C# member name (Items[0].Name). This is the default everywhere and matches + /// Microsoft.Extensions.Validation, MVC model state and Blazor's FieldIdentifier, so ZodSharp and + /// DataAnnotations types in the same API produce the same key shape. + /// + MemberName, + + /// + /// The JSON name produced by the host's configured naming policy (items[0].name). + /// + Json, +} diff --git a/src/src/ZodSharp/Core/ZodPathSegment.cs b/src/src/ZodSharp/Core/ZodPathSegment.cs new file mode 100644 index 00000000..ee8782d3 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodPathSegment.cs @@ -0,0 +1,108 @@ +using System.Globalization; + +namespace ZodSharp.Core; + +/// +/// Identifies what a addresses. +/// +public enum ZodPathSegmentKind +{ + /// A named member: a property, field or object key that names a member. + Member, + + /// A zero-based position in a sequence (array, list, tuple). + Index, + + /// A dictionary or record key. + Key, +} + +/// +/// A single, typed step in a . Keeping the kind alongside the value is what lets an +/// index (Items[0]), a dictionary key (Map["0"]) and a member be told apart after the fact, so +/// every host renders the same location in the same way. +/// +public readonly record struct ZodPathSegment +{ + ZodPathSegment(ZodPathSegmentKind kind, string? name, int index) + { + Kind = kind; + Name = name; + Index = index; + } + + /// + /// Gets what this segment addresses. + /// + public ZodPathSegmentKind Kind { get; } + + /// + /// Gets the member name () or the invariant-rendered key + /// (); for an index. + /// + public string? Name { get; } + + /// + /// Gets the zero-based position for an segment; 0 otherwise. + /// + public int Index { get; } + + /// + /// Creates a member segment. + /// + /// The member name. + /// The segment. + public static ZodPathSegment Member(string name) + { + ArgumentNullException.ThrowIfNull(name); + return new(ZodPathSegmentKind.Member, name, 0); + } + + /// + /// Creates an index segment. + /// + /// The zero-based position. + /// The segment. + public static ZodPathSegment At(int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + return new(ZodPathSegmentKind.Index, null, index); + } + + /// + /// Creates a key segment from an already-rendered key. + /// + /// The key. + /// The segment. + public static ZodPathSegment Key(string key) + { + ArgumentNullException.ThrowIfNull(key); + return new(ZodPathSegmentKind.Key, key, 0); + } + + /// + /// Creates a key segment, rendering the key with the invariant culture so a path never depends on the + /// thread's culture. + /// + /// The key type. + /// The key. + /// The segment. + public static ZodPathSegment Key(TKey key) => Key(FormatKey(key)); + + /// + /// Renders the segment as the string used by the legacy projection: + /// the member name or key as-is, and an index as its invariant digits (Zod's convention). + /// + /// The legacy segment string. + public override string ToString() => + Kind == ZodPathSegmentKind.Index ? Index.ToString(CultureInfo.InvariantCulture) : Name ?? string.Empty; + + internal static string FormatKey(TKey key) => + key switch + { + null => string.Empty, + string text => text, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => key.ToString() ?? string.Empty, + }; +} diff --git a/src/src/ZodSharp/Core/ZodReflectionFeature.cs b/src/src/ZodSharp/Core/ZodReflectionFeature.cs new file mode 100644 index 00000000..851e7a85 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodReflectionFeature.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ZodSharp.Core; + +/// +/// The ZodSharp.Reflection.IsSupported feature switch, which guards the reflection fallbacks that read members +/// of a type the library was not told about (for example a discriminator on a POCO whose type was not supplied to +/// ZodDiscriminatedUnionBuilder.Option<T>). +/// +/// +/// The property is a feature guard for : the trim analyzer accepts the +/// reflection it guards, and ILLink and ILC treat it as when trimming or compiling ahead of +/// time, so the fallback is removed instead of failing unpredictably on trimmed metadata. An untrimmed application +/// keeps it unless it sets the switch to . +/// +static class ZodReflectionFeature +{ + /// The feature switch name. + public const string SwitchName = "ZodSharp.Reflection.IsSupported"; + + /// Gets whether the reflection fallbacks may run. + [FeatureSwitchDefinition(SwitchName)] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + [UnconditionalSuppressMessage( + "Trimming", + "IL4000:FeatureGuard", + Justification = "A RequiresUnreferencedCode feature guard cannot be derived from another guard: none exists. ILLink and ILC " + + "treat this guard as false when trimming, which the trim verification harness proves (no IL2026 from the " + + "guarded calls, and the fallback is observed disabled in the published binary)." + )] + public static bool IsSupported => !AppContext.TryGetSwitch(SwitchName, out var enabled) || enabled; +} diff --git a/src/src/ZodSharp/Core/ZodRuleErrors.cs b/src/src/ZodSharp/Core/ZodRuleErrors.cs new file mode 100644 index 00000000..68eb74e6 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodRuleErrors.cs @@ -0,0 +1,207 @@ +namespace ZodSharp.Core; + +/// +/// Builds the reported for a failed rule, copying the rule's structured identity +/// () so runtime schemas and generated validators report exactly the same error for the +/// same rule. +/// +/// +/// When the rule implements (or +/// for spans) its value-dependent category and parameters are applied too. The raw input is added as the +/// parameter only when +/// is enabled. +/// +public static class ZodRuleErrors +{ + /// + /// Creates the error for a failed . + /// + /// The validated type. + /// The rule that failed. + /// The value that failed. + /// The error path. + /// The origin used when the rule does not report one. + /// A message that replaces the rule's own message (for example an attribute's ErrorMessage). + /// A code that replaces the rule's own code. + /// The error. + public static ValidationError Create( + IValidationRule rule, + in T value, + ZodPath path, + string? fallbackOrigin = null, + string? message = null, + string? code = null + ) + { + ArgumentNullException.ThrowIfNull(rule); + var zodRule = rule as IZodRule; + var failure = rule is IZodFailureDescriber describer ? describer.DescribeFailure(value) : default; + var includeReceived = ZodErrorOptions.IncludeReceivedValues; + return BuildCore( + ResolveCode(code, zodRule?.Code is { } ruleCode ? ruleCode : rule.Code, failure), + message ?? rule.GetErrorMessage(value), + path, + zodRule, + fallbackOrigin, + failure, + includeReceived, + includeReceived ? value : null + ); + } + + // A failure's own code (a direction-dependent code) replaces the rule's code, including when the caller + // passes the rule's code back explicitly as generated validators do; a different explicit code wins. + static string ResolveCode(string? code, string ruleCode, ZodRuleFailure failure) => + failure.Code is { } failureCode && (code is null || string.Equals(code, ruleCode, StringComparison.Ordinal)) + ? failureCode + : code ?? ruleCode; + + /// + /// Creates the error for a failed . + /// + /// The rule that failed. + /// The value that failed. + /// The error path. + /// The origin used when the rule does not report one. + /// A message that replaces the rule's own message. + /// A code that replaces the rule's own code. + /// The error. + public static ValidationError Create( + IStringValidationRule rule, + ReadOnlySpan value, + ZodPath path, + string? fallbackOrigin = "string", + string? message = null, + string? code = null + ) + { + ArgumentNullException.ThrowIfNull(rule); + var zodRule = rule as IZodRule; + var failure = rule is IZodStringFailureDescriber describer ? describer.DescribeFailure(value) : default; + var includeReceived = ZodErrorOptions.IncludeReceivedValues; + return BuildCore( + ResolveCode(code, zodRule?.Code is { } ruleCode ? ruleCode : rule.Code, failure), + message ?? rule.GetErrorMessage(value), + path, + zodRule, + fallbackOrigin, + failure, + includeReceived, + includeReceived ? value.ToString() : null + ); + } + + /// + /// Creates an error from a rule's structured identity with an explicit code and message. + /// + /// The error code. + /// The error message. + /// The error path. + /// The rule supplying the structured identity, if any. + /// The origin used when the rule does not report one. + /// The error. + public static ValidationError Build( + string code, + string message, + ZodPath path, + IZodRule? rule, + string? fallbackOrigin = null + ) => BuildCore(code, message, path, rule, fallbackOrigin, default, false, null); + + /// + /// Creates an error from a rule's structured identity with an explicit code and message, applying a + /// value-dependent description. + /// + /// The error code. + /// The error message. + /// The error path. + /// The rule supplying the structured identity, if any. + /// The value-dependent category and parameters. + /// The origin used when the rule does not report one. + /// The error. + public static ValidationError Build( + string code, + string message, + ZodPath path, + IZodRule? rule, + ZodRuleFailure failure, + string? fallbackOrigin = null + ) => + BuildCore( + rule?.Code is { } ruleCode ? ResolveCode(code, ruleCode, failure) : code, + message, + path, + rule, + fallbackOrigin, + failure, + false, + null + ); + + static ValidationError BuildCore( + string code, + string message, + ZodPath path, + IZodRule? rule, + string? fallbackOrigin, + ZodRuleFailure failure, + bool includeReceived, + object? received + ) + { + var lower = rule?.Lower; + var upper = rule?.Upper; + var minimum = lower is { Value: int lowerValue } ? lowerValue : (int?)null; + var maximum = upper is { Value: int upperValue } ? upperValue : (int?)null; + var inclusive = lower?.Inclusive ?? upper?.Inclusive; + + return ValidationError.Create( + code, + message, + path, + MergeParameters(rule?.Parameters, failure.Parameters, includeReceived, received), + rule?.Origin ?? fallbackOrigin, + minimum, + maximum, + inclusive, + failure.Category ?? rule?.Category + ) with + { + Lower = lower, + Upper = upper, + MessageFormat = rule?.MessageFormat, + }; + } + + static IReadOnlyDictionary? MergeParameters( + IReadOnlyDictionary? ruleParameters, + IReadOnlyDictionary? failureParameters, + bool includeReceived, + object? received + ) + { + if (failureParameters is null && !includeReceived) + return ruleParameters; + + if (ruleParameters is null && !includeReceived) + return failureParameters; + + Dictionary merged = [with(StringComparer.Ordinal)]; + if (ruleParameters is not null) + { + foreach (var pair in ruleParameters) + merged[pair.Key] = pair.Value; + } + + if (failureParameters is not null) + { + foreach (var pair in failureParameters) + merged[pair.Key] = pair.Value; + } + + if (includeReceived) + merged[ZodIssueParameters.Received] = received; + + return merged; + } +} diff --git a/src/src/ZodSharp/Core/ZodSchemaFactory.cs b/src/src/ZodSharp/Core/ZodSchemaFactory.cs index a3d33f1f..c8d659ed 100644 --- a/src/src/ZodSharp/Core/ZodSchemaFactory.cs +++ b/src/src/ZodSharp/Core/ZodSchemaFactory.cs @@ -8,6 +8,7 @@ namespace ZodSharp.Core; public sealed class ZodSchemaFactory : IZodSchemaFactory { readonly ConcurrentDictionary _validators = new(); + readonly ConcurrentDictionary _untyped = new(); /// public IZodSchemaValidator? Resolve() => @@ -22,14 +23,39 @@ public IZodSchemaValidator ResolveRequired() => public ValidationResult Validate(T value) => ResolveRequired().Validate(value); /// - public void Register(IZodSchemaValidator validator) => _validators[typeof(T)] = validator; + public void Register(IZodSchemaValidator validator) + { + _validators[typeof(T)] = validator; + _untyped[typeof(T)] = validator as IZodUntypedValidator ?? new ZodUntypedValidator(validator); + } /// - public void Register(Type targetType, IZodSchemaValidator validator) => _validators[targetType] = validator; + public void Register(Type targetType, IZodSchemaValidator validator) + { + _validators[targetType] = validator; + if (validator is IZodUntypedValidator untyped) + _untyped[targetType] = untyped; + else + _untyped.TryRemove(targetType, out _); + } /// - public bool TryRegister(IZodSchemaValidator validator) => _validators.TryAdd(typeof(T), validator); + public bool TryRegister(IZodSchemaValidator validator) + { + if (!_validators.TryAdd(typeof(T), validator)) + return false; + + _untyped[typeof(T)] = validator as IZodUntypedValidator ?? new ZodUntypedValidator(validator); + return true; + } /// public bool IsRegistered() => _validators.ContainsKey(typeof(T)); + + /// + public bool TryGetUntypedValidator(Type type, out IZodUntypedValidator? validator) + { + ArgumentNullException.ThrowIfNull(type); + return _untyped.TryGetValue(type, out validator); + } } diff --git a/src/src/ZodSharp/Core/ZodSchemaRegistration.cs b/src/src/ZodSharp/Core/ZodSchemaRegistration.cs new file mode 100644 index 00000000..0e4fe00a --- /dev/null +++ b/src/src/ZodSharp/Core/ZodSchemaRegistration.cs @@ -0,0 +1,20 @@ +namespace ZodSharp.Core; + +/// +/// A deferred registration of validators into the built by dependency +/// injection. The generated AddZodSharp{Assembly}Schemas() extension registers one per assembly; the +/// factory applies every registration when it is first resolved. +/// +/// +/// This is the trim- and AOT-safe alternative to +/// : +/// the generated registration names every validator directly, so nothing is discovered through metadata. +/// +/// Registers validators into the factory. +public sealed class ZodSchemaRegistration(Action register) +{ + /// + /// Gets the callback that registers validators into the factory. + /// + public Action Register { get; } = register ?? throw new ArgumentNullException(nameof(register)); +} diff --git a/src/src/ZodSharp/Core/ZodSchemaValidator.cs b/src/src/ZodSharp/Core/ZodSchemaValidator.cs index d49e011d..b267772f 100644 --- a/src/src/ZodSharp/Core/ZodSchemaValidator.cs +++ b/src/src/ZodSharp/Core/ZodSchemaValidator.cs @@ -3,10 +3,15 @@ namespace ZodSharp.Core; /// /// Wraps an as an for DI registration. /// -public sealed class ZodSchemaValidator(IZodSchema schema) : IZodSchemaValidator +public sealed class ZodSchemaValidator(IZodSchema schema) + : IZodSchemaValidator, + JsonSchema.IJsonSchemaInnerSchema { readonly IZodSchema _schema = schema; + // Lets the JSON Schema exporter see the schema this adapter wraps. + object JsonSchema.IJsonSchemaInnerSchema.InnerSchema => _schema; + /// public ValidationResult Validate(T value) => _schema.Validate(value); diff --git a/src/src/ZodSharp/Core/ZodType.cs b/src/src/ZodSharp/Core/ZodType.cs index c92dd3ae..586a00c9 100644 --- a/src/src/ZodSharp/Core/ZodType.cs +++ b/src/src/ZodSharp/Core/ZodType.cs @@ -12,8 +12,6 @@ namespace ZodSharp.Core; /// The input type before validation public abstract class ZodType : IZodSchema, IOptionalSchema { - static readonly string[] EmptyPath = []; - // Read by the JSON Schema exporter, which previously reached this by walking BaseType.BaseType and // reflecting on the field name — fragile, and invisible to the trimmer. internal ImmutableArray> AppliedRules { get; private set; } = []; @@ -54,7 +52,7 @@ public ValidationResult Validate(TInput value) if (!rule.IsValid(validatedValue)) { errors ??= [with(rulesCount)]; - errors.Add(new ValidationError(ResolveCode(rule), rule.GetErrorMessage(validatedValue), EmptyPath)); + errors.Add(ZodRuleErrors.Create(rule, validatedValue, ZodPath.Root)); } } @@ -88,8 +86,9 @@ public ValueTask> ValidateAsync( /// /// Adds a validation rule to this schema. Rules are evaluated after the schema's - /// hook succeeds; a failing rule produces a - /// with code "validation_failed" and an empty path. + /// hook succeeds; a failing rule produces a at the + /// schema's own (empty) path, carrying the rule's code and, when the rule implements , + /// its category, origin, bounds and parameters (see ). /// /// The rule to add. Implementations are expected to be structs to avoid allocations. /// This schema for method chaining. @@ -168,16 +167,6 @@ public virtual ZodType DeniedValues(TOutput[] values, string? m return this; } - /// - /// Resolves the error code to report for a failed rule: a rule that implements - /// and supplies a code (typically from its constructor) wins over the rule's intrinsic - /// . - /// - /// The rule that failed. - /// The code to report. - static string ResolveCode(IValidationRule rule) => - rule is IZodRule zodRule && zodRule.Code is { } code ? code : rule.Code; - /// /// Gets the number of rules accumulated on this schema. /// diff --git a/src/src/ZodSharp/Core/ZodTypeNames.cs b/src/src/ZodSharp/Core/ZodTypeNames.cs new file mode 100644 index 00000000..eb687f3d --- /dev/null +++ b/src/src/ZodSharp/Core/ZodTypeNames.cs @@ -0,0 +1,193 @@ +using System.Collections; +using System.Numerics; +using System.Text.Json; + +namespace ZodSharp.Core; + +/// +/// Maps CLR types and values to the type names Zod uses in its messages and expected/received +/// issue fields (string, number, bigint, boolean, date, object, +/// array, …), so CLR names such as Int32 or JsonElement never reach a client. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Naming", + "CA1720:Identifier contains type name", + Justification = "The constants are Zod's type names." +)] +public static class ZodTypeNames +{ + /// The Zod name for text values. + public const string String = "string"; + + /// The Zod name for numeric values. + public const string Number = "number"; + + /// The Zod name for arbitrary-precision integers. + public const string BigInt = "bigint"; + + /// The Zod name for / values. + public const string Boolean = "boolean"; + + /// The Zod name for dates and times. + public const string Date = "date"; + + /// The Zod name for objects (including dictionaries). + public const string Object = "object"; + + /// The Zod name for arrays and other sequences. + public const string Array = "array"; + + /// The Zod name for tuples. + public const string Tuple = "tuple"; + + /// The Zod name for records (string-keyed dictionaries). + public const string Record = "record"; + + /// The Zod name for sets. + public const string Set = "set"; + + /// The name used for enum values. + public const string Enum = "enum"; + + /// The Zod name for the value. + public const string Null = "null"; + + /// The Zod name for a not-a-number value. + public const string NaN = "nan"; + + /// The Zod name for a value of unknown type. + public const string Unknown = "unknown"; + + /// + /// Gets the Zod type name for . is unwrapped. + /// + /// The CLR type. + /// The Zod type name; when is . + public static string Of(Type? type) + { + if (type is null) + return Unknown; + + type = Nullable.GetUnderlyingType(type) ?? type; + + return OfScalar(type) ?? OfComposite(type); + } + + static string? OfScalar(Type type) + { + if ( + type == typeof(string) + || type == typeof(char) + || type == typeof(Guid) + || type == typeof(Uri) + || type == typeof(TimeOnly) + || type == typeof(TimeSpan) + ) + { + return String; + } + + if (type == typeof(bool)) + return Boolean; + + if (type.IsEnum) + return Enum; + + if (type == typeof(BigInteger) || type == typeof(Int128) || type == typeof(UInt128)) + return BigInt; + + if (IsNumber(type)) + return Number; + + // The final branch of an ordered set of type checks; a conditional expression would hide it. +#pragma warning disable IDE0046 // Convert to conditional expression + if (type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(DateOnly)) + return Date; +#pragma warning restore IDE0046 + + return type == typeof(JsonElement) || type == typeof(object) ? Unknown : null; + } + + static string OfComposite(Type type) + { + if (type.IsArray) + return Array; + + if (typeof(IDictionary).IsAssignableFrom(type) || ImplementsGeneric(type, typeof(IReadOnlyDictionary<,>))) + return Object; + + if ( + ImplementsGeneric(type, typeof(ISet<>)) + || ImplementsGeneric(type, typeof(IReadOnlySet<>)) + || ImplementsGeneric(type, typeof(HashSet<>)) + || ImplementsGeneric(type, typeof(SortedSet<>)) + ) + { + return Set; + } + + // The final branch of an ordered set of type checks; a conditional expression would hide it. +#pragma warning disable IDE0046 // Convert to conditional expression + if (typeof(IEnumerable).IsAssignableFrom(type)) + return Array; +#pragma warning restore IDE0046 + + return Object; + } + + /// + /// Gets the Zod type name for 's runtime type; for + /// , for a not-a-number floating-point value, and the JSON kind for a + /// . + /// + /// The value. + /// The Zod type name. + public static string OfValue(object? value) => + value switch + { + null => Null, + double d when double.IsNaN(d) => NaN, + float f when float.IsNaN(f) => NaN, + JsonElement element => OfJsonKind(element.ValueKind), + _ => Of(value.GetType()), + }; + + /// + /// Gets the Zod type name for a JSON value kind. + /// + /// The JSON value kind. + /// The Zod type name. + public static string OfJsonKind(JsonValueKind kind) => + kind switch + { + JsonValueKind.Object => Object, + JsonValueKind.Array => Array, + JsonValueKind.String => String, + JsonValueKind.Number => Number, + JsonValueKind.True or JsonValueKind.False => Boolean, + JsonValueKind.Null => Null, + JsonValueKind.Undefined => Unknown, + _ => Unknown, + }; + + static bool IsNumber(Type type) => + type == typeof(double) + || type == typeof(float) + || type == typeof(decimal) + || type == typeof(Half) + || type == typeof(int) + || type == typeof(long) + || type == typeof(short) + || type == typeof(byte) + || type == typeof(uint) + || type == typeof(ulong) + || type == typeof(ushort) + || type == typeof(sbyte) + || type == typeof(nint) + || type == typeof(nuint); + + // Only the type's own generic definition is inspected (not its interfaces), which keeps the lookup + // trim-safe; the common concrete dictionaries and sets also implement the non-generic interfaces. + static bool ImplementsGeneric(Type type, Type openGeneric) => + type.IsGenericType && type.GetGenericTypeDefinition() == openGeneric; +} diff --git a/src/src/ZodSharp/Core/ZodValidationDepth.cs b/src/src/ZodSharp/Core/ZodValidationDepth.cs new file mode 100644 index 00000000..d803227e --- /dev/null +++ b/src/src/ZodSharp/Core/ZodValidationDepth.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ZodSharp.Core; + +/// +/// The nesting limit generated validators enforce while walking an object graph. Matches +/// Microsoft.Extensions.Validation's ValidationOptions.MaxDepth (default 32): a graph nested +/// deeper than the limit — almost always a cycle — fails with instead +/// of overflowing the stack. +/// +/// +/// The root value is validated at depth 0 and every nested schema call adds one, so validation throws +/// when a value at depth is reached. +/// +public static class ZodValidationDepth +{ + /// + /// The default limit, matching Microsoft.Extensions.Validation. + /// + public const int DefaultMaxDepth = 32; + + /// + /// Gets or sets the maximum nesting depth. Process-wide; hosts that configure + /// ValidationOptions.MaxDepth may mirror the value here. + /// + /// The value is zero or negative. + public static int MaxDepth + { + get => Volatile.Read(ref field); + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + Volatile.Write(ref field, value); + } + } = DefaultMaxDepth; + + /// + /// Throws when has reached . + /// + /// The depth of the value being validated (the root is 0). + /// The type being validated, named in the exception message. + /// The depth limit was reached. + public static void ThrowIfExceeded(int depth, Type type) + { + var maxDepth = MaxDepth; + if (depth >= maxDepth) + ThrowExceeded(maxDepth, type); + } + + [DoesNotReturn] + static void ThrowExceeded(int maxDepth, Type type) => + throw new InvalidOperationException( + $"Maximum validation depth of {maxDepth} exceeded in '{type?.Name}'. This is likely caused by a circular reference in the object graph. Consider increasing ZodValidationDepth.MaxDepth if deeper validation is required." + ); +} diff --git a/src/src/ZodSharp/DefaultImmutableArrayBehavior.cs b/src/src/ZodSharp/DefaultImmutableArrayBehavior.cs new file mode 100644 index 00000000..dac94cb8 --- /dev/null +++ b/src/src/ZodSharp/DefaultImmutableArrayBehavior.cs @@ -0,0 +1,14 @@ +namespace ZodSharp; + +/// Controls how generated validation handles an uninitialized immutable array. +public enum DefaultImmutableArrayBehavior +{ + /// + /// Treat the default array as absent: Required reports a missing field, a nullable member accepts it, + /// and a non-nullable member reports an invalid type. + /// + ValidationError, + + /// Treat the default array as empty, including when evaluating collection count rules. + TreatAsEmpty, +} diff --git a/src/src/ZodSharp/Expressions/CompiledValidator.cs b/src/src/ZodSharp/Expressions/CompiledValidator.cs index f8fb913c..d31fa802 100644 --- a/src/src/ZodSharp/Expressions/CompiledValidator.cs +++ b/src/src/ZodSharp/Expressions/CompiledValidator.cs @@ -58,20 +58,17 @@ public static Func CompileParser(IZodSchema schema) { ArgumentNullException.ThrowIfNull(schema); - return ValidatorCache.Parsers.GetValue( - schema, - static key => - { - var validate = Compile(key); + return ValidatorCache.Parsers.GetValue(schema, static key => + { + var validate = Compile(key); - return input => - { - var result = validate(input); + return input => + { + var result = validate(input); - return result.IsSuccess ? result.Value : throw new ZodException(result.Errors); - }; - } - ); + return result.IsSuccess ? result.Value : throw new ZodException(result.Errors); + }; + }); } // One table per T. ConditionalWeakTable is thread-safe and holds its keys weakly, so caching here diff --git a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs index f7baeb7b..4ae01918 100644 --- a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs +++ b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Options; using ZodSharp.Core; using ZodSharp.Options; @@ -16,20 +17,133 @@ public static class OptionsServiceCollectionExtensions where TOptions : class { /// - /// Registers a generic singleton that resolves the + /// Registers a for the builder's options name that resolves the /// source-generated schema validator for from the registered /// . /// /// How to behave when no validator is registered for . /// The for chaining. + /// + /// On .NET 11 the validator is registered through OptionsBuilder<T>.Validate<TValidator>(), + /// so the platform's named, asynchronous validation wrapping applies. On earlier frameworks it is + /// registered as an that validates only the builder's name. + /// /// public OptionsBuilder AddZodSchemaValidator( MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw - ) - { - builder.Services.AddZodSchemaOptionsValidator(missingValidatorBehavior); + ) => Register(builder, missingValidatorBehavior, configurationSection: null); + } + + // A classic extension method rather than a second extension block: a block whose type parameter carries + // [DynamicallyAccessedMembers] compiles to a different extension marker, and package validation then + // reports the 2.4.0 overload above as removed (CP0002). + + /// + /// Registers a for the builder's options name, optionally + /// validating at startup and recording the configuration section the options are bound from so failures + /// name the setting (Smtp:Servers:0:Host). + /// + /// How to behave when no validator is registered for . + /// + /// When , calls ValidateOnStart(). On .NET 11 startup validation runs the + /// asynchronous rules too. + /// + /// + /// The configuration section the options are bound from. It only affects failure messages; bind the + /// section yourself (for example with BindConfiguration, which the configuration binder source + /// generator can intercept in a Native AOT application). + /// + /// The options type. + /// The options builder. + /// The for chaining. + public static OptionsBuilder AddZodSchemaValidator< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions + >( + this OptionsBuilder builder, + MissingValidatorBehavior missingValidatorBehavior, + bool validateOnStart, + string? configurationSection = null + ) + where TOptions : class + { + Register(builder, missingValidatorBehavior, configurationSection); + + if (validateOnStart) + builder.ValidateOnStart(); + + return builder; + } + + /// + /// Validates with its ZodSharp schema, naming failures by the configuration keys + /// under (Smtp:Servers:0:Host: Host is required (missing_field)), without + /// binding the options. The trim- and Native AOT-safe counterpart of BindAndValidateWithZod. + /// + /// + /// + /// Bind the section yourself, before or after this call. BindConfiguration(section) is intercepted by the + /// configuration binder source generator (EnableConfigurationBindingGenerator, on by default under + /// PublishAot), so the pair is reflection-free: + /// + /// + /// services.AddOptions<SmtpOptions>() + /// .BindConfiguration("Smtp") + /// .ValidateWithZod("Smtp"); + /// + /// + /// The section is only used to name failures; this method never reads configuration. + /// + /// + /// The options type. + /// The options builder. + /// The configuration section the options are bound from, for example Smtp. + /// When , the options are validated at startup. + /// How to behave when no validator is registered. + /// The builder for chaining. + public static OptionsBuilder ValidateWithZod< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions + >( + this OptionsBuilder builder, + string configurationSection, + bool validateOnStart = true, + MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw + ) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(configurationSection); + + return builder.AddZodSchemaValidator(missingValidatorBehavior, validateOnStart, configurationSection); + } + + static OptionsBuilder Register( + OptionsBuilder builder, + MissingValidatorBehavior missingValidatorBehavior, + string? configurationSection + ) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(builder); + + ZodOptionsValidationRegistration registration = new( + typeof(TOptions), + builder.Name, + string.IsNullOrWhiteSpace(configurationSection) ? null : configurationSection, + missingValidatorBehavior, + builder.Name + ); + builder.Services.AddSingleton(registration); + +#if NET11_0_OR_GREATER + builder.Validate>(); +#else + builder.Services.AddSingleton>(sp => new ZodSchemaOptionsValidator( + sp.GetRequiredService(), + sp, + registration + )); +#endif - return builder; - } + return builder; } } diff --git a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index b1cf6181..ef2639f4 100644 --- a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -22,7 +22,8 @@ public static class ServiceCollectionExtensions /// /// The factory is registered only if one is not already present — repeated calls (with any /// callback) are ignored so earlier registrations are never - /// overwritten. + /// overwritten. Every in the container (added by the generated + /// AddZodSharp{Assembly}Schemas() extensions) is applied before . /// public IServiceCollection AddZodSharpFactory(Action? configure = null) { @@ -30,6 +31,9 @@ public IServiceCollection AddZodSharpFactory(Action? configur { ZodSchemaFactory factory = new(); + foreach (var registration in sp.GetServices()) + registration.Register(factory); + configure?.Invoke(factory); return factory; @@ -38,6 +42,23 @@ public IServiceCollection AddZodSharpFactory(Action? configur return services; } + /// + /// Adds a deferred validator registration, applied when the is first + /// resolved, and ensures a factory is registered. Generated AddZodSharp{Assembly}Schemas() + /// extensions call this with the assembly's generated registration method. + /// + /// Registers validators into the factory. + /// The for chaining. + public IServiceCollection AddZodSchemaRegistration(Action register) + { + ArgumentNullException.ThrowIfNull(register); + + services.AddSingleton(new ZodSchemaRegistration(register)); + services.AddZodSharpFactory(); + + return services; + } + /// /// Registers a generic singleton that resolves the /// source-generated schema validator for from the registered @@ -49,7 +70,10 @@ public IServiceCollection AddZodSharpFactory(Action? configur /// /// Requires an to already be registered, for example via /// - /// or AddZodSharp from the Purview.ZodSharp.AspNetCore package. + /// or AddZodSharp from the Purview.ZodSharp.AspNetCore package. The validator checks every + /// options name. On .NET 11 it also implements IAsyncValidateOptions<T>, which the platform runs + /// for an registration, so ValidateOnStart() checks the asynchronous + /// and service-aware rules too. /// public IServiceCollection AddZodSchemaOptionsValidator( MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw @@ -58,6 +82,7 @@ public IServiceCollection AddZodSchemaOptionsValidator( { services.AddSingleton>(sp => new ZodSchemaOptionsValidator( sp.GetRequiredService(), + sp, missingValidatorBehavior )); diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index 78846830..109a249b 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -172,4 +172,39 @@ public class JsonSchemaDefinition /// [JsonPropertyName("x-zodsharp-unsupported")] public List? UnsupportedRules { get; set; } + + Dictionary? _vendorExtensions; + + /// + /// Gets the vendor extensions (x-zod-*) rules recorded for this schema: information a validation rule + /// enforces that has no JSON Schema keyword, or that a keyword only approximates (the literal behind a + /// startsWith pattern, a UUID version, a CIDR family). + /// + /// The extensions keyed by name; empty when none were recorded. + /// + /// Exposed through methods rather than a property on purpose: the JSON Schema exporters (System.Text.Json + /// and Newtonsoft.Json) serialize properties, so the exported JSON Schema is unchanged. Hosts whose format + /// carries vendor extensions, such as an OpenAPI document, read them from here. + /// + public IReadOnlyDictionary GetVendorExtensions() => + _vendorExtensions ?? (IReadOnlyDictionary)EmptyVendorExtensions; + + /// + /// Records a vendor extension for this schema, replacing any value already recorded under + /// . + /// + /// The extension name; must start with x-. + /// The extension value: a string, number, boolean or . + /// Thrown when does not start with x-. + public void SetVendorExtension(string name, object? value) + { + ArgumentException.ThrowIfNullOrEmpty(name); + if (!name.StartsWith("x-", StringComparison.Ordinal)) + throw new ArgumentException("A vendor extension name must start with 'x-'.", nameof(name)); + + _vendorExtensions ??= [with(StringComparer.Ordinal)]; + _vendorExtensions[name] = value; + } + + static readonly Dictionary EmptyVendorExtensions = [with(StringComparer.Ordinal)]; } diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 0f682acf..332c43f5 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -18,7 +18,28 @@ public static class ToJsonSchemaConverter /// The ZodSharp schema to convert /// Conversion options /// A JSON Schema definition - public static JsonSchemaDefinition Convert(IZodSchema schema, ToJsonSchemaOptions? options = null) + public static JsonSchemaDefinition Convert(IZodSchema schema, ToJsonSchemaOptions? options = null) => + ConvertCore(schema, options); + + /// + /// Converts a schema, validator or validator view whose type is only known at runtime to JSON Schema. + /// + /// + /// A ZodSharp schema, a generated validator (), or an adapter that wraps one + /// (, ), as returned by + /// . + /// + /// Conversion options + /// + /// A JSON Schema definition. A schema the exporter cannot describe produces the empty (any) schema. + /// + public static JsonSchemaDefinition ConvertUntyped(object schema, ToJsonSchemaOptions? options = null) + { + ArgumentNullException.ThrowIfNull(schema); + return ConvertCore(schema, options); + } + + static JsonSchemaDefinition ConvertCore(object schema, ToJsonSchemaOptions? options) { options ??= new ToJsonSchemaOptions(); ConversionContext context = new() { DateTimeOffsetSerialization = options.DateTimeOffsetSerialization }; @@ -92,6 +113,7 @@ static JsonSchemaDefinition ConvertSchema(object schema, ConversionContext ctx) // ZodOptional is handled by the unwrap loop above: in JSON Schema optionality lives in the // parent's "required" list, not in the property's own type. ZodUnion zodUnion => ConvertUnion(zodUnion, ctx), + ZodDiscriminatedUnion discriminatedUnion => ConvertDiscriminatedUnion(discriminatedUnion, ctx), _ => ConvertGeneric(schema, ctx), }; @@ -286,6 +308,29 @@ static JsonSchemaDefinition ConvertUnion(ZodUnion schema, ConversionContext ctx) return result; } + /// + /// A discriminated union matches exactly one option (the one its discriminator selects), so it exports as + /// oneOf. The discriminator property is recorded in the x-zod-discriminator vendor extension, + /// and each option records its discriminator value in x-zod-discriminatorValue. + /// + static JsonSchemaDefinition ConvertDiscriminatedUnion(ZodDiscriminatedUnion schema, ConversionContext ctx) + { + JsonSchemaDefinition result = new() { OneOf = [] }; + result.SetVendorExtension("x-zod-discriminator", schema.Discriminator); + + foreach (var value in schema.OptionKeys) + { + if (!schema.Options.TryGetValue(value, out var option)) + continue; + + var converted = ConvertSchema(option, ctx); + converted.SetVendorExtension("x-zod-discriminatorValue", value); + result.OneOf.Add(converted); + } + + return result; + } + static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) { var schemaType = schema.GetType(); diff --git a/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs b/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs new file mode 100644 index 00000000..4c2357de --- /dev/null +++ b/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs @@ -0,0 +1,49 @@ +using System.Text; +using ZodSharp.Core; + +namespace ZodSharp.Options; + +/// +/// The settings recorded when an options type is registered for ZodSharp validation through an +/// OptionsBuilder<T>, keyed by options type and name. +/// +/// The options type. +/// The options name. +/// The configuration section the options are bound from, if known. +/// How to behave when no validator is registered. +/// When set, only this options name is validated. +sealed record ZodOptionsValidationRegistration( + Type OptionsType, + string Name, + string? ConfigurationSection, + MissingValidatorBehavior MissingValidatorBehavior, + string? NameFilter +); + +/// +/// Renders options validation failures: {name}: {location}: {message} ({code}). +/// +static class ZodOptionsFailureFormatter +{ + public static string Format(string? optionsName, string? configurationSection, in ValidationError error) + { + StringBuilder builder = new(); + if (!string.IsNullOrEmpty(optionsName)) + builder.Append(optionsName).Append(": "); + + var location = + string.IsNullOrEmpty(configurationSection) ? error.FullPath.ToDottedString() + : error.FullPath.IsRoot ? configurationSection + : configurationSection + ":" + error.FullPath.ToConfigurationKey(); + + builder + .Append(location.Length == 0 ? "(root)" : location) + .Append(": ") + .Append(error.Message) + .Append(" (") + .Append(error.Code) + .Append(')'); + + return builder.ToString(); + } +} diff --git a/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs b/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs index 7039f26f..d2dba2ad 100644 --- a/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs +++ b/src/src/ZodSharp/Options/ZodSchemaOptionsValidator.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZodSharp.Core; @@ -9,29 +11,216 @@ namespace ZodSharp.Options; /// allowing a single hand-written class to validate any registered options type. /// /// The options type to validate. -/// Initializes a new instance with the schema factory used to resolve validators. -/// The schema factory. -/// How to behave when no validator is registered for . -public sealed class ZodSchemaOptionsValidator( - IZodSchemaFactory factory, - MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw -) : IValidateOptions +/// +/// +/// Each failure is rendered as {name}: {location}: {message} ({code}). The location is the +/// configuration key (Smtp:Servers:0:Host) when the options were registered with a configuration +/// section, and the dotted path (Servers[0].Host) otherwise; the name prefix is omitted for the default +/// options name. The full errors are available on the returned . +/// +/// +/// On .NET 11 the validator also implements IAsyncValidateOptions<T>. The platform calls +/// only from ValidateOnStart() (through IAsyncStartupValidator), so +/// asynchronous and service-aware rules are checked at startup; synchronous options access +/// (IOptions<T>.Value, monitor reloads, snapshots) runs , which checks the +/// synchronous rules only. That is the platform's own contract. +/// +/// +public sealed class ZodSchemaOptionsValidator : IValidateOptions +#if NET11_0_OR_GREATER + , IAsyncValidateOptions +#endif where T : class { + readonly IZodSchemaFactory _factory; + readonly MissingValidatorBehavior _missingValidatorBehavior; + readonly IServiceProvider? _services; + readonly ZodOptionsValidationRegistration? _registration; + + /// + /// Initializes a new instance with the schema factory used to resolve validators. + /// + /// The schema factory. + /// How to behave when no validator is registered for . + public ZodSchemaOptionsValidator( + IZodSchemaFactory factory, + MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw + ) + : this(factory, services: null, missingValidatorBehavior) { } + + /// + /// Initializes a new instance with the schema factory used to resolve validators and the services passed to + /// asynchronous, service-aware rules. Settings recorded by + /// OptionsBuilder<T>.AddZodSchemaValidator(...) for the validated options name (the missing + /// validator behaviour and the configuration section) take precedence over + /// . + /// + /// The schema factory. + /// The services used for asynchronous validation; a scope is created per validation. + /// How to behave when no validator is registered for . + [ActivatorUtilitiesConstructor] + public ZodSchemaOptionsValidator( + IZodSchemaFactory factory, + IServiceProvider? services, + MissingValidatorBehavior missingValidatorBehavior = MissingValidatorBehavior.Throw + ) + { + ArgumentNullException.ThrowIfNull(factory); + + _factory = factory; + _services = services; + _missingValidatorBehavior = missingValidatorBehavior; + } + + internal ZodSchemaOptionsValidator( + IZodSchemaFactory factory, + IServiceProvider? services, + ZodOptionsValidationRegistration registration + ) + : this(factory, services, registration.MissingValidatorBehavior) + { + _registration = registration; + } + /// public ValidateOptionsResult Validate(string? name, T options) { - var validator = - missingValidatorBehavior == MissingValidatorBehavior.Throw - ? factory.ResolveRequired() - : factory.Resolve(); + var registration = ResolveRegistration(name); + if ( + registration is { NameFilter: { } filter } + && !string.Equals(filter, name ?? string.Empty, StringComparison.Ordinal) + ) + return ValidateOptionsResult.Skip; + var validator = ResolveValidator(registration); if (validator is null) return ValidateOptionsResult.Success; var result = validator.Validate(options); - return result.IsSuccess - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(result.Errors.Select(static error => error.Message)); + return ToResult(name, registration, result); } + + /// + /// Validates asynchronously, running asynchronous and service-aware rules. On + /// .NET 11 this implements IAsyncValidateOptions<T> and is called by ValidateOnStart(). + /// + /// The options name. + /// The options instance. + /// A token that cancels the validation. + /// The validation result. + public async Task ValidateAsync( + string? name, + T options, + CancellationToken cancellationToken = default + ) + { + var registration = ResolveRegistration(name); + if ( + registration is { NameFilter: { } filter } + && !string.Equals(filter, name ?? string.Empty, StringComparison.Ordinal) + ) + return ValidateOptionsResult.Skip; + + var validator = ResolveValidator(registration); + if (validator is null) + return ValidateOptionsResult.Success; + + if (_services?.GetService() is { } scopeFactory) + { + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var scoped = await validator + .ValidateAsync(options, scope.ServiceProvider, cancellationToken) + .ConfigureAwait(false); + return ToResult(name, registration, scoped); + } + } + + var result = await validator.ValidateAsync(options, _services, cancellationToken).ConfigureAwait(false); + return ToResult(name, registration, result); + } + + IZodSchemaValidator? ResolveValidator(ZodOptionsValidationRegistration? registration) => + (registration?.MissingValidatorBehavior ?? _missingValidatorBehavior) == MissingValidatorBehavior.Throw + ? _factory.ResolveRequired() + : _factory.Resolve(); + + ZodOptionsValidationRegistration? ResolveRegistration(string? name) + { + if (_registration is not null) + return _registration; + + if (_services is null) + return null; + + var effectiveName = name ?? Microsoft.Extensions.Options.Options.DefaultName; + ZodOptionsValidationRegistration? match = null; + foreach (var registration in _services.GetServices()) + { + if ( + registration.OptionsType == typeof(T) + && string.Equals(registration.Name, effectiveName, StringComparison.Ordinal) + ) + { + match = registration; + } + } + + return match; + } + + static ValidateOptionsResult ToResult( + string? name, + ZodOptionsValidationRegistration? registration, + in ValidationResult result + ) + { + if (result.IsSuccess) + return ValidateOptionsResult.Success; + + var errors = result.Errors.IsDefault ? [] : result.Errors; + return new ZodValidateOptionsResult(name, registration?.ConfigurationSection, errors); + } +} + +/// +/// A failed produced by that keeps +/// the full s alongside the path-bearing failure messages. +/// +public sealed class ZodValidateOptionsResult : ValidateOptionsResult +{ + internal ZodValidateOptionsResult( + string? optionsName, + string? configurationSection, + ImmutableArray errors + ) + { + OptionsName = optionsName; + ConfigurationSection = configurationSection; + Errors = errors; + + var failures = new string[errors.Length]; + for (var i = 0; i < failures.Length; i++) + failures[i] = ZodOptionsFailureFormatter.Format(optionsName, configurationSection, errors[i]); + + Failed = true; + Failures = failures; + FailureMessage = string.Join("; ", failures); + } + + /// + /// Gets the options name that failed validation. + /// + public string? OptionsName { get; } + + /// + /// Gets the configuration section the options were bound from, when it was recorded at registration. + /// + public string? ConfigurationSection { get; } + + /// + /// Gets the full validation errors. + /// + public ImmutableArray Errors { get; } } diff --git a/src/src/ZodSharp/Rules/AllowedValuesRule.cs b/src/src/ZodSharp/Rules/AllowedValuesRule.cs index 6d14d2a8..d1689cdf 100644 --- a/src/src/ZodSharp/Rules/AllowedValuesRule.cs +++ b/src/src/ZodSharp/Rules/AllowedValuesRule.cs @@ -17,11 +17,15 @@ namespace ZodSharp.Rules; /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Value '{0}' is not one of the allowed values"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Value is not one of the allowed values"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly T[]? _values; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct that accepts a single value. @@ -41,7 +45,7 @@ public AllowedValuesRule(T value, string? message = null, string? code = null) public AllowedValuesRule(T[]? values, string? message = null, string? code = null) { _values = values is { Length: > 0 } ? values : null; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -73,5 +77,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + _values is null ? null : RuleParameters.One(Core.ZodIssueParameters.Options, Array.AsReadOnly(_values)); } diff --git a/src/src/ZodSharp/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index 0dbec1c8..e9979ded 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -6,15 +6,19 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct Base64StringRule : Core.IValidationRule, Core.IZodRule +public readonly record struct Base64StringRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid Base64 string format: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid Base64 string format"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the Base64StringRule struct. @@ -23,7 +27,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public Base64StringRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -60,5 +64,20 @@ public bool IsValid(in string value) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("base64"); + + /// Applies contentEncoding: base64. + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.ContentEncoding = "base64"; + } } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs index 29cf9085..f83ee3be 100644 --- a/src/src/ZodSharp/Rules/Base64UrlRule.cs +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid base64url string: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid base64url string"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the Base64UrlRule struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public Base64UrlRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -74,5 +78,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("base64url"); } diff --git a/src/src/ZodSharp/Rules/CidrRule.cs b/src/src/ZodSharp/Rules/CidrRule.cs index ae314a93..64b7cd84 100644 --- a/src/src/ZodSharp/Rules/CidrRule.cs +++ b/src/src/ZodSharp/Rules/CidrRule.cs @@ -30,16 +30,22 @@ public enum CidrRuleType /// z.string().cidr(). Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct CidrRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct CidrRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value and {1} the required type. - public const string MessageFormat = "Invalid {1} CIDR: {0}"; + /// Gets the default message format; {1} is the required type. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid {1} CIDR"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; readonly CidrRuleType _ruleType; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct with the default rule type (Any). @@ -51,7 +57,6 @@ public enum CidrRuleType public CidrRule() { _ruleType = CidrRuleType.Any; - _message = MessageFormat; } /// @@ -71,7 +76,7 @@ public CidrRule(string? message = null, string? code = null) public CidrRule(CidrRuleType ruleType = CidrRuleType.Any, string? message = null, string? code = null) { _ruleType = ruleType; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -116,12 +121,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => - RuleMessage.Format( - _message ?? MessageFormat, - value, - _ruleType == CidrRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString() - ); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value, TypeName); /// /// Gets the error message for a failed span validation. @@ -129,16 +129,57 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - RuleMessage.Format( - _message ?? MessageFormat, - value.ToString(), - _ruleType == CidrRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString() - ); + RuleMessage.Format(_message ?? MessageFormat, value.ToString(), TypeName); + + string TypeName => + _ruleType switch + { + CidrRuleType.IPv4 => "IPv4", + CidrRuleType.IPv6 => "IPv6", + CidrRuleType.Any => "IPv4/IPv6", + _ => "IPv4/IPv6", + }; + + string FormatName => + _ruleType switch + { + CidrRuleType.IPv4 => "cidrv4", + CidrRuleType.IPv6 => "cidrv6", + CidrRuleType.Any => "cidr", + _ => "cidr", + }; /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => RuleMessage.Template(_message ?? MessageFormat, 1, TypeName, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format(FormatName); + + /// + /// CIDR notation has no JSON Schema format, so the rule is recorded as unsupported and described by the + /// x-zod-cidr vendor extension (v4, v6 or any). + /// + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.SetVendorExtension( + "x-zod-cidr", + _ruleType switch + { + CidrRuleType.IPv4 => "v4", + CidrRuleType.IPv6 => "v6", + CidrRuleType.Any => "any", + _ => "any", + } + ); + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(CidrRule)); + } } diff --git a/src/src/ZodSharp/Rules/CollectionCount.cs b/src/src/ZodSharp/Rules/CollectionCount.cs new file mode 100644 index 00000000..e7d56e6f --- /dev/null +++ b/src/src/ZodSharp/Rules/CollectionCount.cs @@ -0,0 +1,35 @@ +using System.Collections; + +namespace ZodSharp.Rules; + +/// +/// Counts a collection without reflection: through when it is implemented, otherwise by +/// enumerating it. +/// +static class CollectionCount +{ + public static int Of(IEnumerable? value) + { + switch (value) + { + case null: + return 0; + case ICollection collection: + return collection.Count; + default: + var count = 0; + var enumerator = value.GetEnumerator(); + try + { + while (enumerator.MoveNext()) + count++; + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + + return count; + } + } +} diff --git a/src/src/ZodSharp/Rules/CompareRule.cs b/src/src/ZodSharp/Rules/CompareRule.cs index 97dee78a..af9bfb70 100644 --- a/src/src/ZodSharp/Rules/CompareRule.cs +++ b/src/src/ZodSharp/Rules/CompareRule.cs @@ -15,12 +15,16 @@ namespace ZodSharp.Rules; /// Gets the error code reported when the rule fails. public const string ErrorCode = "mismatch"; - /// Gets the message format; {0} is the expected value and {1} the actual value. - public const string MessageFormat = "Value must match '{0}', but got '{1}'"; + /// Gets the default message format, which contains neither value; a custom message may use {0} for the expected value and {1} for the actual value. + public const string MessageFormat = "Value does not match the expected value"; + + static readonly string?[] TemplateNames = [null, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly string _other; readonly StringComparison _comparison; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -38,7 +42,7 @@ public CompareRule( { _other = other.OrNull() ?? throw new ArgumentNullException(nameof(other)); _comparison = comparison; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -76,5 +80,10 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; } diff --git a/src/src/ZodSharp/Rules/CountRangeRule.cs b/src/src/ZodSharp/Rules/CountRangeRule.cs new file mode 100644 index 00000000..c1c51274 --- /dev/null +++ b/src/src/ZodSharp/Rules/CountRangeRule.cs @@ -0,0 +1,100 @@ +using System.Collections; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a collection's element (or entry) count to be within an inclusive range. The +/// trim- and Native AOT-safe counterpart of [Length] on a collection member. +/// +/// +/// The generated [CountRange] attribute is what a [ZodSchema] model uses: the generator emits the +/// count check itself, with the same error identity as [Length] on the same member (too_small below +/// the range and too_big above it), without the BCL attribute's [RequiresUnreferencedCode] +/// constructor. +/// +[Core.ZodRule] +public readonly record struct CountRangeRule + : Core.IValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber +{ + /// Gets the error code reported when the rule fails. + public const string ErrorCode = "invalid_count"; + + /// Gets the message format; {0} is the minimum, {1} the maximum and {2} the actual count. + public const string MessageFormat = "Collection must contain between {0} and {1} element(s), but got {2}"; + + static readonly string?[] TemplateNames = [RuleMessage.Minimum, RuleMessage.Maximum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The inclusive minimum number of elements. + /// The inclusive maximum number of elements. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public CountRangeRule(int minimum, int maximum, string? message = null, string? code = null) + { + Minimum = minimum; + Maximum = maximum; + _message = message.OrNull(); + Code = code.Or(ErrorCode); + } + + /// Gets the inclusive minimum number of elements. + public int Minimum { get; } + + /// Gets the inclusive maximum number of elements. + public int Maximum { get; } + + /// + /// Validates that the collection's count is within the configured range. A collection is + /// not counted. + /// + /// The collection to validate. + /// True if valid, false otherwise. + public bool IsValid(in IEnumerable value) + { + if (value is null) + return true; + + var count = CollectionCount.Of(value); + return count >= Minimum && count <= Maximum; + } + + /// + /// Gets the error message for a failed validation. + /// + /// The collection that failed validation. + /// The error message. + public string GetErrorMessage(in IEnumerable value) => + RuleMessage.Format(_message ?? MessageFormat, Minimum, Maximum, CollectionCount.Of(value)); + + /// Gets the error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Array; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(Minimum); + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(Maximum); + + // The Zod issue code follows the bound that failed. + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in IEnumerable value) + { + var count = CollectionCount.Of(value); + return new( + count < Minimum ? Core.ZodIssueCategories.TooSmall : Core.ZodIssueCategories.TooBig, + RuleParameters.Count(count) + ); + } +} diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index de5ca86a..37e9e466 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -11,10 +11,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid credit card number format: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid credit card number format"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the CreditCardRule struct. @@ -23,7 +27,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public CreditCardRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -98,5 +102,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("credit_card"); } diff --git a/src/src/ZodSharp/Rules/Cuid2Rule.cs b/src/src/ZodSharp/Rules/Cuid2Rule.cs index 8c8daadb..74178313 100644 --- a/src/src/ZodSharp/Rules/Cuid2Rule.cs +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid CUID2: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid CUID2"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the Cuid2Rule struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public Cuid2Rule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -72,5 +76,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("cuid2"); } diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index 2bd9024f..008a01c7 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -7,15 +7,23 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct DateStringRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct DateStringRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid date: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid date"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the DateStringRule struct. @@ -24,7 +32,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public DateStringRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -69,5 +77,20 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("date"); + + /// Applies format: date (an RFC 3339 full-date, yyyy-MM-dd). + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "date"; + } } diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index fdc1d2a0..ca1f1428 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -11,15 +11,20 @@ namespace ZodSharp.Rules; public readonly record struct DatetimeStringRule : Core.IValidationRule, Core.IStringValidationRule, - Core.IZodRule + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid date-time: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid date-time"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the DatetimeStringRule struct. @@ -28,7 +33,7 @@ public readonly record struct DatetimeStringRule /// Optional error code override. If one is not specified then the is used. public DatetimeStringRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -126,5 +131,28 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("datetime"); + + /// The JSON Schema pattern for the UTC (Z-suffixed) date-time this rule accepts. + public const string JsonSchemaPattern = + "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?[Zz]$"; + + /// + /// Applies format: date-time. The rule only accepts UTC values, which the format alone does not + /// express, so is applied as well. + /// + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "date-time"; + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaPattern); + } } diff --git a/src/src/ZodSharp/Rules/DeniedValuesRule.cs b/src/src/ZodSharp/Rules/DeniedValuesRule.cs index 961ce1bd..3e187663 100644 --- a/src/src/ZodSharp/Rules/DeniedValuesRule.cs +++ b/src/src/ZodSharp/Rules/DeniedValuesRule.cs @@ -17,11 +17,15 @@ namespace ZodSharp.Rules; /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Value '{0}' is one of the denied values"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Value is one of the denied values"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly T[]? _values; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct that rejects a single value. @@ -41,7 +45,7 @@ public DeniedValuesRule(T value, string? message = null, string? code = null) public DeniedValuesRule(T[]? values, string? message = null, string? code = null) { _values = values is { Length: > 0 } ? values : null; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -73,5 +77,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; } diff --git a/src/src/ZodSharp/Rules/DurationRule.cs b/src/src/ZodSharp/Rules/DurationRule.cs index ebd51d07..7187b135 100644 --- a/src/src/ZodSharp/Rules/DurationRule.cs +++ b/src/src/ZodSharp/Rules/DurationRule.cs @@ -7,13 +7,21 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct DurationRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct DurationRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid duration: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid duration"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); /// /// Regular expression for ISO 8601-1 durations. Does not accept the 8601-2 extensions (negative @@ -25,7 +33,7 @@ namespace ZodSharp.Rules; RegexRule.DefaultMatchTimeout ); - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -34,7 +42,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public DurationRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -97,5 +105,20 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("duration"); + + /// Applies format: duration (an ISO 8601 duration). + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.Format = "duration"; + } } diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs index 13bc568c..efa9de6f 100644 --- a/src/src/ZodSharp/Rules/E164Rule.cs +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid E.164 phone number: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid E.164 phone number"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the E164Rule struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public E164Rule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -76,5 +80,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("e164"); } diff --git a/src/src/ZodSharp/Rules/EmailRule.cs b/src/src/ZodSharp/Rules/EmailRule.cs index b5986af8..ca0ef2c1 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -16,8 +16,12 @@ public readonly record struct EmailRule /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid email format: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid email format"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); /// /// Regular expression for validating email format. @@ -28,11 +32,11 @@ public readonly record struct EmailRule RegexRule.DefaultMatchTimeout ); - readonly string _message; + readonly string? _message; public EmailRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -78,5 +82,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("email"); } diff --git a/src/src/ZodSharp/Rules/EmojiRule.cs b/src/src/ZodSharp/Rules/EmojiRule.cs index a3e09a65..7a76aafd 100644 --- a/src/src/ZodSharp/Rules/EmojiRule.cs +++ b/src/src/ZodSharp/Rules/EmojiRule.cs @@ -16,10 +16,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid emoji: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid emoji"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -28,7 +32,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public EmojiRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -90,7 +94,14 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("emoji"); /// The Unicode Extended_Pictographic and Emoji_Component tables. static class EmojiRanges diff --git a/src/src/ZodSharp/Rules/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index bf6ac599..d7345f2d 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -17,11 +17,15 @@ public readonly record struct EndsWithRule public const string ErrorCode = "invalid_string"; /// Gets the message format; {0} is the required suffix and {1} the value. - public const string MessageFormat = "String must end with '{0}', but got '{1}'"; + public const string MessageFormat = "String must end with '{0}'"; + + static readonly string?[] TemplateNames = [Core.ZodIssueParameters.Suffix, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly string _suffix; readonly StringComparison _comparison; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the EndsWithRule struct. @@ -39,7 +43,7 @@ public EndsWithRule( { _suffix = suffix.OrNull() ?? throw new ArgumentNullException(nameof(suffix)); _comparison = comparison; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -78,6 +82,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Regex.Escape(_suffix) + "$"); + schema.SetVendorExtension("x-zod-endsWith", _suffix); } /// Gets the Zod-compatible error code reported when the rule fails. @@ -85,5 +90,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + RuleParameters.Two(Core.ZodIssueParameters.Format, "ends_with", Core.ZodIssueParameters.Suffix, _suffix); } diff --git a/src/src/ZodSharp/Rules/EnumRule.cs b/src/src/ZodSharp/Rules/EnumRule.cs index 0012f357..b6171fb9 100644 --- a/src/src/ZodSharp/Rules/EnumRule.cs +++ b/src/src/ZodSharp/Rules/EnumRule.cs @@ -26,15 +26,19 @@ namespace ZodSharp.Rules; /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_enum_value"; - /// Gets the message format; {0} is the offending value and {1} the enum name. - public const string MessageFormat = "'{0}' is not a defined member of {1}"; + /// Gets the message format; {1} is the enum name. The default never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Value is not a defined member of {1}"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; // Resolving the defined members once per closed generic type avoids the per-validation // Enum.IsDefined reflection path. static readonly HashSet DefinedValues = [.. Enum.GetValues()]; + static readonly System.Collections.Immutable.ImmutableArray DefinedNames = [.. Enum.GetNames()]; + readonly TEnum[]? _disallowed; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct that accepts every defined @@ -55,7 +59,7 @@ public EnumRule(string? message = null, string? code = null) public EnumRule(TEnum[]? disallowed, string? message = null, string? code = null) { _disallowed = disallowed is { Length: > 0 } ? disallowed : null; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -101,5 +105,30 @@ .. Enum.GetValues() string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + RuleMessage.Template(_message ?? MessageFormat, 1, typeof(TEnum).Name, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Enum; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; + + // The allowed member names, in declaration order (enum members are configuration, not caller input). + IReadOnlyDictionary? Core.IZodRule.Parameters + { + get + { + var disallowed = _disallowed; + return RuleParameters.One( + Core.ZodIssueParameters.Options, + disallowed is null + ? DefinedNames + : + [ + .. Enum.GetValues() + .Where(value => Array.IndexOf(disallowed, value) < 0) + .Select(static value => value.ToString()), + ] + ); + } + } } diff --git a/src/src/ZodSharp/Rules/EvenRule.cs b/src/src/ZodSharp/Rules/EvenRule.cs index bb3807c9..834f0628 100644 --- a/src/src/ZodSharp/Rules/EvenRule.cs +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -14,10 +14,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_value"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Number must be even, but got {0}"; + /// Gets the message format. The default never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Number must be even"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the EvenRule struct. @@ -26,7 +30,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public EvenRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -57,5 +61,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.Custom; } diff --git a/src/src/ZodSharp/Rules/FiniteRule.cs b/src/src/ZodSharp/Rules/FiniteRule.cs index 4fcd5609..e760fc68 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -14,10 +14,19 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "not_finite"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Number must be finite, but got {0}"; + /// Gets the message format. The default never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Number must be finite"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + static readonly IReadOnlyDictionary FiniteParameters = RuleParameters.One( + Core.ZodIssueParameters.Expected, + Core.ZodTypeNames.Number + ); + + readonly string? _message; /// /// Initializes a new instance of the FiniteRule struct. @@ -26,7 +35,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public FiniteRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -59,5 +68,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Number; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidType; + + IReadOnlyDictionary? Core.IZodRule.Parameters => FiniteParameters; } diff --git a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs index 42d517a0..7ae32219 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -15,12 +15,16 @@ public readonly record struct GreaterThanOrEqualRule /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; - /// Gets the message format; {0} is the bound and {1} the offending value. - public const string MessageFormat = "Value must be greater than or equal to {0}, but got {1}"; + /// Gets the message format; {0} is the bound. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Value must be greater than or equal to {0}"; + + static readonly string?[] TemplateNames = [RuleMessage.Minimum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal T MinValue { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the GreaterThanOrEqualRule struct. @@ -31,7 +35,7 @@ public readonly record struct GreaterThanOrEqualRule public GreaterThanOrEqualRule(T minValue, string? message = null, string? code = null) { MinValue = minValue; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -65,5 +69,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooSmall; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(MinValue); } diff --git a/src/src/ZodSharp/Rules/GreaterThanRule.cs b/src/src/ZodSharp/Rules/GreaterThanRule.cs index 7d87ddbd..2a5eb7d0 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -12,12 +12,16 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; - /// Gets the message format; {0} is the bound and {1} the offending value. - public const string MessageFormat = "Value must be greater than {0}, but got {1}"; + /// Gets the message format; {0} is the bound. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Value must be greater than {0}"; + + static readonly string?[] TemplateNames = [RuleMessage.Minimum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly T _exclusiveMinimum; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the GreaterThanRule struct. @@ -28,7 +32,7 @@ namespace ZodSharp.Rules; public GreaterThanRule(T exclusiveMinimum, string? message = null, string? code = null) { _exclusiveMinimum = exclusiveMinimum; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -63,5 +67,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooSmall; + + // Exclusive: the value must be strictly greater than the bound. + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Excluding(_exclusiveMinimum); } diff --git a/src/src/ZodSharp/Rules/GuidRule.cs b/src/src/ZodSharp/Rules/GuidRule.cs index d9ada147..c4363795 100644 --- a/src/src/ZodSharp/Rules/GuidRule.cs +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -15,8 +15,12 @@ public readonly record struct GuidRule /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid GUID: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid GUID"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); /// /// The JSON Schema pattern for a canonical 8-4-4-4-12 hexadecimal identifier (any version or @@ -26,7 +30,7 @@ public readonly record struct GuidRule public const string JsonSchemaPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -35,7 +39,7 @@ public readonly record struct GuidRule /// Optional error code override. If one is not specified then the is used. public GuidRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -103,5 +107,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("guid"); } diff --git a/src/src/ZodSharp/Rules/HexRule.cs b/src/src/ZodSharp/Rules/HexRule.cs index 89739095..679eed42 100644 --- a/src/src/ZodSharp/Rules/HexRule.cs +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid hexadecimal string: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid hexadecimal string"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the HexRule struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public HexRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -69,5 +73,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("hex"); } diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 64ebeea5..03e2edac 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -29,16 +29,22 @@ public enum IPAddressRuleType /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct IPAddressRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct IPAddressRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid {1} address: {0}"; + /// Gets the default message format; {1} is the required address type. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid {1} address"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; readonly IPAddressRuleType _ruleType; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct with the default rule type (Any). @@ -50,7 +56,6 @@ public enum IPAddressRuleType public IPAddressRule() { _ruleType = IPAddressRuleType.Any; - _message = MessageFormat; } /// @@ -74,7 +79,7 @@ public IPAddressRule( ) { _ruleType = ruleType; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -114,8 +119,7 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => - RuleMessage.Format(_message, value, _ruleType == IPAddressRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString()); + public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value, TypeName); /// /// Gets the error message for a failed span validation. @@ -123,16 +127,60 @@ public string GetErrorMessage(in string value) => /// The value that failed validation /// The error message public string GetErrorMessage(ReadOnlySpan value) => - RuleMessage.Format( - _message, - value.ToString(), - _ruleType == IPAddressRuleType.Any ? "IPv4/IPv6" : _ruleType.ToString() - ); + RuleMessage.Format(_message ?? MessageFormat, value.ToString(), TypeName); + + string TypeName => + _ruleType switch + { + IPAddressRuleType.IPv4 => "IPv4", + IPAddressRuleType.IPv6 => "IPv6", + IPAddressRuleType.Any => "IPv4/IPv6", + _ => "IPv4/IPv6", + }; + + string FormatName => + _ruleType switch + { + IPAddressRuleType.IPv4 => "ipv4", + IPAddressRuleType.IPv6 => "ipv6", + IPAddressRuleType.Any => "ip", + _ => "ip", + }; /// Gets the Zod-compatible error code reported when the rule fails. public string Code => field.Or(ErrorCode); string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => RuleMessage.Template(_message ?? MessageFormat, 1, TypeName, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format(FormatName); + + /// + /// Applies format: ipv4/ipv6. An address of either family has no single JSON Schema format, so it + /// is recorded as unsupported and described by the x-zod-ipVersion vendor extension instead. + /// + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + switch (_ruleType) + { + case IPAddressRuleType.IPv4: + schema.Format = "ipv4"; + break; + case IPAddressRuleType.IPv6: + schema.Format = "ipv6"; + break; + case IPAddressRuleType.Any: + default: + schema.SetVendorExtension("x-zod-ipVersion", "any"); + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(IPAddressRule)); + break; + } + } } diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index a523ab94..71ab34b4 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -17,10 +17,14 @@ public readonly record struct IncludesRule public const string ErrorCode = "invalid_string"; /// Gets the message format; {0} is the required substring and {1} the value. - public const string MessageFormat = "String must contain '{0}', but got '{1}'"; + public const string MessageFormat = "String must contain '{0}'"; + + static readonly string?[] TemplateNames = [Core.ZodIssueParameters.Includes, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly string _substring; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the IncludesRule struct. @@ -31,7 +35,7 @@ public readonly record struct IncludesRule public IncludesRule(string substring, string? message = null, string? code = null) { _substring = substring ?? throw new ArgumentNullException(nameof(substring)); - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -70,6 +74,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); JsonSchema.JsonSchemaRules.ConstrainPattern(schema, Regex.Escape(_substring)); + schema.SetVendorExtension("x-zod-includes", _substring); } /// Gets the Zod-compatible error code reported when the rule fails. @@ -77,5 +82,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + RuleParameters.Two(Core.ZodIssueParameters.Format, "includes", Core.ZodIssueParameters.Includes, _substring); } diff --git a/src/src/ZodSharp/Rules/IntRule.cs b/src/src/ZodSharp/Rules/IntRule.cs index b3907c1b..d243c72a 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -14,14 +14,23 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_type"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Expected integer, but got {0}"; + /// Gets the message format. The default never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Expected integer, received float"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + static readonly IReadOnlyDictionary IntParameters = RuleParameters.One( + Core.ZodIssueParameters.Expected, + "int" + ); + + readonly string? _message; public IntRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -56,5 +65,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Number; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidType; + + IReadOnlyDictionary? Core.IZodRule.Parameters => IntParameters; } diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index 96066b69..12df1273 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -5,15 +5,23 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct JWTRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct JWTRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid JWT: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid JWT"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the JWTRule struct. @@ -22,7 +30,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public JWTRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -92,5 +100,24 @@ static bool IsBase64Url(ReadOnlySpan segment) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("jwt"); + + /// + /// A JWT has no JSON Schema format, so the rule is recorded as unsupported and described by the + /// x-zod-jwt vendor extension. + /// + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + schema.SetVendorExtension("x-zod-jwt", true); + JsonSchema.JsonSchemaRules.MarkUnsupported(schema, nameof(JWTRule)); + } } diff --git a/src/src/ZodSharp/Rules/KsuidRule.cs b/src/src/ZodSharp/Rules/KsuidRule.cs index 1bf682ee..0cc7dc8f 100644 --- a/src/src/ZodSharp/Rules/KsuidRule.cs +++ b/src/src/ZodSharp/Rules/KsuidRule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid KSUID: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid KSUID"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public KsuidRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -74,5 +78,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("ksuid"); } diff --git a/src/src/ZodSharp/Rules/LengthRule.cs b/src/src/ZodSharp/Rules/LengthRule.cs index f0bdb91f..f5ee9299 100644 --- a/src/src/ZodSharp/Rules/LengthRule.cs +++ b/src/src/ZodSharp/Rules/LengthRule.cs @@ -9,7 +9,12 @@ namespace ZodSharp.Rules; /// System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct LengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct LengthRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber, + Core.IZodStringFailureDescriber { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_length"; @@ -17,9 +22,13 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the minimum, {1} the maximum and {2} the actual length. public const string MessageFormat = "String length must be between {0} and {1} characters, but got {2}"; + static readonly string?[] TemplateNames = [RuleMessage.Minimum, RuleMessage.Maximum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + readonly int _minimum; readonly int _maximum; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -32,7 +41,7 @@ public LengthRule(int minimum, int maximum, string? message = null, string? code { _minimum = minimum; _maximum = maximum; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -75,5 +84,25 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(_minimum); + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(_maximum); + + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in string value) => + Describe(value.LengthOrDefault()); + + Core.ZodRuleFailure Core.IZodStringFailureDescriber.DescribeFailure(ReadOnlySpan value) => + Describe(value.Length); + + // The Zod issue code follows the bound that failed. + Core.ZodRuleFailure Describe(int length) => + new( + length < _minimum ? Core.ZodIssueCategories.TooSmall : Core.ZodIssueCategories.TooBig, + RuleParameters.Count(length) + ); } diff --git a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs index 2d7d5c55..5a7544b3 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -15,12 +15,16 @@ public readonly record struct LessThanOrEqualRule /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; - /// Gets the message format; {0} is the bound and {1} the offending value. - public const string MessageFormat = "Value must be less than or equal to {0}, but got {1}"; + /// Gets the message format; {0} is the bound. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Value must be less than or equal to {0}"; + + static readonly string?[] TemplateNames = [RuleMessage.Maximum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal T MaxValue { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the LessThanOrEqualRule struct. @@ -31,7 +35,7 @@ public readonly record struct LessThanOrEqualRule public LessThanOrEqualRule(T maxValue, string? message = null, string? code = null) { MaxValue = maxValue; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -65,5 +69,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooBig; + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(MaxValue); } diff --git a/src/src/ZodSharp/Rules/LessThanRule.cs b/src/src/ZodSharp/Rules/LessThanRule.cs index 9bc348f1..121db28f 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -12,12 +12,16 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; - /// Gets the message format; {0} is the bound and {1} the offending value. - public const string MessageFormat = "Value must be less than {0}, but got {1}"; + /// Gets the message format; {0} is the bound. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Value must be less than {0}"; + + static readonly string?[] TemplateNames = [RuleMessage.Maximum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly T _exclusiveMaximum; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the LessThanRule struct. @@ -28,7 +32,7 @@ namespace ZodSharp.Rules; public LessThanRule(T exclusiveMaximum, string? message = null, string? code = null) { _exclusiveMaximum = exclusiveMaximum; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -63,5 +67,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooBig; + + // Exclusive: the value must be strictly less than the bound. + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Excluding(_exclusiveMaximum); } diff --git a/src/src/ZodSharp/Rules/MaxCountRule.cs b/src/src/ZodSharp/Rules/MaxCountRule.cs new file mode 100644 index 00000000..01cee4d2 --- /dev/null +++ b/src/src/ZodSharp/Rules/MaxCountRule.cs @@ -0,0 +1,81 @@ +using System.Collections; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a collection to contain at most a maximum number of elements (or entries, for a +/// dictionary). The trim- and Native AOT-safe counterpart of [MaxLength] on a collection member. +/// +/// +/// The generated [MaxCount] attribute is what a [ZodSchema] model uses: the generator emits the count +/// check itself, with the same error identity (too_big, the array/record origin and the +/// count parameter) as [MaxLength] on the same member, without the BCL attribute's +/// [RequiresUnreferencedCode] constructor. +/// +[Core.ZodRule] +public readonly record struct MaxCountRule + : Core.IValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_big"; + + /// Gets the message format; {0} is the maximum count and {1} the actual count. + public const string MessageFormat = "Collection must contain at most {0} element(s), but got {1}"; + + static readonly string?[] TemplateNames = [RuleMessage.Maximum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The inclusive maximum number of elements. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public MaxCountRule(int maxCount, string? message = null, string? code = null) + { + MaxCount = maxCount; + _message = message.OrNull(); + Code = code.Or(ErrorCode); + } + + /// Gets the inclusive maximum number of elements. + public int MaxCount { get; } + + /// + /// Validates that the collection contains at most elements. A + /// collection is not counted. + /// + /// The collection to validate. + /// True if valid, false otherwise. + public bool IsValid(in IEnumerable value) => value is null || CollectionCount.Of(value) <= MaxCount; + + /// + /// Gets the error message for a failed validation. + /// + /// The collection that failed validation. + /// The error message. + public string GetErrorMessage(in IEnumerable value) => + RuleMessage.Format(_message ?? MessageFormat, MaxCount, CollectionCount.Of(value)); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Array; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooBig; + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(MaxCount); + + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in IEnumerable value) => + new(Parameters: RuleParameters.Count(CollectionCount.Of(value))); +} diff --git a/src/src/ZodSharp/Rules/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 49ab650f..7f391878 100644 --- a/src/src/ZodSharp/Rules/MaxLengthRule.cs +++ b/src/src/ZodSharp/Rules/MaxLengthRule.cs @@ -9,6 +9,8 @@ public readonly record struct MaxLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, + Core.IZodFailureDescriber, + Core.IZodStringFailureDescriber, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. @@ -17,9 +19,13 @@ public readonly record struct MaxLengthRule /// Gets the message format; {0} is the maximum length and {1} the actual length. public const string MessageFormat = "String must be at most {0} characters long, but got {1}"; + static readonly string?[] TemplateNames = [RuleMessage.Maximum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal int MaxLength { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the MaxLengthRule struct. @@ -30,7 +36,7 @@ public readonly record struct MaxLengthRule public MaxLengthRule(int maxLength, string? message = null, string? code = null) { MaxLength = maxLength; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -69,7 +75,20 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooBig; + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(MaxLength); + + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in string value) => + new(Parameters: RuleParameters.Count(value.LengthOrDefault())); + + Core.ZodRuleFailure Core.IZodStringFailureDescriber.DescribeFailure(ReadOnlySpan value) => + new(Parameters: RuleParameters.Count(value.Length)); /// Applies the JSON Schema maxLength constraint. /// The schema for the value being validated. diff --git a/src/src/ZodSharp/Rules/MaxValueRule.cs b/src/src/ZodSharp/Rules/MaxValueRule.cs index 7a023fee..1dcd8ef4 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -12,12 +12,16 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_big"; - /// Gets the message format; {0} is the bound and {1} the offending value. - public const string MessageFormat = "Value must be at most {0}, but got {1}"; + /// Gets the message format; {0} is the bound. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Value must be at most {0}"; + + static readonly string?[] TemplateNames = [RuleMessage.Maximum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal T MaxValue { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the MaxValueRule struct. @@ -28,7 +32,7 @@ namespace ZodSharp.Rules; public MaxValueRule(T maxValue, string? message = null, string? code = null) { MaxValue = maxValue; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -62,5 +66,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooBig; + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(MaxValue); } diff --git a/src/src/ZodSharp/Rules/MinCountRule.cs b/src/src/ZodSharp/Rules/MinCountRule.cs new file mode 100644 index 00000000..38a184df --- /dev/null +++ b/src/src/ZodSharp/Rules/MinCountRule.cs @@ -0,0 +1,87 @@ +using System.Collections; + +namespace ZodSharp.Rules; + +/// +/// Validation rule that requires a collection to contain at least a minimum number of elements (or entries, for a +/// dictionary). The trim- and Native AOT-safe counterpart of [MinLength] on a collection member. +/// +/// +/// +/// The generated [MinCount] attribute is what a [ZodSchema] model uses: the generator emits the count +/// check itself, with the same error identity (too_small, the array/record origin and the +/// count parameter) as [MinLength] on the same member. Unlike +/// System.ComponentModel.DataAnnotations.MinLengthAttribute, whose constructor is annotated +/// [RequiresUnreferencedCode] because it may reflect over a Count property, nothing here reflects. +/// +/// +/// The rule counts through when the value implements it and by enumerating otherwise. +/// +/// +[Core.ZodRule] +public readonly record struct MinCountRule + : Core.IValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber +{ + /// Gets the Zod-compatible error code reported when the rule fails. + public const string ErrorCode = "too_small"; + + /// Gets the message format; {0} is the minimum count and {1} the actual count. + public const string MessageFormat = "Collection must contain at least {0} element(s), but got {1}"; + + static readonly string?[] TemplateNames = [RuleMessage.Minimum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; + + /// + /// Initializes a new instance of the struct. + /// + /// The inclusive minimum number of elements. + /// Optional error message/ message format. + /// Optional error code override. If one is not specified then the is used. + public MinCountRule(int minCount, string? message = null, string? code = null) + { + MinCount = minCount; + _message = message.OrNull(); + Code = code.Or(ErrorCode); + } + + /// Gets the inclusive minimum number of elements. + public int MinCount { get; } + + /// + /// Validates that the collection contains at least elements. A + /// collection is not counted; use [Required] to reject it. + /// + /// The collection to validate. + /// True if valid, false otherwise. + public bool IsValid(in IEnumerable value) => value is null || CollectionCount.Of(value) >= MinCount; + + /// + /// Gets the error message for a failed validation. + /// + /// The collection that failed validation. + /// The error message. + public string GetErrorMessage(in IEnumerable value) => + RuleMessage.Format(_message ?? MessageFormat, MinCount, CollectionCount.Of(value)); + + /// Gets the Zod-compatible error code reported when the rule fails. + public string Code => field.Or(ErrorCode); + + string? Core.IZodRule.Code => Code; + + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Array; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooSmall; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(MinCount); + + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in IEnumerable value) => + new(Parameters: RuleParameters.Count(CollectionCount.Of(value))); +} diff --git a/src/src/ZodSharp/Rules/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index e770d38f..d61f5b53 100644 --- a/src/src/ZodSharp/Rules/MinLengthRule.cs +++ b/src/src/ZodSharp/Rules/MinLengthRule.cs @@ -9,6 +9,8 @@ public readonly record struct MinLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, + Core.IZodFailureDescriber, + Core.IZodStringFailureDescriber, JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. @@ -17,9 +19,13 @@ public readonly record struct MinLengthRule /// Gets the message format; {0} is the minimum length and {1} the actual length. public const string MessageFormat = "String must be at least {0} characters long, but got {1}"; + static readonly string?[] TemplateNames = [RuleMessage.Minimum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal int MinLength { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the MinLengthRule struct. @@ -30,7 +36,7 @@ public readonly record struct MinLengthRule public MinLengthRule(int minLength, string? message = null, string? code = null) { MinLength = minLength; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -69,7 +75,20 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooSmall; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(MinLength); + + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in string value) => + new(Parameters: RuleParameters.Count(value.LengthOrDefault())); + + Core.ZodRuleFailure Core.IZodStringFailureDescriber.DescribeFailure(ReadOnlySpan value) => + new(Parameters: RuleParameters.Count(value.Length)); /// Applies the JSON Schema minLength constraint. /// The schema for the value being validated. diff --git a/src/src/ZodSharp/Rules/MinValueRule.cs b/src/src/ZodSharp/Rules/MinValueRule.cs index 9b1a0880..cb0579e1 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -12,13 +12,17 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "too_small"; - /// Gets the message format; {0} is the bound and {1} the offending value. - public const string MessageFormat = "Value must be at least {0}, but got {1}"; + /// Gets the message format; {0} is the bound. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Value must be at least {0}"; + + static readonly string?[] TemplateNames = [RuleMessage.Minimum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal T MinValue { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the MinValueRule struct. @@ -29,7 +33,7 @@ namespace ZodSharp.Rules; public MinValueRule(T minValue, string? message = null, string? code = null) { MinValue = minValue; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -63,5 +67,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.TooSmall; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(MinValue); } diff --git a/src/src/ZodSharp/Rules/MultipleOfRule.cs b/src/src/ZodSharp/Rules/MultipleOfRule.cs index 463ae819..6cc02fa8 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -14,8 +14,12 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "not_multiple_of"; - /// Gets the message format; {0} is the divisor and {1} the offending value. - public const string MessageFormat = "Number must be a multiple of {0}, but got {1}"; + /// Gets the message format; {0} is the divisor. The default never contains the input; a custom message may use {1} for the offending value. + public const string MessageFormat = "Number must be a multiple of {0}"; + + static readonly string?[] TemplateNames = [Core.ZodIssueParameters.Divisor, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); /// /// The relative tolerance applied when comparing the distance to the nearest multiple. @@ -24,7 +28,7 @@ namespace ZodSharp.Rules; // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal T Divisor { get; } - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the MultipleOfRule struct. @@ -38,7 +42,7 @@ public MultipleOfRule(T divisor, string? message = null, string? code = null) throw new ArgumentException("Divisor cannot be zero", nameof(divisor)); Divisor = divisor; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -87,5 +91,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.NotMultipleOf; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + RuleParameters.One(Core.ZodIssueParameters.Divisor, Divisor); } diff --git a/src/src/ZodSharp/Rules/NanoidRule.cs b/src/src/ZodSharp/Rules/NanoidRule.cs index e6fa8b75..464063a6 100644 --- a/src/src/ZodSharp/Rules/NanoidRule.cs +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid nanoid: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid nanoid"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the NanoidRule struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public NanoidRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -72,5 +76,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("nanoid"); } diff --git a/src/src/ZodSharp/Rules/NonSentinelRule.cs b/src/src/ZodSharp/Rules/NonSentinelRule.cs index df75c3ca..cab02583 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -35,7 +35,11 @@ namespace ZodSharp.Rules; /// /// [Core.ZodRule] -public readonly record struct NonSentinelRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +public readonly record struct NonSentinelRule + : Core.IValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber, + JsonSchema.IJsonSchemaRule { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_value"; @@ -43,6 +47,10 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is a description of the offending sentinel value. public const string MessageFormat = "Value has a sentinel value, but got {0}"; + static readonly string?[] TemplateNames = ["sentinel"]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + /// /// The JSON Schema pattern for a canonical Guid that is not the all-zero . /// @@ -146,7 +154,7 @@ static string BuildDateTimeOffsetJsonSchemaPattern() static string TwoDigits(int value) => value.ToString("00", CultureInfo.InvariantCulture); - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -155,7 +163,7 @@ static string BuildDateTimeOffsetJsonSchemaPattern() /// Optional error code override. If one is not specified then the is used. public NonSentinelRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -269,7 +277,16 @@ public string GetErrorMessage(in T value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; + + // The description names the sentinel (for example "an empty GUID"), a known constant rather than user input. + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in T value) => + new(Parameters: RuleParameters.One("sentinel", SentinelValues.Describe(value))); } /// diff --git a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs index dfc656e4..9efd447d 100644 --- a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs +++ b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs @@ -27,10 +27,14 @@ public readonly record struct NullOrNonWhiteSpaceRule /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Value must be null or non-whitespace, but got '{0}'"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Value must be null or non-whitespace"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -39,7 +43,7 @@ public readonly record struct NullOrNonWhiteSpaceRule /// Optional error code override. If one is not specified then the is used. public NullOrNonWhiteSpaceRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -75,5 +79,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; } diff --git a/src/src/ZodSharp/Rules/OddRule.cs b/src/src/ZodSharp/Rules/OddRule.cs index 4ec47936..6607e3b1 100644 --- a/src/src/ZodSharp/Rules/OddRule.cs +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -14,10 +14,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_value"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Number must be odd, but got {0}"; + /// Gets the message format. The default never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Number must be odd"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the OddRule struct. @@ -26,7 +30,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public OddRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -57,5 +61,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.Custom; } diff --git a/src/src/ZodSharp/Rules/PhoneRule.cs b/src/src/ZodSharp/Rules/PhoneRule.cs index de283297..79391b72 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -12,10 +12,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid phone number format: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid phone number format"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the PhoneRule struct. @@ -24,7 +28,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public PhoneRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -85,5 +89,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("phone"); } diff --git a/src/src/ZodSharp/Rules/RangeRule.cs b/src/src/ZodSharp/Rules/RangeRule.cs index 49bdf696..34a2dbc4 100644 --- a/src/src/ZodSharp/Rules/RangeRule.cs +++ b/src/src/ZodSharp/Rules/RangeRule.cs @@ -13,20 +13,27 @@ namespace ZodSharp.Rules; /// System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct RangeRule : Core.IValidationRule, Core.IZodRule +public readonly record struct RangeRule : Core.IValidationRule, Core.IZodRule, Core.IZodFailureDescriber where T : IComparable { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_range"; - /// Gets the message format; {0} is the minimum, {1} the maximum and {2} the value. - public const string MessageFormat = "Value must be between {0} and {1}, but got {2}"; + /// + /// Gets the message format; {0} is the minimum and {1} the maximum. The default never contains + /// the input; a custom message may use {2} for the offending value. + /// + public const string MessageFormat = "Value must be between {0} and {1}"; + + static readonly string?[] TemplateNames = [RuleMessage.Minimum, RuleMessage.Maximum, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly T _minimum; readonly T _maximum; readonly bool _minimumIsExclusive; readonly bool _maximumIsExclusive; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -50,7 +57,7 @@ public RangeRule( _maximum = maximum; _minimumIsExclusive = minimumIsExclusive; _maximumIsExclusive = maximumIsExclusive; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -83,5 +90,20 @@ public string GetErrorMessage(in T value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.Of(typeof(T)); + + Core.ErrorBound? Core.IZodRule.Lower => new Core.ErrorBound(_minimum, !_minimumIsExclusive); + + Core.ErrorBound? Core.IZodRule.Upper => new Core.ErrorBound(_maximum, !_maximumIsExclusive); + + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + // The Zod issue code follows the bound that failed. + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in T value) + { + var comparison = value.CompareTo(_minimum); + var belowMinimum = _minimumIsExclusive ? comparison <= 0 : comparison < 0; + return new(belowMinimum ? Core.ZodIssueCategories.TooSmall : Core.ZodIssueCategories.TooBig); + } } diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index a8048a16..42ca6557 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -16,9 +16,13 @@ public readonly record struct RegexRule /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the required pattern. + /// Gets the message format; {0} is the required pattern. A custom message may use {1} for the offending value. public const string MessageFormat = "String does not match the required pattern: {0}"; + static readonly string?[] TemplateNames = [Core.ZodIssueParameters.Pattern, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + /// /// The match timeout applied to every pattern this library compiles itself. /// @@ -39,9 +43,15 @@ public readonly record struct RegexRule /// Supply a yourself if you need a different budget. /// /// - public static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromSeconds(2); + public static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromMilliseconds(DefaultMatchTimeoutMilliseconds); + + /// + /// in milliseconds, as a constant so it can be used in attribute + /// arguments (for example [GeneratedRegex(pattern, options, RegexRule.DefaultMatchTimeoutMilliseconds)]). + /// + public const int DefaultMatchTimeoutMilliseconds = 2000; - readonly string _message; + readonly string? _message; // Read by the JSON Schema exporter, which previously reached this by reflecting on the field name. internal Regex Pattern { get; } @@ -55,7 +65,7 @@ public readonly record struct RegexRule public RegexRule(Regex pattern, string? message = null, string? code = null) { Pattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -110,7 +120,8 @@ public bool IsValid(ReadOnlySpan value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => RuleMessage.Format(_message ?? MessageFormat, value); + public string GetErrorMessage(in string value) => + RuleMessage.Format(_message ?? MessageFormat, Pattern.ToString(), value); /// /// Gets the error message for a failed span validation. @@ -124,7 +135,20 @@ public bool IsValid(ReadOnlySpan value) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + RuleParameters.Two( + Core.ZodIssueParameters.Format, + "regex", + Core.ZodIssueParameters.Pattern, + Pattern.ToString() + ); /// Applies the JSON Schema pattern constraint. /// The schema for the value being validated. diff --git a/src/src/ZodSharp/Rules/RequiredRule.cs b/src/src/ZodSharp/Rules/RequiredRule.cs index 650bf699..70788209 100644 --- a/src/src/ZodSharp/Rules/RequiredRule.cs +++ b/src/src/ZodSharp/Rules/RequiredRule.cs @@ -35,12 +35,18 @@ public readonly record struct RequiredRule /// Gets the error code reported when the rule fails. public const string ErrorCode = "missing_field"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Field is required"; + /// + /// Gets the message format; {0} is the display name ("Value" when the rule does not know the + /// member it validates), as in System.ComponentModel.DataAnnotations. Hosts that know the member can + /// re-format the reported template instead. + /// + public const string MessageFormat = "{0} is required"; + + const string DefaultDisplayName = "Value"; readonly bool _allowEmptyStrings; readonly bool _trimWhitespace; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -58,7 +64,7 @@ public RequiredRule( { _allowEmptyStrings = allowEmptyStrings; _trimWhitespace = trimWhitespace; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -90,7 +96,7 @@ public bool IsValid(in T value) /// /// The value that failed validation /// The error message - public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, value); + public string GetErrorMessage(in T value) => RuleMessage.Format(_message ?? MessageFormat, DefaultDisplayName); /// /// Applies nothing: presence is expressed by the object schema's required list, which the exporter @@ -109,4 +115,14 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidType; + + // The shared "{displayName} is required" template, so a host that knows the member (the generator, the + // ProblemDetails mapper) can name it; a custom message is reported with {0} bound to the display name. + // The display name resolves from the error's path, or "Value" when the error has no member segment. + string? Core.IZodRule.MessageFormat => + _message is null + ? Core.ZodMessages.Required + : RuleMessage.Template(_message, Core.ZodIssueParameters.DisplayName); } diff --git a/src/src/ZodSharp/Rules/RuleMessage.cs b/src/src/ZodSharp/Rules/RuleMessage.cs index 2e8a0765..2a38b5e4 100644 --- a/src/src/ZodSharp/Rules/RuleMessage.cs +++ b/src/src/ZodSharp/Rules/RuleMessage.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Globalization; using System.Text; +using ZodSharp.Core; namespace ZodSharp.Rules; @@ -9,20 +10,29 @@ namespace ZodSharp.Rules; /// parsed so a format string is compiled once rather than on every failure. /// /// -/// Centralising formatting keeps the culture provider in one place (satisfying CA1305) and lets every rule -/// expose its message as a public const string without re-parsing that constant on each failure. +/// +/// Messages are formatted with the invariant culture, so the same failure produces the same text on every +/// thread and platform; hosts localize at the boundary (see ). +/// +/// +/// Formatting never throws. A custom message is user text, so a format that is not a valid composite format +/// (a stray {, or a placeholder index the rule does not supply) is formatted leniently instead, with +/// anything unresolvable written out literally. +/// /// public static class RuleMessage { - static readonly ConcurrentDictionary Formats = new(StringComparer.Ordinal); + static readonly ConcurrentDictionary Formats = new(StringComparer.Ordinal); /// Formats a rule message with a single argument. /// The argument type. /// The message format (a rule's MessageFormat constant). /// The format argument. - /// The formatted message using . + /// The formatted message using . public static string Format(string format, TArg0 arg0) => - string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0); + GetFormat(format, 1) is { } composite + ? string.Format(CultureInfo.InvariantCulture, composite, arg0) + : ZodMessageFormat.Format(format, [arg0], provider: CultureInfo.InvariantCulture); /// Formats a rule message with two arguments. /// The first argument type. @@ -30,9 +40,11 @@ public static string Format(string format, TArg0 arg0) => /// The message format (a rule's MessageFormat constant). /// The first format argument. /// The second format argument. - /// The formatted message using . + /// The formatted message using . public static string Format(string format, TArg0 arg0, TArg1 arg1) => - string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0, arg1); + GetFormat(format, 2) is { } composite + ? string.Format(CultureInfo.InvariantCulture, composite, arg0, arg1) + : ZodMessageFormat.Format(format, [arg0, arg1], provider: CultureInfo.InvariantCulture); /// Formats a rule message with three arguments. /// The first argument type. @@ -42,9 +54,136 @@ public static string Format(string format, TArg0 arg0, TArg1 arg1) /// The first format argument. /// The second format argument. /// The third format argument. - /// The formatted message using . + /// The formatted message using . public static string Format(string format, TArg0 arg0, TArg1 arg1, TArg2 arg2) => - string.Format(CultureInfo.CurrentCulture, GetFormat(format), arg0, arg1, arg2); + GetFormat(format, 3) is { } composite + ? string.Format(CultureInfo.InvariantCulture, composite, arg0, arg1, arg2) + : ZodMessageFormat.Format(format, [arg0, arg1, arg2], provider: CultureInfo.InvariantCulture); - static CompositeFormat GetFormat(string format) => Formats.GetOrAdd(format, CompositeFormat.Parse); + /// + /// Builds the named template a rule reports as from its positional + /// message format: {0} becomes {names[0]}, and so on. Alignment and format specifiers are kept, + /// escaped braces and positions without a name are left as they are. Never throws. + /// + /// The positional format (the rule's MessageFormat, or a custom message). + /// The name each position binds to, in the rule's argument order. + /// The named template. + internal static string Template(string format, params ReadOnlySpan names) => + Template(format, -1, null, names); + + /// + /// Builds a named template like , writing the + /// placeholder at as . Used for a position that + /// carries rule configuration rather than a reported parameter (for example the address family of an + /// ). + /// + /// The positional format. + /// The position replaced by . + /// The text written for ; braces are escaped. + /// The name each other position binds to. + /// The named template. + internal static string Template( + string format, + int literalPosition, + string? literal, + params ReadOnlySpan names + ) + { + if (string.IsNullOrEmpty(format) || format.IndexOf('{', StringComparison.Ordinal) < 0) + return format ?? string.Empty; + + StringBuilder builder = new(format.Length + 16); + var i = 0; + while (i < format.Length) + { + var c = format[i]; + if (c != '{') + { + builder.Append(c); + i++; + continue; + } + + if (i + 1 < format.Length && format[i + 1] == '{') + { + builder.Append("{{"); + i += 2; + continue; + } + + var start = i + 1; + var end = start; + while (end < format.Length && char.IsAsciiDigit(format[end])) + end++; + + if ( + end > start + && end < format.Length + && format[end] is '}' or ',' or ':' + && int.TryParse( + format.AsSpan(start, end - start), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var position + ) + ) + { + if (position == literalPosition && literal is not null) + { + var close = format.IndexOf('}', end); + if (close > 0) + { + builder.Append( + literal + .Replace("{", "{{", StringComparison.Ordinal) + .Replace("}", "}}", StringComparison.Ordinal) + ); + i = close + 1; + continue; + } + } + else if (position < names.Length && names[position] is { Length: > 0 } name) + { + builder.Append('{').Append(name); + i = end; + continue; + } + } + + builder.Append(c); + i++; + } + + return builder.ToString(); + } + + /// The template name of a lower bound, resolved from . + internal const string Minimum = "minimum"; + + /// The template name of an upper bound, resolved from . + internal const string Maximum = "maximum"; + + /// + /// The template name of the failing value. It resolves only when + /// is enabled; otherwise a host keeps the produced message. + /// + internal const string Received = ZodIssueParameters.Received; + + static CompositeFormat? GetFormat(string format, int argumentCount) + { + var composite = Formats.GetOrAdd(format, static f => TryParse(f)); + return composite is not null && composite.MinimumArgumentCount <= argumentCount ? composite : null; + } + + static CompositeFormat? TryParse(string format) + { + try + { + return CompositeFormat.Parse(format); + } + catch (FormatException) + { + return null; + } + } } diff --git a/src/src/ZodSharp/Rules/RuleParameters.cs b/src/src/ZodSharp/Rules/RuleParameters.cs new file mode 100644 index 00000000..b4133453 --- /dev/null +++ b/src/src/ZodSharp/Rules/RuleParameters.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using ZodSharp.Core; + +namespace ZodSharp.Rules; + +/// +/// Builds the of the built-in rules. Parameter sets that do not depend on a +/// rule's configuration are cached, so reporting a failure does not allocate them again. +/// +static class RuleParameters +{ + static readonly ConcurrentDictionary> Formats = new( + StringComparer.Ordinal + ); + + /// The cached { format: name } parameters of a string format rule. + public static IReadOnlyDictionary Format(string format) => + Formats.GetOrAdd(format, static name => One(ZodIssueParameters.Format, name)); + + /// A single parameter. + public static IReadOnlyDictionary One(string name, object? value) => + new Dictionary(1, StringComparer.Ordinal) { [name] = value }; + + /// Two parameters. + public static IReadOnlyDictionary Two( + string name1, + object? value1, + string name2, + object? value2 + ) => new Dictionary(2, StringComparer.Ordinal) { [name1] = value1, [name2] = value2 }; + + /// The { count: n } parameters of a size failure. + public static IReadOnlyDictionary Count(int count) => One(ZodIssueParameters.Count, count); +} diff --git a/src/src/ZodSharp/Rules/SafeIntegerRule.cs b/src/src/ZodSharp/Rules/SafeIntegerRule.cs index c06f92fa..06648edd 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -5,15 +5,32 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct SafeIntegerRule : Core.IValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +public readonly record struct SafeIntegerRule + : Core.IValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber, + JsonSchema.IJsonSchemaRule { - /// Gets the Zod-compatible error code reported when the rule fails. + /// + /// Gets the Zod-compatible error code reported when the rule fails above the safe range. A failure follows its + /// direction: too_small below the range and invalid_type for a fraction, unless the rule was + /// given a code of its own. + /// public const string ErrorCode = "too_big"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Number must be a safe integer, but got {0}"; + static readonly IReadOnlyDictionary IntParameters = RuleParameters.One( + Core.ZodIssueParameters.Expected, + "int" + ); + + /// Gets the message format. The default never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Number must be a safe integer"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the SafeIntegerRule struct. @@ -22,7 +39,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public SafeIntegerRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -56,5 +73,30 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.Number; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(int.MinValue); + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(int.MaxValue); + + // A fraction (or NaN) is not an integer at all, which Zod reports as invalid_type; an out-of-range + // integer is too_small or too_big by direction. The code follows the category unless the rule was given a + // code of its own. + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in double value) + { + Core.ZodRuleFailure failure = + value != Math.Truncate(value) ? new(Core.ZodIssueCategories.InvalidType, IntParameters) + : value < int.MinValue ? new(Core.ZodIssueCategories.TooSmall) + : new(Core.ZodIssueCategories.TooBig); + + return string.Equals(Code, ErrorCode, StringComparison.Ordinal) + ? failure with + { + Code = failure.Category, + } + : failure; + } } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 3824ef79..5bb86386 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -17,11 +17,15 @@ public readonly record struct StartsWithRule public const string ErrorCode = "invalid_string"; /// Gets the message format; {0} is the required prefix and {1} the value. - public const string MessageFormat = "String must start with '{0}', but got '{1}'"; + public const string MessageFormat = "String must start with '{0}'"; + + static readonly string?[] TemplateNames = [Core.ZodIssueParameters.Prefix, RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); readonly string _prefix; readonly StringComparison _comparison; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the StartsWithRule struct. @@ -39,7 +43,7 @@ public StartsWithRule( { _prefix = prefix.OrNull() ?? throw new ArgumentNullException(nameof(prefix)); _comparison = comparison; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -78,6 +82,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) { ArgumentNullException.ThrowIfNull(schema); JsonSchema.JsonSchemaRules.ConstrainPattern(schema, "^" + Regex.Escape(_prefix)); + schema.SetVendorExtension("x-zod-startsWith", _prefix); } /// Gets the Zod-compatible error code reported when the rule fails. @@ -85,5 +90,13 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + RuleParameters.Two(Core.ZodIssueParameters.Format, "starts_with", Core.ZodIssueParameters.Prefix, _prefix); } diff --git a/src/src/ZodSharp/Rules/StringLengthRule.cs b/src/src/ZodSharp/Rules/StringLengthRule.cs index 352768f6..6f774877 100644 --- a/src/src/ZodSharp/Rules/StringLengthRule.cs +++ b/src/src/ZodSharp/Rules/StringLengthRule.cs @@ -9,7 +9,12 @@ namespace ZodSharp.Rules; /// declared by System.ComponentModel.DataAnnotations. /// [Core.ZodRule] -public readonly record struct StringLengthRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct StringLengthRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + Core.IZodFailureDescriber, + Core.IZodStringFailureDescriber { /// Gets the error code reported when the rule fails. public const string ErrorCode = "invalid_length"; @@ -17,9 +22,13 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the maximum, {1} the minimum and {2} the actual length. public const string MessageFormat = "String must be at most {0} characters long and at least {1}, but got {2}"; + static readonly string?[] TemplateNames = [RuleMessage.Maximum, RuleMessage.Minimum, Core.ZodIssueParameters.Count]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + readonly int _maximumLength; readonly int _minimumLength; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -32,7 +41,7 @@ public StringLengthRule(int maximumLength, int minimumLength = 0, string? messag { _maximumLength = maximumLength; _minimumLength = minimumLength; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -75,5 +84,25 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + Core.ErrorBound? Core.IZodRule.Lower => Core.ErrorBound.Including(_minimumLength); + + Core.ErrorBound? Core.IZodRule.Upper => Core.ErrorBound.Including(_maximumLength); + + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in string value) => + Describe(value.LengthOrDefault()); + + Core.ZodRuleFailure Core.IZodStringFailureDescriber.DescribeFailure(ReadOnlySpan value) => + Describe(value.Length); + + // The Zod issue code follows the bound that failed. + Core.ZodRuleFailure Describe(int length) => + new( + length < _minimumLength ? Core.ZodIssueCategories.TooSmall : Core.ZodIssueCategories.TooBig, + RuleParameters.Count(length) + ); } diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 6d1e8e60..c8f2f16d 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -6,15 +6,23 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct TimeStringRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule +public readonly record struct TimeStringRule + : Core.IValidationRule, + Core.IStringValidationRule, + Core.IZodRule, + JsonSchema.IJsonSchemaRule { /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid time: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid time"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the TimeStringRule struct. @@ -23,7 +31,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public TimeStringRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -108,5 +116,28 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("time"); + + /// The JSON Schema pattern for the local HH:mm[:ss[.fff]] time this rule accepts. + public const string JsonSchemaPattern = "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$"; + + /// + /// Applies . JSON Schema's format: time is an RFC 3339 full-time and + /// requires an offset this rule never accepts, so the lexical space is expressed as a pattern and the + /// intent is recorded in the x-zod-format vendor extension. + /// + /// The schema being built for the validated string. + public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaPattern); + schema.SetVendorExtension("x-zod-format", "time"); + } } diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs index 8116a69f..13a7fb39 100644 --- a/src/src/ZodSharp/Rules/ULIDRule.cs +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid ULID: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid ULID"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the ULIDRule struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public ULIDRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -93,5 +97,12 @@ static bool IsCrockfordBase32(char c) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("ulid"); } diff --git a/src/src/ZodSharp/Rules/UriRule.cs b/src/src/ZodSharp/Rules/UriRule.cs index 246edfc3..e68ac562 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -10,11 +10,13 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the and {1} the value. - public const string MessageFormat = "Invalid Uri, kind: {0}, format: {1}"; + /// Gets the default message format; {0} is the . It never contains the input; a custom message may use {1} for the value. + public const string MessageFormat = "Invalid Uri, kind: {0}"; + + static readonly string?[] TemplateNames = [null, RuleMessage.Received]; readonly UriKind _uriKind; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the UriRule struct. @@ -25,7 +27,7 @@ namespace ZodSharp.Rules; public UriRule(UriKind uriKind, string? message = null, string? code = null) { _uriKind = uriKind; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -57,5 +59,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + RuleMessage.Template(_message ?? MessageFormat, 0, _uriKind.ToString(), TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("uri"); } diff --git a/src/src/ZodSharp/Rules/UrlRule.cs b/src/src/ZodSharp/Rules/UrlRule.cs index 9848fee4..4c8e97dd 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -17,8 +17,12 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid URL format: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid URL format"; + + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); static readonly Regex UrlRegex = new( @"^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$", @@ -26,7 +30,7 @@ namespace ZodSharp.Rules; RegexRule.DefaultMatchTimeout ); - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the UrlRule struct. @@ -35,7 +39,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public UrlRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -74,5 +78,12 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("url"); } diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index be72d443..1c7830f4 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -15,11 +15,23 @@ public readonly record struct UUIDRule /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid UUID format: {0}"; + /// + /// Gets the default message format. It never contains the input; a custom message may use {0} for the + /// offending value and {1} for the required version (empty when no version is required). + /// + public const string MessageFormat = "Invalid UUID format"; + + /// + /// Gets the version-specific default message format; {1} is the required version as an integer. The + /// arguments are the same as for : {0} is the offending value. + /// + public const string VersionedMessageFormat = "Invalid UUID v{1} format"; + + static readonly string?[] TemplateNames = [RuleMessage.Received, Core.ZodIssueParameters.Version]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); - /// Gets the version-specific message format; {0} is the version and {1} the value. - public const string VersionedMessageFormat = "Invalid UUID v{0} format: {1}"; + static readonly string VersionedDefaultTemplate = RuleMessage.Template(VersionedMessageFormat, TemplateNames); const string NilUuid = "00000000-0000-0000-0000-000000000000"; const string MaxUuid = "ffffffff-ffff-ffff-ffff-ffffffffffff"; @@ -146,13 +158,9 @@ static bool IsNilOrMax(ReadOnlySpan bytes) string GetErrorMessageCore(string value) { - if (_message is not null) - return RuleMessage.Format(_message, value, _version); - - // If a specific version is required, include it in the message; otherwise, use the generic message. - return _version is UuidVersion version - ? RuleMessage.Format(VersionedMessageFormat, (int)version, value) - : RuleMessage.Format(MessageFormat, value); + // Custom and default messages receive the same arguments: the value, then the version as an integer. + var format = _message ?? (_version is null ? MessageFormat : VersionedMessageFormat); + return RuleMessage.Format(format, value, _version is UuidVersion version ? (int)version : (int?)null); } static bool IsValidVersionless(ReadOnlySpan value) @@ -206,7 +214,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) schema.Format = "uuid"; if (_version is UuidVersion version) + { JsonSchema.JsonSchemaRules.ConstrainPattern(schema, JsonSchemaVersionPattern(version)); + schema.SetVendorExtension("x-zod-uuidVersion", (int)version); + } } /// @@ -225,5 +236,17 @@ public static string JsonSchemaVersionPattern(UuidVersion version) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is not null ? RuleMessage.Template(_message, TemplateNames) + : _version is null ? DefaultTemplate + : VersionedDefaultTemplate; + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => + _version is UuidVersion version + ? RuleParameters.Two(Core.ZodIssueParameters.Format, "uuid", Core.ZodIssueParameters.Version, (int)version) + : RuleParameters.Format("uuid"); } diff --git a/src/src/ZodSharp/Rules/XidRule.cs b/src/src/ZodSharp/Rules/XidRule.cs index 81427083..de893d8b 100644 --- a/src/src/ZodSharp/Rules/XidRule.cs +++ b/src/src/ZodSharp/Rules/XidRule.cs @@ -10,10 +10,14 @@ namespace ZodSharp.Rules; /// Gets the Zod-compatible error code reported when the rule fails. public const string ErrorCode = "invalid_string"; - /// Gets the message format; {0} is the offending value. - public const string MessageFormat = "Invalid XID: {0}"; + /// Gets the default message format. It never contains the input; a custom message may use {0} for the offending value. + public const string MessageFormat = "Invalid XID"; - readonly string _message; + static readonly string?[] TemplateNames = [RuleMessage.Received]; + + static readonly string DefaultTemplate = RuleMessage.Template(MessageFormat, TemplateNames); + + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -22,7 +26,7 @@ namespace ZodSharp.Rules; /// Optional error code override. If one is not specified then the is used. public XidRule(string? message = null, string? code = null) { - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -74,5 +78,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.MessageFormat => + _message is null ? DefaultTemplate : RuleMessage.Template(_message, TemplateNames); + + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("xid"); } diff --git a/src/src/ZodSharp/Schemas/RefineCtx.cs b/src/src/ZodSharp/Schemas/RefineCtx.cs index 86fbfc0d..d99afd86 100644 --- a/src/src/ZodSharp/Schemas/RefineCtx.cs +++ b/src/src/ZodSharp/Schemas/RefineCtx.cs @@ -10,26 +10,45 @@ namespace ZodSharp.Schemas; /// /// The type being validated. /// -/// Initializes a new instance of the class with the -/// value under validation and an optional base path. +/// Issue paths are relative to the refined value; schemas that contain the refined schema (objects, arrays, +/// records, tuples) prefix them with the value's own location, so a refinement never needs to know where it is +/// used. Issues default to the category, as Zod's custom issues do. +/// +/// +/// Initializes a new instance of the class with the value under validation and a +/// typed base path. /// /// The validated value. /// The base path prepended to any added issues. -public sealed class RefineCtx(T value, ImmutableArray path) +public sealed class RefineCtx(T value, ZodPath path) { // The issue list is allocated on the first issue rather than up front, so a refinement that passes // costs a single context allocation and a refinement that never runs costs none. List? _issues; + /// + /// Initializes a new instance of the class with the value under validation and an + /// optional base path. + /// + /// The validated value. + /// The base path prepended to any added issues. + public RefineCtx(T value, ImmutableArray path) + : this(value, ZodPath.FromLegacy(path)) { } + /// /// The value being validated. /// public T Value { get; } = value; /// - /// The base path under which issues are reported. + /// The base path under which issues are reported, in the legacy string form. Prefer . /// - public ImmutableArray Path { get; } = path; + public ImmutableArray Path => FullPath.LegacySegments; + + /// + /// The typed base path under which issues are reported. + /// + public ZodPath FullPath { get; } = path; /// /// The issues added by the refinement so far. @@ -47,16 +66,56 @@ public sealed class RefineCtx(T value, ImmutableArray path) /// /// The issue code (e.g. refinement_failed). /// The human-readable issue message. - /// An optional relative path appended to . - public void AddIssue(string code, string message, string[]? path = null) + /// + /// An optional relative path appended to . Segments of digits (or "[0]") are + /// treated as indexes. + /// + public void AddIssue(string code, string message, string[]? path = null) => + AddIssue(code, message, ZodPath.FromLegacy(path)); + + /// + /// Adds a validation issue at a typed relative path, prefixed by the context's base path. + /// + /// The issue code (e.g. refinement_failed). + /// The human-readable issue message. + /// The path relative to . + /// Optional issue parameters. + /// The issue category; when . + public void AddIssue( + string code, + string message, + ZodPath path, + IReadOnlyDictionary? parameters = null, + string? category = null + ) { if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("Issue code must not be null or whitespace.", nameof(code)); if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("Issue message must not be null or whitespace.", nameof(message)); - var fullPath = BuildPath(Path, path); - (_issues ??= []).Add(new ValidationError(code, message, fullPath)); + Add( + ValidationError.Create( + code, + message, + FullPath.Append(path), + parameters, + category: category ?? ZodIssueCategories.Custom + ) + ); + } + + /// + /// Adds a fully specified issue (bounds, origin, parameters, message format), prefixed by the context's base + /// path. Every field of is kept. + /// + /// The issue, with a path relative to . + public void AddIssue(ValidationError issue) + { + if (string.IsNullOrWhiteSpace(issue.Code)) + throw new ArgumentException("Issue code must not be null or whitespace.", nameof(issue)); + + Add(issue.WithPathPrefix(FullPath)); } /// @@ -65,28 +124,10 @@ public void AddIssue(string code, string message, string[]? path = null) /// /// The human-readable issue message. /// An optional relative path appended to . - public void AddIssue(string message, string[]? path = null) => AddIssue("refinement_failed", message, path); + public void AddIssue(string message, string[]? path = null) => + AddIssue(ZodIssueCodes.RefinementFailed, message, path); - static string[] BuildPath(ImmutableArray basePath, string[]? relativePath) - { - if (basePath.IsDefaultOrEmpty && (relativePath is null || relativePath.Length == 0)) - return []; - - if (basePath.IsDefaultOrEmpty) - return relativePath!; - - if (relativePath is null || relativePath.Length == 0) - { - var copy = new string[basePath.Length]; - basePath.CopyTo(copy, 0); - return copy; - } - - var combined = new string[basePath.Length + relativePath.Length]; - basePath.CopyTo(combined, 0); - relativePath.CopyTo(combined, basePath.Length); - return combined; - } + void Add(ValidationError issue) => (_issues ??= []).Add(issue); internal ImmutableArray ToImmutable() => _issues is { Count: > 0 } ? [.. _issues] : []; } diff --git a/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs b/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs index df2f0ed6..37f4be95 100644 --- a/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs +++ b/src/src/ZodSharp/Schemas/SchemaValueCoercion.cs @@ -42,13 +42,7 @@ public static ValidationResult ValidateWrapped(IZodSchema inner : ValidationResult.Failure(result.Errors); } - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected {GetTypeDisplayName(typeof(T))}, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(ZodIssues.InvalidType(ZodTypeNames.Of(typeof(T)), value)); } /// @@ -139,11 +133,4 @@ static bool IsNumericType(Type type) => || type == typeof(ulong) || type == typeof(ushort) || type == typeof(sbyte); - - /// - /// Returns a human-readable name for , unwrapping - /// to its underlying type for display purposes. - /// - public static string GetTypeDisplayName(Type type) => - Nullable.GetUnderlyingType(type) is Type underlying ? underlying.Name : type.Name; } diff --git a/src/src/ZodSharp/Schemas/ZodArray.cs b/src/src/ZodSharp/Schemas/ZodArray.cs index 2103a67d..f07d5be2 100644 --- a/src/src/ZodSharp/Schemas/ZodArray.cs +++ b/src/src/ZodSharp/Schemas/ZodArray.cs @@ -12,12 +12,13 @@ namespace ZodSharp.Schemas; /// The schema for array elements public class ZodArray(IZodSchema elementSchema) : ZodType, JsonSchema.IJsonSchemaArrayInfo { - static readonly string[] EmptyPath = []; - int? _minLength; int? _maxLength; - string? _errorMessage; + // One message per bound: a shared message meant Min(2).Max(5) reported a too_small failure with the + // Max message. Null means "use the default template", which reports the count. + string? _minMessage; + string? _maxMessage; // IJsonSchemaArrayInfo: the JSON Schema exporter's non-generic seam. It previously reflected on private // field names that do not exist, so bounds and the element schema were silently dropped from every @@ -37,9 +38,7 @@ protected override ValidationResult ParseInternal(T[] value) { if (value == null) { - return ValidationResult.Failure( - new ValidationError("invalid_type", "Expected array, but got null", EmptyPath) - ); + return ValidationResult.Failure(ZodIssues.NullValue(ZodTypeNames.Array)); } // The length constraints depend only on the array's length, so they are checked before any element @@ -51,7 +50,10 @@ protected override ValidationResult ParseInternal(T[] value) // This does change which error is reported when an array is both the wrong length and has invalid // elements: the length failure now wins. That is the more useful answer, and it matches the // container constraint being the outer one. - if (LengthFailure(value.Length) is { } lengthFailure) + if ( + ZodIssues.CollectionSize(value.Length, _minLength, _maxLength, _minMessage, _maxMessage) is + { } lengthFailure + ) return ValidationResult.Failure(lengthFailure); List? errors = null; @@ -63,18 +65,11 @@ protected override ValidationResult ParseInternal(T[] value) if (!itemResult.IsSuccess) { errors ??= []; + // The index is the prefix and the element's own path follows it, so a nested failure renders + // as "[0].email". WithPathPrefix keeps every other field of the element's error. + var segment = ZodPathSegment.At(i); foreach (var error in itemResult.Errors) - { - // The index is the prefix, and the element's own path follows it, so a nested - // failure reads "[0].email". Use the 4-argument CopyTo: the 2-argument overload - // takes a *destination* index, which would copy the element path to the end and - // then have it overwritten by the index segment below. - var path = new string[error.Path.Length + 1]; - path[0] = $"[{i}]"; - - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add(new(error.Code, error.Message, path, error.Parameters)); - } + errors.Add(error.WithPathPrefix(segment)); } else { @@ -98,44 +93,6 @@ protected override ValidationResult ParseInternal(T[] value) return rebuilt is not null ? ValidationResult.Success([.. rebuilt]) : ValidationResult.Success(value); } - /// - /// The length-constraint failure for , or when the length - /// is acceptable. - /// - ValidationError? LengthFailure(int count) - { - if (_minLength.HasValue && count < _minLength.Value) - { - return new ValidationError( - "too_small", - _errorMessage ?? $"Array must have at least {_minLength.Value} elements, but got {count}", - EmptyPath, - parameters: null, - origin: "array", - minimum: _minLength.Value, - maximum: _maxLength, - inclusive: true - ); - } - - if (_maxLength.HasValue && count > _maxLength.Value) - { - return new ValidationError( - "too_big", - _errorMessage ?? $"Array must have at most {_maxLength.Value} elements, but got {count}", - EmptyPath, - parameters: null, - origin: "array", - minimum: _minLength, - maximum: _maxLength.Value, - inclusive: true - ); - } - - // No length failure. - return null; - } - /// /// Returns when two element values are interchangeable, /// i.e. no transform produced a different value. Reference types compare by @@ -159,7 +116,7 @@ static bool SameValue(T left, T right) public ZodArray Min(int minLength, string? message = null) { _minLength = minLength; - _errorMessage = message.Or($"Array must have at least {minLength} elements"); + _minMessage = message.OrNull(); return this; } @@ -173,7 +130,7 @@ public ZodArray Min(int minLength, string? message = null) public ZodArray Max(int maxLength, string? message = null) { _maxLength = maxLength; - _errorMessage = message.Or($"Array must have at most {maxLength} elements"); + _maxMessage = message.OrNull(); return this; } @@ -188,7 +145,8 @@ public ZodArray Length(int length, string? message = null) { _minLength = length; _maxLength = length; - _errorMessage = message.Or($"Array must have exactly {length} elements"); + _minMessage = message.OrNull(); + _maxMessage = _minMessage; return this; } @@ -201,7 +159,7 @@ public ZodArray Length(int length, string? message = null) public ZodArray NonEmpty(string? message = null) { _minLength = 1; - _errorMessage = message.Or("Array must not be empty"); + _minMessage = message.OrNull() ?? ZodMessages.ArrayNonEmpty; return this; } diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs index 5889f26f..d1550650 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs @@ -23,16 +23,54 @@ public class ZodDiscriminatedUnion( ImmutableDictionary> options ) : ZodType { - // Reflection on the discriminator is unavoidable for POCO inputs, but it only has to happen once per - // (type, discriminator) pair: the accessor is compiled and cached, so validation hot paths run direct - // property access with no reflection and no boxing for string discriminators. - static readonly ConditionalWeakTable< - Type, - ConcurrentDictionary> - > DiscriminatorAccessors = []; + // A POCO's discriminator is read through an accessor built once per runtime type and cached on this union (the + // readers depend on the options this union was built with): a compiled property read where dynamic code is + // available, so validation hot paths do no reflection and no boxing for string discriminators. + readonly ConcurrentDictionary> _accessors = new(); static readonly Func MissingDiscriminatorAccessor = static _ => null; + // The option keys in a deterministic order for messages and parameters: declaration order when built by + // ZodDiscriminatedUnionBuilder, otherwise ordinal order. The dictionary's own enumeration order is + // hash-based, so it could differ between processes. + + readonly ImmutableArray _readers = []; + + /// + /// Initializes a new instance of the class, listing the options in + /// in messages and parameters. + /// + /// The discriminator field name. + /// The union options. + /// The option keys in declaration order. + /// The discriminator readers built for the options' CLR types. + internal ZodDiscriminatedUnion( + string discriminator, + ImmutableDictionary> options, + ImmutableArray optionOrder, + ImmutableArray readers + ) + : this(discriminator, options) + { + OptionKeys = optionOrder; + _readers = readers; + } + + /// + /// A discriminator reader for one option's CLR type, built from the type the builder was given through + /// Option<T>, so it needs no reflection over an unknown type. + /// + /// The option type. + /// The accessor, or when the type has no discriminator property. + internal readonly record struct DiscriminatorReader(Type Type, Func? Read); + + // Exposed to the JSON Schema exporter, which exports the union as oneOf with the discriminator recorded. + internal string Discriminator => discriminator; + + internal ImmutableDictionary> Options => options; + + internal ImmutableArray OptionKeys { get; } = [.. options.Keys.Order(StringComparer.Ordinal)]; + /// /// Parses and validates the value using the discriminated union. /// @@ -42,9 +80,7 @@ protected override ValidationResult ParseInternal(object value) { if (value == null) { - return ValidationResult.Failure( - new ValidationError("invalid_type", "Expected object, but got null", []) - ); + return ValidationResult.Failure(ZodIssues.NullValue(ZodTypeNames.Object)); } var discriminatorValue = GetDiscriminatorValue(value); @@ -52,18 +88,14 @@ protected override ValidationResult ParseInternal(object value) if (discriminatorValue is null) { return ValidationResult.Failure( - new ValidationError("missing_discriminator", $"Discriminator field '{discriminator}' not found", []) + DiscriminatorError(ZodIssueCodes.MissingDiscriminator, ZodMessages.MissingDiscriminator) ); } if (!options.TryGetValue(discriminatorValue, out var schema)) { return ValidationResult.Failure( - new ValidationError( - "invalid_discriminator", - $"Invalid discriminator value '{discriminatorValue}'. Expected one of: {string.Join(", ", options.Keys)}", - [] - ) + DiscriminatorError(ZodIssueCodes.InvalidDiscriminator, ZodMessages.InvalidDiscriminator) ); } @@ -71,6 +103,22 @@ protected override ValidationResult ParseInternal(object value) return schema.Validate(value); } + // Discriminator failures are reported at the discriminator's own path, as Zod does. The received value is + // not echoed: it is caller input. + ValidationError DiscriminatorError(string code, string template) => + ZodIssues.Formatted( + code, + ZodIssueCategories.InvalidUnion, + template, + ZodPath.Member(discriminator), + null, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Discriminator] = discriminator, + [ZodIssueParameters.Options] = OptionKeys, + } + ); + string? GetDiscriminatorValue(object value) { if (value is IReadOnlyDictionary readOnlyDictionary) @@ -83,49 +131,96 @@ protected override ValidationResult ParseInternal(object value) return TryGetDictionaryDiscriminatorValue(dictionary); } - var type = value.GetType(); - var accessors = DiscriminatorAccessors.GetValue( - type, - static _ => new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase) - ); - var accessor = accessors.GetOrAdd( - discriminator, - static (name, candidateType) => - BuildDiscriminatorAccessor(candidateType, name) ?? MissingDiscriminatorAccessor, - type + var accessor = _accessors.GetOrAdd( + value.GetType(), + static (type, state) => + ResolveAccessor(type, state.Discriminator, state.Readers) ?? MissingDiscriminatorAccessor, + (Discriminator: discriminator, Readers: _readers) ); return accessor(value); } /// - /// Compiles a that reads the discriminator property from an instance, - /// so subsequent validations avoid reflection and, for discriminators, boxing. + /// Resolves the discriminator accessor for a POCO: the reader built for the option type it is (or derives from), + /// otherwise the reflection fallback when . /// - /// The runtime type of the value being validated. + static Func? ResolveAccessor( + Type type, + string discriminator, + ImmutableArray readers + ) + { + foreach (var reader in readers) + { + if (reader.Type == type && reader.Read is not null) + return reader.Read; + } + + foreach (var reader in readers) + { + if (reader.Type.IsAssignableFrom(type) && reader.Read is not null) + return reader.Read; + } + + return ZodReflectionFeature.IsSupported ? BuildReflectiveAccessor(type, discriminator) : null; + } + + /// + /// Builds the accessor for a type the builder was given through Option<T>. The type's public + /// properties are preserved by the on T, so this is + /// safe under trimming and Native AOT. + /// + /// The option type. /// The discriminator property name. - /// The compiled accessor, or when no readable property exists. - /// - /// The expression-compiled fast path needs runtime code generation, which Native AOT does not provide. - /// When it is unavailable this falls back to plain reflection: slower per call, but correct, and still - /// built once per type because the result is cached by the caller. The discriminator property belongs to - /// the consumer's own type, so a trimmed application has to preserve it — which it already does if that - /// type is serialized. - /// - [UnconditionalSuppressMessage( - "Trimming", - "IL2070:UnrecognizedReflectionPattern", - Justification = "The discriminator is a property of the consumer's own validated type, resolved from " - + "its runtime type. A trimmed application must preserve that type's properties; it is the " - + "consumer's model, not library-internal state." + /// The accessor, or when the type has no readable discriminator property. + internal static Func? BuildAccessor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, + string discriminator + ) => + CreateAccessor( + type, + FindProperty(type.GetProperties(BindingFlags.Public | BindingFlags.Instance), discriminator) + ); + + /// + /// The fallback for a POCO whose type was not supplied through Option<T>: reads the discriminator from + /// the runtime type by name, which a trimmer cannot follow. + /// + [RequiresUnreferencedCode( + "Reads the discriminator from a type that was not supplied through Option. Add each option with Option " + + "so its properties are preserved, or validate dictionaries/JSON." )] - static Func? BuildDiscriminatorAccessor(Type type, string discriminator) - { - var property = type.GetProperty( - discriminator, - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase + static Func? BuildReflectiveAccessor(Type type, string discriminator) => + CreateAccessor( + type, + FindProperty(type.GetProperties(BindingFlags.Public | BindingFlags.Instance), discriminator) ); + // The discriminator matches case-insensitively, as Type.GetProperty with BindingFlags.IgnoreCase did; the first + // declared match wins. + static PropertyInfo? FindProperty(PropertyInfo[] properties, string discriminator) + { + foreach (var property in properties) + { + if (string.Equals(property.Name, discriminator, StringComparison.OrdinalIgnoreCase)) + return property; + } + + return null; + } + + /// + /// Compiles a that reads the discriminator property from an instance, so + /// subsequent validations avoid reflection and, for discriminators, boxing. + /// + /// + /// The expression-compiled fast path needs runtime code generation, which Native AOT does not provide; there the + /// accessor reads the property through instead. Either way it is built + /// once per type, because the result is cached by the caller. + /// + static Func? CreateAccessor(Type type, PropertyInfo? property) + { if (property is null || !property.CanRead || property.GetMethod is not { IsStatic: false }) return null; diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs index 5fe26e07..7dd04bce 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -13,6 +14,8 @@ namespace ZodSharp.Schemas; public class ZodDiscriminatedUnionBuilder(string discriminator) { readonly Dictionary> _options = []; + readonly List _order = []; + readonly List _readers = []; /// /// Adds an object-typed option to the discriminated union. @@ -25,7 +28,7 @@ public ZodDiscriminatedUnionBuilder Option(string value, IZodSchemaThe discriminator value /// The schema for this option /// This builder for method chaining - public ZodDiscriminatedUnionBuilder Option(string value, IZodSchema schema) + /// + /// A CLR value of type (or a type derived from it) has its discriminator read through an + /// accessor built from 's public properties, which the + /// preserves - so a POCO union is trim- and Native AOT-safe. + /// + public ZodDiscriminatedUnionBuilder Option< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T + >(string value, IZodSchema schema) { if (schema is null) throw new ArgumentNullException(nameof(schema)); - _options[value] = new SchemaWrapper(schema); + AddOption(value, new SchemaWrapper(schema)); + AddReader(typeof(T)); return this; } + void AddReader([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type) + { + if (type == typeof(object) || _readers.Exists(reader => reader.Type == type)) + return; + + _readers.Add(new(type, ZodDiscriminatedUnion.BuildAccessor(type, discriminator))); + } + + void AddOption(string value, IZodSchema schema) + { + if (!_options.ContainsKey(value)) + _order.Add(value); + + _options[value] = schema; + } + /// /// Builds the discriminated union schema. /// - public ZodDiscriminatedUnion Build() => new(discriminator, _options.ToImmutableDictionary()); + public ZodDiscriminatedUnion Build() => + new(discriminator, _options.ToImmutableDictionary(), [.. _order], [.. _readers]); sealed class SchemaWrapper(IZodSchema inner) : IZodSchema, IOptionalSchema { @@ -63,13 +91,7 @@ public ValidationResult Validate(object value) if (!SchemaValueCoercion.TryCoerce(value, out var typedValue)) { - return ValidationResult.Failure( - new ValidationError( - "invalid_type", - $"Expected {SchemaValueCoercion.GetTypeDisplayName(typeof(T))}, but got {value?.GetType().Name ?? "null"}", - [] - ) - ); + return ValidationResult.Failure(ZodIssues.InvalidType(ZodTypeNames.Of(typeof(T)), value)); } var result = inner.Validate(typedValue); diff --git a/src/src/ZodSharp/Schemas/ZodEnum.cs b/src/src/ZodSharp/Schemas/ZodEnum.cs index 743f3ea9..d7550135 100644 --- a/src/src/ZodSharp/Schemas/ZodEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodEnum.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using ZodSharp.Core; @@ -14,7 +15,8 @@ namespace ZodSharp.Schemas; [SuppressMessage("Naming", "CA1711", Justification = "Name mirrors Zod's z.enum API.")] public class ZodEnum(HashSet allowedValues) : ZodType, JsonSchema.IJsonSchemaEnumInfo { - static readonly string[] EmptyPath = []; + // Ordinal order: the set's own enumeration order follows string hash codes, which differ per process. + readonly ImmutableArray _options = [.. allowedValues.Order(StringComparer.Ordinal)]; /// /// Gets the set of allowed values. @@ -32,19 +34,20 @@ protected override ValidationResult ParseInternal(string value) { if (value == null) { - return ValidationResult.Failure( - new ValidationError("invalid_type", "Expected string, but got null", EmptyPath) - ); + return ValidationResult.Failure(ZodIssues.NullValue(ZodTypeNames.String)); } // Check if the value is in the allowed values set return allowedValues.Contains(value) ? ValidationResult.Success(value) : ValidationResult.Failure( - new ValidationError( - "invalid_enum_value", - $"Expected one of: {string.Join(", ", allowedValues)}, but got '{value}'", - EmptyPath + ZodIssues.Formatted( + ZodIssueCodes.InvalidEnumValue, + ZodIssueCategories.InvalidValue, + ZodMessages.InvalidEnumValue, + ZodPath.Root, + ZodTypeNames.Enum, + new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.Options] = _options } ) ); } diff --git a/src/src/ZodSharp/Schemas/ZodIntersection.cs b/src/src/ZodSharp/Schemas/ZodIntersection.cs index 9c43e643..4bb4ec70 100644 --- a/src/src/ZodSharp/Schemas/ZodIntersection.cs +++ b/src/src/ZodSharp/Schemas/ZodIntersection.cs @@ -27,7 +27,19 @@ protected override ValidationResult ParseInternal(T value) if (leftResult.IsSuccess && rightResult.IsSuccess) return ValidationResult.Success(value); - List allErrors = [.. leftResult.Errors, .. rightResult.Errors]; + // Both sides often report the same issue (a shared base schema); report it once. + List allErrors = [.. leftResult.Errors]; + foreach (var error in rightResult.Errors) + { + if (!allErrors.Exists(existing => IsSameIssue(existing, error))) + allErrors.Add(error); + } + return ValidationResult.Failure(allErrors); } + + static bool IsSameIssue(ValidationError left, ValidationError right) => + string.Equals(left.Code, right.Code, StringComparison.Ordinal) + && left.FullPath == right.FullPath + && string.Equals(left.Message, right.Message, StringComparison.Ordinal); } diff --git a/src/src/ZodSharp/Schemas/ZodLazy.cs b/src/src/ZodSharp/Schemas/ZodLazy.cs index 652600f4..d07f6c92 100644 --- a/src/src/ZodSharp/Schemas/ZodLazy.cs +++ b/src/src/ZodSharp/Schemas/ZodLazy.cs @@ -14,11 +14,7 @@ namespace ZodSharp.Schemas; /// The function that gets the schema public class ZodLazy(Func> schemaGetter) : ZodType { -#if NET9_0_OR_GREATER readonly Lock _lock = new(); -#else - readonly object _lock = new(); -#endif /// /// Gets the inner schema, evaluating it lazily if needed. diff --git a/src/src/ZodSharp/Schemas/ZodLiteral.cs b/src/src/ZodSharp/Schemas/ZodLiteral.cs index 29cf0611..034c0da4 100644 --- a/src/src/ZodSharp/Schemas/ZodLiteral.cs +++ b/src/src/ZodSharp/Schemas/ZodLiteral.cs @@ -28,16 +28,28 @@ protected override ValidationResult ParseInternal(T value) { if (value is null) { - return ValidationResult.Failure( - new ValidationError("invalid_literal", $"Expected literal value {_value}, but got null", []) - ); + return ValidationResult.Failure(LiteralError(ZodTypeNames.Null)); } // Check if the value is equal to the literal value return value.Equals(_value) ? ValidationResult.Success(value) - : ValidationResult.Failure( - new ValidationError("invalid_literal", $"Expected literal value {_value}, but got {value}", []) - ); + : ValidationResult.Failure(LiteralError(ZodTypeNames.OfValue(value))); } + + // The expected literal is schema configuration, so it is safe to report; the received value is caller + // input, so only its type name is. + ValidationError LiteralError(string receivedType) => + ZodIssues.Formatted( + ZodIssueCodes.InvalidLiteral, + ZodIssueCategories.InvalidValue, + ZodMessages.InvalidLiteral, + ZodPath.Root, + null, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Expected] = _value, + [ZodIssueParameters.Received] = receivedType, + } + ); } diff --git a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs index 74a3b221..5b80a44e 100644 --- a/src/src/ZodSharp/Schemas/ZodNativeEnum.cs +++ b/src/src/ZodSharp/Schemas/ZodNativeEnum.cs @@ -17,7 +17,8 @@ namespace ZodSharp.Schemas; public class ZodNativeEnum() : ZodType, JsonSchema.IJsonSchemaEnumInfo where TEnum : struct, Enum { - static readonly string[] EmptyPath = []; + // The schema's own membership check reports exactly what the [Enum] rule reports. + static readonly Rules.EnumRule MembershipRule = new(message: null); // Resolving the defined members once per closed generic type avoids the per-validation // Enum.IsDefined reflection path. @@ -49,13 +50,7 @@ .. System protected override ValidationResult ParseInternal(TEnum value) => DefinedValues.Contains(value) ? ValidationResult.Success(value) - : ValidationResult.Failure( - new ValidationError( - "invalid_enum_value", - $"'{value}' is not a defined member of {typeof(TEnum).Name}", - EmptyPath - ) - ); + : ValidationResult.Failure(ZodRuleErrors.Create(MembershipRule, value, ZodPath.Root)); /// /// Adds an that validates the value is a defined member of diff --git a/src/src/ZodSharp/Schemas/ZodNull.cs b/src/src/ZodSharp/Schemas/ZodNull.cs index 46d27ce6..cfd00f3b 100644 --- a/src/src/ZodSharp/Schemas/ZodNull.cs +++ b/src/src/ZodSharp/Schemas/ZodNull.cs @@ -18,7 +18,5 @@ public class ZodNull : ZodType, IAcceptsNull protected override ValidationResult ParseInternal(object? value) => value == null ? ValidationResult.Success(null) - : ValidationResult.Failure( - new ValidationError("invalid_type", "Expected null, but got non-null value", []) - ); + : ValidationResult.Failure(ZodIssues.InvalidType(ZodTypeNames.Null, value)); } diff --git a/src/src/ZodSharp/Schemas/ZodNumber.cs b/src/src/ZodSharp/Schemas/ZodNumber.cs index 5a0bb652..f97b75d1 100644 --- a/src/src/ZodSharp/Schemas/ZodNumber.cs +++ b/src/src/ZodSharp/Schemas/ZodNumber.cs @@ -9,8 +9,6 @@ namespace ZodSharp.Schemas; /// public class ZodNumber : ZodType { - static readonly string[] EmptyPath = []; - /// /// Parses and validates a number value. /// @@ -19,7 +17,7 @@ public class ZodNumber : ZodType protected override ValidationResult ParseInternal(double value) => double.IsNaN(value) ? ValidationResult.Failure( - new ValidationError("invalid_type", "Expected number, but got NaN", EmptyPath) + ZodIssues.InvalidType(ZodTypeNames.Number, value, origin: ZodTypeNames.Number) ) : ValidationResult.Success(value); diff --git a/src/src/ZodSharp/Schemas/ZodObject.cs b/src/src/ZodSharp/Schemas/ZodObject.cs index 734830f3..f1a868e9 100644 --- a/src/src/ZodSharp/Schemas/ZodObject.cs +++ b/src/src/ZodSharp/Schemas/ZodObject.cs @@ -23,8 +23,6 @@ public class ZodObject( ImmutableHashSet? requiredKeys = null ) : ZodType, Dictionary> { - static readonly string[] EmptyPath = []; - /// /// The object shape: a map of field names to their schemas. /// @@ -60,19 +58,36 @@ public class ZodObject( { if (value == null) { - return ValidationResult>.Failure( - new ValidationError("invalid_type", "Expected object, but got null", EmptyPath) - ); + return ValidationResult>.Failure(ZodIssues.NullValue(ZodTypeNames.Object)); } List? errors = null; Dictionary? rebuilt = null; + List? unrecognizedKeys = null; foreach (var (key, schema) in shape) ValidateField(value, key, schema, ref errors, ref rebuilt); foreach (var (key, propertyValue) in value) - ValidateUnknownKey(value, key, propertyValue, ref errors, ref rebuilt); + ValidateUnknownKey(value, key, propertyValue, ref errors, ref rebuilt, ref unrecognizedKeys); + + // Zod reports every unrecognized key in one issue at the object's own path. + if (unrecognizedKeys is { Count: > 0 }) + { + (errors ??= []).Add( + ZodIssues.Formatted( + ZodIssueCodes.UnrecognizedKeys, + ZodIssueCategories.UnrecognizedKeys, + ZodMessages.UnrecognizedKeys, + ZodPath.Root, + ZodTypeNames.Object, + new Dictionary(StringComparer.Ordinal) + { + [ZodIssueParameters.Keys] = ImmutableArray.CreateRange(unrecognizedKeys), + } + ) + ); + } if (errors is { Count: > 0 }) return ValidationResult>.Failure(errors); @@ -105,7 +120,7 @@ void ValidateField( if (!allowsMissing) { errors ??= []; - errors.Add(new ValidationError("missing_field", $"Required field '{key}' is missing", [key])); + errors.Add(ZodIssues.MissingField(key, ZodPath.Member(key))); return; } @@ -124,14 +139,9 @@ void ValidateField( if (!result.IsSuccess) { errors ??= []; + var segment = ZodPathSegment.Member(key); foreach (var error in result.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = key; - - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add(new(error.Code, error.Message, path, error.Parameters)); - } + errors.Add(error.WithPathPrefix(segment)); return; } @@ -148,7 +158,8 @@ void ValidateUnknownKey( string key, object? propertyValue, ref List? errors, - ref Dictionary? rebuilt + ref Dictionary? rebuilt, + ref List? unrecognizedKeys ) { if (shape.ContainsKey(key)) @@ -160,13 +171,9 @@ void ValidateUnknownKey( if (!catchallResult.IsSuccess) { errors ??= []; + var segment = ZodPathSegment.Member(key); foreach (var error in catchallResult.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = key; - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add(new(error.Code, error.Message, path, error.Parameters)); - } + errors.Add(error.WithPathPrefix(segment)); } else { @@ -182,8 +189,7 @@ void ValidateUnknownKey( case UnknownKeyPolicy.Passthrough: break; case UnknownKeyPolicy.Strict: - errors ??= []; - errors.Add(new ValidationError("unrecognized_key", $"Unrecognized key '{key}'", [key])); + (unrecognizedKeys ??= []).Add(key); break; default: // Strip: drop the unknown key by forcing the rebuilt copy. diff --git a/src/src/ZodSharp/Schemas/ZodRecord.cs b/src/src/ZodSharp/Schemas/ZodRecord.cs index 8d82b26f..322b590d 100644 --- a/src/src/ZodSharp/Schemas/ZodRecord.cs +++ b/src/src/ZodSharp/Schemas/ZodRecord.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -17,8 +16,6 @@ public class ZodRecord(IZodSchema valueSchema) : ZodType, Dictionary>, JsonSchema.IJsonSchemaRecordInfo { - static readonly string[] EmptyPath = []; - // IJsonSchemaRecordInfo: the JSON Schema exporter's non-generic seam. A record exports as an object whose // additionalProperties is the value schema. See IJsonSchemaRecordInfo. object JsonSchema.IJsonSchemaRecordInfo.ValueSchema => valueSchema; @@ -32,9 +29,7 @@ protected override ValidationResult> ParseInternal(Di { if (value == null) { - return ValidationResult>.Failure( - new ValidationError("invalid_type", "Expected record, but got null", EmptyPath) - ); + return ValidationResult>.Failure(ZodIssues.NullValue(ZodTypeNames.Record)); } List errors = []; @@ -47,25 +42,9 @@ protected override ValidationResult> ParseInternal(Di var result = valueSchema.Validate(itemValue); if (!result.IsSuccess) { + var segment = ZodPathSegment.Key(key); foreach (var error in result.Errors) - { - var path = new string[error.Path.Length + 1]; - path[0] = key; - error.Path.CopyTo(0, path, 1, error.Path.Length); - errors.Add( - ValidationError.Create( - error.Code, - error.Message, - ImmutableArray.Create(path), - error.Parameters, - error.Origin, - error.Minimum, - error.Maximum, - error.Inclusive, - error.Category - ) - ); - } + errors.Add(error.WithPathPrefix(segment)); } else { diff --git a/src/src/ZodSharp/Schemas/ZodRefinement.cs b/src/src/ZodSharp/Schemas/ZodRefinement.cs index 030bb70f..89d2ab07 100644 --- a/src/src/ZodSharp/Schemas/ZodRefinement.cs +++ b/src/src/ZodSharp/Schemas/ZodRefinement.cs @@ -29,8 +29,6 @@ protected override ValidationResult ParseInternal(T value) // On success... return refinement(baseResult.Value) ? ValidationResult.Success(baseResult.Value) - : ValidationResult.Failure( - new ValidationError("refinement_failed", message ?? "Custom validation failed", []) - ); + : ValidationResult.Failure(ZodIssues.RefinementFailed(message)); } } diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index 0cebcd4d..bfe3618c 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -11,8 +11,6 @@ namespace ZodSharp.Schemas; /// public class ZodString : ZodType { - static readonly string[] EmptyPath = []; - // Rules that also implement IStringValidationRule, so ValidateSpan/IsValidSpan can validate the // incoming span without materialising a string. When any rule lacks the span contract the list is // abandoned and span validation falls back to the string path. @@ -47,9 +45,7 @@ public override ZodType AddRule(IValidationRule rule) /// A validation result protected override ValidationResult ParseInternal(string value) => value == null - ? ValidationResult.Failure( - new ValidationError("invalid_type", "Expected string, but got null", EmptyPath) - ) + ? ValidationResult.Failure(ZodIssues.NullValue(ZodTypeNames.String)) : ValidationResult.Success(value); /// @@ -105,7 +101,7 @@ public bool IsValidSpan(ReadOnlySpan value, out ImmutableArray(); - builder.Add(new ValidationError(ResolveCode(rule), rule.GetErrorMessage(value), EmptyPath)); + builder.Add(ZodRuleErrors.Create(rule, value, ZodPath.Root)); } if (builder is null) @@ -115,14 +111,6 @@ public bool IsValidSpan(ReadOnlySpan value, out ImmutableArray - /// Resolves the error code to report for a failed span rule: a rule that implements - /// and supplies a code wins over the rule's intrinsic - /// . - /// - static string ResolveCode(IStringValidationRule rule) => - rule is IZodRule zodRule && zodRule.Code is { } code ? code : rule.Code; - bool SupportsSpanRules => _spanRulesSupported && SupportsSpanValidation && _spanRules.Length == RuleCount; /// diff --git a/src/src/ZodSharp/Schemas/ZodSuperRefinement.cs b/src/src/ZodSharp/Schemas/ZodSuperRefinement.cs index 2f2cd9ce..dc52c202 100644 --- a/src/src/ZodSharp/Schemas/ZodSuperRefinement.cs +++ b/src/src/ZodSharp/Schemas/ZodSuperRefinement.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -17,8 +16,6 @@ namespace ZodSharp.Schemas; /// The context-aware refinement callback. public class ZodSuperRefinement(IZodSchema baseSchema, Action> refinement) : ZodType { - static readonly ImmutableArray EmptyPath = []; - /// /// Parses and validates the value with the context-aware refinement. /// @@ -30,7 +27,10 @@ protected override ValidationResult ParseInternal(T value) if (!baseResult.IsSuccess) return baseResult; - RefineCtx ctx = new(baseResult.Value, EmptyPath); + // Issues are relative to the refined value. A containing schema (object field, array element, record + // value, tuple element) prefixes them with this value's location via ValidationError.WithPathPrefix, so + // the base path is always the root here. + RefineCtx ctx = new(baseResult.Value, ZodPath.Root); refinement(ctx); return ctx.HasIssues diff --git a/src/src/ZodSharp/Schemas/ZodTransform.cs b/src/src/ZodSharp/Schemas/ZodTransform.cs index e83fd617..6be0698c 100644 --- a/src/src/ZodSharp/Schemas/ZodTransform.cs +++ b/src/src/ZodSharp/Schemas/ZodTransform.cs @@ -40,7 +40,14 @@ protected override ValidationResult ParseInternal(TInput value) // internal detail to whoever made the request. Only the fixed code and message are reported. _ = ex; - return ValidationResult.Failure(new ValidationError("transform_error", "Transform failed.", [])); + return ValidationResult.Failure( + ValidationError.Create( + ZodIssueCodes.TransformError, + ZodMessages.TransformFailed, + ZodPath.Root, + category: ZodIssueCategories.Custom + ) + ); } } diff --git a/src/src/ZodSharp/Schemas/ZodTuple.cs b/src/src/ZodSharp/Schemas/ZodTuple.cs index f84f7589..a91027dc 100644 --- a/src/src/ZodSharp/Schemas/ZodTuple.cs +++ b/src/src/ZodSharp/Schemas/ZodTuple.cs @@ -1,3 +1,4 @@ +using System.Globalization; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -17,8 +18,6 @@ namespace ZodSharp.Schemas; /// The schema for the second element. public class ZodTuple(IZodSchema schema1, IZodSchema schema2) : ZodType<(T1, T2), object?[]> { - static readonly string[] EmptyPath = []; - /// /// Validates a two-element array, returning a typed tuple. /// @@ -29,54 +28,24 @@ public class ZodTuple(IZodSchema schema1, IZodSchema sch if (value is null) { return ValidationResult<(T1, T2)>.Failure( - new ValidationError("invalid_type", "Expected tuple, but got null", EmptyPath) + ZodIssues.NullValue(ZodTypeNames.Tuple, origin: ZodTypeNames.Array) ); } - if (value.Length != 2) - { - return ValidationResult<(T1, T2)>.Failure( - new ValidationError( - "invalid_tuple_length", - $"Expected tuple of length 2, but got {value.Length}", - EmptyPath - ) - ); - } + if (ZodTupleElements.LengthError(value.Length, 2) is { } lengthError) + return ValidationResult<(T1, T2)>.Failure(lengthError); List errors = []; - T1? v1 = default; - T2? v2 = default; + T1 v1; + T2 v2; - if (value[0] is T1 t1) - { - var r = schema1.Validate(t1); - if (!r.IsSuccess) - errors.AddRange(r.Errors); - else - v1 = r.Value; - } - else - { - errors.Add(new("invalid_type", $"Expected {typeof(T1).Name} at index 0", ["[0]"])); - } + ZodTupleElements.Validate(schema1, value[0], 0, errors, out v1); - if (value[1] is T2 t2) - { - var r = schema2.Validate(t2); - if (!r.IsSuccess) - errors.AddRange(r.Errors); - else - v2 = r.Value; - } - else - { - errors.Add(new("invalid_type", $"Expected {typeof(T2).Name} at index 1", ["[1]"])); - } + ZodTupleElements.Validate(schema2, value[1], 1, errors, out v2); return errors.Count > 0 ? ValidationResult<(T1, T2)>.Failure(errors) - : ValidationResult<(T1, T2)>.Success((v1!, v2!)); + : ValidationResult<(T1, T2)>.Success((v1, v2)); } } @@ -91,8 +60,6 @@ public class ZodTuple(IZodSchema schema1, IZodSchema sch public class ZodTuple(IZodSchema schema1, IZodSchema schema2, IZodSchema schema3) : ZodType<(T1, T2, T3), object?[]> { - static readonly string[] EmptyPath = []; - /// /// Validates a three-element array, returning a typed tuple. /// @@ -103,67 +70,110 @@ public class ZodTuple(IZodSchema schema1, IZodSchema if (value is null) { return ValidationResult<(T1, T2, T3)>.Failure( - new ValidationError("invalid_type", "Expected tuple, but got null", EmptyPath) + ZodIssues.NullValue(ZodTypeNames.Tuple, origin: ZodTypeNames.Array) ); } - if (value.Length != 3) - { - return ValidationResult<(T1, T2, T3)>.Failure( - new ValidationError( - "invalid_tuple_length", - $"Expected tuple of length 3, but got {value.Length}", - EmptyPath - ) - ); - } + if (ZodTupleElements.LengthError(value.Length, 3) is { } lengthError) + return ValidationResult<(T1, T2, T3)>.Failure(lengthError); List errors = []; - T1? v1 = default; - T2? v2 = default; - T3? v3 = default; + T1 v1; + T2 v2; + T3 v3; + + ZodTupleElements.Validate(schema1, value[0], 0, errors, out v1); + + ZodTupleElements.Validate(schema2, value[1], 1, errors, out v2); + + ZodTupleElements.Validate(schema3, value[2], 2, errors, out v3); + + return errors.Count > 0 + ? ValidationResult<(T1, T2, T3)>.Failure(errors) + : ValidationResult<(T1, T2, T3)>.Success((v1, v2, v3)); + } +} - if (value[0] is T1 t1) +/// +/// The element and length handling shared by the tuple schemas. +/// +static class ZodTupleElements +{ + /// + /// The length error for a tuple of elements that must have exactly + /// , or when the length matches. + /// + public static ValidationError? LengthError(int count, int arity) + { + if (count == arity) + return null; + + var tooSmall = count < arity; + var error = ValidationError.Create( + ZodIssueCodes.InvalidTupleLength, + string.Empty, + ZodPath.Root, + new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.Count] = count }, + ZodTypeNames.Array, + arity, + arity, + inclusive: true, + category: tooSmall ? ZodIssueCategories.TooSmall : ZodIssueCategories.TooBig + ) with { - var r = schema1.Validate(t1); - if (!r.IsSuccess) - errors.AddRange(r.Errors); - else - v1 = r.Value; - } - else + MessageFormat = ZodMessages.TupleLength, + }; + + return error with { - errors.Add(new("invalid_type", $"Expected {typeof(T1).Name} at index 0", ["[0]"])); - } + Message = ZodMessageFormat.Format(ZodMessages.TupleLength, error, CultureInfo.InvariantCulture), + }; + } - if (value[1] is T2 t2) + /// + /// Validates the element at against , adding its errors + /// (prefixed with the index) to . A element is handed to the + /// schema when can hold it, so a nullable element schema decides for itself. + /// + /// when the element is valid. + public static bool Validate( + IZodSchema schema, + object? element, + int index, + List errors, + out T result + ) + { + result = default!; + + T typed; + if (element is T matched) { - var r = schema2.Validate(t2); - if (!r.IsSuccess) - errors.AddRange(r.Errors); - else - v2 = r.Value; + typed = matched; } - else + else if (element is null && default(T) is null) { - errors.Add(new("invalid_type", $"Expected {typeof(T2).Name} at index 1", ["[1]"])); + typed = default!; } - - if (value[2] is T3 t3) + else { - var r = schema3.Validate(t3); - if (!r.IsSuccess) - errors.AddRange(r.Errors); - else - v3 = r.Value; + errors.Add( + ZodIssues.InvalidType(ZodTypeNames.Of(typeof(T)), element, ZodPath.From(ZodPathSegment.At(index))) + ); + return false; } - else + + var validation = schema.Validate(typed); + if (!validation.IsSuccess) { - errors.Add(new("invalid_type", $"Expected {typeof(T3).Name} at index 2", ["[2]"])); + var segment = ZodPathSegment.At(index); + foreach (var error in validation.Errors) + errors.Add(error.WithPathPrefix(segment)); + + return false; } - return errors.Count > 0 - ? ValidationResult<(T1, T2, T3)>.Failure(errors) - : ValidationResult<(T1, T2, T3)>.Success((v1!, v2!, v3!)); + result = validation.Value!; + return true; } } diff --git a/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs b/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs index a583b4f9..8bde5b10 100644 --- a/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodTypedNativeUnion.cs @@ -1,4 +1,5 @@ #if NET11_0_OR_GREATER +using System.Collections.Immutable; using ZodSharp.Core; using ZodSharp.Unions; @@ -20,6 +21,10 @@ namespace ZodSharp.Schemas; public class ZodTypedNativeUnion(IZodSchema option1, IZodSchema option2) : ZodType, object> { + static readonly string Option1Name = ZodTypeNames.Of(typeof(T1)); + static readonly string Option2Name = ZodTypeNames.Of(typeof(T2)); + static readonly ImmutableArray OptionNames = [Option1Name, Option2Name]; + /// /// Validates the value against each option, returning the first match as a /// . @@ -28,27 +33,36 @@ public class ZodTypedNativeUnion(IZodSchema option1, IZodSchema< /// A validation result containing a native union or errors. protected override ValidationResult> ParseInternal(object value) { + ImmutableArray errors1; if (value is T1 typed1) { var result = option1.Validate(typed1); if (result.IsSuccess) return ValidationResult>.Success(result.Value); + + errors1 = result.Errors; + } + else + { + errors1 = [ZodIssues.InvalidType(Option1Name, value)]; } + ImmutableArray errors2; if (value is T2 typed2) { var result = option2.Validate(typed2); if (result.IsSuccess) return ValidationResult>.Success(result.Value); + + errors2 = result.Errors; + } + else + { + errors2 = [ZodIssues.InvalidType(Option2Name, value)]; } - return ValidationResult>.Failure( - new ValidationError( - "invalid_union", - $"Value does not match any of the union options ({typeof(T1).Name}, {typeof(T2).Name})", - [] - ) - ); + // Each option's issues are kept, in declaration order, with paths relative to the union. + return ValidationResult>.Failure(ZodIssues.InvalidUnion([errors1, errors2], OptionNames)); } } #endif diff --git a/src/src/ZodSharp/Schemas/ZodTypedUnion.cs b/src/src/ZodSharp/Schemas/ZodTypedUnion.cs index 1df1827f..d99eeaef 100644 --- a/src/src/ZodSharp/Schemas/ZodTypedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodTypedUnion.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using ZodSharp.Core; using ZodSharp.Unions; @@ -18,6 +19,10 @@ namespace ZodSharp.Schemas; public class ZodTypedUnion(IZodSchema option1, IZodSchema option2) : ZodType, object> { + static readonly string Option1Name = ZodTypeNames.Of(typeof(T1)); + static readonly string Option2Name = ZodTypeNames.Of(typeof(T2)); + static readonly ImmutableArray OptionNames = [Option1Name, Option2Name]; + /// /// Validates the value against each option, returning the first match as a /// . @@ -26,26 +31,35 @@ public class ZodTypedUnion(IZodSchema option1, IZodSchemaA validation result containing a typed union or errors. protected override ValidationResult> ParseInternal(object value) { + ImmutableArray errors1; if (value is T1 typed1) { var result = option1.Validate(typed1); if (result.IsSuccess) return ValidationResult>.Success(Union.Create(result.Value)); + + errors1 = result.Errors; + } + else + { + errors1 = [ZodIssues.InvalidType(Option1Name, value)]; } + ImmutableArray errors2; if (value is T2 typed2) { var result = option2.Validate(typed2); if (result.IsSuccess) return ValidationResult>.Success(Union.Create(result.Value)); + + errors2 = result.Errors; + } + else + { + errors2 = [ZodIssues.InvalidType(Option2Name, value)]; } - return ValidationResult>.Failure( - new ValidationError( - "invalid_union", - $"Value does not match any of the union options ({typeof(T1).Name}, {typeof(T2).Name})", - [] - ) - ); + // Each option's issues are kept, in declaration order, with paths relative to the union. + return ValidationResult>.Failure(ZodIssues.InvalidUnion([errors1, errors2], OptionNames)); } } diff --git a/src/src/ZodSharp/Schemas/ZodUnion.cs b/src/src/ZodSharp/Schemas/ZodUnion.cs index b44d396e..369f45b8 100644 --- a/src/src/ZodSharp/Schemas/ZodUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodUnion.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -23,7 +24,7 @@ public class ZodUnion(IReadOnlyList> options) : ZodTy /// A validation result protected override ValidationResult ParseInternal(object value) { - List? allErrors = null; + var unionErrors = ImmutableArray.CreateBuilder>(options.Count); foreach (var option in options) { @@ -33,17 +34,10 @@ protected override ValidationResult ParseInternal(object value) return result; } - allErrors ??= []; - allErrors.AddRange(result.Errors); + // Each option's issues stay grouped, in declaration order, with paths relative to the union. + unionErrors.Add(result.Errors.IsDefault ? [] : result.Errors); } - return ValidationResult.Failure( - new ValidationError( - "invalid_union", - "Value does not match any of the union options", - [], - new Dictionary { { "errors", allErrors ?? [] } } - ) - ); + return ValidationResult.Failure(ZodIssues.InvalidUnion(unionErrors.MoveToImmutable())); } } diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index c7a057cd..e68bf3f8 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -8,8 +8,11 @@ A high-performance schema validation library for C#, ported from TypeScript [Zod This package is the core library. It also ships the `[ZodSchema]` source generator and JSON Schema **export** (`Z.ToJsonSchema`). JSON Schema **import** (`Z.FromJsonSchema`) and JSON serialization integrations are available in the companion packages: - [Purview.ZodSharp.SystemTextJson](https://www.nuget.org/packages/Purview.ZodSharp.SystemTextJson) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.SystemTextJson.svg)](https://www.nuget.org/packages/Purview.ZodSharp.SystemTextJson) +- [Purview.ZodSharp.Http](https://www.nuget.org/packages/Purview.ZodSharp.Http) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.Http.svg)](https://www.nuget.org/packages/Purview.ZodSharp.Http) - [Purview.ZodSharp.NewtonsoftJson](https://www.nuget.org/packages/Purview.ZodSharp.NewtonsoftJson) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.NewtonsoftJson.svg)](https://www.nuget.org/packages/Purview.ZodSharp.NewtonsoftJson) - [Purview.ZodSharp.AspNetCore](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.AspNetCore.svg)](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore) +- [Purview.ZodSharp.Extensions.Validation](https://www.nuget.org/packages/Purview.ZodSharp.Extensions.Validation) (.NET 11) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.Extensions.Validation.svg)](https://www.nuget.org/packages/Purview.ZodSharp.Extensions.Validation) +- [Purview.ZodSharp.AspNetCore.OpenApi](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore.OpenApi) (.NET 11) [![NuGet version](https://img.shields.io/nuget/v/Purview.ZodSharp.AspNetCore.OpenApi.svg)](https://www.nuget.org/packages/Purview.ZodSharp.AspNetCore.OpenApi) ## Installation @@ -39,6 +42,23 @@ var userSchema = Z.Object() .Build(); ``` +## Errors + +A failure is a list of `ValidationError`s, each carrying machine-readable identity rather than just a message: + +| Member | Meaning | +| --- | --- | +| `Code` | ZodSharp's specific code (`missing_field`, `invalid_length`, a rule's `ErrorCode`, ...) - the same failure reports the same code from every producer | +| `Category` | the Zod v4 issue code (`invalid_type`, `too_small`, `too_big`, `invalid_format`, `invalid_union`, `custom`, ...; see `ZodIssueCategories`) | +| `Message` / `MessageFormat` | the message and the named-placeholder template that produced it (`"{displayName} is required"`) | +| `FullPath` | a typed `ZodPath`; `FullPath.ToDottedString()` renders `Items[0].Name`, `Map[en]`, `Map["0"]` | +| `Origin`, `Lower`, `Upper` | the value's kind and its bounds, with inclusivity, for numbers, dates and sizes | +| `Parameters` | values such as `expected`, `received`, `options`, `count`, `pattern`, `displayName` | + +Validation is culture-independent and messages never contain the input. Hosts re-render messages in the request +culture through `IZodMessageFormatter` (`DefaultZodMessageFormatter` is included). `ZodErrorOptions.IncludeReceivedValues` +(default `false`, development only) adds the raw input to the `received` parameter. + ## Native unions (.NET 11+) When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 15 union instead of the hand-rolled `ZodSharp.Unions.Union`: @@ -87,15 +107,40 @@ var validated = UserSchema.Parse(user); // throws on failure // Value-first composition methods (enable via EnableComposition, on by default): var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); + +// Async and service-aware validation, one member at a time, and typed paths: +var full = await UserSchema.ValidateAsync(user, serviceProvider, cancellationToken); +var name = UserSchema.ValidateMember(user, nameof(User.Name)); +ZodPath namePath = UserSchema.Paths.Name; ``` -DataAnnotations attributes such as `[Required]`, `[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, and `[Compare]` are validated with direct, typed codegen (no reflection). +Every generated schema also has `IsValidatableMember`, `ValidateMemberAsync`, `TryResolveMember` (walks an error path to the +object that owns the failing member, without reflection), `JsonNames`/`ToJsonNamedPath` and a compile-time +`RequiresAsyncValidation`. Each assembly gets a trim- and AOT-safe registration: +`ZodSharp.Generated.{Assembly}ZodSharpSchemas.Register(factory)`, or `services.AddZodSharp{Assembly}Schemas()` with dependency +injection. + +`IValidatableObject` runs after the member rules; on .NET 11, `IAsyncValidatableObject` and `AsyncValidationAttribute` run in +`ValidateAsync`. A custom async method may take `(T value, IServiceProvider services, CancellationToken ct)` to use the caller's +services, and `[ZodAsyncMember(nameof(X))]` runs it for that member's field-level validation. + +DataAnnotations attributes such as `[Required]` (with `RequiredAttribute` semantics: `null`, `""` and whitespace fail unless `AllowEmptyStrings = true`), `[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`, `[RegularExpression]`, `[AllowedValues]`, `[DeniedValues]`, `[EmailAddress]`, and `[Compare]` are validated with direct, typed codegen (no reflection). Enum properties are validated automatically: a value that is not a defined member of the enum type is rejected with `invalid_enum_value`. Mark an enum member `[ZodIgnore]` to exclude it everywhere the enum is validated, or list it in a property's `[DeniedValues]` to exclude it for that property only. `[Flags]` enums are skipped, and `[ZodSchema(ValidateEnumValues = false)]` opts the type out. -Collection elements reject `null` by default: a null element reports `invalid_type` at its own path (for example `["Items", 0]`). Opt in per property with `[CollectionBehavior(AllowNulls = true)]`, per element type with `T?` (for example `List`), per type with `[ZodSchema(AllowNulls = true)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = true)]` or the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins; `[CollectionBehavior]` on a property that cannot hold null elements is reported as `ZODSGEN046`. +Collection elements reject `null` by default: a null element reports `invalid_type` at its own path (for example `Items[0]`). Opt in per property with `[CollectionBehavior(AllowNulls = true)]`, per element type with `T?` (for example `List`), per type with `[ZodSchema(AllowNulls = true)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = true)]` or the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins; `[CollectionBehavior]` on a property that cannot hold null elements is reported as `ZODSGEN046`. -A dictionary property (`IDictionary` / `IReadOnlyDictionary`) is validated as a map: every value is checked against the value type's nested schema, a failure is reported at the entry's key (for example `["Variants", "blue"]`), and `AllowNulls` governs null values. It exports as `{ "type": "object", "additionalProperties": … }` rather than as an array. +Struct collections (including nullable wrappers) are supported. A default `ImmutableArray` is treated +as missing: `[Required]` reports `missing_field`, a nullable member accepts it unless required, and a +non-nullable member reports `invalid_type`. To validate it as empty instead, set +`[CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty)]`, +`[assembly: ZodSharpSchemaDefaults(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty)]`, +or `TreatAsEmpty` in MSBuild. +Precedence is property, assembly, MSBuild, then `ValidationError`. An absent nullable wrapper remains +missing in either mode; the model is not mutated. A property with this immutable-array option is valid +even when its elements cannot be null. + +A dictionary property (`IDictionary` / `IReadOnlyDictionary`) is validated as a map: every value is checked against the value type's nested schema, a failure is reported at the entry's key (for example `Variants[blue].Sku`), and `AllowNulls` governs null values. It exports as `{ "type": "object", "additionalProperties": … }` rather than as an array. ## Custom rules @@ -224,7 +269,7 @@ Add `[ZodRule(AllowMultiple = true)]` to emit `AttributeUsage(..., AllowMultiple public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule { public const string ErrorCode = "not_multiple_of"; - public const string MessageFormat = "Number must be a multiple of {0}, but got {1}"; + public const string MessageFormat = "Number must be a multiple of {0}"; public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; public string GetErrorMessage(in int value) => @@ -345,6 +390,10 @@ builder.Services.AddZodSchemaOptionsValidator(); builder.Services.AddOptions().AddZodSchemaValidator().ValidateOnStart(); ``` +On .NET 11 the options validator also implements `IAsyncValidateOptions`, so `ValidateOnStart()` runs asynchronous +and service-aware rules. Each failure reads `{path}: {message} ({code})`, prefixed with the options name when it is not +the default. + The `Purview.ZodSharp.AspNetCore` package offers `AddZodSharp` with assembly auto-discovery. ## JSON Schema export diff --git a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props index 5d32d293..12484c13 100644 --- a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props +++ b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props @@ -3,6 +3,9 @@ Specifies whether to disable Purview.ZodSharp source generators. + + Specifies whether to disable the Purview.ZodSharp [ErrorType] generator. + Enables auto-detection of IValidateOptions validators for types with a configured name suffix. @@ -21,8 +24,25 @@ Assembly-wide default for whether collection properties may contain null elements; false (the default) rejects them. + + How uninitialized ImmutableArray members are validated: ValidationError (default, normal missing-value rules) or TreatAsEmpty. + Assembly-wide DateTimeOffset wire form assumed by JSON Schema generation; Utc requires a zero offset and makes the non-sentinel mapping exact, PreserveOffset (the default) keeps the value's offset. + + + + + + + + + + diff --git a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.targets b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.targets new file mode 100644 index 00000000..d40974b7 --- /dev/null +++ b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.targets @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/src/ZodSharp/ZodAsyncMemberAttribute.cs b/src/src/ZodSharp/ZodAsyncMemberAttribute.cs new file mode 100644 index 00000000..a810bff3 --- /dev/null +++ b/src/src/ZodSharp/ZodAsyncMemberAttribute.cs @@ -0,0 +1,26 @@ +namespace ZodSharp; + +/// +/// Declares which members a [ZodSchema] type's custom async validation method validates, so +/// field-level validation (ValidateMemberAsync, for example a Blazor field change) runs the method only +/// when one of those members changes, and reports only the errors it raises for that member. +/// +/// +/// Without the attribute the custom method runs only on full validation. Apply it to the method named by +/// [ZodSchema(CustomValidationMethodName = ...)] (by default CustomValidationAsync). +/// +/// +/// +/// [ZodAsyncMember(nameof(Email))] +/// internal static async ValueTask<ValidationResult<User>> CustomValidationAsync(User value, IServiceProvider services, CancellationToken ct) { ... } +/// +/// +/// The C# member names the method validates. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class ZodAsyncMemberAttribute(params string[] members) : Attribute +{ + /// + /// Gets the C# member names the method validates. + /// + public IReadOnlyList Members { get; } = members ?? []; +} diff --git a/src/src/ZodSharp/ZodSchemaAttribute.cs b/src/src/ZodSharp/ZodSchemaAttribute.cs new file mode 100644 index 00000000..0effe808 --- /dev/null +++ b/src/src/ZodSharp/ZodSchemaAttribute.cs @@ -0,0 +1,102 @@ +namespace ZodSharp; + +/// +/// Attribute to mark a class for automatic schema generation. +/// When applied to a class, a zero-allocation validator will be generated at compile time. +/// +/// +/// +/// Defined once, here, rather than generated into every consuming compilation. A generated copy gave every project +/// in a solution an identical type and XML documentation ID, which crashed tools that index documentation +/// across a project and its references (the ASP.NET Core OpenAPI XML-comment generator reported "An item with the +/// same key has already been added"). +/// +/// +/// The source generator reads the attribute by its metadata name, so it recognises it on types declared in this +/// compilation and on types in referenced assemblies alike. +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] +public sealed class ZodSchemaAttribute : Attribute +{ + /// + /// Optional name for the generated schema class. + /// If not specified, uses "{ClassName}Schema". + /// + public string? SchemaName { get; init; } + + /// + /// Whether to generate a static Validate method. + /// Default is true. + /// + public bool GenerateValidateMethod { get; init; } = true; + + /// + /// Whether to generate a static Parse method (throws on failure). + /// Default is true. + /// + public bool GenerateParseMethod { get; init; } = true; + + /// + /// Whether to enable composition methods (.and(), .or(), .refine()). + /// Default is true. + /// + public bool EnableComposition { get; init; } = true; + + /// + /// Optional name of a custom asynchronous validation method to invoke + /// after synchronous validation. When set, the generator looks for a + /// method with the signature: + /// ValueTask<ValidationResult<T>> MethodName(T value, CancellationToken cancellationToken) + /// declared as a static method on the model type or an instance method on the + /// generated {TypeName}SchemaValidator partial. + /// If null, the default name CustomValidationAsync is used. + /// No diagnostic is reported when the default name has no matching method. + /// Mutually exclusive with the OnZodValidate refinement hook (ZODSGEN029). + /// + public string? CustomValidationMethodName { get; init; } + + /// + /// Whether to generate an IValidateOptions<T> validator for this type. + /// Generation is driven by auto-detection by default: enabled when the type name ends + /// with a configured suffix (e.g. Options or Settings) and the + /// ZodSharpAutoGenerateOptionsValidators MSBuild property is not disabled. + /// Set to true to force generation or set SuppressIValidateOptions to opt out. + /// + public bool GenerateIValidateOptions { get; init; } + + /// + /// Suppresses generation of the IValidateOptions<T> validator for this type, + /// even when auto-detection would otherwise enable it. + /// + public bool SuppressIValidateOptions { get; init; } + + /// + /// Whether enum properties are validated automatically: the generated validator rejects a + /// value that is not a defined member of the enum type, or that resolves to a member + /// excluded from the allowed set (an enum member marked [ZodIgnore], or a value the + /// property's [DeniedValues] attribute lists). [Flags] enums are never validated. + /// Default is true. + /// + public bool ValidateEnumValues { get; init; } = true; + + /// + /// Whether to generate the JSON Schema export members for this type: the static + /// BuildJsonSchema method and the IJsonSchemaProvider implementation on the + /// generated validator adapter. Set to false to keep a validation-only type out of the + /// cross-platform (TypeScript/JavaScript) contract surface. Default is true. + /// A type whose export is disabled cannot be referenced by an exported type; the generator + /// falls back to an empty schema and the analyzer reports ZODSGEN045. + /// + public bool GenerateJsonSchema { get; init; } = true; + + /// + /// Default for whether this type's collection properties allow null elements. + /// When unset, the assembly-level default applies (the ZodSharpAllowNulls MSBuild + /// property, overridden by [assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]). + /// When set, it applies to this type's collections unless a property overrides it with + /// [CollectionBehavior(AllowNulls = ...)] or an explicitly nullable element type + /// (T?). The built-in default is to reject null elements. + /// + public bool AllowNulls { get; init; } +} diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index b2377d8c..d804b783 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -1,5 +1,8 @@ - + + + true true Purview.ZodSharp Purview.ZodSharp @@ -19,12 +22,12 @@ rule-type names that did not exist, so object properties, array bounds and element schemas, optional inner schemas and union options were all missing from exported schemas. - RegisterFromAssembly stays reflective by nature and declares [RequiresUnreferencedCode]; the - discriminated-union accessor falls back to plain reflection when dynamic code is unavailable. + RegisterFromAssembly reads the generator's typeof-rooted attribute, so it is safe too; the + discriminated-union accessor falls back to plain reflection when dynamic code is unavailable, behind + the ZodSharp.Reflection.IsSupported feature switch. - The integration packages are NOT marked: Purview.ZodSharp.NewtonsoftJson cannot be, because - Newtonsoft.Json is reflection-based throughout, and Purview.ZodSharp.SystemTextJson needs - JsonTypeInfo/JsonSerializerContext overloads first. + Purview.ZodSharp.NewtonsoftJson is marked too, but only because every API that reaches Newtonsoft.Json + declares [RequiresUnreferencedCode]/[RequiresDynamicCode]; Newtonsoft.Json is reflection-based throughout. --> true $(NoWarn);NU5118 diff --git a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs index cfaab6e2..54bc6776 100644 --- a/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs +++ b/src/src/ZodSharp/ZodSharpSchemaDefaultsAttribute.cs @@ -19,6 +19,14 @@ namespace ZodSharp; [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] public sealed class ZodSharpSchemaDefaultsAttribute : Attribute { + /// Gets or sets how generated schemas validate uninitialized immutable arrays. + /// + /// Overrides ZodSharpDefaultImmutableArrayBehavior in MSBuild; a property's + /// takes precedence. The built-in default is + /// . + /// + public DefaultImmutableArrayBehavior DefaultImmutableArrayBehavior { get; set; } + /// /// Gets or sets the assembly-wide default for whether collection properties allow /// elements (or dictionary values). diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/.editorconfig b/src/tests/AspNetCore.OpenApi.UnitTests/.editorconfig new file mode 100644 index 00000000..64a0a497 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/.editorconfig @@ -0,0 +1,6 @@ +# Layered over the Purview.BuildSdk-managed root .editorconfig (which must not be edited). + +[*.{cs,csx,cake}] +# "OpenApi" is the spelling ASP.NET Core and Microsoft.OpenApi use (OpenApiOptions, IOpenApiSchemaTransformer), +# so ZodSharp's OpenAPI integration follows it rather than PDS0004's "OpenAPI". Scoped to this project. +dotnet_analyzer_configuration.pds0004.allowed_words = Api diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/AspNetCore.OpenApi.UnitTests.csproj b/src/tests/AspNetCore.OpenApi.UnitTests/AspNetCore.OpenApi.UnitTests.csproj new file mode 100644 index 00000000..8feb276b --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/AspNetCore.OpenApi.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + net11.0 + + false + ZodSharp.AspNetCore.OpenApi + Purview.ZodSharp.AspNetCore.OpenApi.UnitTests + Purview.ZodSharp.AspNetCore.OpenApi.UnitTests + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + + + + + + + + + + + + + + diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs b/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs new file mode 100644 index 00000000..102d61e9 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs @@ -0,0 +1,360 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.OpenApi; +using ZodSharp.AspNetCore.OpenApi.Fixtures; +using JsonSchemaDocument = Json.Schema.JsonSchema; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Proves the document does not lie: a JSON Schema validator evaluates fixture payloads against the schemas +/// the document emits, and its verdict must match ZodSharp's for every payload ZodSharp accepts and every +/// payload it rejects for a reason JSON Schema can express. +/// +public class DocumentCrossCheckTests +{ + static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); + + /// A payload, the component it targets, and whether ZodSharp accepts it. + public sealed record Payload(string Component, string Json, bool Accepted, string Reason) + { + public override string ToString() => $"{Component}: {Reason}"; + } + + static readonly Payload[] Payloads = + [ + new( + "StringRulesRequest", + /*lang=json,strict*/""" + { + "name": "ab", "email": "a@example.com", "legacyEmail": "b@example.com", "website": "https://example.com", + "slug": "abc", "prefixed": "abc", "suffixed": "xyz", "containing": "amidb", "bracketed": "abxyz", + "identifier": "6f1c2d3e-4a5b-4c6d-8e7f-0123456789ab", "addressV4": "192.168.0.1", "addressV6": "::1", + "network": "10.0.0.0/8", "timestamp": "2024-01-01T10:00:00Z", "day": "2024-01-31", "timeOfDay": "10:30", + "period": "P1D", "payload": "aGVsbG8=", "colour": "red", "username": "bob", "described": "x" + } + """, + Accepted: true, + "every constraint satisfied" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab" }""", + Accepted: true, + "optional members absent" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "a" }""", + Accepted: false, + "minLength" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "abcdefghijk" }""", + Accepted: false, + "maxLength" + ), + new("StringRulesRequest", """{ }""", Accepted: false, "required member absent"), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "email": "not-an-email" }""", + Accepted: false, + "format: email" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "legacyEmail": "nope" }""", + Accepted: false, + "[EmailAddress]" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "slug": "ABC" }""", + Accepted: false, + "pattern" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "prefixed": "xab" }""", + Accepted: false, + "startsWith" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "suffixed": "yzx" }""", + Accepted: false, + "endsWith" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "containing": "nope" }""", + Accepted: false, + "includes" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "bracketed": "abx" }""", + Accepted: false, + "allOf patterns" + ), + new( + "StringRulesRequest", + /*lang=json,strict*/"""{ "name": "ab", "identifier": "6f1c2d3e-4a5b-1c6d-8e7f-0123456789ab" }""", + Accepted: false, + "uuid version" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "addressV4": "::1" }""", + Accepted: false, + "format: ipv4" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "addressV6": "10.0.0.1" }""", + Accepted: false, + "format: ipv6" + ), + new( + "StringRulesRequest", + /*lang=json,strict*/"""{ "name": "ab", "timestamp": "2024-01-01T10:00:00+01:00" }""", + Accepted: false, + "UTC date-time" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "day": "2024-13-01" }""", + Accepted: false, + "format: date" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "timeOfDay": "25:00" }""", + Accepted: false, + "time pattern" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "colour": "blue" }""", + Accepted: false, + "enum" + ), + new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "username": "admin" }""", + Accepted: false, + "not enum" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: true, + "every constraint satisfied" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 0, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "minimum" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 11, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "maximum" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 0, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "exclusiveMinimum" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 100, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "exclusiveMaximum" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 7, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "maximum (LessThanOrEqual)" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 7, "whole": 3, "safe": 7 }""", + Accepted: false, + "multipleOf" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3.5, "safe": 7 }""", + Accepted: false, + "integer" + ), + new( + "NumberRulesRequest", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 30000000000 }""", + Accepted: false, + "safe integer" + ), + new( + "ShapesRequest", + /*lang=json,strict*/""" + { + "tags": ["a"], "scores": { "x": 1 }, "reference": "r", "title": "t", "createdAt": "2024-01-01T00:00:00Z", + "birthday": "2024-01-01", "elapsed": "00:00:01", "home": { "street": "Main" }, + "others": [{ "street": "Side", "postCode": "12345" }] + } + """, + Accepted: true, + "every constraint satisfied" + ), + new( + "ShapesRequest", + /*lang=json,strict*/"""{ "tags": [], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "minItems" + ), + new( + "ShapesRequest", + /*lang=json,strict*/"""{ "tags": ["a","b","c","d"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "maxItems" + ), + new( + "ShapesRequest", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": { "a": 1, "b": 2, "c": 3 }, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "maxProperties" + ), + new( + "ShapesRequest", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": {}, "reference": null, "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "null where ZodSharp requires a value" + ), + new( + "ShapesRequest", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "" }, "others": [] }""", + Accepted: false, + "nested minLength" + ), + new( + "ShapesRequest", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [{ "street": "S", "postCode": "abc" }] }""", + Accepted: false, + "nested collection pattern" + ), + ]; + + public static IEnumerable> Cases() + { + foreach (var version in OpenApiTestHost.Versions()) + { + foreach (var payload in Payloads) + yield return () => (version, payload); + } + } + + [Test] + [MethodDataSource(nameof(Cases))] + public async Task Document_GivenFixturePayload_AgreesWithZodSharp(OpenApiSpecVersion version, Payload payload) + { + // Arrange + var document = await FixtureApi.GetAsync(version); + var schema = BuildSchema(document, payload.Component); + using var instance = JsonDocument.Parse(payload.Json); + + // Act + var zodSharpAccepts = ValidateWithZodSharp(payload); + var schemaAccepts = schema + .Evaluate(instance.RootElement, new EvaluationOptions { RequireFormatValidation = true }) + .IsValid; + + // Assert + await Assert.That(zodSharpAccepts).IsEqualTo(payload.Accepted); + await Assert.That(schemaAccepts).IsEqualTo(payload.Accepted); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task Document_GivenRuleWithoutJsonSchemaKeyword_DescribesItWithVendorExtension( + OpenApiSpecVersion version + ) + { + // Arrange - ZodSharp rejects these, but JSON Schema has no keyword for a JWT or a CIDR block, so the + // schema accepts them. The document says so instead of staying silent. + Payload jwt = new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "token": "x" }""", + Accepted: false, + "jwt" + ); + Payload cidr = new( + "StringRulesRequest", + /*lang=json,strict*/"""{ "name": "ab", "network": "10.0.0.1" }""", + Accepted: false, + "cidr" + ); + var document = await FixtureApi.GetAsync(version); + + // Act + var jwtAccepted = ValidateWithZodSharp(jwt); + var cidrAccepted = ValidateWithZodSharp(cidr); + + // Assert + var properties = document.Component("StringRulesRequest"); + await Assert.That(jwtAccepted).IsFalse(); + await Assert.That(cidrAccepted).IsFalse(); + await Assert.That(properties.Property("token")["x-zodsharp-unsupported"]).IsNotNull(); + await Assert.That(properties.Property("network")["x-zodsharp-unsupported"]).IsNotNull(); + } + + public static IEnumerable Versions() => OpenApiTestHost.Versions(); + + static bool ValidateWithZodSharp(Payload payload) => + payload.Component switch + { + "StringRulesRequest" => StringRulesRequestSchema + .Validate(JsonSerializer.Deserialize(payload.Json, Web)!) + .IsSuccess, + "NumberRulesRequest" => NumberRulesRequestSchema + .Validate(JsonSerializer.Deserialize(payload.Json, Web)!) + .IsSuccess, + "ShapesRequest" => ShapesRequestSchema + .Validate(JsonSerializer.Deserialize(payload.Json, Web)!) + .IsSuccess, + _ => throw new ArgumentOutOfRangeException(nameof(payload), payload.Component, "Unknown component."), + }; + + /// + /// Turns the document's component schemas into one JSON Schema 2020-12 document: the components become + /// $defs, and the root references the component under test. + /// + static JsonSchemaDocument BuildSchema(JsonObject document, string component) + { + var components = document["components"]!["schemas"]! + .ToJsonString() + .Replace("#/components/schemas/", "#/$defs/", StringComparison.Ordinal); + + JsonObject root = new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["$ref"] = $"#/$defs/{component}", + ["$defs"] = JsonNode.Parse(components), + }; + + // The schema keeps referring to the element it was built from, so it must outlive any JsonDocument. + using var json = JsonDocument.Parse(root.ToJsonString()); + var element = json.RootElement.Clone(); + return JsonSchemaDocument.Build( + element, + new BuildOptions { SchemaRegistry = new SchemaRegistry() }, + new Uri($"https://zodsharp.test/{Guid.NewGuid():N}") + ); + } +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs b/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs new file mode 100644 index 00000000..d4be7320 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs @@ -0,0 +1,55 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using ZodSharp.AspNetCore.OpenApi.Fixtures; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// The fixture API every mapping test reads: one endpoint per fixture type. The documents are built once per +/// OpenAPI version, with and without the ZodSharp transformers, so each test compares the platform's output +/// with ZodSharp's. +/// +static class FixtureApi +{ + static readonly ConcurrentDictionary< + (OpenApiSpecVersion Version, bool ZodSharp), + Lazy> + > Documents = new(); + + public static Task GetAsync(OpenApiSpecVersion version, bool zodSharp = true) => + Documents + .GetOrAdd((version, zodSharp), key => new Lazy>(() => + OpenApiTestHost.GetDocumentAsync( + key.Version, + Map, + CancellationToken.None, + services: RegisterRuntimeSchemas, + addZodSharp: key.ZodSharp + ) + )) + .Value; + + public static void RegisterRuntimeSchemas(IServiceCollection services) => + services.AddZodSharp(options => + { + options.ScanAssemblies.Add(typeof(FixtureApi).Assembly); + options.ConfigureFactory = factory => factory.Register(new PercentageSchema()); + }); + + public static void Map(WebApplication app) + { + app.MapPost("/strings", (StringRulesRequest request) => Results.Ok()); + app.MapPost("/numbers", (NumberRulesRequest request) => Results.Ok()); + app.MapPost("/shapes", (ShapesRequest request) => Results.Ok()); + app.MapPost("/conflict", (ConflictRequest request) => Results.Ok()).ProducesValidationProblem(); + app.MapGet("/search", ([AsParameters] SearchQuery query) => Results.Ok()); + app.MapPost("/plain", (PlainRequest request) => Results.Ok()); + app.MapPost("/polymorphic", (ShapeBase request) => Results.Ok()); + app.MapPost("/percentage", (Percentage request) => Results.Ok()); + app.MapPost("/pets", (PetUnion request) => Results.Ok()); + } +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs new file mode 100644 index 00000000..76deb549 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs @@ -0,0 +1,285 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using ZodSharp.Rules; + +namespace ZodSharp.AspNetCore.OpenApi.Fixtures; + +/// Every string rule row of the keyword mapping. +[ZodSchema] +public partial class StringRulesRequest +{ + [MinLengthZod(2)] + [MaxLengthZod(10)] + public string Name { get; set; } = string.Empty; + + [Email] + public string? Email { get; set; } + + /// The platform does not map [EmailAddress]; ZodSharp fills the gap. + [EmailAddress] + public string? LegacyEmail { get; set; } + + [UrlZod] + public string? Website { get; set; } + + [Regex("^[a-z]+$")] + public string? Slug { get; set; } + + [StartsWith("ab")] + public string? Prefixed { get; set; } + + [EndsWith("yz")] + public string? Suffixed { get; set; } + + [Includes("mid")] + public string? Containing { get; set; } + + /// Two pattern rules on one member promote to allOf. + [StartsWith("ab")] + [EndsWith("yz")] + public string? Bracketed { get; set; } + + [UUID(UuidVersion.V4)] + public string? Identifier { get; set; } + + [IPAddress(IPAddressRuleType.IPv4)] + public string? AddressV4 { get; set; } + + [IPAddress(IPAddressRuleType.IPv6)] + public string? AddressV6 { get; set; } + + [Cidr] + public string? Network { get; set; } + + [DatetimeString] + public string? Timestamp { get; set; } + + [DateString] + public string? Day { get; set; } + + [TimeString] + public string? TimeOfDay { get; set; } + + [Duration] + public string? Period { get; set; } + + [Base64StringZod] + public string? Payload { get; set; } + + [JWT] + public string? Token { get; set; } + + [AllowedValuesZod("red", "green")] + public string? Colour { get; set; } + + [DeniedValuesZod("admin")] + public string? Username { get; set; } + + [Display(Description = "The display description.")] + [MaxLengthZod(50)] + public string? Described { get; set; } +} + +/// Every numeric rule row of the keyword mapping. +[ZodSchema] +public partial class NumberRulesRequest +{ + [MinValue(1)] + [MaxValue(10)] + public int Count { get; set; } + + [GreaterThan(0d)] + [LessThan(100d)] + public double Ratio { get; set; } + + [GreaterThanOrEqual(5)] + [LessThanOrEqual(6)] + public int Bounded { get; set; } + + [MultipleOf(5)] + public int Step { get; set; } + + [Int] + public double Whole { get; set; } + + [SafeInteger] + public double Safe { get; set; } +} + +public enum Shade +{ + Light, + Dark, +} + +/// Collections, maps, nullability, enums, temporal values and nested objects. +[ZodSchema] +public partial class ShapesRequest +{ + [MinLength(1)] + [MaxLength(3)] + public List Tags { get; init; } = []; + + [MaxLength(2)] + public Dictionary Scores { get; init; } = []; + + public string? Nickname { get; set; } + + /// Nullable in C#, but ZodSharp rejects null: the platform's null type is removed. + [RequiredZod] + public string? Reference { get; set; } + + public string Title { get; set; } = string.Empty; + + public int Quantity { get; set; } + + public Shade Shade { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateOnly Birthday { get; set; } + + public TimeSpan Elapsed { get; set; } + + public Address Home { get; set; } = new(); + + public List
Others { get; init; } = []; +} + +[ZodSchema] +public partial class Address +{ + [MinLengthZod(1)] + public string Street { get; set; } = string.Empty; + + [Regex("^[0-9]{5}$")] + public string? PostCode { get; set; } +} + +/// DataAnnotations the platform maps, where ZodSharp agrees or disagrees. +[ZodSchema] +public partial class ConflictRequest +{ + /// Both say maxLength: 10: left untouched. + [StringLength(10)] + public string? Same { get; set; } + + /// The platform says maximum: 100, ZodSharp enforces 50: ZodSharp wins. + [Range(1, 100)] + [MaxValue(50)] + public int Different { get; set; } +} + +/// Bound from the query string through [AsParameters]. +[ZodSchema] +public partial class SearchQuery +{ + [MinLengthZod(3)] + public string? Term { get; set; } + + [MaxValue(50)] + public int Take { get; set; } +} + +/// A type ZodSharp does not know: the platform's schema is left alone. +public class PlainRequest +{ + [Range(1, 5)] + public int Stars { get; set; } + + [MaxLength(20)] + public string? Comment { get; set; } +} + +[ZodSchema] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(CircleShape), "circle")] +[JsonDerivedType(typeof(SquareShape), "square")] +public abstract partial class ShapeBase +{ + public string? Label { get; set; } +} + +public sealed class CircleShape : ShapeBase +{ + public double Radius { get; set; } +} + +public sealed class SquareShape : ShapeBase +{ + public double Side { get; set; } +} + +/// +/// A value object ZodSharp knows only through a runtime schema registered in IZodSchemaFactory. +/// +[JsonConverter(typeof(PercentageConverter))] +public readonly record struct Percentage(double Value); + +public sealed class PercentageConverter : JsonConverter +{ + public override Percentage Read( + ref System.Text.Json.Utf8JsonReader reader, + Type typeToConvert, + System.Text.Json.JsonSerializerOptions options + ) => new(reader.GetDouble()); + + public override void Write( + System.Text.Json.Utf8JsonWriter writer, + Percentage value, + System.Text.Json.JsonSerializerOptions options + ) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WriteNumberValue(value.Value); + } +} + +/// A hand-written runtime schema that describes its own JSON Schema. +public sealed class PercentageSchema : Core.ZodType, JsonSchema.IJsonSchemaProvider +{ + public JsonSchema.JsonSchemaDefinition BuildJsonSchema() => + new() + { + Type = "number", + Minimum = 0, + Maximum = 100, + }; + + protected override Core.ValidationResult ParseInternal(Percentage value) => + value.Value is >= 0 and <= 100 + ? Core.ValidationResult.Success(value) + : Core.ValidationResult.Failure(new Core.ValidationError("too_big", "Out of range", [])); +} + +[ZodSchema] +public partial class CatCase +{ + [MinLengthZod(2)] + public string Name { get; set; } = string.Empty; +} + +[ZodSchema] +public partial class DogCase +{ + [MinValue(0)] + public int Barks { get; set; } +} + +/// An enum that asks System.Text.Json for member names itself, through its own converter. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Tone +{ + Warm, + Cool, +} + +/// Enum members: one serialized by the options (numbers unless a global converter says otherwise), one by +/// its type's own converter. +[ZodSchema] +public partial class EnumsRequest +{ + public Shade Shade { get; set; } + + public Tone Tone { get; set; } +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs new file mode 100644 index 00000000..b00ed8af --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs @@ -0,0 +1,4 @@ +namespace ZodSharp.AspNetCore.OpenApi.Fixtures; + +/// A C# 15 union: the platform describes it with anyOf over the case schemas. +public readonly union PetUnion(CatCase, DogCase); diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs b/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs new file mode 100644 index 00000000..c7e3327b --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// Builds an in-memory minimal API application and returns its OpenAPI document as served at +/// /openapi/v1.json, so tests assert the serialized output a client actually sees. +/// +static class OpenApiTestHost +{ + public static async Task GetDocumentAsync( + OpenApiSpecVersion version, + Action map, + CancellationToken cancellationToken, + Action? configure = null, + Action? services = null, + bool addZodSharp = true, + string environment = "Production" + ) + { + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = environment }); + builder.WebHost.UseTestServer(); + + // The first AddZodSharp wins, so a test's own registration (a runtime schema, say) runs first. + services?.Invoke(builder.Services); + builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(OpenApiTestHost).Assembly)); + builder.Services.AddOpenApi(options => + { + options.OpenApiVersion = version; + if (addZodSharp) + options.AddZodSharp(configure); + }); + + await using var app = builder.Build(); + app.MapOpenApi(); + map(app); + + await app.StartAsync(cancellationToken); + try + { + using var client = app.GetTestClient(); + var json = await client.GetStringAsync(new Uri("/openapi/v1.json", UriKind.Relative), cancellationToken); + return JsonNode.Parse(json)!.AsObject(); + } + finally + { + await app.StopAsync(cancellationToken); + } + } + + public static JsonObject Component(this JsonObject document, string name) => + document["components"]!["schemas"]![name]!.AsObject(); + + public static JsonObject Property(this JsonObject schema, string name) => schema["properties"]![name]!.AsObject(); + + public static IEnumerable Versions() => + [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_2]; +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiOperationTransformerTests.cs b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiOperationTransformerTests.cs new file mode 100644 index 00000000..fd8f385a --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiOperationTransformerTests.cs @@ -0,0 +1,226 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using ZodSharp.AspNetCore.OpenApi.Fixtures; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore.OpenApi; + +public class ZodOpenApiOperationTransformerTests +{ + const string ProblemDetailsReference = "#/components/schemas/ZodValidationProblemDetails"; + + public static IEnumerable Versions() => OpenApiTestHost.Versions(); + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task ValidationIssueSchema_GivenCodeAndCategory_DescribesEachAsWhatTheWriterPutsThere( + OpenApiSpecVersion version + ) + { + // Arrange + var document = await FixtureApi.GetAsync(version); + + // Act + var properties = document.Component("ZodValidationIssue")["properties"]!; + var code = (string?)properties["code"]!["description"]; + var category = (string?)properties["category"]!["description"]; + + // Assert — ValidationIssue.Code is ZodSharp's own code; Category carries the Zod (v4) issue code. + await Assert.That(code).IsNotNull().And.Contains("ZodSharp error code"); + await Assert.That(code!).DoesNotContain("Zod-compatible"); + await Assert.That(category).IsNotNull().And.Contains("Zod issue code"); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenZodSharpBody_DescribesValidationProblemResponse(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + await Assert.That(Responses(platform, "/strings", "post")["400"]).IsNull(); + await Assert + .That(ProblemSchemaReference(zodSharp, "/strings", "post", "400")) + .IsEqualTo(ProblemDetailsReference); + await Assert.That(zodSharp.Component("ZodValidationProblemDetails")["properties"]!["issues"]).IsNotNull(); + await Assert.That(zodSharp.Component("ZodValidationIssue")["properties"]!["segments"]).IsNotNull(); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenAsParametersContainer_DescribesValidationProblemResponse( + OpenApiSpecVersion version + ) + { + // Act + var document = await FixtureApi.GetAsync(version); + + // Assert + await Assert.That(ProblemSchemaReference(document, "/search", "get", "400")).IsEqualTo(ProblemDetailsReference); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenUnionOfZodSharpCases_DescribesValidationProblemResponse( + OpenApiSpecVersion version + ) + { + // Act + var document = await FixtureApi.GetAsync(version); + + // Assert + await Assert.That(ProblemSchemaReference(document, "/pets", "post", "400")).IsEqualTo(ProblemDetailsReference); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenProducesValidationProblem_MergesIntoDeclaredResponse( + OpenApiSpecVersion version + ) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert - one 400, the developer's description kept, the schema replaced by the ZodSharp contract. + var declared = Responses(platform, "/conflict", "post")["400"]!; + var merged = Responses(zodSharp, "/conflict", "post")["400"]!; + await Assert + .That(ProblemSchemaReference(platform, "/conflict", "post", "400")) + .IsEqualTo("#/components/schemas/HttpValidationProblemDetails"); + await Assert.That(merged["description"]!.ToJsonString()).IsEqualTo(declared["description"]!.ToJsonString()); + await Assert + .That(ProblemSchemaReference(zodSharp, "/conflict", "post", "400")) + .IsEqualTo(ProblemDetailsReference); + await Assert.That(merged["content"]!.AsObject().Count).IsEqualTo(1); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenUnknownParameterTypes_AddsNoResponse(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + await Assert + .That(Responses(zodSharp, "/plain", "post").ToJsonString()) + .IsEqualTo(Responses(platform, "/plain", "post").ToJsonString()); + } + + [Test] + public async Task TransformAsync_GivenNoValidatedEndpoint_AddsNoErrorContractComponents( + CancellationToken cancellationToken + ) + { + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/plain", (PlainRequest request) => Results.Ok()), + cancellationToken + ); + + // Assert + var schemas = document["components"]!["schemas"]!.AsObject(); + await Assert.That(schemas.ContainsKey("ZodValidationProblemDetails")).IsFalse(); + await Assert.That(schemas.ContainsKey("ZodValidationIssue")).IsFalse(); + } + + [Test] + public async Task TransformAsync_GivenWithZodSharpValidationFilter_DescribesValidationProblemResponse( + CancellationToken cancellationToken + ) + { + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => + app.MapPost("/filtered", (NumberRulesRequest request) => Results.Ok()) + .WithZodSharpValidation(), + cancellationToken + ); + + // Assert + await Assert + .That(ProblemSchemaReference(document, "/filtered", "post", "400")) + .IsEqualTo(ProblemDetailsReference); + } + + [Test] + public async Task TransformAsync_GivenRegistered422ErrorType_DescribesUnprocessableEntityResponse( + CancellationToken cancellationToken + ) + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register(new ErrorType("order_locked", HttpStatus: StatusCodes.Status422UnprocessableEntity)); + + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/strings", (StringRulesRequest request) => Results.Ok()), + cancellationToken, + services: services => services.AddZodSharpProblemDetails(options => options.Registry = registry) + ); + + // Assert + await Assert + .That(ProblemSchemaReference(document, "/strings", "post", "400")) + .IsEqualTo(ProblemDetailsReference); + await Assert + .That(ProblemSchemaReference(document, "/strings", "post", "422")) + .IsEqualTo(ProblemDetailsReference); + } + + [Test] + public async Task TransformAsync_GivenNo422ErrorType_DescribesBadRequestOnly(CancellationToken cancellationToken) + { + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/strings", (StringRulesRequest request) => Results.Ok()), + cancellationToken, + services: services => + services.AddZodSharpProblemDetails(options => options.Registry = new ErrorTypeRegistry()) + ); + + // Assert + await Assert.That(Responses(document, "/strings", "post")["422"]).IsNull(); + } + + [Test] + public async Task TransformAsync_GivenDescribeValidationProblemsDisabled_AddsNoResponse( + CancellationToken cancellationToken + ) + { + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/strings", (StringRulesRequest request) => Results.Ok()), + cancellationToken, + configure: options => options.DescribeValidationProblems = false + ); + + // Assert + await Assert.That(Responses(document, "/strings", "post")["400"]).IsNull(); + } + + static JsonObject Responses(JsonObject document, string path, string method) => + document["paths"]![path]![method]!["responses"]!.AsObject(); + + static string? ProblemSchemaReference(JsonObject document, string path, string method, string status) => + Responses(document, path, method)[status] + ?["content"]?["application/problem+json"]?["schema"]?["$ref"]?.GetValue(); +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Enums.cs b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Enums.cs new file mode 100644 index 00000000..53bb38a1 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Enums.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using ZodSharp.AspNetCore.OpenApi.Fixtures; + +namespace ZodSharp.AspNetCore.OpenApi; + +partial class ZodOpenApiSchemaTransformerTests +{ + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenDefaultEnumSerialization_DescribesEnumsAsNumbers( + OpenApiSpecVersion version, + CancellationToken cancellationToken + ) + { + // Arrange / Act + var document = await GetEnumsDocumentAsync(version, configureJson: null, cancellationToken); + + // Assert + await Assert.That(EnumOf(document, "Shade")).IsEqualTo("[0,1]"); + await Assert.That(EnumOf(document, "Tone")).IsEqualTo("""["Warm","Cool"]"""); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenGlobalStringEnumConverter_DescribesEnumsByMemberName( + OpenApiSpecVersion version, + CancellationToken cancellationToken + ) + { + // Arrange / Act + var document = await GetEnumsDocumentAsync( + version, + static options => options.Converters.Add(new JsonStringEnumConverter()), + cancellationToken + ); + + // Assert + await Assert.That(EnumOf(document, "Shade")).IsEqualTo("""["Light","Dark"]"""); + await Assert.That(EnumOf(document, "Tone")).IsEqualTo("""["Warm","Cool"]"""); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenGlobalConverterWithNamingPolicy_DescribesEnumsAsTheyAreWritten( + OpenApiSpecVersion version, + CancellationToken cancellationToken + ) + { + // Arrange / Act + var document = await GetEnumsDocumentAsync( + version, + static options => options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)), + cancellationToken + ); + + // Assert + await Assert.That(EnumOf(document, "Shade")).IsEqualTo("""["light","dark"]"""); + } + + static Task GetEnumsDocumentAsync( + OpenApiSpecVersion version, + Action? configureJson, + CancellationToken cancellationToken + ) => + OpenApiTestHost.GetDocumentAsync( + version, + static app => app.MapPost("/enums", (EnumsRequest request) => Results.Ok()), + cancellationToken, + services: services => + { + FixtureApi.RegisterRuntimeSchemas(services); + if (configureJson is not null) + services.ConfigureHttpJsonOptions(options => configureJson(options.SerializerOptions)); + } + ); + + static string? EnumOf(JsonObject document, string component) => + document.Component(component)["enum"]?.ToJsonString(); +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs new file mode 100644 index 00000000..7eb431a9 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs @@ -0,0 +1,151 @@ +using System.Text.Json.Nodes; +using Microsoft.OpenApi; + +namespace ZodSharp.AspNetCore.OpenApi; + +/// +/// One row per keyword mapping: what the platform emits on its own, and what the document says once +/// ZodSharp's transformers run, for both OpenAPI 3.1 and 3.2. +/// +public partial class ZodOpenApiSchemaTransformerTests +{ + /// A keyword mapping row. null means the keyword is absent. + public sealed record MappingRow( + string Rule, + string Component, + string? Property, + string Keyword, + string? Platform, + string? ZodSharp + ) + { + public override string ToString() => $"{Rule}: {Component}.{Property}.{Keyword}"; + } + + static readonly MappingRow[] Rows = + [ + new("MinLength (string)", "StringRulesRequest", "name", "minLength", null, "2"), + new("MaxLength (string)", "StringRulesRequest", "name", "maxLength", null, "10"), + new("MinLength (collection)", "ShapesRequest", "tags", "minItems", "1", "1"), + new("MaxLength (collection)", "ShapesRequest", "tags", "maxItems", "3", "3"), + new("MaxLength (map)", "ShapesRequest", "scores", "maxProperties", null, "2"), + new("MinValue", "NumberRulesRequest", "count", "minimum", null, "1"), + new("MaxValue", "NumberRulesRequest", "count", "maximum", null, "10"), + new("GreaterThanOrEqual", "NumberRulesRequest", "bounded", "minimum", null, "5"), + new("LessThanOrEqual", "NumberRulesRequest", "bounded", "maximum", null, "6"), + new("GreaterThan", "NumberRulesRequest", "ratio", "exclusiveMinimum", null, "0"), + new("LessThan", "NumberRulesRequest", "ratio", "exclusiveMaximum", null, "100"), + new("MultipleOf", "NumberRulesRequest", "step", "multipleOf", null, "5"), + new("Int", "NumberRulesRequest", "whole", "type", """["number","string"]""", """["integer","string"]"""), + new("SafeInteger", "NumberRulesRequest", "safe", "type", """["number","string"]""", """["integer","string"]"""), + new("SafeInteger", "NumberRulesRequest", "safe", "minimum", null, "-2147483648"), + new("SafeInteger", "NumberRulesRequest", "safe", "maximum", null, "2147483647"), + new("Regex", "StringRulesRequest", "slug", "pattern", null, "\"^[a-z]+$\""), + new( + "Several patterns", + "StringRulesRequest", + "bracketed", + "allOf", + null, + /*lang=json,strict*/"""[{"pattern":"^ab"},{"pattern":"yz$"}]""" + ), + new("Several patterns", "StringRulesRequest", "bracketed", "pattern", null, null), + new("StartsWith", "StringRulesRequest", "prefixed", "pattern", null, "\"^ab\""), + new("StartsWith", "StringRulesRequest", "prefixed", "x-zod-startsWith", null, "\"ab\""), + new("EndsWith", "StringRulesRequest", "suffixed", "pattern", null, "\"yz$\""), + new("EndsWith", "StringRulesRequest", "suffixed", "x-zod-endsWith", null, "\"yz\""), + new("Includes", "StringRulesRequest", "containing", "pattern", null, "\"mid\""), + new("Includes", "StringRulesRequest", "containing", "x-zod-includes", null, "\"mid\""), + new("Email", "StringRulesRequest", "email", "format", null, "\"email\""), + new("EmailAddress", "StringRulesRequest", "legacyEmail", "format", null, "\"email\""), + new("Url", "StringRulesRequest", "website", "format", null, "\"uri\""), + new("Uuid(version)", "StringRulesRequest", "identifier", "format", null, "\"uuid\""), + new("Uuid(version)", "StringRulesRequest", "identifier", "x-zod-uuidVersion", null, "4"), + new("IPAddress(v4)", "StringRulesRequest", "addressV4", "format", null, "\"ipv4\""), + new("IPAddress(v6)", "StringRulesRequest", "addressV6", "format", null, "\"ipv6\""), + new("Cidr", "StringRulesRequest", "network", "x-zod-cidr", null, "\"any\""), + new("Cidr", "StringRulesRequest", "network", "x-zodsharp-unsupported", null, """["CidrRule"]"""), + new("DatetimeString", "StringRulesRequest", "timestamp", "format", null, "\"date-time\""), + new("DateString", "StringRulesRequest", "day", "format", null, "\"date\""), + new("TimeString", "StringRulesRequest", "timeOfDay", "x-zod-format", null, "\"time\""), + new("Duration", "StringRulesRequest", "period", "format", null, "\"duration\""), + new("Base64", "StringRulesRequest", "payload", "contentEncoding", null, "\"base64\""), + new("Jwt", "StringRulesRequest", "token", "x-zod-jwt", null, "true"), + new("AllowedValues", "StringRulesRequest", "colour", "enum", null, """["red","green"]"""), + new( + "DeniedValues", + "StringRulesRequest", + "username", + "not", + null, /*lang=json,strict*/ + """{"enum":["admin"]}""" + ), + new("Enum", "Shade", null, "enum", null, "[0,1]"), + new( + "Display(Description)", + "StringRulesRequest", + "described", + "description", + null, + "\"The display description.\"" + ), + new("Nullable", "ShapesRequest", "nickname", "type", """["null","string"]""", """["null","string"]"""), + new( + "RequiredZod on a nullable member", + "ShapesRequest", + "reference", + "type", + """["null","string"]""", + "\"string\"" + ), + new("DateTime", "ShapesRequest", "createdAt", "format", "\"date-time\"", "\"date-time\""), + new("DateOnly", "ShapesRequest", "birthday", "format", "\"date\"", "\"date\""), + // System.Text.Json writes a TimeSpan as "c" (d.hh:mm:ss), not an ISO 8601 duration. + new("TimeSpan", "ShapesRequest", "elapsed", "format", null, null), + new("Nested", "Address", "street", "minLength", null, "1"), + new("Nested", "Address", "postCode", "pattern", null, "\"^[0-9]{5}$\""), + new("Runtime schema (IZodSchemaFactory)", "Percentage", null, "maximum", null, "100"), + ]; + + public static IEnumerable> MappingRows() + { + foreach (var version in OpenApiTestHost.Versions()) + { + foreach (var row in Rows) + yield return () => (version, row); + } + } + + [Test] + [MethodDataSource(nameof(MappingRows))] + public async Task TransformAsync_GivenMappedRule_DescribesWhatZodSharpEnforces( + OpenApiSpecVersion version, + MappingRow row + ) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + var zodSharp = await FixtureApi.GetAsync(version); + + // Act + var platformValue = Keyword(platform, row); + var zodSharpValue = Keyword(zodSharp, row); + + // Assert + await Assert.That(Render(platformValue)).IsEqualTo(Render(Parse(row.Platform))); + await Assert.That(Render(zodSharpValue)).IsEqualTo(Render(Parse(row.ZodSharp))); + } + + static JsonNode? Keyword(JsonObject document, MappingRow row) + { + var schema = document.Component(row.Component); + if (row.Property is not null) + schema = schema.Property(row.Property); + + return schema[row.Keyword]; + } + + static JsonNode? Parse(string? json) => json is null ? null : JsonNode.Parse(json); + + static string Render(JsonNode? node) => node?.ToJsonString() ?? ""; +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.cs b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.cs new file mode 100644 index 00000000..86f0b5a9 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.cs @@ -0,0 +1,268 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using ZodSharp.AspNetCore.OpenApi.Fixtures; + +namespace ZodSharp.AspNetCore.OpenApi; + +public partial class ZodOpenApiSchemaTransformerTests +{ + public static IEnumerable Versions() => OpenApiTestHost.Versions(); + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenUnknownType_LeavesPlatformSchemaUnchanged(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + await Assert + .That(zodSharp.Component("PlainRequest").ToJsonString()) + .IsEqualTo(platform.Component("PlainRequest").ToJsonString()); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenZodSharpRequiredMembers_ListsReferenceMembersOnly(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert - non-nullable reference members and [RequiredZod] members are required; value-type members + // (an absent value deserializes to its default) and nullable members are not. + await Assert.That(platform.Component("ShapesRequest")["required"]).IsNull(); + await Assert + .That(RequiredOf(zodSharp, "ShapesRequest")) + .IsEquivalentTo(["home", "others", "reference", "scores", "tags", "title"]); + await Assert.That(RequiredOf(zodSharp, "StringRulesRequest")).IsEquivalentTo(["name"]); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenDataAnnotationThatDiffers_ZodSharpWins(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + var platformDifferent = platform.Component("ConflictRequest").Property("different"); + var zodSharpDifferent = zodSharp.Component("ConflictRequest").Property("different"); + await Assert.That(platformDifferent["maximum"]!.ToJsonString()).IsEqualTo("100"); + await Assert.That(zodSharpDifferent["maximum"]!.ToJsonString()).IsEqualTo("50"); + await Assert.That(zodSharpDifferent["minimum"]!.ToJsonString()).IsEqualTo("1"); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenDataAnnotationThatAgrees_LeavesKeywordUntouched(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + await Assert + .That(zodSharp.Component("ConflictRequest").Property("same").ToJsonString()) + .IsEqualTo(platform.Component("ConflictRequest").Property("same").ToJsonString()); + } + + [Test] + public async Task TransformAsync_GivenConflictInDevelopment_LogsOnlyTheOverriddenKeyword( + CancellationToken cancellationToken + ) + { + // Arrange + using CapturingLoggerProvider logs = new(); + + // Act + await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/conflict", (ConflictRequest request) => Results.Ok()), + cancellationToken, + services: services => + services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Debug).AddProvider(logs)), + environment: "Development" + ); + + // Assert + var overrides = logs + .Messages.Where(static message => message.Contains("ZodSharp overrode", StringComparison.Ordinal)) + .ToList(); + await Assert.That(overrides).Count().IsEqualTo(1); + await Assert.That(overrides[0]).Contains("'maximum'"); + await Assert.That(overrides[0]).Contains("ConflictRequest.different"); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenPolymorphicZodSharpType_DescribesCasesWithOneOf(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + var platformShape = platform.Component("ShapeBase"); + var zodSharpShape = zodSharp.Component("ShapeBase"); + await Assert.That(platformShape["anyOf"]).IsNotNull(); + await Assert.That(zodSharpShape["anyOf"]).IsNull(); + await Assert.That(zodSharpShape["oneOf"]!.ToJsonString()).IsEqualTo(platformShape["anyOf"]!.ToJsonString()); + await Assert + .That(zodSharpShape["discriminator"]!.ToJsonString()) + .IsEqualTo(platformShape["discriminator"]!.ToJsonString()); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenPolymorphicZodSharpType_KeepsCaseReferencesResolvable( + OpenApiSpecVersion version + ) + { + // Act + var document = await FixtureApi.GetAsync(version); + + // Assert - the cases keep the platform's polymorphic component names, which the discriminator maps to. + var schemas = document["components"]!["schemas"]!.AsObject(); + var references = document.Component("ShapeBase")["oneOf"]! + .AsArray() + .Select(static item => item!["$ref"]!.GetValue()["#/components/schemas/".Length..]) + .ToList(); + await Assert.That(references).IsEquivalentTo(["ShapeBaseCircleShape", "ShapeBaseSquareShape"]); + await Assert.That(references.All(schemas.ContainsKey)).IsTrue(); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenCSharpUnion_KeepsAnyOfAndConstrainsCases(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert - the union is not restructured; ZodSharp's constraints land inside each case schema. + await Assert + .That(zodSharp.Component("PetUnion").ToJsonString()) + .IsEqualTo(platform.Component("PetUnion").ToJsonString()); + await Assert.That(platform.Component("CatCase").Property("name")["minLength"]).IsNull(); + await Assert.That(zodSharp.Component("CatCase").Property("name")["minLength"]!.ToJsonString()).IsEqualTo("2"); + await Assert.That(zodSharp.Component("DogCase").Property("barks")["minimum"]!.ToJsonString()).IsEqualTo("0"); + } + + [Test] + public async Task TransformAsync_GivenDiscriminatedUnionsAsOneOfDisabled_KeepsAnyOf( + CancellationToken cancellationToken + ) + { + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/polymorphic", (ShapeBase request) => Results.Ok()), + cancellationToken, + configure: options => options.DiscriminatedUnionsAsOneOf = false + ); + + // Assert + await Assert.That(document.Component("ShapeBase")["anyOf"]).IsNotNull(); + await Assert.That(document.Component("ShapeBase")["oneOf"]).IsNull(); + } + + [Test] + public async Task TransformAsync_GivenVendorExtensionsDisabled_OmitsZodExtensions( + CancellationToken cancellationToken + ) + { + // Act + var document = await OpenApiTestHost.GetDocumentAsync( + OpenApiSpecVersion.OpenApi3_2, + app => app.MapPost("/strings", (StringRulesRequest request) => Results.Ok()), + cancellationToken, + configure: options => options.EmitVendorExtensions = false + ); + + // Assert - the keywords stay; only the x-zod-* descriptions go. + var schema = document.Component("StringRulesRequest"); + var extensions = schema["properties"]! + .AsObject() + .SelectMany(static property => property.Value!.AsObject().Select(static keyword => keyword.Key)) + .Where(static keyword => keyword.StartsWith("x-zod", StringComparison.Ordinal)) + .ToList(); + await Assert.That(extensions).IsEmpty(); + await Assert.That(schema.Property("prefixed")["pattern"]!.GetValue()).IsEqualTo("^ab"); + } + + [Test] + [MethodDataSource(nameof(Versions))] + public async Task TransformAsync_GivenAsParametersMembers_ConstrainsQueryParameters(OpenApiSpecVersion version) + { + // Arrange + var platform = await FixtureApi.GetAsync(version, zodSharp: false); + + // Act + var zodSharp = await FixtureApi.GetAsync(version); + + // Assert + var platformTerm = Parameter(platform, "/search", "Term"); + var zodSharpTerm = Parameter(zodSharp, "/search", "Term"); + var zodSharpTake = Parameter(zodSharp, "/search", "Take"); + await Assert.That(platformTerm["minLength"]).IsNull(); + await Assert.That(zodSharpTerm["minLength"]!.ToJsonString()).IsEqualTo("3"); + await Assert.That(zodSharpTake["maximum"]!.ToJsonString()).IsEqualTo("50"); + } + + static List RequiredOf(JsonObject document, string component) => + [.. document.Component(component)["required"]!.AsArray().Select(static node => node!.GetValue())]; + + static JsonObject Parameter(JsonObject document, string path, string name) => + document["paths"]![path]!["get"]!["parameters"]! + .AsArray() + .Single(parameter => + string.Equals(parameter!["name"]!.GetValue(), name, StringComparison.Ordinal) + )!["schema"]!.AsObject(); + + sealed class CapturingLoggerProvider : ILoggerProvider + { + readonly ConcurrentQueue _messages = new(); + + public IReadOnlyCollection Messages => _messages; + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(_messages); + + public void Dispose() { } + + sealed class CapturingLogger(ConcurrentQueue messages) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => messages.Enqueue(formatter(state, exception)); + } + } +} diff --git a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj index 4f2ffd00..d3b82b5d 100644 --- a/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj +++ b/src/tests/AspNetCore.UnitTests/AspNetCore.UnitTests.csproj @@ -1,8 +1,14 @@ - + Purview.ZodSharp.AspNetCore.UnitTests + + $(NoWarn);CA1506 + + + + diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs index 931339b0..a9065f93 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs @@ -1,3 +1,6 @@ +// The deprecated 2.x conversion overloads are exercised here on purpose: their behaviour is kept for compatibility. +#pragma warning disable CS0618 + using Microsoft.AspNetCore.Http; using ZodSharp.Core; @@ -74,7 +77,7 @@ public async Task GivenMappedCode_ExposesTitleAndParametersInExtensionsAndIssues // Assert await Assert.That(details.Title).IsEqualTo("Resource is locked"); await Assert.That(details.Status).IsEqualTo(StatusCodes.Status423Locked); - await Assert.That(details.Extensions["resourceId"]).IsEqualTo("res-42"); + await Assert.That(details.Extensions.ContainsKey("resourceId")).IsFalse(); await Assert.That(issues).HasSingleItem(); await Assert.That(issues[0].Parameters).IsNotNull(); await Assert.That(issues[0].Parameters!["ResourceId"]).IsEqualTo("res-42"); @@ -117,8 +120,11 @@ public async Task GivenMultipleCodesWithDifferentStatuses_HighestStatusWins() } [Test] - public async Task GivenMessageFormat_MissingPlaceholderIsLeftAsIs() + public async Task GivenMessageFormat_MissingPlaceholder_KeepsTheErrorMessage() { + // Defect D2: a template with an unresolved placeholder used to replace the message, leaving + // "{AggregateType}" in the response. The template now applies only when every placeholder resolves. + // Arrange ErrorTypeRegistry registry = new(); registry.Register( @@ -148,9 +154,7 @@ public async Task GivenMessageFormat_MissingPlaceholderIsLeftAsIs() var details = exception.ToHttpValidationProblemDetails(registry); // Assert - await Assert - .That(details.Errors[string.Empty]) - .IsEquivalentTo(["Aggregate 'agg-123' (of type {AggregateType}) failed to save"]); + await Assert.That(details.Errors[string.Empty]).IsEquivalentTo(["Save failed."]); } [Test] diff --git a/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs index 84c2d4be..428ab582 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeSourceGeneratorIntegrationTests.cs @@ -1,3 +1,6 @@ +// The deprecated 2.x conversion overloads are exercised here on purpose: their behaviour is kept for compatibility. +#pragma warning disable CS0618 + using Microsoft.AspNetCore.Http; using ZodSharp.Core; diff --git a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/LegacyConversionOverloadTests.cs b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/LegacyConversionOverloadTests.cs new file mode 100644 index 00000000..fdf852a0 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/LegacyConversionOverloadTests.cs @@ -0,0 +1,189 @@ +// The deprecated 2.x conversion overloads are exercised here on purpose: their behaviour is kept for compatibility. +#pragma warning disable CS0618 + +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.AspNetCore; +using ZodSharp.Core; + +namespace ZodSharp; + +/// +/// The 2.x ProblemDetails conversion overloads take neither options nor a request, so they cannot apply the options an +/// application registers. They are obsolete, keep their 2.x output, and that output is exactly what the documented +/// equivalent produce. +/// +public class LegacyConversionOverloadTests +{ + static readonly ErrorType Conflict = new( + Code: "email_taken", + Category: "invalid_value", + Description: "The email is taken.", + HttpStatus: StatusCodes.Status409Conflict, + MessageFormat: "'{email}' is already registered" + ) + { + Parameters = [new ErrorTypeParameter("email", typeof(string))], + }; + + [Test] + [Arguments(typeof(ProblemDetailsExtensions))] + [Arguments(typeof(ZodExceptionExtensions))] + public async Task LegacyOverloads_GivenExtensionClass_AreObsoleteAndTheOptionOverloadsAreNot( + Type extensions, + CancellationToken cancellationToken + ) + { + // Arrange + var methods = extensions + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(static method => + method.Name is "ToHttpValidationProblemDetails" or "ToValidationProblemDetails" or "ToValidationProblem" + ) + .ToArray(); + + // Act + var obsolete = methods.Where(static method => method.IsDefined(typeof(ObsoleteAttribute))).ToArray(); + var current = methods.Except(obsolete).ToArray(); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(obsolete).IsNotEmpty(); + await Assert.That(obsolete.All(static method => !TakesOptionsOrRequest(method))).IsTrue(); + await Assert.That(current).IsNotEmpty(); + await Assert.That(current.All(TakesOptionsOrRequest)).IsTrue(); + } + + [Test] + public async Task ToHttpValidationProblemDetails_GivenRegisteredOptions_OnlyTheHttpContextOverloadAppliesThem( + CancellationToken cancellationToken + ) + { + // Arrange + var result = ValidationResult.Failure( + ValidationError.Create( + "email_taken", + "taken", + ZodPath.ParseDotted("Email"), + parameters: new Dictionary { ["email"] = "ada@example.com" } + ) + ); + ServiceCollection services = new(); + services.AddLogging(); + services.Configure(options => + { + options.IncludeIssues = false; + options.Registry = new ErrorTypeRegistry(); + options.Registry.Register(Conflict); + }); + await using var provider = services.BuildServiceProvider(); + DefaultHttpContext httpContext = new() { RequestServices = provider }; + + // Act + var legacy = result.ToHttpValidationProblemDetails(); + var configured = result.ToHttpValidationProblemDetails(httpContext); + + // Assert - the legacy overload keeps its 2.x output: no ErrorType, the raw message, the issues projection. + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(legacy.Status).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(legacy.Errors["Email"]).IsEquivalentTo(["taken"]); + await Assert.That(legacy.Extensions.ContainsKey("issues")).IsTrue(); + + await Assert.That(configured.Status).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(configured.Errors["Email"]).IsEquivalentTo(["'ada@example.com' is already registered"]); + await Assert.That(configured.Extensions.ContainsKey("issues")).IsFalse(); + } + + [Test] + public async Task LegacyOverloads_GivenTheDocumentedEquivalentOptions_ProduceTheSameBody( + CancellationToken cancellationToken + ) + { + // Arrange + ZodException exception = new([ + ValidationError.Create( + "email_taken", + "taken", + ZodPath.ParseDotted("Email"), + parameters: new Dictionary { ["email"] = "ada@example.com" } + ), + ValidationError.Create("too_small", "Name is too short", ZodPath.ParseDotted("Name"), minimum: 2), + ]); + ErrorTypeRegistry registry = new(); + registry.Register(Conflict); + ErrorType? Lookup(string code) => registry.TryGet(code, out var errorType) ? errorType : null; + + // Act + (string Legacy, string Equivalent)[] pairs = + [ + ( + Serialize(exception.ToHttpValidationProblemDetails(422)), + Serialize( + exception.ToHttpValidationProblemDetails( + new ZodProblemDetailsOptions + { + Registry = new(), + FormatMessages = false, + DefaultStatusCode = 422, + } + ) + ) + ), + ( + Serialize(exception.ToHttpValidationProblemDetails(registry)), + Serialize( + exception.ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = registry }) + ) + ), + ( + Serialize(exception.ToHttpValidationProblemDetails(Lookup)), + Serialize( + exception.ToHttpValidationProblemDetails( + new ZodProblemDetailsOptions { ErrorTypeResolver = Lookup } + ) + ) + ), + ]; + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + foreach (var (legacy, equivalent) in pairs) + { + await Assert.That(legacy).Contains("Email|email_taken|"); + await Assert.That(legacy).IsEqualTo(equivalent); + } + } + + static bool TakesOptionsOrRequest(MethodInfo method) => + method + .GetParameters() + .Any(static parameter => + parameter.ParameterType == typeof(HttpContext) + || parameter.ParameterType == typeof(ZodProblemDetailsOptions) + ); + + static string Serialize(HttpValidationProblemDetails details) + { + var issues = + details.Extensions.TryGetValue("issues", out var value) && value is IEnumerable list + ? string.Join( + ";", + list.Select(static issue => + $"{issue.DottedPath}|{issue.Code}|{issue.Category}|{issue.Message}|{issue.Minimum}" + ) + ) + : null; + return JsonSerializer.Serialize( + new + { + details.Status, + details.Title, + details.Type, + details.Errors, + Issues = issues, + } + ); + } +} diff --git a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ProblemDetailsExtensionsTests.cs b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ProblemDetailsExtensionsTests.cs index 42bd322e..5505d3dd 100644 --- a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ProblemDetailsExtensionsTests.cs +++ b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ProblemDetailsExtensionsTests.cs @@ -1,3 +1,6 @@ +// The deprecated 2.x conversion overloads are exercised here on purpose: their behaviour is kept for compatibility. +#pragma warning disable CS0618 + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using ZodSharp.Core; diff --git a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ZodExceptionExtensionsTests.cs b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ZodExceptionExtensionsTests.cs index d7219d0f..302e7305 100644 --- a/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ZodExceptionExtensionsTests.cs +++ b/src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/ZodExceptionExtensionsTests.cs @@ -1,3 +1,6 @@ +// The deprecated 2.x conversion overloads are exercised here on purpose: their behaviour is kept for compatibility. +#pragma warning disable CS0618 + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using ZodSharp.Core; diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/DelegateValidator.cs b/src/tests/AspNetCore.UnitTests/Fixtures/DelegateValidator.cs new file mode 100644 index 00000000..9d62c5cf --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/DelegateValidator.cs @@ -0,0 +1,21 @@ +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore.Fixtures; + +/// +/// A validator whose errors come from a delegate, for tests that need specific paths or parameters. +/// +public sealed class DelegateValidator(Func> check) : IZodSchemaValidator +{ + public int Calls { get; private set; } + + public ValidationResult Validate(T value) + { + Calls++; + ValidationError[] errors = [.. check(value)]; + return errors.Length == 0 ? ValidationResult.Success(value) : ValidationResult.Failure(errors); + } + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + ValueTask.FromResult(Validate(value)); +} diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs b/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs new file mode 100644 index 00000000..305a9744 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs @@ -0,0 +1,9 @@ +#if NET11_0_OR_GREATER +namespace ZodSharp.AspNetCore.Fixtures; + +/// +/// A C# 15 union body whose case ZodSharp validates; the union itself has no validator. +/// The cases have different JSON value kinds (object and string), so System.Text.Json can pick the case. +/// +public union OrderOrReference(OrderRequest, string); +#endif diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/OrderRequest.cs b/src/tests/AspNetCore.UnitTests/Fixtures/OrderRequest.cs new file mode 100644 index 00000000..b3ef72f0 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/OrderRequest.cs @@ -0,0 +1,93 @@ +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore.Fixtures; + +public sealed class OrderRequest +{ + public string? Email { get; set; } + + public List Items { get; set; } = []; +} + +public sealed class OrderLine +{ + public int Quantity { get; set; } +} + +public sealed class CustomerQuery +{ + public string? Name { get; set; } +} + +/// +/// A hand-written validator for producing the errors the ProblemDetails contract +/// documents, so the tests do not depend on generated message wording. +/// +public sealed class OrderRequestValidator : IZodSchemaValidator +{ + public int Calls { get; private set; } + + public static ValidationError InvalidEmail => + ValidationError.Create( + "invalid_email", + "Email must be a valid email address", + ZodPath.Member("Email"), + origin: "string", + category: ZodIssueCategories.InvalidFormat + ); + + public static ValidationError QuantityTooSmall(int index) => + ValidationError.Create( + "too_small", + "Quantity must be at least 1", + ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(index), ZodPathSegment.Member("Quantity")), + origin: "number", + minimum: 1, + inclusive: true, + category: ZodIssueCategories.TooSmall + ); + + public static IEnumerable Check(OrderRequest value) + { + if (value.Email is null || !value.Email.Contains('@', StringComparison.Ordinal)) + yield return InvalidEmail; + + for (var i = 0; i < value.Items.Count; i++) + { + if (value.Items[i].Quantity < 1) + yield return QuantityTooSmall(i); + } + } + + public ValidationResult Validate(OrderRequest value) + { + Calls++; + ValidationError[] errors = [.. Check(value)]; + return errors.Length == 0 + ? ValidationResult.Success(value) + : ValidationResult.Failure(errors); + } + + public ValueTask> ValidateAsync( + OrderRequest value, + CancellationToken cancellationToken = default + ) => ValueTask.FromResult(Validate(value)); +} + +/// +/// A validator for that reports a root-level error when the name is missing. +/// +public sealed class CustomerQueryValidator : IZodSchemaValidator +{ + public ValidationResult Validate(CustomerQuery value) => + string.IsNullOrEmpty(value.Name) + ? ValidationResult.Failure( + ValidationError.Create("required", "Name is required", ZodPath.Member("Name")) + ) + : ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + CustomerQuery value, + CancellationToken cancellationToken = default + ) => ValueTask.FromResult(Validate(value)); +} diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/PipelineFixtures.cs b/src/tests/AspNetCore.UnitTests/Fixtures/PipelineFixtures.cs new file mode 100644 index 00000000..592fe73f --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/PipelineFixtures.cs @@ -0,0 +1,54 @@ +#if NET11_0_OR_GREATER +using Microsoft.Extensions.Logging; + +namespace ZodSharp.AspNetCore.Fixtures; + +/// +/// The marker type for the ValidationMessages resources used by the localization tests. +/// +public sealed class ValidationMessages; + +/// +/// Collects log messages so a test can assert what was logged. +/// +public sealed class CapturingLoggerProvider : ILoggerProvider +{ + readonly Lock _lock = new(); + readonly List<(string Category, LogLevel Level, string Message)> _messages = []; + + public IReadOnlyList<(string Category, LogLevel Level, string Message)> Messages + { + get + { + lock (_lock) + return [.. _messages]; + } + } + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName); + + public void Dispose() { } + + void Add(string category, LogLevel level, string message) + { + lock (_lock) + _messages.Add((category, level, message)); + } + + sealed class CapturingLogger(CapturingLoggerProvider provider, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => provider.Add(category, logLevel, formatter(state, exception)); + } +} +#endif diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs b/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs new file mode 100644 index 00000000..b1ff83b2 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs @@ -0,0 +1,110 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZodSharp.Core; + +// A stand-in for the Purview.ValueObjects [Scalar] attribute, which the generator recognizes by name and namespace, so +// these fixtures are shaped exactly like value objects without referencing that package. +#pragma warning disable IDE0130 // The stand-in must live in the namespace the generator recognizes. +namespace Purview.ValueObjects.Serialization +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute(string propertyName = "Value") : Attribute + { + public string PropertyName { get; } = propertyName; + } +} + +#pragma warning restore IDE0130 + +namespace ZodSharp.AspNetCore.Fixtures +{ + /// A scalar value object read by a strict converter, which rejects an invalid value while reading. + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [JsonConverter(typeof(EmailAddressJsonConverter))] + public readonly partial record struct EmailAddress + { + public EmailAddress(string value) => Value = value; + + [Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Value { get; } + } + + /// A numeric scalar value object read by a strict converter. + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [JsonConverter(typeof(OrderQuantityJsonConverter))] + public readonly partial record struct OrderQuantity + { + public OrderQuantity(int value) => Value = value; + + [Range(1, 100)] + public int Value { get; } + } + + [ZodSchema] + public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); + + [ZodSchema] + public sealed partial record ShippingCustomer(EmailAddress Email); + + [ZodSchema] + public sealed partial record ShippingLine(OrderQuantity Quantity); + + /// The nested and collection cases: Customer.Email.Value and Lines[0].Quantity.Value. + [ZodSchema] + public sealed partial record ShippingOrder(ShippingCustomer Customer, IReadOnlyList Lines); + + /// + /// What a Purview.ValueObjects strict converter does: create the value, and turn a validation failure into a + /// carrying the , so the serializer records where it was read. + /// + static class StrictValueObject + { + public static T Create(T candidate, Func> validate) + { + var result = validate(candidate); + return result.IsSuccess + ? result.Value + : throw new JsonException( + $"Cannot deserialize {typeof(T).Name}: the value failed validation.", + new ZodException(result.Errors) + ); + } + } + + public sealed class EmailAddressJsonConverter : JsonConverter + { + public override EmailAddress Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) => + StrictValueObject.Create(new EmailAddress(reader.GetString() ?? string.Empty), static v => + EmailAddressSchema.Validate(v) + ); + + public override void Write(Utf8JsonWriter writer, EmailAddress value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + public sealed class OrderQuantityJsonConverter : JsonConverter + { + public override OrderQuantity Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) => + StrictValueObject.Create(new OrderQuantity(reader.GetInt32()), static v => OrderQuantitySchema.Validate(v)); + + public override void Write(Utf8JsonWriter writer, OrderQuantity value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] + [JsonSerializable(typeof(PlaceOrderRequest))] + [JsonSerializable(typeof(ShippingOrder))] + public sealed partial class StrictValueObjectJsonContext : JsonSerializerContext; +} diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs b/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs new file mode 100644 index 00000000..97247594 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs @@ -0,0 +1,62 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ZodSharp.AspNetCore.Fixtures; + +/// +/// An in-memory minimal-API host for integration tests. +/// +public sealed partial class TestApp : IAsyncDisposable +{ + TestApp(WebApplication app) + { + App = app; + Client = app.GetTestClient(); + } + + public WebApplication App { get; } + + public HttpClient Client { get; } + + public static async Task StartAsync( + Action configure, + Action map, + CancellationToken cancellationToken, + string? environment = null + ) + { + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = environment }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + configure(builder); + + var app = builder.Build(); + map(app); + await app.StartAsync(cancellationToken); + + return new TestApp(app); + } + + public static Task StartAsync( + Action services, + Action map, + CancellationToken cancellationToken + ) => StartAsync(builder => services(builder.Services), map, cancellationToken); + + /// + /// Replaces the per-request trace id so bodies from different requests can be compared byte for byte. + /// + public static string NormalizeTraceId(string json) => TraceIdPattern().Replace(json, "\"traceId\":\"*\""); + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.DisposeAsync(); + } + + [GeneratedRegex("\"traceId\":\"[^\"]*\"")] + private static partial Regex TraceIdPattern(); +} diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs b/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs index ff1d42af..00bbaaf1 100644 --- a/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs +++ b/src/tests/AspNetCore.UnitTests/Fixtures/ValidatedRequest.cs @@ -9,3 +9,14 @@ public class ValidatedRequest [MinLength(3)] public string? Name { get; set; } } + +/// Number ranges, whose bounds must reach ProblemDetails as JSON numbers. +[ZodSchema] +public class RangedRequest +{ + [Range(1, 10)] + public int Quantity { get; set; } = 1; + + [Range(0.5, 2.5)] + public double Ratio { get; set; } = 1; +} diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.fr.resx b/src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.fr.resx new file mode 100644 index 00000000..82fae152 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.fr.resx @@ -0,0 +1,21 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + L'adresse e-mail doit être valide + + + La quantité doit être au moins {minimum} + + diff --git a/src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.resx b/src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.resx new file mode 100644 index 00000000..0b1b54d2 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The neutral resources exist so English falls back to each error's own message. + + diff --git a/src/tests/AspNetCore.UnitTests/GeneratedSchemaDITests.cs b/src/tests/AspNetCore.UnitTests/GeneratedSchemaDITests.cs index ac0e4503..081e51d0 100644 --- a/src/tests/AspNetCore.UnitTests/GeneratedSchemaDITests.cs +++ b/src/tests/AspNetCore.UnitTests/GeneratedSchemaDITests.cs @@ -19,6 +19,22 @@ public async Task AddZodSharp_WithAssemblyScan_RegistersGeneratedUserDtoValidato await Assert.That(ok.IsSuccess).IsTrue(); } + [Test] + public async Task AddZodSharp_GivenSchemaRegistration_AppliesItWithoutAssemblyScanning() + { + // Arrange - the shape the generated AddZodSharp{Assembly}Schemas() extensions register. + ServiceCollection services = new(); + services.AddZodSchemaRegistration(static factory => factory.Register(new UserDtoSchemaValidator())); + services.AddZodSharp(); + + // Act + var factory = services.BuildServiceProvider().GetRequiredService(); + + // Assert + await Assert.That(factory.IsRegistered()).IsTrue(); + await Assert.That(factory.TryGetUntypedValidator(typeof(UserDto), out _)).IsTrue(); + } + [Test] public async Task GeneratedValidator_ProducesCorrectValidationResult() { diff --git a/src/tests/AspNetCore.UnitTests/HttpRequestValidationReportSinkTests.cs b/src/tests/AspNetCore.UnitTests/HttpRequestValidationReportSinkTests.cs new file mode 100644 index 00000000..d0fbdf64 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/HttpRequestValidationReportSinkTests.cs @@ -0,0 +1,82 @@ +#if NET11_0_OR_GREATER +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +public class HttpRequestValidationReportSinkTests +{ + static readonly System.Collections.Immutable.ImmutableArray Errors = + [ + ValidationError.Create("too_small", "Too small", ZodPath.Member("Quantity")), + ]; + + [Test] + public async Task Add_GivenActiveRequest_StoresErrorsOnTheRequest() + { + // Arrange + DefaultHttpContext httpContext = new(); + HttpRequestValidationReportSink sink = new(new HttpContextAccessor { HttpContext = httpContext }); + + // Act + sink.Add(typeof(object), string.Empty, Errors); + sink.Add(typeof(object), string.Empty, Errors); + + // Assert + await Assert.That(ZodRequestValidationReport.Get(httpContext)!.Errors.Length).IsEqualTo(2); + } + + [Test] + public async Task Add_GivenNoHttpContext_DiscardsErrors() + { + // Arrange - an interactive Blazor circuit, WebAssembly or a background service has no current request. + HttpRequestValidationReportSink sink = new(new HttpContextAccessor()); + + // Act + sink.Add(typeof(object), string.Empty, Errors); + + // Assert - nothing to observe beyond not throwing and not retaining anything. + await Assert.That(new HttpContextAccessor().HttpContext).IsNull(); + } + + [Test] + public async Task Add_GivenResponseAlreadyStarted_DiscardsErrors() + { + // Arrange - a long-lived connection (a Blazor Server circuit) whose response has started. + DefaultHttpContext httpContext = new(); + httpContext.Features.Set(new StartedResponseFeature()); + HttpRequestValidationReportSink sink = new(new HttpContextAccessor { HttpContext = httpContext }); + + // Act + sink.Add(typeof(object), string.Empty, Errors); + + // Assert + await Assert.That(ZodRequestValidationReport.Get(httpContext)).IsNull(); + } + + [Test] + public async Task Get_GivenSeparateRequests_DoesNotShareErrors() + { + // Arrange + DefaultHttpContext first = new(); + DefaultHttpContext second = new(); + HttpContextAccessor accessor = new(); + HttpRequestValidationReportSink sink = new(accessor); + + // Act + accessor.HttpContext = first; + sink.Add(typeof(object), string.Empty, Errors); + accessor.HttpContext = second; + + // Assert + await Assert.That(ZodRequestValidationReport.Get(first)!.HasErrors).IsTrue(); + await Assert.That(ZodRequestValidationReport.Get(second)).IsNull(); + } + + sealed class StartedResponseFeature : HttpResponseFeature + { + public override bool HasStarted => true; + } +} +#endif diff --git a/src/tests/AspNetCore.UnitTests/Options/BindAndValidateWithZodTests.cs b/src/tests/AspNetCore.UnitTests/Options/BindAndValidateWithZodTests.cs new file mode 100644 index 00000000..bad419fe --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Options/BindAndValidateWithZodTests.cs @@ -0,0 +1,241 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZodSharp.AspNetCore.Fixtures; +using ZodSharp.Core; +#if NET11_0_OR_GREATER +using ZodSharp.Options; +#endif + +namespace ZodSharp.AspNetCore.Options; + +public class BindAndValidateWithZodTests +{ + public sealed class SmtpOptions + { + public List Servers { get; set; } = []; + } + + public sealed class SmtpServer + { + public string? Host { get; set; } + + public int Port { get; set; } + } + + static IEnumerable Check(SmtpOptions options) + { + for (var i = 0; i < options.Servers.Count; i++) + { + if (string.IsNullOrEmpty(options.Servers[i].Host)) + { + yield return ValidationError.Create( + "required", + "Host is required", + ZodPath.From(ZodPathSegment.Member("Servers"), ZodPathSegment.At(i), ZodPathSegment.Member("Host")) + ); + } + } + } + + static void Configure(Microsoft.AspNetCore.Builder.WebApplicationBuilder builder) + { + builder.Configuration.AddInMemoryCollection(new Dictionary { ["Smtp:Servers:0:Port"] = "25" }); + builder.Services.AddZodSharpFactory(static factory => + factory.Register(new DelegateValidator(Check)) + ); + } + + [Test] + public async Task BindAndValidateWithZod_GivenInvalidSection_FailsStartupWithConfigurationKeys( + CancellationToken cancellationToken + ) + { + // Arrange / Act + OptionsValidationException? exception = null; + try + { + await using var app = await TestApp.StartAsync( + builder => + { + Configure(builder); + builder.Services.AddOptions().BindAndValidateWithZod("Smtp"); + }, + static _ => { }, + cancellationToken + ); + } + catch (OptionsValidationException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).IsEquivalentTo(["Smtp:Servers:0:Host: Host is required (required)"]); + } + + [Test] + public async Task BindAndValidateWithZod_GivenValidSection_BindsTheOptions(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync( + builder => + { + Configure(builder); + builder.Configuration.AddInMemoryCollection( + new Dictionary { ["Smtp:Servers:0:Host"] = "mail.example.com" } + ); + builder.Services.AddOptions().BindAndValidateWithZod("Smtp"); + }, + static _ => { }, + cancellationToken + ); + + // Act + var options = app.App.Services.GetRequiredService>().Value; + + // Assert + await Assert.That(options.Servers[0].Host).IsEqualTo("mail.example.com"); + await Assert.That(options.Servers[0].Port).IsEqualTo(25); + } + + [Test] + public async Task ValidateWithZod_GivenBindConfigurationAndInvalidSection_FailsStartupWithConfigurationKeys( + CancellationToken cancellationToken + ) + { + // Arrange / Act + OptionsValidationException? exception = null; + try + { + await using var app = await TestApp.StartAsync( + builder => + { + Configure(builder); + builder.Services.AddOptions().BindConfiguration("Smtp").ValidateWithZod("Smtp"); + }, + static _ => { }, + cancellationToken + ); + } + catch (OptionsValidationException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).IsEquivalentTo(["Smtp:Servers:0:Host: Host is required (required)"]); + } + + [Test] + public async Task ValidateWithZod_GivenValidSectionBoundByTheApplication_ExposesTheBoundOptions( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + builder => + { + Configure(builder); + builder.Configuration.AddInMemoryCollection( + new Dictionary { ["Smtp:Servers:0:Host"] = "mail.example.com" } + ); + builder.Services.AddOptions().ValidateWithZod("Smtp").BindConfiguration("Smtp"); + }, + static _ => { }, + cancellationToken + ); + + // Act + var options = app.App.Services.GetRequiredService>().Value; + + // Assert — the call order does not matter: options are validated after every configuration step. + await Assert.That(options.Servers[0].Host).IsEqualTo("mail.example.com"); + await Assert.That(options.Servers[0].Port).IsEqualTo(25); + } + + [Test] + public async Task ValidateWithZod_GivenNoBinding_DoesNotReadConfiguration(CancellationToken cancellationToken) + { + // Arrange — the section only names failures; without a bind the options keep their defaults. + await using var app = await TestApp.StartAsync( + builder => + { + Configure(builder); + builder.Services.AddOptions().ValidateWithZod("Smtp"); + }, + static _ => { }, + cancellationToken + ); + + // Act + var options = app.App.Services.GetRequiredService>().Value; + + // Assert + await Assert.That(options.Servers).IsEmpty(); + } + + [Test] + public async Task ValidateWithZod_GivenBlankSection_Throws(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + ServiceCollection services = new(); + var builder = services.AddOptions(); + + // Act / Assert + await Assert.That(() => builder.ValidateWithZod(" ")).Throws(); + } + +#if NET11_0_OR_GREATER + sealed class AsyncOnlyValidator : IZodSchemaValidator + { + public bool RequiresAsyncValidation => true; + + public ValidationResult Validate(SmtpOptions value) => + ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + SmtpOptions value, + CancellationToken cancellationToken = default + ) => + ValueTask.FromResult( + ValidationResult.Failure( + ValidationError.Create("unreachable", "Server is unreachable", ZodPath.Member("Servers")) + ) + ); + } + + [Test] + public async Task HostStart_GivenAsyncOnlyRule_FailsWithTheAsyncError(CancellationToken cancellationToken) + { + // Arrange / Act + OptionsValidationException? exception = null; + try + { + await using var app = await TestApp.StartAsync( + builder => + { + builder.Services.AddZodSharpFactory(static factory => factory.Register(new AsyncOnlyValidator())); + builder + .Services.AddOptions() + .AddZodSchemaValidator(MissingValidatorBehavior.Throw, validateOnStart: true, "Smtp"); + }, + static _ => { }, + cancellationToken + ); + } + catch (OptionsValidationException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).IsEquivalentTo(["Smtp:Servers: Server is unreachable (unreachable)"]); + } +#endif +} diff --git a/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs new file mode 100644 index 00000000..90c8c058 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs @@ -0,0 +1,158 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.AspNetCore.Fixtures; +using ZodSharp.Core; +#if !NET11_0_OR_GREATER +using System.Net.Http.Json; +#endif + +namespace ZodSharp.AspNetCore; + +/// +/// One ProblemDetails contract on every path: the endpoint filter, a thrown handled by +/// , the manual results and, on .NET 11, the built-in validation pipeline. +/// +public class ProblemDetailsContractTests +{ + const string ExpectedContract = /*lang=json,strict*/ + """ + { + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { + "Email": ["Email must be a valid email address"], + "Items[0].Quantity": ["Quantity must be at least 1"] + }, + "issues": [ + { "path": "Email", "segments": ["Email"], "code": "invalid_email", "category": "invalid_format", + "origin": "string", "message": "Email must be a valid email address" }, + { "path": "Items[0].Quantity", "segments": ["Items", 0, "Quantity"], "code": "too_small", + "category": "too_small", "origin": "number", "minimum": 1, "inclusive": true, + "message": "Quantity must be at least 1" } + ], + "traceId": "*" + } + """; + + static readonly OrderRequest InvalidOrder = new() { Email = "nope", Items = [new() { Quantity = 0 }] }; + + static readonly string[] Paths = + [ + "/filter", + "/exception", + "/manual", + "/typed", + "/problem", +#if NET11_0_OR_GREATER + "/pipeline", + "/pipeline-and-filter", +#endif + ]; + + /// + /// On .NET 11 the built-in validation pipeline would answer first; the endpoints that exercise the other paths + /// opt out of it so each path is the one that writes the response. + /// + static TBuilder ModeBOnly(TBuilder builder) + where TBuilder : IEndpointConventionBuilder => +#if NET11_0_OR_GREATER + builder.DisableValidation(); +#else + builder; +#endif + + [Test] + public async Task ProblemDetails_GivenTheSameErrors_IsIdenticalOnEveryPath(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharp(options => + options.ConfigureFactory = static factory => factory.Register(new OrderRequestValidator()) + ); + services.AddZodSharpProblemDetails(static options => options.Registry = new ErrorTypeRegistry()); +#if NET11_0_OR_GREATER + // Mode A: the built-in validation pipeline validates every endpoint below that does not opt out. + services.AddZodSharpValidation(); +#endif + }, + app => + { + app.UseExceptionHandler(); + + ModeBOnly(app.MapPost("/filter", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation()); + ModeBOnly( + app.MapPost("/exception", IResult (OrderRequest order) => + throw new ZodException(OrderRequestValidator.Check(order)) + ) + ); + ModeBOnly( + app.MapPost( + "/manual", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? result.ToValidationProblem(context) + : TypedResults.Ok() + ) + ); + ModeBOnly( + app.MapPost( + "/typed", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? ZodTypedResults.ValidationProblem(result, context) + : TypedResults.Ok() + ) + ); + ModeBOnly( + app.MapPost( + "/problem", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? ZodTypedResults.Problem(result, context) + : TypedResults.Ok() + ) + ); +#if NET11_0_OR_GREATER + // Mode A alone, and Mode A with the (then no-op) filter. + app.MapPost("/pipeline", (OrderRequest order) => TypedResults.Ok()); + app.MapPost("/pipeline-and-filter", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(); +#endif + }, + cancellationToken + ); + + // Act + List<(string Path, int Status, string? ContentType, string Body)> responses = []; + foreach (var path in Paths) + { + using var response = await app.Client.PostAsJsonAsync(path, InvalidOrder, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + responses.Add( + ( + path, + (int)response.StatusCode, + response.Content.Headers.ContentType?.MediaType, + TestApp.NormalizeTraceId(body) + ) + ); + } + + // Assert + var reference = responses[0]; + await Assert + .That(JsonNode.DeepEquals(JsonNode.Parse(reference.Body), JsonNode.Parse(ExpectedContract))) + .IsTrue(); + + foreach (var response in responses) + { + await Assert.That(response.Status).IsEqualTo(StatusCodes.Status400BadRequest).Because(response.Path); + await Assert.That(response.ContentType).IsEqualTo("application/problem+json").Because(response.Path); + await Assert.That(response.Body).IsEqualTo(reference.Body).Because(response.Path); + } + } +} diff --git a/src/tests/AspNetCore.UnitTests/StrictDeserializationProblemDetailsTests.cs b/src/tests/AspNetCore.UnitTests/StrictDeserializationProblemDetailsTests.cs new file mode 100644 index 00000000..a899055e --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/StrictDeserializationProblemDetailsTests.cs @@ -0,0 +1,437 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ZodSharp.AspNetCore.Fixtures; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// A minimal-API body rejected by a strict value object converter while it is deserialized +/// (JsonException(inner: ZodException)) is answered with the errors located under the member that held the +/// value - the same body validating the hydrated model produces. +/// +public class StrictDeserializationProblemDetailsTests +{ + const string InvalidEmailBody = /*lang=json,strict*/ + """{"customerEmail":"not-an-email","quantity":5}"""; + + [Test] + public async Task PostBody_GivenStrictConverterRejectsMember_ReportsTheMemberPath( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken, static o => o.HandleBindingFailures = true); + + // Act + var (response, document) = await PostAsync(app, "/orders", InvalidEmailBody, cancellationToken); + + // Assert + using (document) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(response.Content.Headers.ContentType?.MediaType).IsEqualTo("application/problem+json"); + + var root = document!.RootElement; + var errors = root.GetProperty("errors"); + await Assert + .That(errors.EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo(["CustomerEmail.Value"]); + await Assert + .That(errors.GetProperty("CustomerEmail.Value")[0].GetString()) + .IsEqualTo("Field 'CustomerEmail' must be a valid email address."); + + var issue = root.GetProperty("issues").EnumerateArray().Single(); + await Assert.That(issue.GetProperty("path").GetString()).IsEqualTo("CustomerEmail.Value"); + await Assert + .That( + string.Join('|', issue.GetProperty("segments").EnumerateArray().Select(static s => s.GetString())) + ) + .IsEqualTo("CustomerEmail|Value"); + await Assert.That(issue.GetProperty("code").GetString()).IsEqualTo("invalid_string"); + await Assert.That(issue.GetProperty("origin").GetString()).IsEqualTo("string"); + } + } + + [Test] + public async Task PostBody_GivenStrictConverterRejectsMember_MatchesValidatingTheHydratedModel( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken, static o => o.HandleBindingFailures = true); + + // Act + var (strict, strictDocument) = await PostAsync(app, "/orders", InvalidEmailBody, cancellationToken); + var (hydrated, hydratedDocument) = await PostAsync(app, "/hydrated/orders", "{}", cancellationToken); + + // Assert + using (strictDocument) + using (hydratedDocument) + { + await Assert.That(strict.StatusCode).IsEqualTo(hydrated.StatusCode); + await AssertSameContract(strictDocument!, hydratedDocument!); + } + } + + [Test] + [Arguments( /*lang=json,strict*/ + """{"customer":{"email":"x"},"lines":[{"quantity":1}]}""", + "/hydrated/shipping/email", + "Customer.Email.Value" + )] + [Arguments( /*lang=json,strict*/ + """{"customer":{"email":"ada@example.com"},"lines":[{"quantity":1},{"quantity":0}]}""", + "/hydrated/shipping/quantity", + "Lines[1].Quantity.Value" + )] + public async Task PostBody_GivenNestedOrCollectionMemberRejected_ReportsTheFullMemberPath( + string body, + string hydratedRoute, + string expectedKey, + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken, static o => o.HandleBindingFailures = true); + + // Act + var (strict, strictDocument) = await PostAsync(app, "/shipping", body, cancellationToken); + var (_, hydratedDocument) = await PostAsync(app, hydratedRoute, "{}", cancellationToken); + + // Assert + using (strictDocument) + using (hydratedDocument) + { + await Assert.That(strict.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(strictDocument!.RootElement.GetProperty("errors").EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo([expectedKey]); + await AssertSameContract(strictDocument, hydratedDocument!); + } + } + + [Test] + public async Task PostBody_GivenJsonPathNaming_UsesJsonNamesLikeValidatingTheHydratedModel( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken, static o => + { + o.HandleBindingFailures = true; + o.PathNaming = ZodPathNaming.Json; + }); + + // Act + var (_, strictDocument) = await PostAsync(app, "/orders", InvalidEmailBody, cancellationToken); + var (_, hydratedDocument) = await PostAsync(app, "/hydrated/orders", "{}", cancellationToken); + + // Assert + using (strictDocument) + using (hydratedDocument) + { + var root = strictDocument!.RootElement; + await Assert + .That(root.GetProperty("errors").EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo(["customerEmail.value"]); + await Assert + .That( + string.Join( + '|', + root.GetProperty("issues")[0] + .GetProperty("segments") + .EnumerateArray() + .Select(static s => s.GetString()) + ) + ) + .IsEqualTo("customerEmail|value"); + await AssertSameContract(strictDocument, hydratedDocument!); + } + } + + [Test] + [Arguments("""{"customerEmail":""", "application/json", HttpStatusCode.BadRequest)] + [Arguments( /*lang=json,strict*/ + """{"customerEmail":"ada@example.com","quantity":"five"}""", + "application/json", + HttpStatusCode.BadRequest + )] + public async Task PostBody_GivenBadRequestWithoutZodErrors_AnswersItsOwnStatusWithStandardProblemDetails( + string body, + string mediaType, + HttpStatusCode expectedStatus, + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken, static o => o.HandleBindingFailures = true); + + // Act + var (response, document) = await PostAsync(app, "/orders", body, cancellationToken, mediaType); + + // Assert + using (document) + { + await Assert.That(response.StatusCode).IsEqualTo(expectedStatus); + await Assert.That(response.Content.Headers.ContentType?.MediaType).IsEqualTo("application/problem+json"); + + var root = document!.RootElement; + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo((int)expectedStatus); + await Assert.That(root.TryGetProperty("issues", out _)).IsFalse(); + await Assert.That(root.TryGetProperty("errors", out _)).IsFalse(); + // The serializer's message can quote the request, so it is never written. + await Assert.That(root.TryGetProperty("detail", out _)).IsFalse(); + } + } + + [Test] + public async Task PostBody_GivenUnsupportedContentTypeWithOptIn_IsStillAnsweredByBindingWith415( + CancellationToken cancellationToken + ) + { + // Arrange: binding answers an unexpected content type itself, whatever ThrowOnBadRequest says. + await using var app = await StartAsync(cancellationToken, static o => o.HandleBindingFailures = true); + + // Act + var (response, document) = await PostAsync(app, "/orders", InvalidEmailBody, cancellationToken, "text/plain"); + + // Assert + using (document) + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.UnsupportedMediaType); + } + +#if NET11_0_OR_GREATER + [Test] + public async Task PostBody_GivenProductionWithoutOptIn_RewritesBindingsProblemWithTheMemberPath( + CancellationToken cancellationToken + ) + { + // Arrange: .NET 11 binding writes its own validation problem for an unreadable body, through the ProblemDetails + // service with the exception attached, so ZodSharp rewrites it without ThrowOnBadRequest. + await using var app = await StartAsync(cancellationToken); + + // Act + var (strict, strictDocument) = await PostAsync(app, "/orders", InvalidEmailBody, cancellationToken); + var (_, hydratedDocument) = await PostAsync(app, "/hydrated/orders", "{}", cancellationToken); + + // Assert + using (strictDocument) + using (hydratedDocument) + { + await Assert.That(strict.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(strictDocument!.RootElement.GetProperty("errors").EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo(["CustomerEmail.Value"]); + await AssertSameContract(strictDocument, hydratedDocument!); + } + } + + [Test] + public async Task PostBody_GivenProductionWithoutOptInAndMalformedJson_LeavesBindingsProblemKeyedByJsonPath( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken); + + // Act + var (response, document) = await PostAsync(app, "/orders", """{"customerEmail":""", cancellationToken); + + // Assert + using (document) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(document!.RootElement.GetProperty("errors").EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo(["$.customerEmail"]); + } + } +#else + [Test] + [Arguments(InvalidEmailBody)] + [Arguments("""{"customerEmail":""")] + public async Task PostBody_GivenProductionWithoutOptIn_AnswersBareBadRequestAsBefore( + string body, + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(cancellationToken); + + // Act + var (response, document) = await PostAsync(app, "/orders", body, cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(document).IsNull(); + } +#endif + + [Test] + public async Task PostBody_GivenDevelopmentWithoutOptIn_ReportsTheMemberPath(CancellationToken cancellationToken) + { + // Arrange: Development already throws on a bad request, so the handler sees the failure without the opt-in. + await using var app = await StartAsync(cancellationToken, environment: Environments.Development); + + // Act + var (response, document) = await PostAsync(app, "/orders", InvalidEmailBody, cancellationToken); + + // Assert + using (document) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(document!.RootElement.GetProperty("errors").EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo(["CustomerEmail.Value"]); + } + } + + [Test] + public async Task Handler_GivenBareZodException_ReportsItsOwnPathAsBefore(CancellationToken cancellationToken) + { + // Arrange + await using var app = await StartAsync(cancellationToken, static o => o.HandleBindingFailures = true); + + // Act + var (response, document) = await PostAsync(app, "/throws", "{}", cancellationToken); + + // Assert + using (document) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(document!.RootElement.GetProperty("errors").EnumerateObject().Select(static p => p.Name)) + .IsEquivalentTo(["Value"]); + } + } + + [Test] + public async Task TryHandleAsync_GivenOtherBadRequestWithoutOptIn_ReturnsFalse(CancellationToken cancellationToken) + { + // Arrange + ZodExceptionHandler handler = new(Microsoft.Extensions.Options.Options.Create(new ZodProblemDetailsOptions())); + DefaultHttpContext httpContext = new(); + BadHttpRequestException exception = new("Failed to read the body.", new JsonException("Unexpected end.")); + + // Act + var handled = await handler.TryHandleAsync(httpContext, exception, cancellationToken); + + // Assert + await Assert.That(handled).IsFalse(); + } + + [Test] + public async Task TryHandleAsync_GivenBadRequestCarryingZodException_ReturnsTrueWithoutOptIn( + CancellationToken cancellationToken + ) + { + // Arrange + ZodExceptionHandler handler = new(Microsoft.Extensions.Options.Options.Create(new ZodProblemDetailsOptions())); + DefaultHttpContext httpContext = new(); + httpContext.Response.Body = new MemoryStream(); + BadHttpRequestException exception = new( + "Failed to read the body.", + new JsonException( + "Cannot deserialize EmailAddress: the value failed validation.", + new ZodException([ValidationError.Create("invalid", "Invalid value.", ZodPath.Member("Value"))]) + ) + ); + + // Act + var handled = await handler.TryHandleAsync(httpContext, exception, cancellationToken); + + // Assert + await Assert.That(handled).IsTrue(); + await Assert.That(httpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status400BadRequest); + } + + static async Task AssertSameContract(JsonDocument actual, JsonDocument expected) + { + foreach (var member in new[] { "status", "title", "type", "errors", "issues" }) + { + await Assert + .That(actual.RootElement.GetProperty(member).GetRawText()) + .IsEqualTo(expected.RootElement.GetProperty(member).GetRawText()); + } + } + + static Task StartAsync( + CancellationToken cancellationToken, + Action? configure = null, + string environment = "Production" + ) => + TestApp.StartAsync( + builder => + { + builder.Services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, StrictValueObjectJsonContext.Default) + ); + builder.Services.AddZodSharpProblemDetails(configure); + }, + static app => + { + app.UseExceptionHandler(); + app.MapPost("/orders", static (PlaceOrderRequest request) => Results.Ok()); + app.MapPost("/shipping", static (ShippingOrder order) => Results.Ok()); + // The same models validated after hydration (bound leniently, then validated): the target contract. + app.MapPost("/hydrated/orders", static IResult () => + throw new ZodException( + PlaceOrderRequestSchema + .Validate(new PlaceOrderRequest(new EmailAddress("not-an-email"), new OrderQuantity(5))) + .Errors + ) + ); + app.MapPost("/hydrated/shipping/email", static IResult () => + throw new ZodException( + ShippingOrderSchema + .Validate( + new ShippingOrder( + new ShippingCustomer(new EmailAddress("x")), + [new ShippingLine(new OrderQuantity(1))] + ) + ) + .Errors + ) + ); + app.MapPost("/hydrated/shipping/quantity", static IResult () => + throw new ZodException( + ShippingOrderSchema + .Validate( + new ShippingOrder( + new ShippingCustomer(new EmailAddress("ada@example.com")), + [new ShippingLine(new OrderQuantity(1)), new ShippingLine(new OrderQuantity(0))] + ) + ) + .Errors + ) + ); + app.MapPost("/throws", static IResult () => + throw new ZodException([ + ValidationError.Create("invalid", "Invalid value.", ZodPath.Member("Value")), + ]) + ); + }, + cancellationToken, + environment + ); + + static async Task<(HttpResponseMessage Response, JsonDocument? Document)> PostAsync( + TestApp app, + string path, + string body, + CancellationToken cancellationToken, + string mediaType = "application/json" + ) + { + using StringContent content = new(body, Encoding.UTF8, mediaType); + var response = await app.Client.PostAsync(new Uri(path, UriKind.Relative), content, cancellationToken); + var text = await response.Content.ReadAsStringAsync(cancellationToken); + return (response, text.Length == 0 ? null : JsonDocument.Parse(text)); + } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs new file mode 100644 index 00000000..fa204b78 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs @@ -0,0 +1,477 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.AspNetCore.Fixtures; +using ZodSharp.Core; +#if !NET11_0_OR_GREATER +using System.Net.Http.Json; +#endif + +namespace ZodSharp.AspNetCore; + +public class ZodEndpointValidationTests +{ + static readonly OrderRequest ValidOrder = new() { Email = "a@b.c", Items = [new() { Quantity = 1 }] }; + static readonly OrderRequest InvalidOrder = new() { Email = "nope", Items = [new() { Quantity = 0 }] }; + + static void AddServices( + IServiceCollection services, + IZodSchemaValidator? orderValidator = null, + Action? configure = null + ) + { + services.AddZodSharp(options => + options.ConfigureFactory = factory => + { + factory.Register(orderValidator ?? new OrderRequestValidator()); + factory.Register(new CustomerQueryValidator()); + } + ); + services.AddZodSharpProblemDetails(options => + { + options.Registry = new ErrorTypeRegistry(); + configure?.Invoke(options); + }); + } + + [Test] + public async Task WithZodSharpValidation_GivenInvalidBody_ReturnsProblemDetailsContract( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync("/orders", InvalidOrder, cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(response.Content.Headers.ContentType?.MediaType).IsEqualTo("application/problem+json"); + await Assert + .That(root.GetProperty("errors").GetProperty("Email")[0].GetString()) + .IsEqualTo("Email must be a valid email address"); + await Assert + .That(root.GetProperty("errors").GetProperty("Items[0].Quantity")[0].GetString()) + .IsEqualTo("Quantity must be at least 1"); + await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(2); + await Assert.That(root.TryGetProperty("traceId", out _)).IsTrue(); + } + + [Test] + public async Task WithZodSharpValidation_GivenValidBody_InvokesHandler(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok("handled")).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync("/orders", ValidOrder, cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(await response.Content.ReadAsStringAsync(cancellationToken)).IsEqualTo("\"handled\""); + } + + [Test] + public async Task WithZodSharpValidation_GivenMappedErrorType_UsesRegistryStatusAndAddsTraceId( + CancellationToken cancellationToken + ) + { + // Defect D6: the filter ignored ErrorTypeRegistry and ZodProblemDetailsOptions, always answered 400 and + // never added a traceId. + + // Arrange + await using var app = await TestApp.StartAsync( + services => + AddServices(services, configure: options => + options.MapErrorType( + new ErrorType("invalid_email", HttpStatus: StatusCodes.Status422UnprocessableEntity) + ) + ), + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync("/orders", InvalidOrder, cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.UnprocessableEntity); + await Assert.That(document.RootElement.GetProperty("status").GetInt32()).IsEqualTo(422); + await Assert.That(document.RootElement.GetProperty("traceId").GetString()).IsNotNull().And.IsNotEmpty(); + } + + [Test] + public async Task WithZodSharpValidation_GivenSeveralValidatedArguments_ValidatesEveryArgument( + CancellationToken cancellationToken + ) + { + // Defect D12: only the first matching argument was validated. + + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => + app.MapPost("/orders", (OrderRequest order, [AsParameters] CustomerQuery customer) => TypedResults.Ok()) + .WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync("/orders", InvalidOrder, cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var errors = document.RootElement.GetProperty("errors"); + + // Assert + await Assert.That(errors.TryGetProperty("Email", out _)).IsTrue(); + await Assert.That(errors.GetProperty("Name")[0].GetString()).IsEqualTo("Name is required"); + } + + [Test] + public async Task WithZodSharpValidationOfT_GivenSeveralArgumentsOfT_ValidatesEveryArgument( + CancellationToken cancellationToken + ) + { + // Defect D12: only the first argument of type T was validated. + + // Arrange + DelegateValidator validator = new(static value => + value.Length >= 3 ? [] : [ValidationError.Create("too_small", "Too short.", ZodPath.Root)] + ); + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharpFactory(factory => factory.Register(validator)); + services.AddZodSharpProblemDetails(); + }, + app => + app.MapGet("/search", (string first, string second) => TypedResults.Ok()) + .WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync( + new Uri("/search?first=ab&second=cd", UriKind.Relative), + cancellationToken + ); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var errors = document.RootElement.GetProperty("errors"); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(errors.TryGetProperty("first", out _)).IsTrue(); + await Assert.That(errors.TryGetProperty("second", out _)).IsTrue(); + await Assert.That(validator.Calls).IsEqualTo(2); + } + + [Test] + public async Task WithZodSharpValidationOfT_GivenNoValidatorRegistered_FailsWhenTheEndpointIsBuilt( + CancellationToken cancellationToken + ) + { + // Defect D12: a missing validator surfaced as a 500 on every request rather than once, at startup. + + // Arrange + await using var app = await TestApp.StartAsync( + services => services.AddZodSharp(), + app => + app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()) + .WithZodSharpValidation(), + cancellationToken + ); + + // Act + var exception = BuildEndpointsAndCatch(app.App); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("parameter 'order'"); + await Assert.That(exception.Message).Contains(typeof(OrderRequest).FullName!); + } + + [Test] + public async Task WithZodSharpValidation_GivenThrowIfNoValidatorAndUnregisteredComplexParameter_FailsWhenTheEndpointIsBuilt( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => services.AddZodSharp(), + app => + app.MapPost("/orders/{id}", (int id, OrderRequest order) => TypedResults.Ok()) + .WithZodSharpValidation(options => options.ThrowIfNoValidator = true), + cancellationToken + ); + + // Act + var exception = BuildEndpointsAndCatch(app.App); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("parameter 'order'"); + } + + [Test] + public async Task WithZodSharpValidation_GivenParametersWithoutValidators_SkipsThem( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => services.AddZodSharp(), + app => + app.MapPost("/orders/{id}", (int id, OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync("/orders/1", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task WithZodSharpValidation_OnRouteGroup_ValidatesEveryEndpointInTheGroup( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => + { + var group = app.MapGroup("/orders").WithZodSharpValidation(); + group.MapPost("/", (OrderRequest order) => TypedResults.Ok()); + group.MapPut("/{id}", (int id, OrderRequest order) => TypedResults.Ok()); + }, + cancellationToken + ); + + // Act + using var post = await app.Client.PostAsJsonAsync("/orders/", InvalidOrder, cancellationToken); + using var put = await app.Client.PutAsJsonAsync("/orders/7", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(post.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(put.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task WithZodSharpValidation_GivenServiceParameter_DoesNotValidateIt( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services); + services.AddSingleton(InvalidOrder); + }, + app => + app.MapGet("/orders/current", ([FromServices] OrderRequest current) => TypedResults.Ok()) + .WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync(new Uri("/orders/current", UriKind.Relative), cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task WithZodSharpValidation_GivenSequenceParameter_PrefixesTheParameterName( + CancellationToken cancellationToken + ) + { + // Arrange + DelegateValidator> linesValidator = new(static lines => + lines.Select( + static (line, index) => + ValidationError.Create( + "too_small", + "Quantity must be at least 1", + ZodPath.From(ZodPathSegment.At(index), ZodPathSegment.Member("Quantity")) + ) + ) + ); + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharpFactory(factory => factory.Register(linesValidator)); + services.AddZodSharpProblemDetails(); + }, + app => app.MapPost("/lines", (List lines) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync( + "/lines", + new List { new() }, + cancellationToken + ); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + + // Assert + await Assert + .That(document.RootElement.GetProperty("errors").TryGetProperty("lines[0].Quantity", out _)) + .IsTrue(); + } + + [Test] + public async Task WithZodSharpValidation_GivenGeneratedValidatorRegisteredByAssemblyScan_ValidatesTheParameter( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + services.AddZodSharp(); + services.AddZodSharpAssembly(typeof(ValidatedRequest).Assembly); + }, + app => app.MapPost("/requests", (ValidatedRequest request) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.PostAsJsonAsync( + "/requests", + new ValidatedRequest { Name = "a" }, + cancellationToken + ); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(document.RootElement.GetProperty("errors").TryGetProperty("Name", out _)).IsTrue(); + } + + [Test] + public async Task WithZodSharpValidation_AddsValidationProblemResponseMetadata(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + var metadata = GetEndpoints(app.App) + .SelectMany(static endpoint => endpoint.Metadata.OfType()) + .ToArray(); + + // Assert + await Assert + .That( + metadata.Any(static m => + m.StatusCode == StatusCodes.Status400BadRequest + && m.Type == typeof(HttpValidationProblemDetails) + && m.ContentTypes.Contains("application/problem+json") + ) + ) + .IsTrue(); + } + +#if NET11_0_OR_GREATER + [Test] + public async Task WithZodSharpValidation_GivenBindingFailureAndPassThrough_KeepsTheBindersResponse( + CancellationToken cancellationToken + ) + { + // Defect D11: .NET 11 runs endpoint filters after a binding failure; validating the unbound arguments + // replaced the binder's response. + + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => + app.MapGet("/customers", (int page, [AsParameters] CustomerQuery customer) => TypedResults.Ok()) + .WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync( + new Uri("/customers?page=abc", UriKind.Relative), + cancellationToken + ); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(body).DoesNotContain("\"issues\""); + } + + [Test] + public async Task WithZodSharpValidation_GivenBindingFailureAndConvertToProblemDetails_WritesTheContract( + CancellationToken cancellationToken + ) + { + // Defect D11: opt-in conversion of a binding failure into the ZodSharp contract. + + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => + app.MapGet("/customers", (int page, [AsParameters] CustomerQuery customer) => TypedResults.Ok()) + .WithZodSharpValidation(options => + options.OnBindingFailure = ZodBindingFailureBehavior.ConvertToProblemDetails + ), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync( + new Uri("/customers?page=abc", UriKind.Relative), + cancellationToken + ); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var issue = document.RootElement.GetProperty("issues")[0]; + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(issue.GetProperty("code").GetString()).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(issue.GetProperty("category").GetString()).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(document.RootElement.GetProperty("errors").TryGetProperty("Name", out _)).IsFalse(); + } +#endif + + static Endpoint[] GetEndpoints(WebApplication app) => + [.. ((IEndpointRouteBuilder)app).DataSources.SelectMany(static source => source.Endpoints)]; + + static InvalidOperationException? BuildEndpointsAndCatch(WebApplication app) + { + try + { + _ = GetEndpoints(app); + return null; + } + catch (InvalidOperationException ex) + { + return ex; + } + } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs index 9900a041..a7e31cd5 100644 --- a/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodExceptionHandlerTests.cs @@ -30,7 +30,10 @@ CancellationToken cancellationToken var root = document.RootElement; await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(StatusCodes.Status400BadRequest); await Assert.That(root.GetProperty("errors").GetProperty("value")[0].GetString()).IsEqualTo("Invalid value."); - await Assert.That(root.GetProperty("traceId").GetString()).IsEqualTo("trace-1"); + // The trace id matches ASP.NET Core's own ProblemDetails writer: the current activity, else TraceIdentifier. + await Assert + .That(root.GetProperty("traceId").GetString()) + .IsEqualTo(System.Diagnostics.Activity.Current?.Id ?? "trace-1"); await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(1); } @@ -77,9 +80,11 @@ CancellationToken cancellationToken await Assert .That(root.GetProperty("errors").GetProperty("")[0].GetString()) .IsEqualTo("Aggregate 'agg-123' failed to save"); - await Assert.That(root.GetProperty("aggregateId").GetString()).IsEqualTo("agg-123"); + // Parameters are only written per issue unless MergeParametersIntoExtensions is enabled. + await Assert.That(root.TryGetProperty("aggregateId", out _)).IsFalse(); await Assert - .That(root.GetProperty("issues")[0].GetProperty("parameters").GetProperty("AggregateId").GetString()) + // Defect D4: issue parameter keys are camel-cased, like the top-level extensions. + .That(root.GetProperty("issues")[0].GetProperty("parameters").GetProperty("aggregateId").GetString()) .IsEqualTo("agg-123"); } diff --git a/src/tests/AspNetCore.UnitTests/ZodProblemDetailsDiagnosticParameterTests.cs b/src/tests/AspNetCore.UnitTests/ZodProblemDetailsDiagnosticParameterTests.cs new file mode 100644 index 00000000..18460f58 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodProblemDetailsDiagnosticParameterTests.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; +using ZodSharp.Core; + +namespace ZodSharp.AspNetCore; + +/// +/// Generated validators attach declaringType (a full CLR type name) and member to every error, for +/// localization. They describe the server's implementation, so ProblemDetails leaves them out unless asked. +/// +public class ZodProblemDetailsDiagnosticParameterTests +{ + static ValidationError CreateError() => + ValidationError.Create( + "too_small", + "Name is too short", + ZodPath.ParseDotted("Customer.Name"), + parameters: new Dictionary + { + [ZodIssueParameters.DisplayName] = "Name", + [ZodIssueParameters.Member] = "Name", + [ZodIssueParameters.DeclaringType] = "Contoso.Orders.Internal.CustomerDto", + ["minimum"] = 2, + } + ); + + [Test] + public async Task Create_GivenDefaultOptions_OmitsDiagnosticParametersButKeepsThemOnTheError( + CancellationToken cancellationToken + ) + { + // Arrange + var error = CreateError(); + ZodProblemDetailsWriter writer = new(new ZodProblemDetailsOptions { MergeParametersIntoExtensions = true }); + + // Act + var details = writer.Create([error]); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + var issue = Issue(details); + await Assert.That(issue.SafeParameters).IsNotNull(); + await Assert.That(issue.SafeParameters!.ContainsKey("declaringType")).IsFalse(); + await Assert.That(issue.SafeParameters.ContainsKey("member")).IsFalse(); + await Assert.That(issue.SafeParameters["displayName"]).IsEqualTo("Name"); + await Assert.That(issue.SafeParameters.ContainsKey("minimum")).IsTrue(); + + await Assert.That(details.Extensions.ContainsKey("declaringType")).IsFalse(); + await Assert.That(details.Extensions.ContainsKey("member")).IsFalse(); + await Assert.That(details.Extensions.ContainsKey("displayName")).IsTrue(); + + // Still available server-side, for localization and logging. + await Assert + .That(issue.Parameters![ZodIssueParameters.DeclaringType]) + .IsEqualTo("Contoso.Orders.Internal.CustomerDto"); + } + + [Test] + public async Task Create_GivenIncludeDiagnosticParameters_WritesThem(CancellationToken cancellationToken) + { + // Arrange + ZodProblemDetailsWriter writer = new( + new ZodProblemDetailsOptions { IncludeDiagnosticParameters = true, MergeParametersIntoExtensions = true } + ); + + // Act + var details = writer.Create([CreateError()]); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + var issue = Issue(details); + await Assert.That(issue.SafeParameters!["declaringType"]).IsEqualTo("Contoso.Orders.Internal.CustomerDto"); + await Assert.That(issue.SafeParameters["member"]).IsEqualTo("Name"); + await Assert.That(details.Extensions.ContainsKey("declaringType")).IsTrue(); + } + + static ValidationIssue Issue(HttpValidationProblemDetails details) => + ((IEnumerable)details.Extensions["issues"]!).Single(); +} diff --git a/src/tests/AspNetCore.UnitTests/ZodProblemDetailsWriterTests.cs b/src/tests/AspNetCore.UnitTests/ZodProblemDetailsWriterTests.cs new file mode 100644 index 00000000..6c4a87b1 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodProblemDetailsWriterTests.cs @@ -0,0 +1,497 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Core; +using HttpJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; + +// ZODSASP001: the templates deliberately use undeclared or built-in placeholders (path, minimum, code). +#pragma warning disable ZODSASP001 + +namespace ZodSharp.AspNetCore; + +public class ZodProblemDetailsWriterTests +{ + static ZodProblemDetailsWriter CreateWriter(Action? configure = null) + { + ZodProblemDetailsOptions options = new() { Registry = new ErrorTypeRegistry() }; + configure?.Invoke(options); + return new ZodProblemDetailsWriter(options); + } + + static ValidationIssue[] Issues(HttpValidationProblemDetails details) => + (ValidationIssue[])details.Extensions["issues"]!; + + static JsonElement Serialize(HttpValidationProblemDetails details) => + JsonSerializer.SerializeToElement(details, ZodProblemDetailsJsonContext.Default.HttpValidationProblemDetails); + + [Test] + public async Task Create_GivenIndexKeyAndRootPaths_RendersDottedKeys() + { + // Defect D1: keys were built by joining segments (Items.0.Name), with no escaping. + + // Arrange + ValidationError[] errors = + [ + ValidationError.Create( + "invalid", + "Bad name.", + ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(0), ZodPathSegment.Member("Name")) + ), + ValidationError.Create( + "invalid", + "Bad key.", + ZodPath.From(ZodPathSegment.Member("Map"), ZodPathSegment.Key("a.b")) + ), + ValidationError.Create("invalid", "Bad root.", ZodPath.Root), + ]; + + // Act + var details = CreateWriter().Create(errors); + + // Assert + await Assert.That(details.Errors.Keys).IsEquivalentTo(["Items[0].Name", "Map[\"a.b\"]", string.Empty]); + } + + [Test] + public async Task Create_GivenErrorTypeTemplateWithUnresolvedPlaceholder_KeepsTheErrorMessage() + { + // Defect D2: the template replaced the message even when placeholders could not be resolved. + + // Arrange + var writer = CreateWriter(options => + options.MapErrorType(new ErrorType("save_failed", MessageFormat: "Save of '{AggregateId}' failed.")) + ); + + // Act + var details = writer.Create([ValidationError.Create("save_failed", "Save failed.", ZodPath.Root)]); + + // Assert + await Assert.That(details.Errors[string.Empty]).IsEquivalentTo(["Save failed."]); + } + + [Test] + public async Task Create_GivenErrorTypeTemplateWithBuiltInPlaceholders_FormatsTheMessage() + { + // Arrange + var writer = CreateWriter(options => + options.MapErrorType(new ErrorType("too_small", MessageFormat: "{path} must be at least {minimum}.")) + ); + var error = ValidationError.Create( + "too_small", + "Too small.", + ZodPath.Member("Age"), + minimum: 18, + inclusive: true + ); + + // Act + var details = writer.Create([error]); + + // Assert + await Assert.That(details.Errors["Age"]).IsEquivalentTo(["Age must be at least 18."]); + } + + [Test] + public async Task ToHttpValidationProblemDetails_GivenMergeEnabledAndNoRegistry_StillMergesParameters() + { + // Defect D3: parameters were merged into the extensions only by the registry/lookup overloads. + + // Arrange + var result = ValidationResult.Failure( + ValidationError.Create( + "locked", + "Locked.", + ["Id"], + parameters: new Dictionary { ["ResourceId"] = "res-1" } + ) + ); + + // Act + var details = result.ToHttpValidationProblemDetails( + new ZodProblemDetailsOptions { MergeParametersIntoExtensions = true }, + httpContext: null + ); + + // Assert + await Assert.That(details.Extensions["resourceId"]).IsEqualTo("res-1"); + } + + [Test] + public async Task Create_GivenParameters_CamelCasesThemPerIssueAndHonoursTheMergeOption() + { + // Defect D4: parameters were flattened into the top-level extensions only, and issue parameters kept + // their raw keys while the extensions were camel-cased. + + // Arrange + var writer = CreateWriter(static options => options.MergeParametersIntoExtensions = false); + var error = ValidationError.Create( + "locked", + "Locked.", + ["Id"], + parameters: new Dictionary { ["ResourceId"] = "res-1" } + ); + + // Act + var details = writer.Create([error]); + var issue = Serialize(details).GetProperty("issues")[0]; + + // Assert + await Assert.That(details.Extensions.ContainsKey("resourceId")).IsFalse(); + await Assert.That(issue.GetProperty("parameters").GetProperty("resourceId").GetString()).IsEqualTo("res-1"); + } + + [Test] + public async Task Create_GivenParameterNamedLikeAReservedMember_DoesNotOverwriteIt() + { + // Defect D5: a parameter named "errors" (as ZodUnion used) wrote a second "errors" JSON member. + + // Arrange + var error = ValidationError.Create( + "invalid", + "Invalid.", + ["Id"], + parameters: new Dictionary + { + ["errors"] = "x", + ["Status"] = 7, + ["traceId"] = "fake", + } + ); + + // Act + var details = CreateWriter(static options => options.MergeParametersIntoExtensions = true) + .Create([error], new DefaultHttpContext { TraceIdentifier = "real-trace" }); + var json = Serialize(details); + + // Assert + await Assert.That(details.Extensions["param_errors"]).IsEqualTo("x"); + await Assert.That(details.Extensions["param_status"]).IsEqualTo(7L); + await Assert.That(details.Extensions["param_traceId"]).IsEqualTo("fake"); + await Assert.That(json.GetProperty("traceId").GetString()).IsNotEqualTo("fake"); + await Assert.That(json.GetProperty("status").GetInt32()).IsEqualTo(400); + await Assert.That(json.GetProperty("errors").ValueKind).IsEqualTo(JsonValueKind.Object); + } + + [Test] + public async Task TryHandleAsync_GivenFormatMessagesDisabled_KeepsTheErrorMessage( + CancellationToken cancellationToken + ) + { + // Defect D7: ZodProblemDetailsOptions.FormatMessages was never read. + + // Arrange + ZodProblemDetailsOptions options = new() { Registry = new ErrorTypeRegistry(), FormatMessages = false }; + options.MapErrorType(new ErrorType("conflict", MessageFormat: "Templated {code}.")); + ZodExceptionHandler handler = new(Microsoft.Extensions.Options.Options.Create(options)); + DefaultHttpContext httpContext = new(); + httpContext.Response.Body = new MemoryStream(); + + // Act + await handler.TryHandleAsync( + httpContext, + new ZodException([ValidationError.Create("conflict", "Original.", ZodPath.Root)]), + cancellationToken + ); + httpContext.Response.Body.Position = 0; + using var document = await JsonDocument.ParseAsync( + httpContext.Response.Body, + cancellationToken: cancellationToken + ); + + // Assert + await Assert + .That(document.RootElement.GetProperty("errors").GetProperty("")[0].GetString()) + .IsEqualTo("Original."); + } + + [Test] + public async Task Create_GivenStatusCodeSelectorAndMappedErrorType_SelectorWins() + { + // Defect D8: the selector's status was overridden by ErrorType.HttpStatus. + + // Arrange + var writer = CreateWriter(static options => + { + options.MapErrorType(new ErrorType("conflict", HttpStatus: StatusCodes.Status409Conflict)); + options.StatusCodeSelector = static _ => StatusCodes.Status503ServiceUnavailable; + }); + + // Act + var details = writer.Create([ValidationError.Create("conflict", "Conflict.", ZodPath.Root)]); + + // Assert + await Assert.That(details.Status).IsEqualTo(StatusCodes.Status503ServiceUnavailable); + } + + [Test] + public async Task Create_GivenStatusCodeOverride_ReceivesTheComputedStatus() + { + // Arrange + var received = 0; + var writer = CreateWriter(options => + { + options.MapErrorType(new ErrorType("conflict", HttpStatus: StatusCodes.Status409Conflict)); + options.StatusCodeOverride = (_, computed) => + { + received = computed; + return computed + 1; + }; + }); + + // Act + var details = writer.Create([ValidationError.Create("conflict", "Conflict.", ZodPath.Root)]); + + // Assert + await Assert.That(received).IsEqualTo(StatusCodes.Status409Conflict); + await Assert.That(details.Status).IsEqualTo(410); + } + + [Test] + public async Task ToValidationProblemDetails_CopiesEveryExtension() + { + // Defect D9: the MVC copy kept only "issues". + + // Arrange + ZodException exception = new([ + ValidationError.Create( + "locked", + "Locked.", + ["Id"], + parameters: new Dictionary { ["ResourceId"] = "res-1" } + ), + ]); + + // Act + var details = exception.ToValidationProblemDetails( + new ZodProblemDetailsOptions { MergeParametersIntoExtensions = true }, + httpContext: null + ); + + // Assert + await Assert.That(details.Extensions.ContainsKey("issues")).IsTrue(); + await Assert.That(details.Extensions["resourceId"]).IsEqualTo("res-1"); + } + + [Test] + public async Task Create_SerializesASafeIssueShape() + { + // Defect D10: raw parameters were serialized, including nested union errors and arbitrary objects. + + // Arrange + var inner = ValidationError.Create("invalid_type", "Expected number.", ZodPath.Member("Value")); + var union = ValidationError.Create( + "invalid_union", + "No option matched.", + ZodPath.Member("Value"), + parameters: new Dictionary + { + [ZodIssueParameters.UnionErrors] = ImmutableArray.Create(ImmutableArray.Create(inner)), + ["Secret"] = new Uri("https://internal").GetType().Assembly, + ["Count"] = 3, + }, + category: ZodIssueCategories.InvalidUnion + ); + var range = ValidationError.Create( + "too_small", + "Too small.", + ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(2), ZodPathSegment.Member("Price")), + category: ZodIssueCategories.TooSmall + ) with + { + Lower = ErrorBound.Excluding(0.5), + Upper = ErrorBound.Including(9.5m), + }; + var date = ValidationError.Create( + "too_big", + "Too late.", + ZodPath.Member("When"), + category: ZodIssueCategories.TooBig + ) with + { + Upper = ErrorBound.Including(new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)), + }; + + // Act + var issues = Serialize(CreateWriter().Create([union, range, date])).GetProperty("issues"); + + // Assert + var unionIssue = issues[0]; + await Assert.That(unionIssue.GetProperty("parameters").TryGetProperty("secret", out _)).IsFalse(); + await Assert.That(unionIssue.GetProperty("parameters").GetProperty("count").GetInt32()).IsEqualTo(3); + await Assert.That(unionIssue.GetProperty("parameters").TryGetProperty("unionErrors", out _)).IsFalse(); + await Assert + .That(unionIssue.GetProperty("unionErrors")[0][0].GetProperty("code").GetString()) + .IsEqualTo("invalid_type"); + + var rangeIssue = issues[1]; + await Assert.That(rangeIssue.GetProperty("path").GetString()).IsEqualTo("Items[2].Price"); + await Assert.That(rangeIssue.GetProperty("segments")[1].GetInt32()).IsEqualTo(2); + await Assert.That(rangeIssue.GetProperty("minimum").GetDouble()).IsEqualTo(0.5); + await Assert.That(rangeIssue.GetProperty("maximum").GetDecimal()).IsEqualTo(9.5m); + await Assert.That(rangeIssue.GetProperty("inclusive").GetBoolean()).IsFalse(); + + var dateIssue = issues[2]; + await Assert.That(dateIssue.GetProperty("maximum").GetString()).IsEqualTo("2026-01-02T03:04:05.0000000Z"); + await Assert.That(dateIssue.GetProperty("inclusive").GetBoolean()).IsTrue(); + } + + [Test] + public async Task Create_GivenHttpContext_AddsTheTraceId() + { + // Arrange + DefaultHttpContext httpContext = new() { TraceIdentifier = "trace-42" }; + + // Act + var details = CreateWriter().Create([ValidationError.Create("invalid", "Invalid.", ZodPath.Root)], httpContext); + + // Assert + await Assert + .That(details.Extensions["traceId"]) + .IsEqualTo(System.Diagnostics.Activity.Current?.Id ?? "trace-42"); + } + + [Test] + public async Task Create_GivenJsonPathNaming_UsesTheConfiguredNamingPolicy() + { + // Arrange + ServiceCollection services = new(); + services.Configure(static options => + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + ); + await using var provider = services.BuildServiceProvider(); + DefaultHttpContext httpContext = new() { RequestServices = provider }; + var writer = CreateWriter(static options => options.PathNaming = ZodPathNaming.Json); + var error = ValidationError.Create( + "invalid", + "Invalid.", + ZodPath.From(ZodPathSegment.Member("LineItems"), ZodPathSegment.At(0), ZodPathSegment.Member("UnitPrice")) + ); + + // Act + var details = writer.Create([error], httpContext); + + // Assert + await Assert.That(details.Errors.Keys).IsEquivalentTo(["line_items[0].unit_price"]); + await Assert.That(Issues(details)[0].Segments).IsEquivalentTo(new object[] { "line_items", 0, "unit_price" }); + } + + [Test] + public async Task Create_GivenAMessageFormatter_FormatsInTheCurrentUICulture() + { + // Arrange + CultureInfo? seen = null; + ZodProblemDetailsWriter writer = new( + new ZodProblemDetailsOptions { Registry = new ErrorTypeRegistry() }, + new RecordingFormatter(culture => seen = culture) + ); + var previous = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR"); + + try + { + // Act + var details = writer.Create([ValidationError.Create("invalid", "Invalid.", ZodPath.Member("Name"))]); + + // Assert + await Assert.That(details.Errors["Name"]).IsEquivalentTo(["localized:invalid"]); + await Assert.That(seen?.Name).IsEqualTo("fr-FR"); + await Assert.That(Issues(details)[0].Code).IsEqualTo("invalid"); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Test] + public async Task Create_GivenGeneratedRangeFailures_SerializesTheBoundParametersAsNumbers( + CancellationToken cancellationToken + ) + { + // Arrange + var result = await Fixtures.RangedRequestSchema.ValidateAsync( + new Fixtures.RangedRequest { Quantity = 0, Ratio = 3 }, + services: null, + cancellationToken + ); + var errors = result.Errors; + + // Act + var issues = Serialize(CreateWriter().Create(errors)).GetProperty("issues"); + var quantity = issues[0].GetProperty("parameters"); + var ratio = issues[1].GetProperty("parameters"); + + // Assert + await Assert.That(errors[0].Parameters!["minimum"]).IsTypeOf(); + await Assert.That(quantity.GetProperty("minimum").ValueKind).IsEqualTo(JsonValueKind.Number); + await Assert.That(quantity.GetProperty("maximum").GetInt32()).IsEqualTo(10); + await Assert.That(ratio.GetProperty("minimum").GetDouble()).IsEqualTo(0.5); + await Assert.That(ratio.GetProperty("maximum").GetDouble()).IsEqualTo(2.5); + await Assert + .That(errors[1].Message) + .IsEqualTo("Field 'Ratio' must be greater than or equal to 0.5 and less than or equal to 2.5."); + } + + [Test] + public async Task Apply_GivenEverySequenceShape_RewritesTheSameWayWithoutAmbiguity() + { + // Arrange + var error = ValidationError.Create("invalid_email", "Email is invalid.", ZodPath.Member("Email")); + ImmutableArray immutable = [error]; + List list = [error]; + ValidationError[] array = [error]; + var sequence = list.Where(static _ => true); + var writer = CreateWriter(); + HttpValidationProblemDetails[] targets = [new(), new(), new(), new(), new(), new()]; + + // Act — each call compiles against the overloads as written, so an ambiguity is a build failure. + writer.Apply(targets[0], immutable); + writer.Apply(targets[1], list); + writer.Apply(targets[2], array); + writer.Apply(targets[3], sequence); + writer.Apply(targets[4], [error]); + writer.Apply(targets[5], default(ImmutableArray)); + + // Assert + foreach (var target in targets[..5]) + await Assert.That(target.Errors["Email"]).IsEquivalentTo(["Email is invalid."]); + + await Assert.That(targets[5].Errors).IsEmpty(); + } + + [Test] + public async Task Apply_GivenExistingDataAnnotationsErrors_MergesThemAndProjectsIssues() + { + // Arrange + HttpValidationProblemDetails existing = new( + new Dictionary + { + ["Email"] = ["stale"], + ["page"] = ["The field page must be between 1 and 10."], + } + ); + + // Act + CreateWriter() + .Apply(existing, [ValidationError.Create("invalid_email", "Email is invalid.", ZodPath.Member("Email"))]); + var issues = Issues(existing); + + // Assert + await Assert.That(existing.Errors["Email"]).IsEquivalentTo(["Email is invalid."]); + await Assert.That(existing.Errors["page"]).IsEquivalentTo(["The field page must be between 1 and 10."]); + await Assert.That(issues.Length).IsEqualTo(2); + await Assert.That(issues[1].Code).IsEqualTo(ZodProblemDetailsWriter.DataAnnotationsCode); + await Assert.That(issues[1].DottedPath).IsEqualTo("page"); + } + + sealed class RecordingFormatter(Action onFormat) : IZodMessageFormatter + { + public string Format(in ValidationError validationError, CultureInfo culture) + { + onFormat(culture); + return "localized:" + validationError.Code; + } + } +} diff --git a/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs b/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs index cf16ce02..07925991 100644 --- a/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodValidationFilterTests.cs @@ -13,9 +13,8 @@ public async Task InvokeAsync_GivenValidRequest_InvokesNext() { var context = CreateContext(CreateProvider(), new ValidatedRequest { Name = "abc" }); - var result = await new ZodValidationFilter().InvokeAsync( - context, - static _ => ValueTask.FromResult(NextResult) + var result = await new ZodValidationFilter().InvokeAsync(context, static _ => + ValueTask.FromResult(NextResult) ); await Assert.That(result).IsEqualTo(NextResult); @@ -26,9 +25,8 @@ public async Task InvokeAsync_GivenInvalidRequest_ReturnsValidationProblem() { var context = CreateContext(CreateProvider(), new ValidatedRequest { Name = "a" }); - var result = await new ZodValidationFilter().InvokeAsync( - context, - static _ => ValueTask.FromResult(NextResult) + var result = await new ZodValidationFilter().InvokeAsync(context, static _ => + ValueTask.FromResult(NextResult) ); await Assert.That(result is IResult).IsTrue(); @@ -43,9 +41,8 @@ public async Task InvokeAsync_GivenNoFactoryRegistered_Throws() InvalidOperationException? exception = null; try { - await new ZodValidationFilter().InvokeAsync( - context, - static _ => ValueTask.FromResult(NextResult) + await new ZodValidationFilter().InvokeAsync(context, static _ => + ValueTask.FromResult(NextResult) ); } catch (InvalidOperationException ex) @@ -66,9 +63,8 @@ public async Task InvokeAsync_GivenNoValidatorForType_Throws() InvalidOperationException? exception = null; try { - await new ZodValidationFilter().InvokeAsync( - context, - static _ => ValueTask.FromResult(NextResult) + await new ZodValidationFilter().InvokeAsync(context, static _ => + ValueTask.FromResult(NextResult) ); } catch (InvalidOperationException ex) diff --git a/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs new file mode 100644 index 00000000..60eab152 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs @@ -0,0 +1,537 @@ +#if NET11_0_OR_GREATER +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using ZodSharp.AspNetCore.Fixtures; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; + +namespace ZodSharp.AspNetCore; + +/// +/// Mode A: validation through ASP.NET Core's built-in Microsoft.Extensions.Validation pipeline +/// (AddZodSharpValidation()), enriched to the full ZodSharp contract by AddZodSharpProblemDetails(). +/// +public class ZodValidationPipelineTests +{ + static readonly OrderRequest ValidOrder = new() { Email = "a@b.c", Items = [new() { Quantity = 1 }] }; + static readonly OrderRequest InvalidOrder = new() { Email = "nope", Items = [new() { Quantity = 0 }] }; + + public enum CustomizeRegistration + { + ConfiguredBefore, + ConfiguredAfter, + ProblemDetailsArgument, + } + + static void AddServices( + IServiceCollection services, + IZodSchemaValidator? orderValidator = null, + Action? configure = null + ) + { + services.AddZodSharp(options => + options.ConfigureFactory = factory => factory.Register(orderValidator ?? new OrderRequestValidator()) + ); + services.AddZodSharpValidation(); + services.AddZodSharpProblemDetails(options => + { + options.Registry = new ErrorTypeRegistry(); + configure?.Invoke(options); + }); + } + + static async Task<(HttpStatusCode StatusCode, string? MediaType, JsonElement Root)> PostAsync( + TestApp app, + string path, + object body, + CancellationToken cancellationToken + ) + { + using var response = await app.Client.PostAsJsonAsync(path, body, cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return ( + response.StatusCode, + response.Content.Headers.ContentType?.MediaType, + json.Length == 0 ? default : JsonElement.Parse(json) + ); + } + + static Uri Relative(string path) => new(path, UriKind.Relative); + + [Test] + public async Task Pipeline_GivenInvalidBodyWithoutAddProblemDetails_WritesZodSharpContract( + CancellationToken cancellationToken + ) + { + // Arrange - AddProblemDetails() is never called by the application: AddZodSharpProblemDetails registers it, + // which is what makes the CustomizeProblemDetails enrichment run. + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()), + cancellationToken + ); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/orders", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(mediaType).IsEqualTo("application/problem+json"); + await Assert.That(app.App.Services.GetService()).IsNotNull(); + await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(2); + await Assert.That(root.GetProperty("issues")[0].GetProperty("code").GetString()).IsEqualTo("invalid_email"); + await Assert.That(root.GetProperty("issues")[1].GetProperty("code").GetString()).IsEqualTo("too_small"); + await Assert.That(root.GetProperty("issues")[1].GetProperty("minimum").GetInt32()).IsEqualTo(1); + await Assert.That(root.TryGetProperty("traceId", out _)).IsTrue(); + } + + [Test] + public async Task Pipeline_GivenZodBodyAndDataAnnotationsQuery_MergesErrorsAndIssues( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services); + services.AddValidation(); + }, + app => app.MapPost("/orders", (OrderRequest order, [Range(1, 10)] int page) => TypedResults.Ok()), + cancellationToken + ); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/orders?page=0", InvalidOrder, cancellationToken); + var errors = root.GetProperty("errors"); + var issues = root.GetProperty("issues"); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(errors.GetProperty("Email")[0].GetString()).IsEqualTo("Email must be a valid email address"); + await Assert + .That(errors.GetProperty("Items[0].Quantity")[0].GetString()) + .IsEqualTo("Quantity must be at least 1"); + await Assert.That(errors.TryGetProperty("page", out var page)).IsTrue(); + await Assert.That(issues.GetArrayLength()).IsEqualTo(3); + await Assert.That(issues[0].GetProperty("code").GetString()).IsEqualTo("invalid_email"); + await Assert.That(issues[1].GetProperty("code").GetString()).IsEqualTo("too_small"); + await Assert + .That(issues[2].GetProperty("code").GetString()) + .IsEqualTo(ZodProblemDetailsWriter.DataAnnotationsCode); + await Assert.That(issues[2].GetProperty("category").GetString()).IsEqualTo(ZodIssueCategories.InvalidValue); + await Assert.That(issues[2].GetProperty("path").GetString()).IsEqualTo("page"); + await Assert.That(issues[2].GetProperty("message").GetString()).IsEqualTo(page[0].GetString()); + } + + [Test] + public async Task Pipeline_GivenDataAnnotationsOnlyFailure_ProjectsIssuesAndKeepsPayload( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services); + services.AddValidation(); + }, + app => app.MapGet("/items", ([Range(1, 10)] int page) => TypedResults.Ok()), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync(Relative("/items?page=0"), cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(root.GetProperty("title").GetString()).IsEqualTo(ZodProblemDetailsWriter.DefaultTitle); + await Assert.That(root.GetProperty("errors").TryGetProperty("page", out _)).IsTrue(); + await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(1); + await Assert + .That(root.GetProperty("issues")[0].GetProperty("code").GetString()) + .IsEqualTo(ZodProblemDetailsWriter.DataAnnotationsCode); + } + + [Test] + public async Task Pipeline_GivenProjectDataAnnotationsIssuesOff_LeavesDataAnnotationsPayloadUntouched( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services, configure: static options => options.ProjectDataAnnotationsIssues = false); + services.AddValidation(); + }, + app => app.MapGet("/items", ([Range(1, 10)] int page) => TypedResults.Ok()), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync(Relative("/items?page=0"), cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(document.RootElement.GetProperty("errors").TryGetProperty("page", out _)).IsTrue(); + await Assert.That(document.RootElement.TryGetProperty("issues", out _)).IsFalse(); + } + + [Test] + public async Task Pipeline_GivenRouteGroup_ValidatesEveryEndpointAutomatically(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => + { + var orders = app.MapGroup("/orders"); + orders.MapPost("/", (OrderRequest order) => TypedResults.Ok()); + orders.MapPut("/{id}", (int id, OrderRequest order) => TypedResults.Ok()); + }, + cancellationToken + ); + + // Act + var (postStatus, _, postRoot) = await PostAsync(app, "/orders/", InvalidOrder, cancellationToken); + using var put = await app.Client.PutAsJsonAsync("/orders/1", InvalidOrder, cancellationToken); + using var valid = await app.Client.PostAsJsonAsync("/orders/", ValidOrder, cancellationToken); + + // Assert + await Assert.That(postStatus).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(postRoot.GetProperty("issues").GetArrayLength()).IsEqualTo(2); + await Assert.That(put.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(valid.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task Pipeline_GivenDisableValidation_DoesNotValidate(CancellationToken cancellationToken) + { + // Arrange + OrderRequestValidator validator = new(); + await using var app = await TestApp.StartAsync( + services => AddServices(services, validator), + app => + { + app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()).DisableValidation(); + app.MapGroup("/imports").DisableValidation().MapPost("/", (OrderRequest order) => TypedResults.Ok()); + }, + cancellationToken + ); + + // Act + using var endpoint = await app.Client.PostAsJsonAsync("/orders", InvalidOrder, cancellationToken); + using var group = await app.Client.PostAsJsonAsync("/imports/", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(endpoint.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(group.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(validator.Calls).IsEqualTo(0); + } + + [Test] + public async Task Pipeline_GivenFromServicesParameter_DoesNotValidateIt(CancellationToken cancellationToken) + { + // Arrange + OrderRequestValidator validator = new(); + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services, validator); + services.AddSingleton(InvalidOrder); + }, + app => app.MapGet("/orders", ([FromServices] OrderRequest order) => TypedResults.Ok()), + cancellationToken + ); + + // Act + using var response = await app.Client.GetAsync(Relative("/orders"), cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(validator.Calls).IsEqualTo(0); + } + + [Test] + public async Task Pipeline_GivenErrorTypeWith422_ChangesStatus(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + AddServices(services, configure: static options => + options.MapErrorType( + new ErrorType("invalid_email", HttpStatus: StatusCodes.Status422UnprocessableEntity) + ) + ), + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()), + cancellationToken + ); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/orders", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.UnprocessableEntity); + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert + .That(root.GetProperty("type").GetString()) + .IsEqualTo("https://tools.ietf.org/html/rfc9110#section-15.5.21"); + } + + [Test] + public async Task Pipeline_GivenStatusCodeSelectorAndOverride_OverrideWinsLast(CancellationToken cancellationToken) + { + // Arrange + await using var app = await TestApp.StartAsync(services => AddServices(services, configure: static options => + { + options.MapErrorType( + new ErrorType("invalid_email", HttpStatus: StatusCodes.Status422UnprocessableEntity) + ); + options.StatusCodeSelector = static _ => StatusCodes.Status409Conflict; + options.StatusCodeOverride = static (_, computed) => + computed == StatusCodes.Status409Conflict ? StatusCodes.Status418ImATeapot : computed; + }), app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()), cancellationToken); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/orders", InvalidOrder, cancellationToken); + + // Assert - the 422 ErrorType is computed, the selector replaces it, and the override sees and replaces that. + await Assert.That((int)status).IsEqualTo(StatusCodes.Status418ImATeapot); + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(StatusCodes.Status418ImATeapot); + } + + [Test] + [Arguments(CustomizeRegistration.ConfiguredBefore)] + [Arguments(CustomizeRegistration.ConfiguredAfter)] + [Arguments(CustomizeRegistration.ProblemDetailsArgument)] + public async Task Pipeline_GivenUserCustomizeProblemDetails_RunsAfterZodSharpAndCanModifyResult( + CustomizeRegistration registration, + CancellationToken cancellationToken + ) + { + // Arrange + static void UserCallback(ProblemDetailsContext context) + { + // Runs second: the ZodSharp issues are already there, and the user's change is the one that is written. + context.ProblemDetails.Extensions["sawIssues"] = context.ProblemDetails.Extensions.ContainsKey("issues"); + context.ProblemDetails.Title = "Customized by the application"; + context.ProblemDetails.Status = StatusCodes.Status422UnprocessableEntity; + context.HttpContext.Response.StatusCode = StatusCodes.Status422UnprocessableEntity; + } + + await using var app = await TestApp.StartAsync( + services => + { + if (registration == CustomizeRegistration.ConfiguredBefore) + services.AddProblemDetails(static options => options.CustomizeProblemDetails = UserCallback); + + services.AddZodSharp(static options => + options.ConfigureFactory = static factory => factory.Register(new OrderRequestValidator()) + ); + services.AddZodSharpValidation(); + services.AddZodSharpProblemDetails( + static options => options.Registry = new ErrorTypeRegistry(), + registration == CustomizeRegistration.ProblemDetailsArgument + ? static options => options.CustomizeProblemDetails = UserCallback + : null + ); + + if (registration == CustomizeRegistration.ConfiguredAfter) + services.Configure(static options => + options.CustomizeProblemDetails = UserCallback + ); + }, + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()), + cancellationToken + ); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/orders", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.UnprocessableEntity); + await Assert.That(root.GetProperty("sawIssues").GetBoolean()).IsTrue(); + await Assert.That(root.GetProperty("title").GetString()).IsEqualTo("Customized by the application"); + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(2); + } + + [Test] + public async Task Pipeline_GivenFilterOnValidatedEndpoint_ValidatesOnceAndLogsAtDebug( + CancellationToken cancellationToken + ) + { + // Arrange + OrderRequestValidator validator = new(); + using CapturingLoggerProvider logs = new(); + await using var app = await TestApp.StartAsync( + builder => + { + builder.Logging.AddProvider(logs); + builder.Logging.SetMinimumLevel(LogLevel.Debug); + AddServices(builder.Services, validator); + }, + app => app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(), + cancellationToken + ); + + // Act + using var invalid = await app.Client.PostAsJsonAsync("/orders", InvalidOrder, cancellationToken); + var callsAfterInvalid = validator.Calls; + using var valid = await app.Client.PostAsJsonAsync("/orders", ValidOrder, cancellationToken); + var guardLogs = logs + .Messages.Where(static message => + message.Message.Contains("WithZodSharpValidation", StringComparison.Ordinal) + ) + .ToArray(); + + // Assert + await Assert.That(invalid.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(callsAfterInvalid).IsEqualTo(1); + await Assert.That(valid.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(validator.Calls).IsEqualTo(2); + await Assert.That(guardLogs.Length).IsEqualTo(1); + await Assert.That(guardLogs[0].Level).IsEqualTo(LogLevel.Debug); + await Assert.That(guardLogs[0].Message).Contains("'order'"); + } + + [Test] + public async Task Pipeline_GivenFilterOnEndpointWithValidationDisabled_FilterStillValidates( + CancellationToken cancellationToken + ) + { + // Arrange + OrderRequestValidator validator = new(); + await using var app = await TestApp.StartAsync( + services => AddServices(services, validator), + app => + app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()) + .WithZodSharpValidation() + .DisableValidation(), + cancellationToken + ); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/orders", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(root.GetProperty("issues").GetArrayLength()).IsEqualTo(2); + await Assert.That(validator.Calls).IsEqualTo(1); + } + + [Test] + public async Task Pipeline_GivenUnionBody_ValidatesActiveCaseWithZodSharp(CancellationToken cancellationToken) + { + // Arrange - the bridge claims parameters of ZodSharp-validated types only; a union parameter is claimed by + // the runtime parameter resolver the application's own AddValidation() call adds, which then asks the + // bridge for the union's type info. + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services); + services.AddValidation(); + }, + app => app.MapPost("/shipments", (OrderOrReference body) => TypedResults.Ok()), + cancellationToken + ); + + // Act + var (status, mediaType, root) = await PostAsync(app, "/shipments", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(root.GetProperty("issues")[0].GetProperty("code").GetString()).IsEqualTo("invalid_email"); + // The active case is reported under the union's Value member, Microsoft.Extensions.Validation's convention. + await Assert.That(root.GetProperty("issues")[0].GetProperty("path").GetString()).IsEqualTo("Value.Email"); + await Assert.That(root.GetProperty("errors").TryGetProperty("Value.Email", out _)).IsTrue(); + await Assert.That(root.GetProperty("errors").TryGetProperty("Value.Items[0].Quantity", out _)).IsTrue(); + } + + [Test] + public async Task Pipeline_GivenUnionBodyWithoutApplicationAddValidation_StillValidatesActiveCase( + CancellationToken cancellationToken + ) + { + // Arrange - only the ZodSharp registrations: the bridge must claim the union parameter itself. + await using var app = await TestApp.StartAsync( + services => AddServices(services), + app => app.MapPost("/shipments", (OrderOrReference body) => TypedResults.Ok()), + cancellationToken + ); + + // Act + var (status, _, root) = await PostAsync(app, "/shipments", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(root.GetProperty("errors").TryGetProperty("Value.Email", out _)).IsTrue(); + } + + [Test] + [Arguments("fr", "L'adresse e-mail doit être valide", "La quantité doit être au moins 1")] + [Arguments("en", "Email must be a valid email address", "Quantity must be at least 1")] + public async Task Pipeline_GivenAcceptLanguage_LocalizesMessagesButNotCodesOrPaths( + string culture, + string expectedEmail, + string expectedQuantity, + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await TestApp.StartAsync( + services => + { + AddServices(services); + services.AddLocalization(); + services.AddSingleton(static sp => new StringLocalizerZodMessageFormatter( + sp.GetRequiredService(), + sharedResourceType: typeof(ValidationMessages) + )); + }, + app => + { + app.UseRequestLocalization(static options => + options.SetDefaultCulture("en").AddSupportedCultures("en", "fr").AddSupportedUICultures("en", "fr") + ); + app.MapPost("/orders", (OrderRequest order) => TypedResults.Ok()); + }, + cancellationToken + ); + using HttpRequestMessage request = new(HttpMethod.Post, "/orders") + { + Content = JsonContent.Create(InvalidOrder), + }; + request.Headers.AcceptLanguage.ParseAdd(culture); + + // Act + using var response = await app.Client.SendAsync(request, cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + var issues = root.GetProperty("issues"); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(root.GetProperty("errors").GetProperty("Email")[0].GetString()).IsEqualTo(expectedEmail); + await Assert + .That(root.GetProperty("errors").GetProperty("Items[0].Quantity")[0].GetString()) + .IsEqualTo(expectedQuantity); + await Assert.That(issues[0].GetProperty("code").GetString()).IsEqualTo("invalid_email"); + await Assert.That(issues[0].GetProperty("path").GetString()).IsEqualTo("Email"); + await Assert.That(issues[0].GetProperty("message").GetString()).IsEqualTo(expectedEmail); + await Assert.That(issues[1].GetProperty("code").GetString()).IsEqualTo("too_small"); + await Assert.That(issues[1].GetProperty("path").GetString()).IsEqualTo("Items[0].Quantity"); + } +} +#endif diff --git a/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj b/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj new file mode 100644 index 00000000..7c2f08ae --- /dev/null +++ b/src/tests/Blazor.UnitTests/Blazor.UnitTests.csproj @@ -0,0 +1,60 @@ + + + + net11.0 + + ZodSharp.Blazor + Purview.ZodSharp.Blazor.UnitTests + Purview.ZodSharp.Blazor.UnitTests + false + + 11.0.0-rc.3.26522.148 + 11.0.0-rc.3.26522.148 + + $(NoWarn);CA1506 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs b/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs new file mode 100644 index 00000000..1134fa35 --- /dev/null +++ b/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs @@ -0,0 +1,338 @@ +using System.Linq.Expressions; +using AngleSharp.Dom; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Web; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Core; +using static ZodSharp.Blazor.Fixtures.Errors; + +namespace ZodSharp.Blazor; + +/// +/// Markup parity of and with the built-in +/// components, plus their ZodSharp additions. +/// +public class ComponentMarkupTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ZodValidationMessage_GivenPlainMessages_RendersTheSameMarkupAsValidationMessage(bool interactive) + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + ValidationMessageStore store = new(editContext); + store.Add(editContext.Field(nameof(Order.Reference)), "Reference is required"); + store.Add(editContext.Field(nameof(Order.Reference)), "Reference is too short"); + + // Act + var builtIn = RenderMessage, string?>( + context, + editContext, + () => order.Reference, + interactive + ); + var zod = RenderMessage, string?>( + context, + editContext, + () => order.Reference, + interactive + ); + + // Assert + zod.MarkupMatches(builtIn.Markup); + await Assert.That(zod.FindAll("div.validation-message").Count).IsEqualTo(2); + } + + [Test] + public async Task ZodValidationMessage_GivenNoMessagesInStaticRender_RendersTheHiddenPlaceholder() + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + + // Act + var builtIn = RenderMessage, string?>( + context, + editContext, + () => order.Reference, + false + ); + var zod = RenderMessage, string?>( + context, + editContext, + () => order.Reference, + false + ); + + // Assert + zod.MarkupMatches(builtIn.Markup); + await Assert.That(zod.Find("div").HasAttribute("hidden")).IsTrue(); + } + + [Test] + public async Task ZodValidationMessage_GivenRichError_AddsCodeAndCategoryAttributes() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + + // Act + var zod = RenderMessage, string?>( + context, + editContext, + () => order.Reference, + interactive: true, + showCode: true + ); + + // Assert + var message = zod.Find("div.validation-message"); + await Assert.That(message.GetAttribute("data-code")).IsEqualTo("too_small"); + await Assert.That(message.GetAttribute("data-category")).IsEqualTo("too_small"); + await Assert.That(message.QuerySelector("span.validation-code")?.TextContent).IsEqualTo("too_small"); + await Assert.That(message.TextContent).StartsWith("Reference too short"); + } + + [Test] + public async Task ZodValidationMessage_GivenTemplate_RendersItForRichErrors() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + + RenderFragment Template(ValidationError error) => + builder => + { + builder.OpenElement(0, "strong"); + builder.AddContent(1, $"{error.Code}: {error.Message}"); + builder.CloseElement(); + }; + + // Act + var zod = context.Render>(parameters => + parameters + .Add(static p => p.For, () => order.Reference) + .Add(static p => p.Template, Template) + .AddCascadingValue(editContext) + ); + + // Assert + await Assert + .That(zod.Find("div.validation-message strong").TextContent) + .IsEqualTo("too_small: Reference too short"); + } + + [Test] + public async Task ZodValidationMessage_GivenCollectionElementInStaticRender_UsesTheFormFieldName() + { + // Arrange + Order order = new() { Lines = [new(), new()] }; + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + var index = 1; + + // Act + var builtIn = RenderMessage, int>( + context, + editContext, + () => order.Lines[index].Quantity, + false + ); + var zod = RenderMessage, int>( + context, + editContext, + () => order.Lines[index].Quantity, + false + ); + + // Assert + await Assert + .That(zod.Find("div").GetAttribute("data-valmsg-for")) + .IsEqualTo(builtIn.Find("div").GetAttribute("data-valmsg-for")); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ZodValidationSummary_GivenPlainMessages_RendersTheSameMarkupAsValidationSummary(bool interactive) + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + ValidationMessageStore store = new(editContext); + store.Add(editContext.Field(nameof(Order.Reference)), "Reference is required"); + store.Add(new FieldIdentifier(order, string.Empty), "Order is invalid"); + + // Act + var builtIn = RenderSummary(context, editContext, interactive); + var zod = RenderSummary(context, editContext, interactive); + + // Assert + zod.MarkupMatches(builtIn.Markup); + await Assert.That(zod.FindAll("li.validation-message").Count).IsEqualTo(2); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ZodValidationSummary_GivenNoMessages_RendersTheSameMarkupAsValidationSummary(bool interactive) + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + + // Act + var builtIn = RenderSummary(context, editContext, interactive); + var zod = RenderSummary(context, editContext, interactive); + + // Assert + zod.MarkupMatches(builtIn.Markup); + await Assert.That(zod.FindAll("li").Count).IsEqualTo(0); + } + + [Test] + public async Task ZodValidationSummary_GivenModel_ShowsOnlyModelLevelMessagesLikeValidationSummary() + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + ValidationMessageStore store = new(editContext); + store.Add(editContext.Field(nameof(Order.Reference)), "Reference is required"); + store.Add(new FieldIdentifier(order, string.Empty), "Order is invalid"); + + // Act + var zod = context.Render(parameters => + parameters.Add(static p => p.ShowModelOnly, true).AddCascadingValue(editContext) + ); + + // Assert + await Assert + .That(zod.FindAll("li").Select(static li => li.TextContent).ToArray()) + .IsEquivalentTo(["Order is invalid"]); + } + + [Test] + public async Task ZodValidationSummary_GivenGroupByPath_GroupsRichErrorsUnderTheirPath() + { + // Arrange + Order order = new() { Lines = [new()] }; + FakeValidator validator = new() + { + Errors = static _ => + [ + At("too_small", "Quantity too small", M("Lines"), I(0), M("Quantity")), + At("not_multiple_of", "Quantity must be even", M("Lines"), I(0), M("Quantity")), + At("too_small", "Reference too short", M("Reference")), + ], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + + // Act + var zod = context.Render(parameters => + parameters.Add(static p => p.GroupByPath, true).AddCascadingValue(editContext) + ); + + // Assert + var groups = zod.FindAll("li.validation-group"); + await Assert + .That(groups.Select(static g => g.GetAttribute("data-path")!).ToArray()) + .IsEquivalentTo(["Lines[0].Quantity", "Reference"]); + var lines = zod.Find("li.validation-group[data-path='Lines[0].Quantity']") + .QuerySelectorAll("li.validation-message"); + await Assert + .That(lines.Select(static l => l.GetAttribute("data-code")!).ToArray()) + .IsEquivalentTo(["too_small", "not_multiple_of"]); + } + + [Test] + public async Task ZodValidationSummary_GivenShowUnionDetails_ListsEachOptionsIssues() + { + // Arrange + Order order = new(); + var union = ZodIssues.InvalidUnion([ + [At("invalid_type", "Expected string", M("Reference"))], + [At("too_small", "Too short", M("Reference"))], + ]); + FakeValidator validator = new() { Errors = _ => [union] }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + + // Act + var zod = context.Render(parameters => + parameters.Add(static p => p.ShowUnionDetails, true).AddCascadingValue(editContext) + ); + + // Assert + var options = zod.FindAll("ol.validation-union > li.validation-union-option"); + await Assert.That(options.Count).IsEqualTo(2); + await Assert.That(options[1].QuerySelector("li")?.GetAttribute("data-code")).IsEqualTo("too_small"); + } + + static IRenderedComponent RenderMessage( + BunitContext context, + EditContext editContext, + Expression> accessor, + bool interactive, + bool showCode = false + ) + where TComponent : IComponent + { + void Build(ComponentParameterCollectionBuilder parameters) + { + parameters.TryAdd("For", accessor); + parameters.AddCascadingValue(editContext); + if (showCode) + parameters.TryAdd("ShowCode", true); + + if (interactive) + parameters.SetAssignedRenderMode(RenderMode.InteractiveServer); + } + + return context.Render(Build); + } + + static IRenderedComponent RenderSummary( + BunitContext context, + EditContext editContext, + bool interactive + ) + where TComponent : IComponent + { + void Build(ComponentParameterCollectionBuilder parameters) + { + parameters.AddCascadingValue(editContext); + if (interactive) + parameters.SetAssignedRenderMode(RenderMode.InteractiveServer); + } + + return context.Render(Build); + } +} diff --git a/src/tests/Blazor.UnitTests/DataAnnotationsValidatorRouteTests.cs b/src/tests/Blazor.UnitTests/DataAnnotationsValidatorRouteTests.cs new file mode 100644 index 00000000..aab3006d --- /dev/null +++ b/src/tests/Blazor.UnitTests/DataAnnotationsValidatorRouteTests.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Extensions.Validation; +using static ZodSharp.Blazor.Fixtures.Errors; + +namespace ZodSharp.Blazor; + +/// +/// Route 1: the standard <DataAnnotationsValidator /> with AddZodSharpBlazor() and nothing else. +/// +public class DataAnnotationsValidatorRouteTests +{ + [Test] + public async Task ValidateAsync_GivenNestedCollectionAndDictionaryErrors_PlacesEachOnItsOwningField() + { + // Arrange + Order order = new() + { + Lines = [new(), new()], + ByCode = new() { ["a"] = new(), ["b"] = new() }, + }; + FakeValidator validator = new() + { + Errors = static _ => + [ + At("invalid_string", "Email is invalid", M("Customer"), M("Email")), + At("too_small", "Quantity too small", M("Lines"), I(1), M("Quantity")), + At("too_small", "Sku required", M("ByCode"), K("b"), M("Sku")), + At("custom", "Order is invalid"), + ], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsFalse(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Customer, "Email"))) + .IsEquivalentTo(["Email is invalid"]); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Lines[1], "Quantity"))) + .IsEquivalentTo(["Quantity too small"]); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.ByCode["b"], "Sku"))) + .IsEquivalentTo(["Sku required"]); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, string.Empty))) + .IsEquivalentTo(["Order is invalid"]); + await Assert.That(editContext.GetValidationMessages(new FieldIdentifier(order.Lines[0], "Quantity"))).IsEmpty(); + } + + [Test] + public async Task FieldChange_GivenMemberValidator_RunsOnlyThatMembersRules() + { + // Arrange + Order order = new(); + FakeValidator customer = new() + { + Errors = static _ => [At("custom", "nested leak", M("ConfirmEmail"))], + MemberErrors = static (_, member) => + member == nameof(Customer.Email) ? [At("invalid_string", "Email is invalid", M("Email"))] : [], + }; + FakeValidator root = new() { Errors = static _ => [At("custom", "root leak", M("Reference"))] }; + await using var context = FormHost.CreateContext(factory => + { + factory.Register(root); + factory.Register(customer); + }); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + FieldIdentifier email = new(order.Customer, nameof(Customer.Email)); + + // Act + await form.ChangeAsync(editContext, email); + + // Assert + await Assert.That(editContext.GetValidationMessages(email)).IsEquivalentTo(["Email is invalid"]); + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Email is invalid"]); + await Assert.That(customer.MemberCalls).IsEquivalentTo([nameof(Customer.Email)]); + await Assert.That(customer.Calls).IsEqualTo(0); + await Assert.That(root.Calls).IsEqualTo(0); + } + + [Test] + public async Task FieldChange_GivenAsyncMemberRule_IsPendingUntilItCompletes() + { + // Arrange + Order order = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + FakeValidator validator = new() + { + MemberErrors = static (_, _) => [At("custom", "Reference taken", M("Reference"))], + MemberGate = (_, _) => release.Task, + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + var pending = editContext.IsValidationPending(reference); + release.SetResult(); + + // Assert + await Assert.That(pending).IsTrue(); + await FormHost.WaitUntilAsync(() => !editContext.IsValidationPending(reference), "still pending"); + await Assert.That(editContext.GetValidationMessages(reference)).IsEquivalentTo(["Reference taken"]); + await Assert.That(editContext.IsValidationFaulted(reference)).IsFalse(); + } + + [Test] + public async Task FieldChange_GivenThrowingMemberRule_MarksFieldFaulted() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + MemberErrors = static (_, _) => [], + MemberGate = static (_, _) => Task.FromException(new InvalidOperationException("boom")), + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + + // Assert + await FormHost.WaitUntilAsync(() => editContext.IsValidationFaulted(reference), "not faulted"); + await Assert.That(editContext.IsValidationPending(reference)).IsFalse(); + } + + [Test] + public async Task ValidateAsync_GivenStringLocalizer_LocalizesMessagesByCode() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + services => + services.AddSingleton( + new FakeStringLocalizerFactory(new() { ["too_small"] = "Trop court" }) + ) + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + await form.ValidateAsync(editContext); + + // Assert + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, nameof(Order.Reference)))) + .IsEquivalentTo(["Trop court"]); + } + + [Test] + public async Task AddZodSharpBlazor_GivenServices_RegistersNoValidationReportSink() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddZodSharpBlazor(); + + // Assert + await Assert.That(services.Any(static d => d.ServiceType == typeof(IZodValidationReportSink))).IsFalse(); + await Assert.That(services.Any(static d => d.ServiceType == typeof(ZodValidatableInfoResolver))).IsTrue(); + await Assert.That(services.Any(static d => d.ServiceType == typeof(TimeProvider))).IsTrue(); + } +} diff --git a/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs b/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs new file mode 100644 index 00000000..b679cf9b --- /dev/null +++ b/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Blazor.Fixtures; + +namespace ZodSharp.Blazor; + +/// +/// AddZodSharpBlazor() registers the Microsoft.Extensions.Validation bridge for form models only. The platform's +/// endpoint filter validates every parameter a registered resolver claims, so the bridge must not claim parameters on +/// its own account: a Blazor Server host that also maps minimal APIs keeps its endpoints as they were unless it asks +/// for endpoint validation (AddZodSharpValidation() or ValidateParameters). +/// +public class EndpointValidationRegistrationTests +{ + const string InvalidCustomer = /*lang=json,strict*/ + """{ "email": "not-an-email" }"""; + + [Test] + public async Task AddZodSharpBlazor_GivenAnEndpointBindingAZodSchemaType_LeavesTheEndpointUnvalidated( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync(static services => services.AddZodSharpBlazor(), cancellationToken); + + // Act + using var response = await PostAsync(app, cancellationToken); + + // Assert - the handler ran. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task AddZodSharpBlazor_GivenAddZodSharpValidationToo_ValidatesTheEndpointInEitherOrder( + bool blazorFirst, + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync( + services => + { + if (blazorFirst) + { + services.AddZodSharpBlazor(); + services.AddZodSharpValidation(); + } + else + { + services.AddZodSharpValidation(); + services.AddZodSharpBlazor(); + } + }, + cancellationToken + ); + + // Act + using var response = await PostAsync(app, cancellationToken); + + // Assert - answered by the validation filter before the handler. + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task AddZodSharpBlazor_GivenValidateParametersTrue_ValidatesTheEndpoint( + CancellationToken cancellationToken + ) + { + // Arrange + await using var app = await StartAsync( + static services => services.AddZodSharpBlazor(static options => options.ValidateParameters = true), + cancellationToken + ); + + // Act + using var response = await PostAsync(app, cancellationToken); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + static async Task StartAsync( + Action register, + CancellationToken cancellationToken + ) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddZodSharpFactory(static factory => + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + ); + register(builder.Services); + + var app = builder.Build(); + app.MapPost("/customers", static (GeneratedCustomer customer) => Results.Ok(customer.Email)); + await app.StartAsync(cancellationToken); + return app; + } + + static async Task PostAsync(WebApplication app, CancellationToken cancellationToken) + { + using StringContent content = new(InvalidCustomer, Encoding.UTF8, "application/json"); + return await app.GetTestClient().PostAsync(new Uri("/customers", UriKind.Relative), content, cancellationToken); + } +} diff --git a/src/tests/Blazor.UnitTests/Fixtures/FakeValidator.cs b/src/tests/Blazor.UnitTests/Fixtures/FakeValidator.cs new file mode 100644 index 00000000..ea66dc74 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Fixtures/FakeValidator.cs @@ -0,0 +1,105 @@ +using ZodSharp.Core; + +namespace ZodSharp.Blazor.Fixtures; + +/// +/// A scriptable validator. Member validation is supported only when is set, like a +/// generated validator; member resolution is never offered, so field placement goes through the reflection +/// fallback (as for a runtime schema). +/// +sealed class FakeValidator : IZodSchemaValidator, IZodMemberValidator +{ + public Func> Errors { get; init; } = static _ => []; + + /// + /// The errors of one member; when member validation is not supported. + /// + public Func>? MemberErrors { get; init; } + + /// + /// Awaited before every member validation completes, so tests can hold a validation pending. + /// + public Func? MemberGate { get; init; } + + /// + /// Awaited before every full validation completes. + /// + public Func? Gate { get; init; } + + public bool RequiresAsync { get; init; } + + public int Calls { get; private set; } + + public List MemberCalls { get; } = []; + + public List MemberTokens { get; } = []; + + public IServiceProvider? ReceivedServices { get; private set; } + + public bool RequiresAsyncValidation => RequiresAsync; + + public ValidationResult Validate(T value) + { + Calls++; + return ToResult(value, Errors(value)); + } + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + ValidateAsync(value, null, cancellationToken); + + public async ValueTask> ValidateAsync( + T value, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) + { + Calls++; + ReceivedServices = services; + if (Gate is not null) + await Gate(cancellationToken); + + return ToResult(value, Errors(value)); + } + + public bool IsValidatableMember(string memberName) => MemberErrors is not null; + + public ValidationResult ValidateMember(T value, string memberName) + { + MemberCalls.Add(memberName); + return ToResult(value, MemberErrors?.Invoke(value, memberName) ?? []); + } + + async ValueTask> IZodMemberValidator.ValidateMemberAsync( + T value, + string memberName, + IServiceProvider? services, + CancellationToken cancellationToken + ) + { + MemberCalls.Add(memberName); + MemberTokens.Add(cancellationToken); + ReceivedServices = services; + if (MemberGate is not null) + await MemberGate(memberName, cancellationToken); + + return ToResult(value, MemberErrors?.Invoke(value, memberName) ?? []); + } + + static ValidationResult ToResult(T value, IEnumerable errors) + { + ValidationError[] produced = [.. errors]; + return produced.Length == 0 ? ValidationResult.Success(value) : ValidationResult.Failure(produced); + } +} + +static class Errors +{ + public static ValidationError At(string code, string message, params ZodPathSegment[] path) => + ValidationError.Create(code, message, ZodPath.From(path), category: code); + + public static ZodPathSegment M(string name) => ZodPathSegment.Member(name); + + public static ZodPathSegment I(int index) => ZodPathSegment.At(index); + + public static ZodPathSegment K(string key) => ZodPathSegment.Key(key); +} diff --git a/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs b/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs new file mode 100644 index 00000000..39f52616 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Fixtures/GeneratedModels.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp.Rules; + +namespace ZodSharp.Blazor.Fixtures; + +/// +/// A [ZodSchema] model with nested objects and a collection, and no [ValidatableType]: the shape a Blazor +/// WebAssembly client validates with no Microsoft.Extensions.Validation generator. +/// +[ZodSchema] +public class GeneratedPerson +{ + [MinLength(3)] + public string Name { get; set; } = string.Empty; + + public GeneratedAddress Address { get; set; } = new(); + + public List PreviousAddresses { get; set; } = []; +} + +[ZodSchema] +public class GeneratedAddress +{ + [Required] + public string? Street { get; set; } +} + +/// +/// A [ZodSchema] order with a nested object, a collection and a dictionary of nested models. +/// +[ZodSchema] +public class GeneratedOrder +{ + [Required] + [StartsWith("ORD-")] + public string? Reference { get; set; } + + public GeneratedCustomer Customer { get; set; } = new(); + + public List Lines { get; set; } = []; + + // Nullable: static-SSR form binding leaves a dictionary that has no posted entries null. + public Dictionary? ByCode { get; set; } = []; +} + +[ZodSchema] +public class GeneratedCustomer +{ + [Required] + [EmailAddress] + public string? Email { get; set; } +} + +[ZodSchema] +public class GeneratedLine +{ + [Required] + public string? Sku { get; set; } + + [Range(1, 100)] + public int Quantity { get; set; } = 1; +} diff --git a/src/tests/Blazor.UnitTests/Fixtures/Models.cs b/src/tests/Blazor.UnitTests/Fixtures/Models.cs new file mode 100644 index 00000000..e9cfce2b --- /dev/null +++ b/src/tests/Blazor.UnitTests/Fixtures/Models.cs @@ -0,0 +1,26 @@ +namespace ZodSharp.Blazor.Fixtures; + +public sealed class Order +{ + public string? Reference { get; set; } + + public Customer Customer { get; set; } = new(); + + public List Lines { get; set; } = []; + + public Dictionary ByCode { get; set; } = []; +} + +public sealed class Customer +{ + public string? Email { get; set; } + + public string? ConfirmEmail { get; set; } +} + +public sealed class OrderLine +{ + public string? Sku { get; set; } + + public int Quantity { get; set; } +} diff --git a/src/tests/Blazor.UnitTests/Fixtures/StructMemberModels.cs b/src/tests/Blazor.UnitTests/Fixtures/StructMemberModels.cs new file mode 100644 index 00000000..2a2c22bf --- /dev/null +++ b/src/tests/Blazor.UnitTests/Fixtures/StructMemberModels.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; + +// A stand-in for the Purview.ValueObjects [Scalar] attribute, which the generator recognizes by name and namespace, so +// these fixtures are shaped exactly like value objects without referencing that package. +#pragma warning disable IDE0130 // The stand-in must live in the namespace the generator recognizes. +namespace Purview.ValueObjects.Serialization +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute(string propertyName = "Value") : Attribute + { + public string PropertyName { get; } = propertyName; + } +} + +#pragma warning restore IDE0130 + +namespace ZodSharp.Blazor.Fixtures +{ + /// A scalar value object: a struct whose own member (Value) carries the rules. + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + public readonly partial record struct ContactEmail + { + [Required] + [EmailAddress] + public string? Value { get; init; } + } + + /// A struct that is not a scalar: its members keep their own names in messages. + [ZodSchema] + public partial record struct Coordinates + { + [Range(-90, 90)] + public double Latitude { get; set; } + } + + /// A form model whose members are structs, so their errors are located inside a value type. + [ZodSchema] + public sealed class SignUp + { + [Required] + public string? Name { get; set; } = "Ada"; + + public ContactEmail Email { get; set; } = new() { Value = "ada@example.com" }; + + [Display(Name = "Backup email")] + public ContactEmail? BackupEmail { get; set; } + + public Coordinates Location { get; set; } + } +} diff --git a/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs b/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs new file mode 100644 index 00000000..fbfafd35 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs @@ -0,0 +1,261 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; + +namespace ZodSharp.Blazor.Fixtures; + +/// +/// A whose clock only moves when the test advances it, so debounce delays are +/// deterministic. +/// +sealed class ManualTimeProvider : TimeProvider +{ + readonly Lock _lock = new(); + readonly List _timers = []; + DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + ManualTimer timer = new(this, callback, state); + timer.Change(dueTime, period); + return timer; + } + + public int PendingTimers + { + get + { + lock (_lock) + return _timers.Count(static t => t.Due is not null); + } + } + + public void Advance(TimeSpan by) + { + List due; + lock (_lock) + { + _now += by; + due = [.. _timers.Where(t => t.Due is { } at && at <= _now)]; + foreach (var timer in due) + timer.Due = null; + } + + foreach (var timer in due) + timer.Fire(); + } + + void Track(ManualTimer timer) + { + lock (_lock) + { + if (!_timers.Contains(timer)) + _timers.Add(timer); + } + } + + sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer + { + public DateTimeOffset? Due { get; set; } + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + Due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime; + owner.Track(this); + return true; + } + + public void Fire() => callback(state); + + public void Dispose() => Due = null; + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} + +/// +/// Captures log entries. +/// +sealed class CapturingLoggerProvider : ILoggerProvider +{ + public ConcurrentQueue<(string Category, LogLevel Level, string Message, Exception? Exception)> Entries { get; } = + new(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName); + + public void Dispose() { } + + sealed class CapturingLogger(CapturingLoggerProvider owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => owner.Entries.Enqueue((category, logLevel, formatter(state, exception), exception)); + } +} + +sealed class FakeStringLocalizerFactory(Dictionary resources) : IStringLocalizerFactory +{ + public IStringLocalizer Create(Type resourceSource) => new FakeStringLocalizer(resources); + + public IStringLocalizer Create(string baseName, string location) => new FakeStringLocalizer(resources); +} + +sealed class FakeStringLocalizer(Dictionary resources) : IStringLocalizer +{ + public LocalizedString this[string name] + { + get + { + var culture = CultureInfo.CurrentUICulture.Name; + // A conditional expression would nest the culture-specific lookup inside the fallback lookup. +#pragma warning disable IDE0046 // Convert to conditional expression + if (resources.TryGetValue($"{culture}:{name}", out var specific)) + return new(name, specific, resourceNotFound: false); +#pragma warning restore IDE0046 + + return resources.TryGetValue(name, out var value) + ? new(name, value, resourceNotFound: false) + : new(name, name, resourceNotFound: true); + } + } + + public LocalizedString this[string name, params object[] arguments] => this[name]; + + public IEnumerable GetAllStrings(bool includeParentCultures) => + resources.Select(static pair => new LocalizedString(pair.Key, pair.Value)); +} + +/// +/// Records what the bridge reports; must stay empty for Blazor validations through . +/// +sealed class RecordingSink : IZodValidationReportSink +{ + public List Calls { get; } = []; + + public void Add( + Type validatedType, + string pathPrefix, + System.Collections.Immutable.ImmutableArray errors + ) => Calls.Add(validatedType); +} + +static class FormHost +{ + /// + /// Creates a bUnit context with ZodSharp for Blazor registered. + /// + public static BunitContext CreateContext( + Action? register = null, + Action? configure = null, + Action? options = null + ) + { + BunitContext context = new(); + context.Services.AddZodSharpFactory(register); + context.Services.AddZodSharpBlazor(configure: options); + configure?.Invoke(context.Services); + return context; + } + + /// + /// Renders inside a cascading , as an + /// would. + /// + public static IRenderedComponent> Render( + this BunitContext context, + EditContext editContext, + RenderFragment content + ) => + context.Render>(parameters => + parameters.Add(static p => p.Value, editContext).Add(static p => p.IsFixed, true).AddChildContent(content) + ); + + public static RenderFragment DataAnnotations(bool disableClientValidation = false) => + builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter( + 1, + nameof(DataAnnotationsValidator.DisableClientValidation), + disableClientValidation + ); + builder.CloseComponent(); + }; + + public static RenderFragment Zod( + ZodValidationTrigger trigger = ZodValidationTrigger.Change, + int debounceMilliseconds = 0, + IZodUntypedValidator? schema = null, + bool validateNestedOnFieldChange = false, + bool enableClientValidation = true + ) => + builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(ZodValidator.ValidateOn), trigger); + builder.AddComponentParameter(2, nameof(ZodValidator.DebounceMilliseconds), debounceMilliseconds); + builder.AddComponentParameter(3, nameof(ZodValidator.Schema), schema); + builder.AddComponentParameter( + 4, + nameof(ZodValidator.ValidateNestedOnFieldChange), + validateNestedOnFieldChange + ); + builder.AddComponentParameter(5, nameof(ZodValidator.EnableClientValidation), enableClientValidation); + builder.CloseComponent(); + }; + + public static RenderFragment Both(params RenderFragment[] fragments) => + builder => + { + foreach (var fragment in fragments) + fragment(builder); + }; + + /// + /// Polls until holds: platform validation tasks complete on the renderer's + /// dispatcher, which does not necessarily re-render anything bUnit could wait on. + /// + public static async Task WaitUntilAsync(Func condition, string because) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition()) + { + if (DateTime.UtcNow > deadline) + throw new TimeoutException(because); + + await Task.Delay(5); + } + } + + public static Task ValidateAsync( + this IRenderedComponent> form, + EditContext editContext + ) => form.InvokeAsync(() => editContext.ValidateAsync()); + + public static Task ChangeAsync( + this IRenderedComponent> form, + EditContext editContext, + FieldIdentifier field + ) => form.InvokeAsync(() => editContext.NotifyFieldChanged(field)); +} diff --git a/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs new file mode 100644 index 00000000..bf487cbb --- /dev/null +++ b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Extensions.Validation; +using ZodSharp.Http; + +namespace ZodSharp.Blazor; + +/// +/// Forms over real [ZodSchema] models, registered through the generated, trim-safe registration - including the +/// headline WebAssembly guarantee: nested validation with no [ValidatableType] and no +/// Microsoft.Extensions.Validation generator. +/// +public class GeneratedSchemaTests +{ + [Test] + public async Task DataAnnotationsValidator_GivenNestedZodSchemaModelWithoutValidationsGenerator_ValidatesTheWholeGraph() + { + // Arrange + GeneratedPerson person = new() + { + Name = "ab", + Address = new() { Street = null }, + PreviousAddresses = [new() { Street = "1 High St" }, new() { Street = null }], + }; + await using var context = CreateContext(); + EditContext editContext = new(person); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsFalse(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(person, nameof(GeneratedPerson.Name)))) + .IsNotEmpty(); + await Assert + .That( + editContext.GetValidationMessages(new FieldIdentifier(person.Address, nameof(GeneratedAddress.Street))) + ) + .IsNotEmpty(); + await Assert + .That( + editContext.GetValidationMessages( + new FieldIdentifier(person.PreviousAddresses[1], nameof(GeneratedAddress.Street)) + ) + ) + .IsNotEmpty(); + await Assert + .That( + editContext.GetValidationMessages( + new FieldIdentifier(person.PreviousAddresses[0], nameof(GeneratedAddress.Street)) + ) + ) + .IsEmpty(); + await Assert.That(editContext.GetValidationMessages().Count()).IsEqualTo(3); + } + + [Test] + public async Task Compilation_GivenThisTestAssembly_HasNoValidationsGeneratorOutputOrValidatableTypes() + { + // Arrange + var assembly = typeof(GeneratedSchemaTests).Assembly; + await using var context = CreateContext(); + + // Act + var generated = assembly + .GetTypes() + .Where(static t => t.FullName?.Contains("ValidatableInfoResolver", StringComparison.Ordinal) == true) + .ToArray(); + var validatable = assembly.GetTypes().Where(static t => t.IsDefined(typeof(ValidatableTypeAttribute), false)); + var resolvers = context.Services.GetRequiredService>().Value.Resolvers; + + // Assert + await Assert.That(generated).IsEmpty(); + await Assert.That(validatable).IsEmpty(); + await Assert.That(resolvers.Count).IsEqualTo(1); + await Assert.That(resolvers[0]).IsTypeOf(); + } + + [Test] + public async Task DataAnnotationsValidator_GivenNestedCollectionAndDictionaryErrors_PlacesEachOnItsOwningField() + { + // Arrange + GeneratedOrder order = new() + { + Reference = "ORD-1", + Customer = new() { Email = "not-an-email" }, + Lines = [new() { Sku = "A" }, new() { Sku = "B", Quantity = 0 }], + ByCode = new() + { + ["a"] = new() { Sku = "A" }, + ["b"] = new() { Sku = null }, + }, + }; + await using var context = CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + await form.ValidateAsync(editContext); + + // Assert + await Assert + .That( + editContext.GetValidationMessages(new FieldIdentifier(order.Customer, nameof(GeneratedCustomer.Email))) + ) + .IsNotEmpty(); + await Assert + .That( + editContext.GetValidationMessages(new FieldIdentifier(order.Lines[1], nameof(GeneratedLine.Quantity))) + ) + .IsNotEmpty(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.ByCode["b"], nameof(GeneratedLine.Sku)))) + .IsNotEmpty(); + await Assert.That(editContext.GetValidationMessages().Count()).IsEqualTo(3); + } + + [Test] + public async Task DataAnnotationsValidator_GivenFieldChange_RunsOnlyThatMembersOwnRules() + { + // Arrange + GeneratedOrder order = new() + { + Reference = "bad", + Customer = new() { Email = "not-an-email" }, + }; + await using var context = CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + FieldIdentifier email = new(order.Customer, nameof(GeneratedCustomer.Email)); + + // Act + await form.ChangeAsync(editContext, email); + + // Assert + await Assert.That(editContext.GetValidationMessages(email)).IsNotEmpty(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, nameof(GeneratedOrder.Reference)))) + .IsEmpty(); + } + + [Test] + public async Task ZodValidator_GivenGeneratedModel_KeepsCodesAndPlacesNestedErrors() + { + // Arrange + GeneratedOrder order = new() { Reference = "bad", Lines = [new() { Sku = "A", Quantity = 500 }] }; + await using var context = CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + await form.ValidateAsync(editContext); + + // Assert + var reference = editContext.GetZodErrors(() => order.Reference); + await Assert.That(reference.Select(static e => e.Code)).Contains("invalid_string"); + var quantity = editContext.GetZodErrors(new FieldIdentifier(order.Lines[0], nameof(GeneratedLine.Quantity))); + await Assert.That(quantity.Single().Category).IsEqualTo("too_big"); + } + + [Test] + public async Task ZodValidator_GivenFieldChangeOnGeneratedModel_UsesMemberValidation() + { + // Arrange + GeneratedOrder order = new() { Reference = "bad", Lines = [new() { Sku = null, Quantity = 0 }] }; + await using var context = CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + FieldIdentifier quantity = new(order.Lines[0], nameof(GeneratedLine.Quantity)); + + // Act + await form.ChangeAsync(editContext, quantity); + + // Assert + await Assert.That(editContext.GetValidationMessages(quantity)).IsNotEmpty(); + await Assert.That(editContext.GetValidationMessages().Count()).IsEqualTo(1); + } + + [Test] + public async Task AddZodProblemDetails_GivenGeneratedModel_ResolvesPathsWithTheGeneratedResolver() + { + // Arrange + GeneratedOrder order = new() { Lines = [new(), new()] }; + await using var context = CreateContext(); + EditContext editContext = new(order); + context.Render(editContext, FormHost.Zod()); + ZodValidationProblemDetails problem = new() + { + Errors = new Dictionary { ["Lines[1].Sku"] = ["Sku is required"] }, + }; + + // Act + editContext.AddZodProblemDetails(problem); + + // Assert + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Lines[1], nameof(GeneratedLine.Sku)))) + .IsEquivalentTo(["Sku is required"]); + } + + [Test] + public async Task DataAnnotationsValidator_GivenValidGeneratedModel_IsValid() + { + // Arrange + GeneratedOrder order = new() + { + Reference = "ORD-1", + Customer = new() { Email = "someone@example.com" }, + Lines = [new() { Sku = "A", Quantity = 5 }], + }; + await using var context = CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(editContext.IsValidationFaulted()).IsFalse(); + await Assert.That(valid).IsTrue(); + } + + static BunitContext CreateContext() => + FormHost.CreateContext(static factory => + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + ); +} diff --git a/src/tests/Blazor.UnitTests/GlobalUsings.cs b/src/tests/Blazor.UnitTests/GlobalUsings.cs new file mode 100644 index 00000000..99fa08cd --- /dev/null +++ b/src/tests/Blazor.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Bunit; diff --git a/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs b/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs new file mode 100644 index 00000000..2bffc2f7 --- /dev/null +++ b/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs @@ -0,0 +1,283 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.AspNetCore; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Core; +using ZodSharp.Http; +using static ZodSharp.Blazor.Fixtures.Errors; + +namespace ZodSharp.Blazor; + +/// +/// Server validation problems mapped onto forms with AddZodProblemDetails. Reading them +/// (TryReadZodValidationProblemAsync) is covered by Purview.ZodSharp.Http's tests. +/// +public class ProblemDetailsMappingTests +{ + [Test] + public async Task AddZodProblemDetails_GivenServerResponse_PlacesIssuesBySegments( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() { Lines = [new(), new()] }; + await using var app = await StartServerAsync( + ValidationResult.Failure([ + ValidationError.Create( + "too_small", + "Quantity must be at least 1", + ZodPath.From(M("Lines"), I(1), M("Quantity")), + origin: "number", + minimum: 1, + inclusive: true, + category: "too_small" + ), + At("invalid_string", "Email is invalid", M("Customer"), M("Email")), + ]), + cancellationToken + ); + using var client = app.GetTestClient(); + using var response = await client.PostAsJsonAsync("/orders", new { }, cancellationToken); + EditContext editContext = new(order); + + // Act + var problem = await response.TryReadZodValidationProblemAsync(cancellationToken); + editContext.AddZodProblemDetails(problem!); + + // Assert + await Assert.That(problem).IsNotNull(); + await Assert.That(problem!.Issues).IsNotNull(); + FieldIdentifier quantity = new(order.Lines[1], nameof(OrderLine.Quantity)); + await Assert.That(editContext.GetValidationMessages(quantity)).IsEquivalentTo(["Quantity must be at least 1"]); + var error = editContext.GetZodErrors(quantity).Single(); + await Assert.That(error.Code).IsEqualTo("too_small"); + await Assert.That(error.Origin).IsEqualTo("number"); + await Assert.That(error.Lower?.Inclusive).IsTrue(); + await Assert + .That(Convert.ToInt32(error.Lower?.Value, System.Globalization.CultureInfo.InvariantCulture)) + .IsEqualTo(1); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Customer, nameof(Customer.Email)))) + .IsEquivalentTo(["Email is invalid"]); + } + + [Test] + public async Task AddZodProblemDetails_GivenOnlyDottedErrors_ParsesTheKeys() + { + // Arrange + Order order = new() { Lines = [new()] }; + EditContext editContext = new(order); + ZodValidationProblemDetails problem = new() + { + Errors = new Dictionary + { + ["Lines[0].Sku"] = ["Sku is required"], + ["Reference"] = ["Reference is required"], + [""] = ["Order is invalid"], + }, + }; + + // Act + editContext.AddZodProblemDetails(problem); + + // Assert + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Lines[0], nameof(OrderLine.Sku)))) + .IsEquivalentTo(["Sku is required"]); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, nameof(Order.Reference)))) + .IsEquivalentTo(["Reference is required"]); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, string.Empty))) + .IsEquivalentTo(["Order is invalid"]); + await Assert.That(editContext.GetZodErrors(() => order.Reference).Single().Code).IsEqualTo("custom"); + } + + [Test] + public async Task AddZodProblemDetails_GivenCamelCasedSegmentsAndDictionaryKey_PlacesOnClrFields() + { + // Arrange + Order order = new() { ByCode = new() { ["abc"] = new() } }; + EditContext editContext = new(order); + ZodValidationProblemDetails problem = new() + { + Issues = + [ + new() + { + Path = "byCode.abc.sku", + Segments = [Json("\"byCode\""), Json("\"abc\""), Json("\"sku\"")], + Code = "too_small", + Message = "Sku is required", + }, + ], + }; + + // Act + editContext.AddZodProblemDetails(problem); + + // Assert + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.ByCode["abc"], nameof(OrderLine.Sku)))) + .IsEquivalentTo(["Sku is required"]); + } + + [Test] + public async Task AddZodErrors_GivenErrorsFromOutsideTheForm_PlacesThemAndKeepsTheFullErrors() + { + // Arrange + Order order = new() { Lines = [new()] }; + EditContext editContext = new(order); + ValidationError[] errors = + [ + At("too_small", "Sku is required", M("Lines"), I(0), M("Sku")), + At("custom", "Order is invalid"), + ]; + + // Act + editContext.AddZodErrors(errors); + + // Assert + FieldIdentifier sku = new(order.Lines[0], nameof(OrderLine.Sku)); + await Assert.That(editContext.GetValidationMessages(sku)).IsEquivalentTo(["Sku is required"]); + await Assert.That(editContext.GetZodErrors(sku).Single().Code).IsEqualTo("too_small"); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, string.Empty))) + .IsEquivalentTo(["Order is invalid"]); + } + + [Test] + public async Task AddZodErrors_GivenEarlierProblemDetails_ReplacesThem() + { + // Arrange + Order order = new(); + EditContext editContext = new(order); + editContext.AddZodProblemDetails( + new() { Errors = new Dictionary { ["Reference"] = ["Reference is taken"] } } + ); + + // Act + editContext.AddZodErrors([At("custom", "Email is invalid", M("Customer"), M("Email"))]); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Email is invalid"]); + } + + [Test] + public async Task AddZodProblemDetails_GivenFieldChange_ClearsOnlyThatFieldsServerErrors() + { + // Arrange + Order order = new(); + EditContext editContext = new(order); + editContext.AddZodProblemDetails( + new() + { + Errors = new Dictionary + { + ["Reference"] = ["Reference is taken"], + ["Customer.Email"] = ["Email is taken"], + }, + } + ); + + // Act + editContext.NotifyFieldChanged(new FieldIdentifier(order, nameof(Order.Reference))); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Email is taken"]); + await Assert.That(editContext.GetZodErrors().Single().Message).IsEqualTo("Email is taken"); + } + + [Test] + public async Task AddZodProblemDetails_GivenClearOnFieldChangeDisabled_KeepsServerErrors() + { + // Arrange + Order order = new(); + EditContext editContext = new(order); + editContext.AddZodProblemDetails( + new() { Errors = new Dictionary { ["Reference"] = ["Reference is taken"] } }, + clearOnFieldChange: false + ); + + // Act + editContext.NotifyFieldChanged(new FieldIdentifier(order, nameof(Order.Reference))); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Reference is taken"]); + } + + [Test] + public async Task AddZodProblemDetails_GivenSecondProblem_ReplacesTheFirst() + { + // Arrange + Order order = new(); + EditContext editContext = new(order); + editContext.AddZodProblemDetails( + new() { Errors = new Dictionary { ["Reference"] = ["first"] } } + ); + + // Act + editContext.AddZodProblemDetails( + new() { Errors = new Dictionary { ["Customer.Email"] = ["second"] } } + ); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["second"]); + } + + [Test] + public async Task AddZodProblemDetails_GivenSuppliedStore_AddsMessagesToIt() + { + // Arrange + Order order = new(); + EditContext editContext = new(order); + ValidationMessageStore store = new(editContext); + + // Act + editContext.AddZodProblemDetails( + new() { Errors = new Dictionary { ["Reference"] = ["Reference is taken"] } }, + store + ); + store.Clear(); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEmpty(); + } + + [Test] + public async Task ClearZodErrors_GivenServerErrors_RemovesThem() + { + // Arrange + Order order = new(); + EditContext editContext = new(order); + editContext.AddZodProblemDetails( + new() { Errors = new Dictionary { ["Reference"] = ["Reference is taken"] } } + ); + + // Act + editContext.ClearZodErrors(); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEmpty(); + await Assert.That(editContext.GetZodErrors()).IsEmpty(); + } + + static System.Text.Json.JsonElement Json(string json) => System.Text.Json.JsonElement.Parse(json); + + static async Task StartServerAsync( + ValidationResult result, + CancellationToken cancellationToken + ) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddZodSharpProblemDetails(); + var app = builder.Build(); + app.MapPost("/orders", (HttpContext context) => ZodTypedResults.ValidationProblem(result, context)); + await app.StartAsync(cancellationToken); + return app; + } +} diff --git a/src/tests/Blazor.UnitTests/Ssr/ClientRuleParityFixtureTests.cs b/src/tests/Blazor.UnitTests/Ssr/ClientRuleParityFixtureTests.cs new file mode 100644 index 00000000..2fe51f85 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/ClientRuleParityFixtureTests.cs @@ -0,0 +1,321 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json; +using ZodSharp.AspNetCore.ClientValidation; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.Blazor.Ssr; + +/// +/// The C# producer of the client-validator parity fixtures: for each ZodSharp rule, the client rule the server emits +/// (through ) and the C# rule's verdict on a set of inputs. The vitest suite +/// tests/ts/client-validators.test.ts runs zodsharp-validation.js against the same inputs and fails when +/// the JavaScript validators disagree with C#. +/// +public class ClientRuleParityFixtureTests +{ + const string FixtureRelativePath = "src/tests/cross-platform/client-rules"; + + static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + const string V4 = "550e8400-e29b-41d4-a716-446655440000"; + const string V7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + + [Test] + public async Task ClientRules_GivenEveryMappedRule_WritesParityFixtures(CancellationToken cancellationToken) + { + // Arrange + var outputDirectory = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", FixtureRelativePath) + ); + Directory.CreateDirectory(outputDirectory); + foreach (var stale in Directory.EnumerateFiles(outputDirectory, "*.json")) + File.Delete(stale); + + List fixtures = + [ + Text( + "starts-with", + new StartsWithAttribute("ab"), + new StartsWithRule("ab"), + "abc", + "ab", + "xab", + "Abc", + "a" + ), + Text( + "starts-with-ignore-case", + new StartsWithAttribute("ab", StringComparison.OrdinalIgnoreCase), + new StartsWithRule("ab", StringComparison.OrdinalIgnoreCase), + "ABc", + "aB", + "ba" + ), + Text("ends-with", new EndsWithAttribute("yz"), new EndsWithRule("yz"), "xyz", "yz", "yzx", "XYZ"), + Text( + "ends-with-ignore-case", + new EndsWithAttribute("yz", StringComparison.OrdinalIgnoreCase), + new EndsWithRule("yz", StringComparison.OrdinalIgnoreCase), + "XYZ", + "zy" + ), + Text("includes", new IncludesAttribute("mid"), new IncludesRule("mid"), "amidb", "mid", "MID", "mi d"), + Text( + "uuid", + new UUIDAttribute(), + new UUIDRule(), + V4, + V4.ToUpperInvariant(), + V7, + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + "550e8400-e29b-01d4-a716-446655440000", + "550e8400-e29b-91d4-a716-446655440000", + "550e8400-e29b-41d4-c716-446655440000", + "550e8400e29b41d4a716446655440000", + "550e8400-e29b-41d4-a716-44665544000g", + "550e8400-e29b-41d4-a716-4466554400001", + " 550e8400-e29b-41d4-a716-44665544000" + ), + Text("uuid-v7", new UUIDAttribute(UuidVersion.V7), new UUIDRule(UuidVersion.V7), V7, V4), + Text("cuid2", new Cuid2Attribute(), new Cuid2Rule(), "tz4a98xxat96iws9zmbrgj3a", "TZ4A98", "a-b", "abc"), + Text( + "ulid", + new ULIDAttribute(), + new ULIDRule(), + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "01arz3ndektsv4rrffq69g5fav", + "81ARZ3NDEKTSV4RRFFQ69G5FAV", + "01ARZ3NDEKTSV4RRFFQ69G5FAI", + "01ARZ3NDEKTSV4RRFFQ69G5FAL", + "01ARZ3NDEKTSV4RRFFQ69G5FAO", + "01ARZ3NDEKTSV4RRFFQ69G5FAU", + "01ARZ3NDEKTSV4RRFFQ69G5FA" + ), + Text( + "ip-any", + new IPAddressAttribute(), + new IPAddressRule(IPAddressRuleType.Any), + "192.168.0.1", + "0.0.0.0", + "255.255.255.255", + "256.1.1.1", + "1.2.3.4.5", + "1..2.3", + "abc", + "::1", + "::", + "2001:db8::1", + "2001:0db8:0000:0000:0000:ff00:0042:8329", + "::ffff:192.168.0.1", + "1::2::3", + "12345::", + "2001:db8:::1", + "1:2:3:4:5:6:7:8:9", + "fe80::1%2", + "127.1", + "1", + "0x7f.0.0.1", + "010.0.0.1", + "09.0.0.1" + ), + Text( + "ip-v4", + new IPAddressAttribute(IPAddressRuleType.IPv4), + new IPAddressRule(IPAddressRuleType.IPv4), + "10.1.2.3", + "::1" + ), + Text( + "ip-v6", + new IPAddressAttribute(IPAddressRuleType.IPv6), + new IPAddressRule(IPAddressRuleType.IPv6), + "10.1.2.3", + "::1" + ), + Text( + "cidr-any", + new CidrAttribute(), + new CidrRule(CidrRuleType.Any), + "10.0.0.0/8", + "10.0.0.0/0", + "10.0.0.0/32", + "10.0.0.0/33", + "2001:db8::/32", + "2001:db8::/128", + "2001:db8::/129", + "10.0.0.0", + "/8", + "10.0.0.0/", + "10.0.0.0/+8", + "10.0.0.0/ 8", + "10.0.0.0/8/8", + "300.0.0.0/8" + ), + Text( + "cidr-v4", + new CidrAttribute(CidrRuleType.IPv4), + new CidrRule(CidrRuleType.IPv4), + "10.0.0.0/8", + "::/0" + ), + Text( + "cidr-v6", + new CidrAttribute(CidrRuleType.IPv6), + new CidrRule(CidrRuleType.IPv6), + "10.0.0.0/8", + "::/0" + ), + Text("regex-partial", new RegexAttribute("\\d{3}"), new RegexRule("\\d{3}"), "ab123cd", "123", "ab12"), + Text( + "regex-anchored", + new RegexAttribute("^[a-z]+\\d$"), + new RegexRule("^[a-z]+\\d$"), + "abc1", + "ABC1", + "abc" + ), + Number("int", new IntAttribute(), new IntRule(), "1", "-3", "0", "1.5", "-0.25", "1e3"), + Number( + "multiple-of-tenth", + new MultipleOfAttribute(0.1), + new MultipleOfRule(0.1), + "0.3", + "1", + "-0.2", + "0.35", + "0.05" + ), + Number( + "multiple-of-five", + new MultipleOfAttribute(5), + new MultipleOfRule(5), + "10", + "-15", + "0", + "11", + "2.5" + ), + Number("min-value", new MinValueAttribute(3), new MinValueRule(3), "3", "10", "2.99", "-4"), + Number("max-value", new MaxValueAttribute(3), new MaxValueRule(3), "3", "-10", "3.01"), + Number("greater-than", new GreaterThanAttribute(3), new GreaterThanRule(3), "3", "3.01", "2"), + Number( + "greater-than-or-equal", + new GreaterThanOrEqualAttribute(3), + new GreaterThanOrEqualRule(3), + "3", + "2.5" + ), + Number("less-than", new LessThanAttribute(3), new LessThanRule(3), "3", "2.99", "4"), + Number( + "less-than-or-equal", + new LessThanOrEqualAttribute(3), + new LessThanOrEqualRule(3), + "3", + "3.5" + ), + Date( + "date-min", + new MinValueRule(new(2024, 1, 15)), + "2024-01-15", + "2024-01-15T00:00:01", + "2024-01-14", + "2024-01-14T23:59:59.9999999", + "2025-06-01T10:30" + ), + Date( + "date-max", + new MaxValueRule(new(2024, 1, 15, 12, 0, 0)), + "2024-01-15T12:00", + "2024-01-15T12:00:01", + "2024-01-15" + ), + Date( + "date-gt", + new GreaterThanRule(new(2024, 1, 15)), + "2024-01-15", + "2024-01-15T00:00:00.0000001" + ), + Date("date-lt", new LessThanRule(new(2024, 1, 15)), "2024-01-15", "2024-01-14T23:59"), + ]; + + // Act + foreach (var fixture in fixtures) + { + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, fixture.Name + ".json"), + JsonSerializer.Serialize(fixture, SerializerOptions), + cancellationToken + ); + } + + // Assert + await Assert.That(Directory.EnumerateFiles(outputDirectory, "*.json").Count()).IsEqualTo(fixtures.Count); + foreach (var fixture in fixtures) + { + await Assert.That(fixture.Rules).IsNotEmpty().Because($"{fixture.Name} maps to no client rule"); + await Assert + .That(fixture.Cases.Any(static c => c.Valid)) + .IsTrue() + .Because($"{fixture.Name} has no valid case"); + await Assert + .That(fixture.Cases.Any(static c => !c.Valid)) + .IsTrue() + .Because($"{fixture.Name} has no invalid case"); + } + } + + internal sealed record FixtureRule(string Name, string Message, IReadOnlyDictionary Params); + + internal sealed record FixtureCase(string Value, bool Valid); + + internal sealed record Fixture(string Name, IReadOnlyList Rules, IReadOnlyList Cases); + + static IReadOnlyList RulesOf(ValidationAttribute attribute) => + [ + .. ZodClientValidationRules + .FromAttribute(attribute) + .Select(static r => new FixtureRule(r.Name, r.Message, r.Parameters)), + ]; + + static Fixture Text(string name, ValidationAttribute attribute, TRule rule, params string[] values) + where TRule : IValidationRule => + new(name, RulesOf(attribute), [.. values.Select(value => new FixtureCase(value, rule.IsValid(value)))]); + + static Fixture Number(string name, ValidationAttribute attribute, TRule rule, params string[] values) + where TRule : IValidationRule => + new( + name, + RulesOf(attribute), + [ + .. values.Select(value => new FixtureCase( + value, + rule.IsValid(double.Parse(value, CultureInfo.InvariantCulture)) + )), + ] + ); + + static Fixture Date(string name, TRule rule, params string[] values) + where TRule : IValidationRule, IZodRule => + new( + name, + [ + .. ZodClientValidationRules + .FromRule(rule, "Invalid date") + .Select(static r => new FixtureRule(r.Name, r.Message, r.Parameters)), + ], + [ + .. values.Select(value => new FixtureCase( + value, + rule.IsValid(DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.None)) + )), + ] + ); +} diff --git a/src/tests/Blazor.UnitTests/Ssr/ClientValidationRegistrationTests.cs b/src/tests/Blazor.UnitTests/Ssr/ClientValidationRegistrationTests.cs new file mode 100644 index 00000000..9ac95125 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/ClientValidationRegistrationTests.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using ZodSharp.AspNetCore.ClientValidation; +using ZodSharp.Blazor.Fixtures; + +namespace ZodSharp.Blazor.Ssr; + +/// +/// AddZodSharpClientValidation's defences against the AddRazorComponents ordering hazard. +/// +public class ClientValidationRegistrationTests +{ + [Test] + public async Task AddZodSharpClientValidation_GivenCalledBeforeAddRazorComponents_DecoratesTheDefaultProvider() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + // Act + services.AddZodSharpClientValidation(); + services.AddRazorComponents(); + + // Assert + await AssertDecoratesDefaultAsync(builder); + } + + [Test] + public async Task AddZodSharpClientValidation_GivenCalledAfterAddRazorComponents_DecoratesTheDefaultProvider() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + + // Act + services.AddRazorComponents(); + services.AddZodSharpClientValidation(); + + // Assert + await AssertDecoratesDefaultAsync(builder); + } + + [Test] + public async Task AddZodSharpClientValidation_GivenCalledTwice_DecoratesOnce() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + services.AddRazorComponents(); + + // Act + services.AddZodSharpClientValidation(); + services.AddZodSharpClientValidation(); + + // Assert + await Assert.That(services.Count(static d => d.ServiceType == typeof(ClientValidationProvider))).IsEqualTo(1); + await AssertDecoratesDefaultAsync(builder); + } + + [Test] + public async Task AddZodSharpClientValidation_GivenCustomProviderRegisteredFirst_DecoratesIt() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + var services = builder.Services; + services.AddScoped(); + + // Act + services.AddZodSharpClientValidation(); + services.AddRazorComponents(); + + // Assert + await using var app = builder.Build(); + await using var scope = app.Services.CreateAsyncScope(); + var resolved = scope.ServiceProvider.GetRequiredService(); + await Assert.That(resolved).IsTypeOf(); + await Assert.That(((ZodClientValidationProvider)resolved).Inner).IsTypeOf(); + } + + [Test] + public async Task Startup_GivenProviderReplacedAfterRegistration_WarnsInDevelopment( + CancellationToken cancellationToken + ) + { + // Arrange + using CapturingLoggerProvider logs = new(); + + // Act + await using var host = await StaticSsrHost.StartAsync( + static services => + { + services.AddRazorComponents(); + services.AddZodSharpClientValidation(); + services.Replace(ServiceDescriptor.Scoped()); + }, + logs, + cancellationToken + ); + + // Assert + var warning = logs + .Entries.Where(static e => + e.Category.EndsWith("ZodClientValidationStartupFilter", StringComparison.Ordinal) + ) + .ToArray(); + await Assert.That(warning.Length).IsEqualTo(1); + await Assert.That(warning.Single().Level).IsEqualTo(LogLevel.Warning); + await Assert.That(warning.Single().Message).Contains(typeof(EmptyProvider).FullName!); + } + + [Test] + public async Task Startup_GivenProviderInPlace_DoesNotWarn(CancellationToken cancellationToken) + { + // Arrange + using CapturingLoggerProvider logs = new(); + + // Act + await using var host = await StaticSsrHost.StartAsync( + static services => + { + services.AddZodSharpClientValidation(); + services.AddRazorComponents(); + }, + logs, + cancellationToken + ); + + // Assert + await Assert + .That( + logs.Entries.Where(static e => + e.Category.EndsWith("ZodClientValidationStartupFilter", StringComparison.Ordinal) + ) + ) + .IsEmpty(); + } + + static async Task AssertDecoratesDefaultAsync(WebApplicationBuilder builder) + { + await using var app = builder.Build(); + await using var scope = app.Services.CreateAsyncScope(); + var resolved = scope.ServiceProvider.GetRequiredService(); + + await Assert.That(resolved).IsTypeOf(); + await Assert + .That(((ZodClientValidationProvider)resolved).Inner?.GetType().Name) + .IsEqualTo("DataAnnotationsClientValidationProvider"); + } + + sealed class EmptyProvider : ClientValidationProvider + { + public override RenderFragment? RenderClientValidationRules( + EditContext editContext, + IReadOnlyDictionary renderedFields + ) => null; + } +} diff --git a/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs b/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs new file mode 100644 index 00000000..c359c0f2 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs @@ -0,0 +1,233 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using ZodSharp.AspNetCore.ClientValidation; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.Blazor.Ssr; + +/// +/// The mapping from ZodSharp rules to client rules, and the provider's payload merge. +/// +public class ClientValidationRulesTests +{ + [Test] + public async Task FromAttribute_GivenCultureSensitiveComparison_EmitsNoClientRule() + { + // Arrange + StartsWithAttribute attribute = new("ab", StringComparison.CurrentCulture); + + // Act + var rules = ZodClientValidationRules.FromAttribute(attribute); + + // Assert + await Assert.That(rules).IsEmpty(); + } + + [Test] + public async Task FromAttribute_GivenDataAnnotationsAttribute_EmitsNoClientRule() + { + // Arrange + RequiredAttribute attribute = new(); + + // Act + var rules = ZodClientValidationRules.FromAttribute(attribute); + + // Assert + await Assert.That(rules).IsEmpty(); + } + + [Test] + public async Task FromAttribute_GivenCustomRuleAttribute_MapsTheRulesMetadata() + { + // Arrange + OrderPrefixAttribute attribute = new() { Prefix = "ORD-" }; + + // Act + var rule = ZodClientValidationRules.FromAttribute(attribute).Single(); + + // Assert + await Assert.That(rule.Name).IsEqualTo(ZodClientValidationRules.StartsWith); + await Assert.That(rule.Parameters["prefix"]).IsEqualTo("ORD-"); + await Assert.That(rule.Message).IsEqualTo("'{value}' must start with ORD-"); + await Assert.That(rule.Parameters[ZodClientValidationRules.MessageTemplateParameter]).IsEqualTo(rule.Message); + } + + [Test] + public async Task For_GivenRule_ReturnsPlatformClientValidationRules() + { + // Arrange + UUIDRule rule = new(UuidVersion.V4); + + // Act + var rules = ZodClientValidationRules.For(rule); + + // Assert + await Assert.That(rules.Single().Name).IsEqualTo(ZodClientValidationRules.Uuid); + await Assert.That(rules.Single().Parameters["version"]).IsEqualTo("4"); + } + + [Test] + public async Task FromJsonSchema_GivenRuntimeSchemaProperty_MapsTheKeywords() + { + // Arrange + var schema = Z.ToJsonSchema(Z.Object().Field("Reference", Z.String().Min(3).Max(10)).Build()); + + // Act + var rules = ZodClientValidationRules.FromJsonSchema(schema.Properties!["Reference"], required: true); + + // Assert + await Assert.That(rules.Select(static r => r.Name).ToArray()).IsEquivalentTo(["required", "length"]); + await Assert.That(rules[1].Parameters["min"]).IsEqualTo("3"); + await Assert.That(rules[1].Parameters["max"]).IsEqualTo("10"); + } + + [Test] + public async Task RenderClientValidationRules_GivenRegisteredRuntimeSchema_EmitsItsRules() + { + // Arrange + Order order = new(); + ZodClientValidationOptions options = new(); + options.AddSchema(Z.Object().Field("Reference", Z.String().Min(3)).Build()); + ZodClientValidationProvider provider = new(inner: null, options); + EditContext editContext = new(order); + Dictionary fields = new() + { + [editContext.Field(nameof(Order.Reference))] = "Model.Reference", + }; + + // Act + var json = ReadRules(provider.RenderClientValidationRules(editContext, fields)); + + // Assert + await Assert.That(json).Contains("\"name\":\"Model.Reference\""); + await Assert.That(json).Contains("\"name\":\"minlength\""); + } + + [Test] + public async Task RenderClientValidationRules_GivenInnerPayload_MergesRulesIntoTheSameField() + { + // Arrange + GeneratedOrder order = new(); + FixedProvider inner = new( + /*lang=json,strict*/"""{"fields":[{"name":"Model.Reference","rules":[{"name":"required","message":"Required"}]}]}""" + ); + ZodClientValidationProvider provider = new(inner, new()); + EditContext editContext = new(order); + Dictionary fields = new() + { + [editContext.Field(nameof(GeneratedOrder.Reference))] = "Model.Reference", + }; + + // Act + var json = ReadRules(provider.RenderClientValidationRules(editContext, fields)); + + // Assert + await Assert + .That(json) + .IsEqualTo( + /*lang=json,strict*/"""{"fields":[{"name":"Model.Reference","rules":[{"name":"required","message":"Required"},{"name":"zod-startswith","message":"String must start with \u0027ORD-\u0027","params":{"prefix":"ORD-","comparison":"ordinal"}}]}]}""" + ); + } + + [Test] + public async Task RenderClientValidationRules_GivenNoZodRules_ReturnsTheInnerFragmentUnchanged() + { + // Arrange + Order order = new(); + FixedProvider inner = new( /*lang=json,strict*/ + """{"fields":[]}""" + ); + ZodClientValidationProvider provider = new(inner, new()); + EditContext editContext = new(order); + Dictionary fields = new() + { + [editContext.Field(nameof(Order.Reference))] = "Model.Reference", + }; + + // Act + var fragment = provider.RenderClientValidationRules(editContext, fields); + + // Assert + await Assert.That(fragment).IsSameReferenceAs(inner.Fragment); + } + +#pragma warning disable BL0006 // Do not use RenderTree types: reading the rendered payload is the point of the test. + static string ReadRules(RenderFragment? fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + using RenderTreeBuilder builder = new(); + fragment(builder); + var frames = builder.GetFrames(); + for (var i = 0; i < frames.Count; i++) + { + var frame = frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Attribute && frame.AttributeName == "data-rules") + return (string)frame.AttributeValue; + } + + throw new InvalidOperationException("No data-rules attribute was rendered."); + } +#pragma warning restore BL0006 + + sealed class FixedProvider(string json) : ClientValidationProvider + { + public RenderFragment Fragment { get; } = + builder => + { + builder.OpenElement(0, "blazor-client-validation-data"); + builder.AddAttribute(1, "data-rules", json); + builder.CloseElement(); + }; + + public override RenderFragment? RenderClientValidationRules( + EditContext editContext, + IReadOnlyDictionary renderedFields + ) => Fragment; + } +} + +/// +/// A hand-authored rule that reports Zod's starts_with metadata, so it gets the same client rule as the +/// built-in . +/// +public readonly record struct OrderPrefixRule : IValidationRule, IStringValidationRule, IZodRule +{ + public const string ErrorCode = "invalid_order_reference"; + + public const string MessageFormat = "'{0}' must start with {1}"; + + readonly string _prefix; + + public OrderPrefixRule(string prefix) => _prefix = prefix; + + public bool IsValid(in string value) => value?.StartsWith(_prefix, StringComparison.Ordinal) == true; + + public bool IsValid(ReadOnlySpan value) => value.StartsWith(_prefix, StringComparison.Ordinal); + + public string GetErrorMessage(in string value) => RuleMessage.Format(MessageFormat, value, _prefix); + + public string GetErrorMessage(ReadOnlySpan value) => GetErrorMessage(value.ToString()); + + string? IZodRule.Code => ErrorCode; + + string? IZodRule.Origin => ZodTypeNames.String; + + IReadOnlyDictionary? IZodRule.Parameters => + new Dictionary + { + [ZodIssueParameters.Format] = "starts_with", + [ZodIssueParameters.Prefix] = _prefix, + }; +} + +[ZodRule(typeof(OrderPrefixRule))] +[AttributeUsage(AttributeTargets.Property)] +public sealed class OrderPrefixAttribute : ValidationAttribute +{ + public string Prefix { get; set; } = string.Empty; +} diff --git a/src/tests/Blazor.UnitTests/Ssr/DataAnnotationsOrderPage.razor b/src/tests/Blazor.UnitTests/Ssr/DataAnnotationsOrderPage.razor new file mode 100644 index 00000000..f310b377 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/DataAnnotationsOrderPage.razor @@ -0,0 +1,37 @@ +@page "/order/data-annotations" +@using ZodSharp.Blazor.Fixtures + + + + + + + + + @for (var i = 0; i < Model!.Lines.Count; i++) + { + var index = i; + + + + + } + + + +@if (_submitted) +{ +

Saved

+} + +@code { + bool _submitted; + + [SupplyParameterFromForm(FormName = "order")] + GeneratedOrder? Model { get; set; } + + protected override void OnInitialized() => + Model ??= new() { Lines = [new() { Sku = "A" }, new() { Sku = "B" }] }; + + void Submit() => _submitted = true; +} diff --git a/src/tests/Blazor.UnitTests/Ssr/SsrApp.razor b/src/tests/Blazor.UnitTests/Ssr/SsrApp.razor new file mode 100644 index 00000000..0c31c52a --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/SsrApp.razor @@ -0,0 +1,14 @@ +@* The root component of the in-process static-SSR host (StaticSsrHost). *@ + + + + ZodSharp static SSR + + + + + + + + + diff --git a/src/tests/Blazor.UnitTests/Ssr/StaticSsrHost.cs b/src/tests/Blazor.UnitTests/Ssr/StaticSsrHost.cs new file mode 100644 index 00000000..4ad13665 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/StaticSsrHost.cs @@ -0,0 +1,84 @@ +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ZodSharp.Blazor.Ssr; + +/// +/// Hosts the test assembly's Razor components in-process with static server-side rendering, the way a Blazor Web +/// App serves a form that is not interactive. +/// +sealed class StaticSsrHost : IAsyncDisposable +{ + readonly WebApplication _app; + + StaticSsrHost(WebApplication app, HttpClient client) + { + _app = app; + Client = client; + } + + public HttpClient Client { get; } + + public IServiceProvider Services => _app.Services; + + public static async Task StartAsync( + Action configure, + ILoggerProvider? logger = null, + CancellationToken cancellationToken = default + ) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions + { + EnvironmentName = Environments.Development, + ApplicationName = typeof(SsrApp).Assembly.GetName().Name, + } + ); + builder.WebHost.UseTestServer(); + if (logger is not null) + builder.Logging.AddProvider(logger); + + configure(builder.Services); + + var app = builder.Build(); + app.UseAntiforgery(); + app.MapRazorComponents().DisableAntiforgery(); + await app.StartAsync(cancellationToken); + return new(app, app.GetTestClient()); + } + + public async Task GetAsync(string path, CancellationToken cancellationToken) + { + using var response = await Client.GetAsync(new Uri(path, UriKind.Relative), cancellationToken); + response.EnsureSuccessStatusCode(); + return await ParseAsync(response, cancellationToken); + } + + public async Task PostAsync( + string path, + IEnumerable> fields, + CancellationToken cancellationToken + ) + { + using FormUrlEncodedContent content = new(fields); + using var response = await Client.PostAsync(new Uri(path, UriKind.Relative), content, cancellationToken); + return await ParseAsync(response, cancellationToken); + } + + static async Task ParseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var html = await response.Content.ReadAsStringAsync(cancellationToken); + return await new HtmlParser().ParseDocumentAsync(html, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.DisposeAsync(); + } +} diff --git a/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs b/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs new file mode 100644 index 00000000..1e1be325 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using AngleSharp.Dom; +using AngleSharp.Html.Dom; +using Microsoft.Extensions.DependencyInjection; + +namespace ZodSharp.Blazor.Ssr; + +/// +/// Static server-side rendering: server validation of posted forms, and the client-validation payload. +/// +public class StaticSsrTests +{ + [Test] + public async Task Get_GivenZodSharpClientValidation_RendersZodRulesBesideTheBuiltInRules( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await StartAsync(cancellationToken); + + // Act + var document = await host.GetAsync("/order/data-annotations", cancellationToken); + + // Assert + var rules = ReadRules(document); + var reference = rules["Model.Reference"]; + await Assert.That(reference.Select(static r => r.Name)).Contains("required"); + await Assert.That(reference.Select(static r => r.Name)).Contains("zod-startswith"); + var startsWith = reference.Single(static r => r.Name == "zod-startswith"); + await Assert.That(startsWith.Parameters["prefix"]).IsEqualTo("ORD-"); + await Assert.That(startsWith.Parameters["comparison"]).IsEqualTo("ordinal"); + await Assert.That(startsWith.Message).IsEqualTo("String must start with 'ORD-'"); + } + + [Test] + public async Task Get_GivenNestedAndCollectionFields_RendersRulesUnderTheFormFieldNames( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await StartAsync(cancellationToken); + + // Act + var document = await host.GetAsync("/order/data-annotations", cancellationToken); + + // Assert + var rules = ReadRules(document); + await Assert.That(rules.Keys).Contains("Model.Customer.Email"); + await Assert.That(rules["Model.Lines[1].Quantity"].Select(static r => r.Name)).Contains("range"); + await Assert.That(document.QuerySelector("input[name='Model.Lines[1].Quantity']")).IsNotNull(); + } + + [Test] + public async Task Get_GivenZodValidator_RendersTheClientValidationPayloadToo(CancellationToken cancellationToken) + { + // Arrange + await using var host = await StartAsync(cancellationToken); + + // Act + var document = await host.GetAsync("/order/zod", cancellationToken); + + // Assert + var rules = ReadRules(document); + await Assert.That(rules["Model.Reference"].Select(static r => r.Name)).Contains("zod-startswith"); + } + + [Test] + public async Task Get_GivenNoZodSharpClientValidation_RendersOnlyTheBuiltInRules( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await StaticSsrHost.StartAsync( + static services => + { + services.AddRazorComponents(); + services.AddZodSharpFactory(static factory => + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + ); + services.AddZodSharpBlazor(); + }, + cancellationToken: cancellationToken + ); + + // Act + var document = await host.GetAsync("/order/data-annotations", cancellationToken); + + // Assert + var rules = ReadRules(document); + await Assert.That(rules["Model.Reference"].Select(static r => r.Name)).DoesNotContain("zod-startswith"); + await Assert.That(rules["Model.Reference"].Select(static r => r.Name)).Contains("required"); + } + + [Test] + public async Task Post_GivenInvalidNestedAndCollectionFields_RendersMessagesNextToTheRightInputs( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await StartAsync(cancellationToken); + + // Act + var document = await host.PostAsync( + "/order/data-annotations", + [ + new("_handler", "order"), + new("Model.Reference", "ORD-1"), + new("Model.Customer.Email", "not-an-email"), + new("Model.Lines[0].Sku", "A"), + new("Model.Lines[0].Quantity", "5"), + new("Model.Lines[1].Sku", "B"), + new("Model.Lines[1].Quantity", "0"), + ], + cancellationToken + ); + + // Assert + await Assert.That(document.QuerySelector("#submitted")).IsNull(); + await Assert.That(MessageAfter(document, "Model.Customer.Email")).IsNotEmpty(); + await Assert.That(MessageAfter(document, "Model.Lines[1].Quantity")).IsNotEmpty(); + await Assert.That(MessageAfter(document, "Model.Lines[0].Quantity")).IsEmpty(); + await Assert.That(MessageAfter(document, "Model.Reference")).IsEmpty(); + await Assert.That(ReadRules(document).Keys).Contains("Model.Reference"); + } + + [Test] + public async Task Post_GivenValidForm_Submits(CancellationToken cancellationToken) + { + // Arrange + await using var host = await StartAsync(cancellationToken); + + // Act + var document = await host.PostAsync( + "/order/data-annotations", + [ + new("_handler", "order"), + new("Model.Reference", "ORD-1"), + new("Model.Customer.Email", "someone@example.com"), + new("Model.Lines[0].Sku", "A"), + new("Model.Lines[0].Quantity", "5"), + ], + cancellationToken + ); + + // Assert + await Assert + .That(document.QuerySelector("#submitted")) + .IsNotNull() + .Because( + string.Join(" | ", document.QuerySelectorAll(".validation-message").Select(static m => m.TextContent)) + ); + } + + [Test] + public async Task Post_GivenZodValidatorForm_RendersCodesOnTheMessages(CancellationToken cancellationToken) + { + // Arrange + await using var host = await StartAsync(cancellationToken); + + // Act + var document = await host.PostAsync( + "/order/zod", + [ + new("_handler", "order"), + new("Model.Reference", "bad"), + new("Model.Lines[0].Sku", "A"), + new("Model.Lines[0].Quantity", "500"), + ], + cancellationToken + ); + + // Assert + var quantity = document.QuerySelector("div.validation-message[data-valmsg-for='Model.Lines[0].Quantity']"); + await Assert.That(quantity).IsNotNull(); + await Assert.That(quantity!.GetAttribute("data-category")).IsEqualTo("too_big"); + var reference = document.QuerySelector("div.validation-message[data-valmsg-for='Model.Reference']"); + await Assert.That(reference!.GetAttribute("data-code")).IsEqualTo("invalid_string"); + } + + static Task StartAsync(CancellationToken cancellationToken) => + StaticSsrHost.StartAsync( + static services => + { + services.AddZodSharpClientValidation(); + services.AddRazorComponents(); + services.AddZodSharpFactory(static factory => + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + ); + services.AddZodSharpBlazor(); + }, + cancellationToken: cancellationToken + ); + + /// + /// The text of the validation message rendered after the input named . + /// + static string MessageAfter(IHtmlDocument document, string name) + { + var input = + document.QuerySelector($"[name='{name}']") ?? throw new InvalidOperationException($"No input '{name}'."); + var sibling = input.NextElementSibling; + var text = string.Empty; + while (sibling is not null && sibling.ClassList.Contains("validation-message")) + { + text += sibling.TextContent; + sibling = sibling.NextElementSibling; + } + + return text.Trim(); + } + + internal sealed record Rule(string Name, string Message, Dictionary Parameters); + + /// + /// Reads the data-rules payload: the wire contract between the server and the browser's validators. + /// + internal static Dictionary> ReadRules(IDocument document) + { + var element = + document.QuerySelector("blazor-client-validation-data") + ?? throw new InvalidOperationException("No client validation payload was rendered."); + using var json = JsonDocument.Parse(element.GetAttribute("data-rules") ?? "{}"); + + Dictionary> fields = [with(StringComparer.Ordinal)]; + foreach (var field in json.RootElement.GetProperty("fields").EnumerateArray()) + { + List rules = []; + foreach (var rule in field.GetProperty("rules").EnumerateArray()) + { + Dictionary parameters = []; + if (rule.TryGetProperty("params", out var values)) + { + foreach (var value in values.EnumerateObject()) + parameters[value.Name] = value.Value.GetString() ?? string.Empty; + } + + rules.Add( + new(rule.GetProperty("name").GetString()!, rule.GetProperty("message").GetString()!, parameters) + ); + } + + fields[field.GetProperty("name").GetString()!] = rules; + } + + return fields; + } +} diff --git a/src/tests/Blazor.UnitTests/Ssr/ZodOrderPage.razor b/src/tests/Blazor.UnitTests/Ssr/ZodOrderPage.razor new file mode 100644 index 00000000..a31a5275 --- /dev/null +++ b/src/tests/Blazor.UnitTests/Ssr/ZodOrderPage.razor @@ -0,0 +1,31 @@ +@page "/order/zod" +@using ZodSharp.Blazor.Fixtures + + + + + + @for (var i = 0; i < Model!.Lines.Count; i++) + { + var index = i; + + + } + + + +@if (_submitted) +{ +

Saved

+} + +@code { + bool _submitted; + + [SupplyParameterFromForm(FormName = "order")] + GeneratedOrder? Model { get; set; } + + protected override void OnInitialized() => Model ??= new() { Lines = [new() { Sku = "A" }] }; + + void Submit() => _submitted = true; +} diff --git a/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs new file mode 100644 index 00000000..e32dc890 --- /dev/null +++ b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs @@ -0,0 +1,184 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Http; +using static ZodSharp.Blazor.Fixtures.Errors; + +namespace ZodSharp.Blazor; + +/// +/// An error inside a struct member (Email.Value, where Email is a value object) belongs to the struct's +/// nearest reference-type owner: a boxed copy of the struct cannot be a model, so the +/// message is placed on FieldIdentifier(model, "Email"), next to the input bound to Email. Both routes are +/// covered Server-style (the reflection fallback available) and WebAssembly-style (the +/// ZodSharp.Reflection.IsSupported switch off, as trimming sets it). +/// +public class StructMemberPlacementTests +{ + const string ReflectionSwitch = "ZodSharp.Reflection.IsSupported"; + + [Test] + public async Task DataAnnotationsValidator_GivenInvalidValueObjectMember_ServerStyle_PlacesTheMessageOnTheOwningMember( + CancellationToken cancellationToken + ) => await AssertPlacedOnOwningMembersAsync(FormHost.DataAnnotations(), cancellationToken); + + [Test] + [NotInParallel] + public async Task DataAnnotationsValidator_GivenInvalidValueObjectMember_WebAssemblyStyle_PlacesTheMessageOnTheOwningMember( + CancellationToken cancellationToken + ) + { + using var trimmed = WithoutReflection(); + await AssertPlacedOnOwningMembersAsync(FormHost.DataAnnotations(), cancellationToken); + } + + [Test] + public async Task ZodValidator_GivenInvalidValueObjectMember_ServerStyle_PlacesTheMessageOnTheOwningMember( + CancellationToken cancellationToken + ) => await AssertPlacedOnOwningMembersAsync(FormHost.Zod(), cancellationToken); + + [Test] + [NotInParallel] + public async Task ZodValidator_GivenInvalidValueObjectMember_WebAssemblyStyle_PlacesTheMessageOnTheOwningMember( + CancellationToken cancellationToken + ) + { + using var trimmed = WithoutReflection(); + await AssertPlacedOnOwningMembersAsync(FormHost.Zod(), cancellationToken); + } + + [Test] + public async Task DataAnnotationsValidator_GivenValidatorWithoutMemberResolution_WalksToTheOwningMember( + CancellationToken cancellationToken + ) + { + // Arrange - a validator that does not resolve members, so placement goes through the reflection walk. + SignUp model = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("invalid_string", "Email is invalid", M("Email"), M("Value"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(model); + var form = context.Render(editContext, FormHost.DataAnnotations()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(valid).IsFalse(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(model, nameof(SignUp.Email)))) + .IsEquivalentTo(["Email is invalid"]); + } + + [Test] + public async Task ZodValidator_GivenValidatorWithoutMemberResolution_WalksToTheOwningMember( + CancellationToken cancellationToken + ) + { + // Arrange + SignUp model = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_big", "Latitude is out of range", M("Location"), M("Latitude"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(model); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + await form.ValidateAsync(editContext); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(model, nameof(SignUp.Location)))) + .IsEquivalentTo(["Latitude is out of range"]); + } + + [Test] + public async Task AddZodProblemDetails_GivenAnIssueInsideAValueObject_PlacesItOnTheOwningMember( + CancellationToken cancellationToken + ) + { + // Arrange + SignUp model = new(); + await using var context = CreateContext(); + EditContext editContext = new(model); + context.Render(editContext, FormHost.Zod()); + ZodValidationProblemDetails problem = new() + { + Errors = new Dictionary { ["Email.Value"] = ["That address is taken"] }, + }; + + // Act + editContext.AddZodProblemDetails(problem); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(model, nameof(SignUp.Email)))) + .IsEquivalentTo(["That address is taken"]); + } + + static async Task AssertPlacedOnOwningMembersAsync(RenderFragment validator, CancellationToken cancellationToken) + { + // Arrange + SignUp model = new() + { + Email = new() { Value = "not-an-email" }, + BackupEmail = new() { Value = null }, + Location = new() { Latitude = 120 }, + }; + await using var context = CreateContext(); + EditContext editContext = new(model); + var form = context.Render(editContext, validator); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + var email = editContext.GetValidationMessages(new FieldIdentifier(model, nameof(SignUp.Email))).ToArray(); + var backup = editContext + .GetValidationMessages(new FieldIdentifier(model, nameof(SignUp.BackupEmail))) + .ToArray(); + var location = editContext.GetValidationMessages(new FieldIdentifier(model, nameof(SignUp.Location))).ToArray(); + string[] all = [.. editContext.GetValidationMessages()]; + + await Assert.That(valid).IsFalse(); + + // Each message sits next to the member's input, and none is lost or left only in the summary. + await Assert.That(email).HasSingleItem(); + await Assert.That(backup).HasSingleItem(); + await Assert.That(location).HasSingleItem(); + await Assert.That(all.Length).IsEqualTo(3); + + // A scalar value object's messages name the owning member (its display name), never its own 'Value'; a struct + // that is not a scalar keeps its member's name. + await Assert.That(email[0]).Contains("Email"); + await Assert.That(backup[0]).Contains("Backup email"); + await Assert.That(all.Any(static message => message.Contains("Value", StringComparison.Ordinal))).IsFalse(); + await Assert.That(location[0]).Contains("Latitude"); + } + + static BunitContext CreateContext() => + FormHost.CreateContext(static factory => + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + ); + + // The switch trimming sets: the reflection fallbacks are unavailable, as in a Blazor WebAssembly client. Tests that + // use it run on their own ([NotInParallel]), because the switch is process-wide. + static Restore WithoutReflection() + { + AppContext.SetSwitch(ReflectionSwitch, false); + return new(); + } + + readonly struct Restore : IDisposable + { + public void Dispose() => AppContext.SetSwitch(ReflectionSwitch, true); + } +} diff --git a/src/tests/Blazor.UnitTests/ZodValidatorTests.cs b/src/tests/Blazor.UnitTests/ZodValidatorTests.cs new file mode 100644 index 00000000..59abbb5f --- /dev/null +++ b/src/tests/Blazor.UnitTests/ZodValidatorTests.cs @@ -0,0 +1,592 @@ +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZodSharp.Blazor.Fixtures; +using ZodSharp.Extensions.Validation; +using static ZodSharp.Blazor.Fixtures.Errors; + +namespace ZodSharp.Blazor; + +/// +/// Route 2: <ZodValidator />. +/// +public class ZodValidatorTests +{ + [Test] + public async Task ValidateAsync_GivenErrors_PlacesMessagesAndKeepsFullErrors() + { + // Arrange + Order order = new() { Lines = [new(), new()] }; + var lineError = At("too_small", "Quantity too small", M("Lines"), I(1), M("Quantity")); + FakeValidator validator = new() + { + Errors = _ => [At("invalid_string", "Email is invalid", M("Customer"), M("Email")), lineError], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsFalse(); + FieldIdentifier quantity = new(order.Lines[1], nameof(OrderLine.Quantity)); + await Assert.That(editContext.GetValidationMessages(quantity)).IsEquivalentTo(["Quantity too small"]); + await Assert.That(editContext.GetZodErrors(quantity)).IsEquivalentTo([lineError]); + await Assert + .That(editContext.GetZodErrors(() => order.Customer.Email).Single().Code) + .IsEqualTo("invalid_string"); + await Assert.That(editContext.GetZodErrors().Count).IsEqualTo(2); + await Assert.That(validator.ReceivedServices).IsNotNull(); + } + + [Test] + public async Task ValidateAsync_GivenFixedErrors_ReplacesPreviousRun() + { + // Arrange + Order order = new(); + var fail = true; + FakeValidator validator = new() + { + Errors = _ => fail ? [At("too_small", "Reference too short", M("Reference"))] : [], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + fail = false; + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsTrue(); + await Assert.That(editContext.GetValidationMessages()).IsEmpty(); + await Assert.That(editContext.GetZodErrors()).IsEmpty(); + } + + [Test] + public async Task FieldChange_GivenSubmitTrigger_DoesNotValidate() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + MemberErrors = static (_, _) => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(ZodValidationTrigger.Submit)); + + // Act + await form.ChangeAsync(editContext, new FieldIdentifier(order, nameof(Order.Reference))); + + // Assert + await Assert.That(validator.MemberCalls).IsEmpty(); + await Assert.That(editContext.GetValidationMessages()).IsEmpty(); + } + + [Test] + public async Task FieldChange_GivenChangeTrigger_ValidatesOnlyTheChangedMember() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("custom", "should not run", M("Customer"), M("Email"))], + MemberErrors = static (_, member) => [At("too_small", $"{member} too short", M(member))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(ZodValidationTrigger.Change)); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + + // Assert + await Assert.That(validator.MemberCalls).IsEquivalentTo([nameof(Order.Reference)]); + await Assert.That(validator.Calls).IsEqualTo(0); + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Reference too short"]); + await Assert.That(editContext.GetZodErrors(reference).Single().Code).IsEqualTo("too_small"); + } + + [Test] + public async Task FieldChange_GivenInputTriggerWithDebounce_ValidatesOnlyAfterTheDelay() + { + // Arrange + Order order = new(); + ManualTimeProvider time = new(); + FakeValidator validator = new() + { + MemberErrors = static (_, _) => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + services => services.AddSingleton(time) + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(ZodValidationTrigger.Input, debounceMilliseconds: 300)); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + var callsBeforeDelay = validator.MemberCalls.Count; + var pendingDuringDelay = editContext.IsValidationPending(reference); + time.Advance(TimeSpan.FromMilliseconds(299)); + var callsJustBefore = validator.MemberCalls.Count; + time.Advance(TimeSpan.FromMilliseconds(1)); + await FormHost.WaitUntilAsync(() => !editContext.IsValidationPending(reference), "debounced validation"); + + // Assert + await Assert.That(callsBeforeDelay).IsEqualTo(0); + await Assert.That(pendingDuringDelay).IsTrue(); + await Assert.That(callsJustBefore).IsEqualTo(0); + await Assert.That(validator.MemberCalls.Count).IsEqualTo(1); + await Assert.That(editContext.GetValidationMessages(reference)).IsEquivalentTo(["Reference too short"]); + } + + [Test] + public async Task FieldChange_GivenBurstOfInputChanges_ValidatesOnce() + { + // Arrange + Order order = new(); + ManualTimeProvider time = new(); + FakeValidator validator = new() { MemberErrors = static (_, _) => [] }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + services => services.AddSingleton(time) + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(ZodValidationTrigger.Input, debounceMilliseconds: 250)); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + time.Advance(TimeSpan.FromMilliseconds(100)); + await form.ChangeAsync(editContext, reference); + time.Advance(TimeSpan.FromMilliseconds(100)); + await form.ChangeAsync(editContext, reference); + time.Advance(TimeSpan.FromMilliseconds(250)); + await FormHost.WaitUntilAsync(() => !editContext.IsValidationPending(reference), "debounced validation"); + + // Assert + await Assert.That(validator.MemberCalls.Count).IsEqualTo(1); + } + + [Test] + public async Task FieldChange_GivenNewerChangeWhileValidating_CancelsAndIgnoresTheSupersededRun() + { + // Arrange + Order order = new(); + TaskCompletionSource first = new(TaskCreationOptions.RunContinuationsAsynchronously); + var runs = 0; + FakeValidator validator = new() + { + MemberErrors = (value, _) => [At("custom", $"run for '{value.Reference}'", M("Reference"))], + MemberGate = (_, cancellationToken) => + Interlocked.Increment(ref runs) == 1 ? first.Task.WaitAsync(cancellationToken) : Task.CompletedTask, + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + order.Reference = "a"; + await form.ChangeAsync(editContext, reference); + order.Reference = "ab"; + await form.ChangeAsync(editContext, reference); + first.TrySetResult(); + await FormHost.WaitUntilAsync(() => !editContext.IsValidationPending(reference), "second run"); + + // Assert + await Assert.That(validator.MemberTokens[0].IsCancellationRequested).IsTrue(); + await Assert.That(editContext.GetValidationMessages(reference)).IsEquivalentTo(["run for 'ab'"]); + await Assert.That(editContext.IsValidationFaulted(reference)).IsFalse(); + } + + [Test] + public async Task FieldChange_GivenAsyncMemberRule_ReportsPendingThenResolves() + { + // Arrange + Order order = new(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + FakeValidator validator = new() + { + MemberErrors = static (_, _) => [At("custom", "Reference taken", M("Reference"))], + MemberGate = (_, _) => release.Task, + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + var pending = editContext.IsValidationPending(reference); + release.SetResult(); + await FormHost.WaitUntilAsync(() => !editContext.IsValidationPending(reference), "resolved"); + + // Assert + await Assert.That(pending).IsTrue(); + await Assert.That(editContext.GetValidationMessages(reference)).IsEquivalentTo(["Reference taken"]); + } + + [Test] + public async Task FieldChange_GivenThrowingValidator_MarksFaultedAndLogs() + { + // Arrange + Order order = new(); + using CapturingLoggerProvider logs = new(); + FakeValidator validator = new() + { + MemberErrors = static (_, _) => [], + MemberGate = static (_, _) => Task.FromException(new InvalidOperationException("boom")), + }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + services => services.AddLogging(builder => builder.AddProvider(logs)) + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + FieldIdentifier reference = new(order, nameof(Order.Reference)); + + // Act + await form.ChangeAsync(editContext, reference); + await FormHost.WaitUntilAsync(() => editContext.IsValidationFaulted(reference), "faulted"); + + // Assert + var entry = logs.Entries.Single(static e => e.Category == typeof(ZodValidator).FullName); + await Assert.That(entry.Level).IsEqualTo(LogLevel.Error); + await Assert.That(entry.Message).Contains(nameof(Order.Reference)); + await Assert.That(entry.Exception).IsTypeOf(); + } + + [Test] + public async Task FieldChange_GivenNoMemberValidation_KeepsOnlyTheChangedFieldsErrors() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => + [ + At("too_small", "Reference too short", M("Reference")), + At("invalid_string", "Email is invalid", M("Customer"), M("Email")), + ], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + await form.ChangeAsync(editContext, new FieldIdentifier(order.Customer, nameof(Customer.Email))); + + // Assert + await Assert.That(validator.Calls).IsEqualTo(1); + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Email is invalid"]); + } + + [Test] + public async Task FieldChange_GivenOtherFieldsMessages_LeavesThemInPlace() + { + // Arrange + Order order = new(); + var email = true; + FakeValidator validator = new() + { + Errors = _ => + email + ? + [ + At("too_small", "Reference too short", M("Reference")), + At("invalid_string", "Email is invalid", M("Customer"), M("Email")), + ] + : [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + email = false; + + // Act + await form.ChangeAsync(editContext, new FieldIdentifier(order.Customer, nameof(Customer.Email))); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Reference too short"]); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, nameof(Order.Reference)))) + .IsEquivalentTo(["Reference too short"]); + } + + [Test] + public async Task FieldChange_GivenValidateNestedOnFieldChange_UpdatesTheWholeOwningObject() + { + // Arrange + Order order = new(); + FakeValidator customer = new() + { + Errors = static value => + value.Email == value.ConfirmEmail ? [] : [At("custom", "Emails differ", M("ConfirmEmail"))], + MemberErrors = static (_, _) => [], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(customer)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(schema: null, validateNestedOnFieldChange: true)); + FieldIdentifier email = new(order.Customer, nameof(Customer.Email)); + FieldIdentifier confirm = new(order.Customer, nameof(Customer.ConfirmEmail)); + + // Act + order.Customer.Email = "a@example.com"; + await form.ChangeAsync(editContext, email); + var afterMismatch = editContext.GetValidationMessages(confirm).ToArray(); + order.Customer.ConfirmEmail = "a@example.com"; + await form.ChangeAsync(editContext, email); + + // Assert + await Assert.That(afterMismatch).IsEquivalentTo(["Emails differ"]); + await Assert.That(editContext.GetValidationMessages(confirm)).IsEmpty(); + await Assert.That(customer.MemberCalls).IsEmpty(); + } + + [Test] + public async Task FieldChange_GivenNoNestedValidation_DoesNotTouchSiblingFields() + { + // Arrange + Order order = new(); + FakeValidator customer = new() + { + Errors = static _ => [At("custom", "Emails differ", M("ConfirmEmail"))], + MemberErrors = static (_, _) => [], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(customer)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + await form.ChangeAsync(editContext, new FieldIdentifier(order.Customer, nameof(Customer.Email))); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEmpty(); + await Assert.That(customer.MemberCalls).IsEquivalentTo([nameof(Customer.Email)]); + } + + [Test] + public async Task ValidateAsync_GivenRuntimeObjectSchema_PlacesErrorsOnTheClrFields() + { + // Arrange + Order order = new() + { + Reference = "ab", + Customer = new() { Email = "not-an-email" }, + }; + var schema = Z.Object() + .Field("Reference", Z.String().Min(3)) + .Field("Customer", Z.Object().Field("Email", Z.String().Email()).Build()) + .Build(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(schema: ZodModelSchema.For(schema))); + + // Act + var valid = await form.ValidateAsync(editContext); + + // Assert + await Assert.That(valid).IsFalse(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, nameof(Order.Reference)))) + .IsNotEmpty(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Customer, nameof(Customer.Email)))) + .IsNotEmpty(); + await Assert.That(editContext.GetZodErrors(() => order.Reference).Single().Category).IsEqualTo("too_small"); + } + + [Test] + public async Task FieldChange_GivenRuntimeObjectSchema_UpdatesOnlyTheChangedField() + { + // Arrange + Order order = new() + { + Reference = "ab", + Customer = new() { Email = "not-an-email" }, + }; + var schema = Z.Object() + .Field("Reference", Z.String().Min(3)) + .Field("Customer", Z.Object().Field("Email", Z.String().Email()).Build()) + .Build(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod(schema: ZodModelSchema.For(schema))); + + // Act + await form.ChangeAsync(editContext, new FieldIdentifier(order, nameof(Order.Reference))); + + // Assert + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order, nameof(Order.Reference)))) + .IsNotEmpty(); + await Assert + .That(editContext.GetValidationMessages(new FieldIdentifier(order.Customer, nameof(Customer.Email)))) + .IsEmpty(); + } + + [Test] + public async Task Render_GivenDataAnnotationsValidator_ShowsEachMessageOnceAndWarnsOnce() + { + // Arrange + Order order = new(); + using CapturingLoggerProvider logs = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + services => services.AddLogging(builder => builder.AddProvider(logs)) + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Both(FormHost.DataAnnotations(), FormHost.Zod())); + + // Act + await form.ValidateAsync(editContext); + await form.ValidateAsync(editContext); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Reference too short"]); + await Assert.That(editContext.GetZodErrors(() => order.Reference).Single().Code).IsEqualTo("too_small"); + await Assert.That(logs.Entries.Count(static e => e.Level == LogLevel.Warning)).IsEqualTo(1); + } + + [Test] + public async Task Render_GivenZodValidatorFirst_StillDetectsDataAnnotationsValidator() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Both(FormHost.Zod(), FormHost.DataAnnotations())); + + // Act + await form.ValidateAsync(editContext); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["Reference too short"]); + } + + [Test] + public async Task Render_GivenEnableClientValidation_SetsTheClientValidationKeyUntilDisposed() + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + + // Act + var form = context.Render(editContext, FormHost.Zod()); + var whileRendered = editContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out _); + await context.DisposeComponentsAsync(); + + // Assert + await Assert.That(whileRendered).IsTrue(); + await Assert.That(editContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out _)).IsFalse(); + } + + [Test] + public async Task Render_GivenClientValidationDisabled_DoesNotSetTheClientValidationKey() + { + // Arrange + Order order = new(); + await using var context = FormHost.CreateContext(); + EditContext editContext = new(order); + + // Act + context.Render(editContext, FormHost.Zod(enableClientValidation: false)); + + // Assert + await Assert.That(editContext.Properties.TryGetValue(typeof(DataAnnotationsValidator), out _)).IsFalse(); + } + + [Test] + public async Task ValidateAsync_GivenRegisteredReportSink_LeavesItUntouched() + { + // Arrange + Order order = new(); + RecordingSink sink = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + services => services.AddSingleton(sink) + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + await form.ValidateAsync(editContext); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsNotEmpty(); + await Assert.That(sink.Calls).IsEmpty(); + } + + [Test] + public async Task ClearZodErrors_GivenValidatorErrors_RemovesMessagesAndErrors() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext(factory => factory.Register(validator)); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + await form.ValidateAsync(editContext); + + // Act + await form.InvokeAsync(editContext.ClearZodErrors); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEmpty(); + await Assert.That(editContext.GetZodErrors()).IsEmpty(); + } + + [Test] + public async Task ValidateAsync_GivenMessageFormatter_RendersMessagesThroughIt() + { + // Arrange + Order order = new(); + FakeValidator validator = new() + { + Errors = static _ => [At("too_small", "Reference too short", M("Reference"))], + }; + await using var context = FormHost.CreateContext( + factory => factory.Register(validator), + options: static o => o.MessageFormatter = new PrefixFormatter("!") + ); + EditContext editContext = new(order); + var form = context.Render(editContext, FormHost.Zod()); + + // Act + await form.ValidateAsync(editContext); + + // Assert + await Assert.That(editContext.GetValidationMessages()).IsEquivalentTo(["!Reference too short"]); + } + + sealed class PrefixFormatter(string prefix) : Core.IZodMessageFormatter + { + public string Format(in Core.ValidationError validationError, System.Globalization.CultureInfo culture) => + prefix + validationError.Message; + } +} diff --git a/src/tests/Blazor.UnitTests/_Imports.razor b/src/tests/Blazor.UnitTests/_Imports.razor new file mode 100644 index 00000000..25dbe08f --- /dev/null +++ b/src/tests/Blazor.UnitTests/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using ZodSharp.Blazor diff --git a/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs b/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs new file mode 100644 index 00000000..23e3f3cc --- /dev/null +++ b/src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs @@ -0,0 +1,226 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Examples.Blazor.Client; +using ZodSharp.Examples.Blazor.Components; + +namespace ZodSharp.Examples.Blazor; + +/// +/// The server-side half of the HttpClient story: the the Interactive Server page uses, +/// resolved from the sample's own registration (a typed client from IHttpClientFactory, in a request scope) and +/// pointed at the in-memory server. Every call goes through the real API, its validation and its ProblemDetails. +/// +public class CustomerAPIClientTests +{ + [Test] + public async Task RegisterAsync_GivenValidCustomer_RegistersThroughTheAPI(CancellationToken cancellationToken) + { + // Arrange + await using ClientHost host = new(); + var customers = host.Resolve(); + var email = UniqueEmail(); + + // Act + var registration = await customers.RegisterAsync( + new() + { + Name = "Ada", + Email = email, + Age = 36, + }, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(registration.Outcome).IsEqualTo(RegistrationOutcome.Registered); + await Assert.That(registration.Customer!.Email).IsEqualTo(email); + await Assert.That(host.Posts).IsEqualTo(1); + } + + [Test] + public async Task RegisterAsync_GivenInvalidCustomer_RejectsWithoutSending(CancellationToken cancellationToken) + { + // Arrange + await using ClientHost host = new(); + var customers = host.Resolve(); + + // Act + var registration = await customers.RegisterAsync( + new() + { + Name = "A", + Email = "not-an-email", + Age = 5, + }, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(registration.Outcome).IsEqualTo(RegistrationOutcome.RejectedBeforeSending); + await Assert + .That(registration.Errors.Select(static error => error.FullPath.ToDottedString())) + .IsEquivalentTo(["Name", "Email", "Age"]); + await Assert.That(host.Posts).IsEqualTo(0); + } + + [Test] + public async Task RegisterAsync_GivenRegisteredEmail_RunsTheServiceAwareRuleBeforeSending( + CancellationToken cancellationToken + ) + { + // Arrange + await using ClientHost host = new(); + var customers = host.Resolve(); + + // Act + var registration = await customers.RegisterAsync( + new() + { + Name = "Ann", + Email = InMemoryCustomerEmailRegistry.SeededEmail, + Age = 30, + }, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(registration.Outcome).IsEqualTo(RegistrationOutcome.RejectedBeforeSending); + var error = registration.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(RegisterCustomerRequest.EmailTakenCode); + await Assert.That(error.FullPath).IsEqualTo(RegisterCustomerRequestSchema.Paths.Email); + await Assert.That(host.Posts).IsEqualTo(0); + } + + [Test] + public async Task RegisterAsync_GivenEmailRegisteredByAnEarlierCall_RejectsTheSecondBeforeSending( + CancellationToken cancellationToken + ) + { + // Arrange + await using ClientHost host = new(); + var customers = host.Resolve(); + RegisterCustomerRequest request = new() + { + Name = "Grace", + Email = UniqueEmail(), + Age = 85, + }; + var first = await customers.RegisterAsync(request, cancellationToken: cancellationToken); + + // Act + var second = await customers.RegisterAsync(request, cancellationToken: cancellationToken); + + // Assert + await Assert.That(first.Outcome).IsEqualTo(RegistrationOutcome.Registered); + await Assert.That(second.Outcome).IsEqualTo(RegistrationOutcome.RejectedBeforeSending); + await Assert.That(second.Errors.Single().Code).IsEqualTo(RegisterCustomerRequest.EmailTakenCode); + await Assert.That(host.Posts).IsEqualTo(1); + } + + [Test] + public async Task RegisterAsync_GivenValidateBeforeSendingFalse_ReturnsTheServersErrors( + CancellationToken cancellationToken + ) + { + // Arrange + await using ClientHost host = new(); + var customers = host.Resolve(); + + // Act + var registration = await customers.RegisterAsync( + new() + { + Name = "A", + Email = "not-an-email", + Age = 5, + }, + validateBeforeSending: false, + cancellationToken + ); + + // Assert + await Assert.That(registration.Outcome).IsEqualTo(RegistrationOutcome.RejectedByServer); + await Assert.That(registration.Status).IsEqualTo(400); + await Assert + .That(registration.Errors.Select(static error => (error.FullPath.ToDottedString(), error.Code))) + .Contains(("Email", "invalid_string")); + await Assert + .That(registration.Errors.Select(static error => error.FullPath.ToDottedString()).Distinct()) + .IsEquivalentTo(["Name", "Email", "Age"]); + await Assert.That(host.Posts).IsEqualTo(1); + } + + [Test] + public async Task GetRegisterServer_GivenInteractiveServerPage_PrerendersTheSharedForm( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + + // Act + var document = await SampleAppFactory.GetDocumentAsync(client, "/register-server", cancellationToken); + + // Assert + await Assert.That(document.QuerySelector("h1")?.TextContent).IsEqualTo("Register a customer (server)"); + await Assert.That(document.QuerySelector("#name")).IsNotNull(); + await Assert.That(document.QuerySelector("#email")).IsNotNull(); + await Assert.That(document.QuerySelector("#age")).IsNotNull(); + await Assert.That(document.QuerySelector("#post-unvalidated")).IsNotNull(); + } + + static string UniqueEmail() => $"{Guid.NewGuid():N}@example.com"; + + /// + /// The sample with its registration kept as it is, except that the typed client's + /// primary handler is the in-memory server's and it gets that server's base address - an in-memory server has no + /// listening address to discover. Counts the requests the client actually sends to the API. + /// + sealed class ClientHost : IAsyncDisposable + { + readonly SampleAppFactory _factory = new(); + readonly WebApplicationFactory _configured; + readonly AsyncServiceScope _scope; + int _posts; + + public ClientHost() + { + _configured = _factory.WithWebHostBuilder(builder => + builder.ConfigureTestServices(services => + services + .AddHttpClient() + .ConfigureHttpClient(static http => http.BaseAddress = new("http://localhost/")) + .ConfigurePrimaryHttpMessageHandler(() => + new CountingHandler(this, _configured!.Server.CreateHandler()) + ) + ) + ); + _scope = _configured.Services.CreateAsyncScope(); + } + + public int Posts => _posts; + + public CustomerAPIClient Resolve() => _scope.ServiceProvider.GetRequiredService(); + + public async ValueTask DisposeAsync() + { + await _scope.DisposeAsync(); + await _configured.DisposeAsync(); + await _factory.DisposeAsync(); + } + + sealed class CountingHandler(ClientHost host, HttpMessageHandler inner) : DelegatingHandler(inner) + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Interlocked.Increment(ref host._posts); + return base.SendAsync(request, cancellationToken); + } + } + } +} diff --git a/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs b/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs new file mode 100644 index 00000000..1a7abbe4 --- /dev/null +++ b/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ZodSharp.Examples.Blazor; + +/// +/// The API the WebAssembly form posts to: the ZodValidationProblemDetails contract it maps back onto its fields, the +/// uniqueness lookup and the culture switch. +/// +public class CustomerEndpointTests +{ + static async Task<(HttpStatusCode Status, string? MediaType, JsonElement Body)> PostCustomerAsync( + HttpClient client, + string json, + CancellationToken cancellationToken, + string? acceptLanguage = null + ) + { + using HttpRequestMessage request = new(HttpMethod.Post, new Uri("/api/customers", UriKind.Relative)) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + if (acceptLanguage is not null) + request.Headers.AcceptLanguage.ParseAdd(acceptLanguage); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return ( + response.StatusCode, + response.Content.Headers.ContentType?.MediaType, + body.Length == 0 ? default : JsonElement.Parse(body) + ); + } + + static JsonElement Issue(JsonElement problem, string path) => + problem.GetProperty("issues").EnumerateArray().First(issue => issue.GetProperty("path").GetString() == path); + + [Test] + public async Task PostCustomers_GivenInvalidCustomer_ReturnsZodValidationProblemDetails( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + + // Act + var (status, mediaType, problem) = await PostCustomerAsync( + client, + /*lang=json,strict*/"""{ "name": "A", "email": "not-an-email", "age": 5 }""", + cancellationToken + ); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(mediaType).IsEqualTo("application/problem+json"); + await Assert + .That(problem.GetProperty("errors").EnumerateObject().Select(static e => e.Name)) + .IsEquivalentTo(["Name", "Email", "Age"]); + await Assert.That(Issue(problem, "Email").GetProperty("code").GetString()).IsEqualTo("invalid_string"); + await Assert.That(Issue(problem, "Age").GetProperty("code").GetString()).IsEqualTo("invalid_range"); + await Assert.That(Issue(problem, "Age").GetProperty("segments").GetArrayLength()).IsEqualTo(1); + await Assert.That(problem.TryGetProperty("traceId", out _)).IsTrue(); + } + + [Test] + public async Task PostCustomers_GivenRegisteredEmailInFrench_ReturnsTheLocalizedAsyncRuleError( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + + // Act + var (status, _, problem) = await PostCustomerAsync( + client, + /*lang=json,strict*/$$"""{ "name": "Ann", "email": "{{InMemoryCustomerEmailRegistry.SeededEmail}}", "age": 30 }""", + cancellationToken, + acceptLanguage: "fr" + ); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + var issue = Issue(problem, "Email"); + await Assert.That(issue.GetProperty("code").GetString()).IsEqualTo(RegisterCustomerRequest.EmailTakenCode); + await Assert + .That(issue.GetProperty("message").GetString()) + .IsEqualTo("Cette adresse e-mail est déjà enregistrée"); + } + + [Test] + public async Task PostCustomers_GivenValidCustomer_RegistersTheEmail(CancellationToken cancellationToken) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + const string customer = /*lang=json,strict*/ + """{ "name": "Ann", "email": "ann@example.com", "age": 30 }"""; + + // Act + var (first, _, _) = await PostCustomerAsync(client, customer, cancellationToken); + using var lookup = await client.GetAsync( + new Uri("/api/customers/registered?email=ann@example.com", UriKind.Relative), + cancellationToken + ); + var registered = JsonElement.Parse(await lookup.Content.ReadAsStringAsync(cancellationToken)); + var (second, _, problem) = await PostCustomerAsync(client, customer, cancellationToken); + + // Assert + await Assert.That(first).IsEqualTo(HttpStatusCode.Created); + await Assert.That(registered.GetProperty("registered").GetBoolean()).IsTrue(); + await Assert.That(second).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(Issue(problem, "Email").GetProperty("code").GetString()) + .IsEqualTo(RegisterCustomerRequest.EmailTakenCode); + } + + [Test] + public async Task GetCultureSet_GivenFrench_StoresTheCultureAndRendersTheNextPageInFrench( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + + // Act + var document = await SampleAppFactory.GetDocumentAsync( + client, + "/culture/set?culture=fr&redirectUri=%2Freturns", + cancellationToken + ); + + // Assert + await Assert.That(document.DocumentElement.GetAttribute("lang")).IsEqualTo("fr"); + await Assert.That(document.QuerySelector("h1")?.TextContent).IsEqualTo("Return items"); + } + + [Test] + public async Task GetCultureSet_GivenAnExternalRedirect_RedirectsHome(CancellationToken cancellationToken) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + // Act + using var response = await client.GetAsync( + new Uri("/culture/set?culture=fr&redirectUri=https%3A%2F%2Fexample.com", UriKind.Relative), + cancellationToken + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect); + await Assert.That(response.Headers.Location?.OriginalString).IsEqualTo("/"); + } +} diff --git a/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj b/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj new file mode 100644 index 00000000..7ad158b7 --- /dev/null +++ b/src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj @@ -0,0 +1,25 @@ + + + + net11.0 + + false + + 11.0.0-rc.3.26522.148 + 11.0.0-rc.3.26522.148 + + $(NoWarn);CA1506;NU5104 + + + + + + + + + + diff --git a/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs b/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs new file mode 100644 index 00000000..1cd253db --- /dev/null +++ b/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using AngleSharp.Dom; +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Logging; +using ZodSharp.Examples.Blazor.Components; + +namespace ZodSharp.Examples.Blazor; + +/// +/// Hosts the Blazor sample in memory - the same Program.cs, Razor components, static web assets and API - on a +/// TestServer. +/// +sealed class SampleAppFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder.UseEnvironment("Development").ConfigureLogging(static logging => logging.ClearProviders()); + + /// + /// Gets and parses the HTML. + /// + public static async Task GetDocumentAsync( + HttpClient client, + string path, + CancellationToken cancellationToken + ) + { + using var response = await client.GetAsync(new Uri(path, UriKind.Relative), cancellationToken); + response.EnsureSuccessStatusCode(); + return await ParseAsync(response, cancellationToken); + } + + /// + /// Posts a static-SSR form the way a browser does: the fields, the form name and the antiforgery token rendered + /// into (its cookie is kept by ). + /// + public static async Task PostFormAsync( + HttpClient client, + string path, + IHtmlDocument page, + string formName, + IEnumerable> fields, + CancellationToken cancellationToken + ) + { + var token = + page.QuerySelector("input[name='__RequestVerificationToken']")?.GetAttribute("value") + ?? throw new InvalidOperationException("The form rendered no antiforgery token."); + + using FormUrlEncodedContent content = new([ + new("_handler", formName), + new("__RequestVerificationToken", token), + .. fields, + ]); + using var response = await client.PostAsync(new Uri(path, UriKind.Relative), content, cancellationToken); + response.EnsureSuccessStatusCode(); + return await ParseAsync(response, cancellationToken); + } + + static async Task ParseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var html = await response.Content.ReadAsStringAsync(cancellationToken); + return await new HtmlParser().ParseDocumentAsync(html, cancellationToken); + } + + /// + /// The text of the validation messages rendered directly after the input named . + /// + public static string MessageAfter(IDocument document, string name) + { + var input = + document.QuerySelector($"[name='{name}']") ?? throw new InvalidOperationException($"No input '{name}'."); + var text = string.Empty; + for ( + var sibling = input.NextElementSibling; + sibling is not null && sibling.ClassList.Contains("validation-message"); + sibling = sibling.NextElementSibling + ) + text += sibling.TextContent; + + return text.Trim(); + } + + /// + /// One client-side rule from the data-rules payload. + /// + public sealed record ClientRule(string Name, string Message, IReadOnlyDictionary Parameters); + + /// + /// Reads the data-rules payload of the form - the contract between the server and the browser's validators - + /// keyed by form field name. + /// + public static Dictionary> ReadClientRules(IDocument document) + { + var element = + document.QuerySelector("blazor-client-validation-data") + ?? throw new InvalidOperationException("No client validation payload was rendered."); + using var json = JsonDocument.Parse(element.GetAttribute("data-rules") ?? "{}"); + + Dictionary> fields = [with(StringComparer.Ordinal)]; + foreach (var field in json.RootElement.GetProperty("fields").EnumerateArray()) + { + List rules = []; + foreach (var rule in field.GetProperty("rules").EnumerateArray()) + { + Dictionary parameters = [with(StringComparer.Ordinal)]; + if (rule.TryGetProperty("params", out var values)) + { + foreach (var value in values.EnumerateObject()) + parameters[value.Name] = value.Value.GetString() ?? string.Empty; + } + + rules.Add( + new(rule.GetProperty("name").GetString()!, rule.GetProperty("message").GetString()!, parameters) + ); + } + + fields[field.GetProperty("name").GetString()!] = rules; + } + + return fields; + } +} diff --git a/src/tests/Examples.Blazor.IntegrationTests/StaticSsrFormTests.cs b/src/tests/Examples.Blazor.IntegrationTests/StaticSsrFormTests.cs new file mode 100644 index 00000000..892ed145 --- /dev/null +++ b/src/tests/Examples.Blazor.IntegrationTests/StaticSsrFormTests.cs @@ -0,0 +1,130 @@ +namespace ZodSharp.Examples.Blazor; + +/// +/// The sample's static-SSR form (/returns): the client-validation payload, and server validation of the posted +/// form with each message next to its input. +/// +public class StaticSsrFormTests +{ + const string Path = "/returns"; + const string FormName = "return"; + + static KeyValuePair[] ValidFields() => + [ + new("Model.Reference", "RMA-1001"), + new("Model.OrderId", "3f2504e0-4f89-41d3-9a0c-0305e82c3301"), + new("Model.ReturnAddress.Street", "1 Main Street"), + new("Model.ReturnAddress.City", "Leeds"), + new("Model.ReturnAddress.PostCode", "LS1 1AA"), + new("Model.Items[0].Sku", "ABC-1234"), + new("Model.Items[0].Quantity", "1"), + new("Model.Items[1].Sku", "DEF-5678"), + new("Model.Items[1].Quantity", "2"), + ]; + + [Test] + public async Task GetReturns_GivenAddZodSharpClientValidation_RendersZodSharpRulesInTheClientRules( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + + // Act + var document = await SampleAppFactory.GetDocumentAsync(client, Path, cancellationToken); + var rules = SampleAppFactory.ReadClientRules(document); + + // Assert + var reference = rules["Model.Reference"]; + await Assert.That(reference.Select(static r => r.Name)).Contains("required"); + var startsWith = reference.Single(static r => r.Name == "zod-startswith"); + await Assert.That(startsWith.Parameters["prefix"]).IsEqualTo(ReturnRequest.ReferencePrefix); + await Assert.That(rules["Model.OrderId"].Select(static r => r.Name)).Contains("zod-uuid"); + await Assert.That(rules["Model.ReturnAddress.City"].Select(static r => r.Name)).Contains("required"); + await Assert.That(rules["Model.Items[1].Quantity"].Select(static r => r.Name)).Contains("range"); + } + + [Test] + public async Task PostReturns_GivenInvalidNestedAndCollectionFields_RendersMessagesNextToTheRightInputs( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + var page = await SampleAppFactory.GetDocumentAsync(client, Path, cancellationToken); + var fields = ValidFields() + .Select(static f => + f.Key switch + { + "Model.Reference" => new(f.Key, "RET-1001"), + "Model.ReturnAddress.City" => new(f.Key, ""), + "Model.Items[1].Quantity" => new(f.Key, "0"), + _ => f, + } + ) + .ToArray(); + + // Act + var document = await SampleAppFactory.PostFormAsync(client, Path, page, FormName, fields, cancellationToken); + + // Assert + await Assert.That(document.QuerySelector("#submitted")).IsNull(); + await Assert.That(SampleAppFactory.MessageAfter(document, "Model.Reference")).Contains("RMA-"); + await Assert.That(SampleAppFactory.MessageAfter(document, "Model.ReturnAddress.City")).IsNotEmpty(); + await Assert.That(SampleAppFactory.MessageAfter(document, "Model.Items[1].Quantity")).IsNotEmpty(); + await Assert.That(SampleAppFactory.MessageAfter(document, "Model.Items[0].Quantity")).IsEmpty(); + await Assert.That(SampleAppFactory.MessageAfter(document, "Model.ReturnAddress.Street")).IsEmpty(); + await Assert.That(SampleAppFactory.MessageAfter(document, "Model.OrderId")).IsEmpty(); + // The re-rendered form carries the client rules again. + await Assert.That(SampleAppFactory.ReadClientRules(document).Keys).Contains("Model.Reference"); + } + + [Test] + public async Task PostReturns_GivenValidForm_Submits(CancellationToken cancellationToken) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + var page = await SampleAppFactory.GetDocumentAsync(client, Path, cancellationToken); + + // Act + var document = await SampleAppFactory.PostFormAsync( + client, + Path, + page, + FormName, + ValidFields(), + cancellationToken + ); + + // Assert + await Assert + .That(document.QuerySelector("#submitted")) + .IsNotNull() + .Because( + string.Join(" | ", document.QuerySelectorAll(".validation-message").Select(static m => m.TextContent)) + ); + } + + [Test] + public async Task GetOrders_GivenInteractiveServerPage_PrerendersTheNestedCollectionAndDictionaryForm( + CancellationToken cancellationToken + ) + { + // Arrange + await using SampleAppFactory factory = new(); + using var client = factory.CreateClient(); + + // Act + var document = await SampleAppFactory.GetDocumentAsync(client, "/orders", cancellationToken); + + // Assert + await Assert.That(document.QuerySelector("#shipping-city")).IsNotNull(); + await Assert.That(document.QuerySelector("#sku-0")).IsNotNull(); + await Assert + .That(document.QuerySelectorAll("button").Select(static b => b.TextContent)) + .Contains("Add a billing address"); + } +} diff --git a/src/tests/Examples.MinimalApi.IntegrationTests/.editorconfig b/src/tests/Examples.MinimalApi.IntegrationTests/.editorconfig new file mode 100644 index 00000000..52689348 --- /dev/null +++ b/src/tests/Examples.MinimalApi.IntegrationTests/.editorconfig @@ -0,0 +1,5 @@ +# Layered over the Purview.BuildSdk-managed root .editorconfig (which must not be edited). + +[*.{cs,csx,cake}] +# Follows the sample's "MinimalApi" spelling (see src/src/Examples.MinimalApi/.editorconfig). Scoped to this project. +dotnet_analyzer_configuration.pds0004.allowed_words = Api diff --git a/src/tests/Examples.MinimalApi.IntegrationTests/Examples.MinimalApi.IntegrationTests.csproj b/src/tests/Examples.MinimalApi.IntegrationTests/Examples.MinimalApi.IntegrationTests.csproj new file mode 100644 index 00000000..24905229 --- /dev/null +++ b/src/tests/Examples.MinimalApi.IntegrationTests/Examples.MinimalApi.IntegrationTests.csproj @@ -0,0 +1,16 @@ + + + + net11.0 + + false + + $(NoWarn);CA1506 + + + + + + + diff --git a/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppHost.cs b/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppHost.cs new file mode 100644 index 00000000..1f4704f9 --- /dev/null +++ b/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppHost.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ZodSharp.Examples.MinimalApi; + +/// +/// Starts the sample in memory: the same registration and pipeline Program.cs runs, +/// on a instead of Kestrel. +/// +sealed class SampleAppHost : IAsyncDisposable +{ + SampleAppHost(WebApplication app) + { + App = app; + Client = app.GetTestClient(); + } + + public WebApplication App { get; } + + public HttpClient Client { get; } + + public static async Task StartAsync( + CancellationToken cancellationToken, + IDictionary? settings = null + ) + { + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = "Production" }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + + // The sample's appsettings.json lives beside the sample, not the test assembly, so the validated options + // are supplied here; a test can override them to exercise startup validation. + builder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["Sample:ApiName"] = "Smoke test", + ["Sample:SupportEmail"] = "support@example.com", + ["Sample:MaxLinesPerOrder"] = "20", + } + ); + if (settings is not null) + builder.Configuration.AddInMemoryCollection(settings); + + SampleApp.ConfigureServices(builder); + + var app = builder.Build(); + SampleApp.Configure(app); + + try + { + await app.StartAsync(cancellationToken); + } + catch + { + await app.DisposeAsync(); + throw; + } + + return new SampleAppHost(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.DisposeAsync(); + } +} diff --git a/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs b/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs new file mode 100644 index 00000000..ff0ac16f --- /dev/null +++ b/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace ZodSharp.Examples.MinimalApi; + +/// +/// Smoke tests for the minimal API sample, started in memory. CI additionally publishes it with Native AOT and +/// runs the same failing request against the native binary. +/// +public partial class SampleAppTests +{ + const string InvalidOrder = /*lang=json,strict*/ + """ + { + "customerEmail": "not-an-email", + "shippingAddress": { "street": "1 Main Street", "city": "", "postCode": "LS1 1AA" }, + "lines": [{ "sku": "ABC-1234", "quantity": 0 }], + "additionalAddresses": { "billing": { "street": "2 High Street", "city": "Leeds", "postCode": "1" } } + } + """; + + const string ValidOrder = /*lang=json,strict*/ + """ + { + "customerEmail": "ann@example.com", + "shippingAddress": { "street": "1 Main Street", "city": "Leeds", "postCode": "LS1 1AA" }, + "lines": [{ "sku": "ABC-1234", "quantity": 2 }] + } + """; + + static async Task<(HttpStatusCode Status, string? MediaType, string Body)> PostAsync( + SampleAppHost host, + string path, + string json, + CancellationToken cancellationToken, + string? acceptLanguage = null + ) + { + using HttpRequestMessage request = new(HttpMethod.Post, new Uri(path, UriKind.Relative)) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + if (acceptLanguage is not null) + request.Headers.AcceptLanguage.ParseAdd(acceptLanguage); + + using var response = await host.Client.SendAsync(request, cancellationToken); + return ( + response.StatusCode, + response.Content.Headers.ContentType?.MediaType, + await response.Content.ReadAsStringAsync(cancellationToken) + ); + } + + static string Code(JsonElement issues, string path) + { + foreach (var issue in issues.EnumerateArray()) + { + if (issue.GetProperty("path").GetString() == path) + return issue.GetProperty("code").GetString()!; + } + + throw new InvalidOperationException($"No issue at '{path}'."); + } + + [Test] + public async Task PostOrders_GivenInvalidOrder_ReturnsZodValidationProblemDetails( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await SampleAppHost.StartAsync(cancellationToken); + + // Act + var (status, mediaType, body) = await PostAsync(host, "/orders", InvalidOrder, cancellationToken); + var root = JsonElement.Parse(body); + var errors = root.GetProperty("errors"); + var issues = root.GetProperty("issues"); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(mediaType).IsEqualTo("application/problem+json"); + await Assert.That(root.GetProperty("status").GetInt32()).IsEqualTo(400); + await Assert + .That(errors.EnumerateObject().Select(static e => e.Name)) + .IsEquivalentTo([ + "CustomerEmail", + "ShippingAddress.City", + "Lines[0].Quantity", + "AdditionalAddresses[billing].PostCode", + ]); + await Assert.That(Code(issues, "CustomerEmail")).IsEqualTo("invalid_string"); + await Assert.That(Code(issues, "ShippingAddress.City")).IsEqualTo("missing_field"); + await Assert.That(Code(issues, "Lines[0].Quantity")).IsEqualTo("invalid_range"); + await Assert.That(Code(issues, "AdditionalAddresses[billing].PostCode")).IsEqualTo("invalid_length"); + await Assert.That(root.TryGetProperty("traceId", out _)).IsTrue(); + } + + [Test] + public async Task PostOrders_GivenValidOrder_ReturnsCreated(CancellationToken cancellationToken) + { + // Arrange + await using var host = await SampleAppHost.StartAsync(cancellationToken); + + // Act + var (status, _, body) = await PostAsync(host, "/orders", ValidOrder, cancellationToken); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.Created); + await Assert.That(JsonElement.Parse(body).GetProperty("lines").GetInt32()).IsEqualTo(1); + } + + [Test] + public async Task PostOrders_GivenPipelineFilterAndManualEndpoints_ReturnTheSameContract( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await SampleAppHost.StartAsync(cancellationToken); + + // Act + var pipeline = await PostAsync(host, "/orders", InvalidOrder, cancellationToken); + var filter = await PostAsync(host, "/filtered/orders", InvalidOrder, cancellationToken); + var manual = await PostAsync(host, "/manual/orders", InvalidOrder, cancellationToken); + + // Assert + await Assert.That(filter.Status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(manual.Status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(NormalizeTraceId(filter.Body)).IsEqualTo(NormalizeTraceId(pipeline.Body)); + await Assert.That(NormalizeTraceId(manual.Body)).IsEqualTo(NormalizeTraceId(pipeline.Body)); + } + + [Test] + public async Task PostCustomers_GivenRegisteredEmail_ReportsTheAsyncUniquenessRule( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await SampleAppHost.StartAsync(cancellationToken); + var customer = JsonSerializer.Serialize( + new + { + name = "Ann", + email = InMemoryCustomerEmailRegistry.SeededEmail, + age = 30, + } + ); + + // Act + var (status, _, body) = await PostAsync(host, "/customers", customer, cancellationToken); + var issues = JsonElement.Parse(body).GetProperty("issues"); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(Code(issues, "Email")).IsEqualTo(RegisterCustomerRequest.EmailTakenCode); + } + + [Test] + public async Task PostOrders_GivenFrenchAcceptLanguage_LocalizesMessagesButNotCodesOrPaths( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await SampleAppHost.StartAsync(cancellationToken); + + // Act + var (status, _, body) = await PostAsync(host, "/orders", InvalidOrder, cancellationToken, "fr"); + var root = JsonElement.Parse(body); + + // Assert + await Assert.That(status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert + .That(root.GetProperty("errors").GetProperty("Lines[0].Quantity")[0].GetString()) + .IsEqualTo("Le champ « Quantity » doit être compris entre 1 et 100."); + await Assert.That(Code(root.GetProperty("issues"), "Lines[0].Quantity")).IsEqualTo("invalid_range"); + } + + [Test] + public async Task OpenApiDocument_GivenValidatedEndpoints_DescribesZodValidationProblemDetails( + CancellationToken cancellationToken + ) + { + // Arrange + await using var host = await SampleAppHost.StartAsync(cancellationToken); + + // Act + var document = await host.Client.GetFromJsonAsync( + new Uri("/openapi/v1.json", UriKind.Relative), + cancellationToken + ); + var schemas = document.GetProperty("components").GetProperty("schemas"); + var badRequest = document + .GetProperty("paths") + .GetProperty("/orders") + .GetProperty("post") + .GetProperty("responses") + .GetProperty("400") + .GetProperty("content") + .GetProperty("application/problem+json") + .GetProperty("schema"); + + // Assert + await Assert.That(schemas.TryGetProperty("ZodValidationProblemDetails", out _)).IsTrue(); + await Assert.That(schemas.TryGetProperty("ZodValidationIssue", out _)).IsTrue(); + await Assert + .That(badRequest.GetProperty("$ref").GetString()) + .IsEqualTo("#/components/schemas/ZodValidationProblemDetails"); + } + + [Test] + public async Task Start_GivenSupportEmailIsACustomerAddress_FailsTheAsyncOptionsRule( + CancellationToken cancellationToken + ) + { + // Arrange + Dictionary settings = new() + { + ["Sample:SupportEmail"] = InMemoryCustomerEmailRegistry.SeededEmail, + }; + + // Act + async Task Act() => await (await SampleAppHost.StartAsync(cancellationToken, settings)).DisposeAsync(); + + // Assert + await Assert + .That(Act) + .Throws() + .WithMessageContaining("Sample:SupportEmail: The support mailbox must not be a customer address"); + } + + static string NormalizeTraceId(string json) => TraceIdPattern().Replace(json, "\"traceId\":\"*\""); + + [GeneratedRegex("\"traceId\":\"[^\"]*\"")] + private static partial Regex TraceIdPattern(); +} diff --git a/src/tests/Extensions.Validation.UnitTests/Extensions.Validation.UnitTests.csproj b/src/tests/Extensions.Validation.UnitTests/Extensions.Validation.UnitTests.csproj new file mode 100644 index 00000000..69d60ae3 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Extensions.Validation.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net11.0 + + ZodSharp.Extensions.Validation + Purview.ZodSharp.Extensions.Validation.UnitTests + Purview.ZodSharp.Extensions.Validation.UnitTests + false + + + + + + + + + + + + + + + diff --git a/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..7d10a693 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs @@ -0,0 +1,253 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace Microsoft.Extensions.DependencyInjection; + +public class ZodSharpValidationServiceCollectionExtensionsTests +{ + // The Microsoft.Extensions.Validation generator runs on this test project (it references the package with + // analyzers), so every AddValidation() call below is intercepted exactly as in an application: the + // generated DataAnnotations resolver is inserted at index 0 and a runtime parameter resolver is appended. + // Both are file-local classes, so their runtime names carry a file-scoped prefix; match on the suffix. + const string GeneratedResolverName = "GeneratedValidatableInfoResolver"; + const string RuntimeParameterResolverName = "RuntimeValidatableParameterInfoResolver"; + + [Test] + public async Task AddZodSharpValidation_GivenAddValidationCalledBefore_PlacesZodResolverFirst() + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpValidation(); + + // Act + var resolvers = services.BuildServiceProvider().GetValidationOptions().Resolvers; + + // Assert + await Assert.That(resolvers[0]).IsTypeOf(); + await Assert.That(resolvers[1].GetType().Name).EndsWith(GeneratedResolverName); + } + + [Test] + public async Task AddZodSharpValidation_GivenAddValidationCalledAfter_PlacesZodResolverFirst() + { + // Arrange + ServiceCollection services = new(); + services.AddZodSharpValidation(); + services.AddValidation(); + + // Act + var resolvers = services.BuildServiceProvider().GetValidationOptions().Resolvers; + + // Assert + await Assert.That(resolvers[0]).IsTypeOf(); + await Assert.That(resolvers[1].GetType().Name).EndsWith(GeneratedResolverName); + } + + [Test] + public async Task AddZodSharpValidation_GivenAddValidationCalledBeforeAndAfter_PlacesZodResolverFirst() + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpValidation(); + services.AddValidation(static options => options.MaxDepth = 16); + + // Act + var options = services.BuildServiceProvider().GetValidationOptions(); + + // Assert + await Assert.That(options.Resolvers[0]).IsTypeOf(); + await Assert + .That( + options.Resolvers.Count(static r => + r.GetType().Name.EndsWith(GeneratedResolverName, StringComparison.Ordinal) + ) + ) + .IsEqualTo(2); + await Assert.That(options.MaxDepth).IsEqualTo(16); + } + + [Test] + public async Task AddZodSharpValidation_GivenAddValidationNotCalled_RegistersOnlyZodResolver() + { + // Arrange + ServiceCollection services = new(); + services.AddZodSharpValidation(); + + // Act + var resolvers = services.BuildServiceProvider().GetValidationOptions().Resolvers; + + // Assert + await Assert.That(resolvers.Count).IsEqualTo(1); + await Assert.That(resolvers[0]).IsTypeOf(); + } + + [Test] + public async Task AddZodSharpValidation_GivenTypeKnownToBothResolvers_ValidatesWithZodSharp( + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpFactory(static factory => + factory.Register( + new ScriptedValidator(Error("too_small", "ZodSharp: Name is required.", M("Name"))) + ) + ); + services.AddZodSharpValidation(); + var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new DualModel(), context, cancellationToken); + + // Assert + await Assert.That(context.Single().ErrorMessage).IsEqualTo("ZodSharp: Name is required."); + } + + [Test] + public async Task AddZodSharpValidation_GivenAfterGeneratedPlacement_PlacesZodResolverAfterGeneratedResolver() + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpValidation(static options => options.Placement = ZodResolverPlacement.AfterGenerated); + + // Act + var resolvers = services.BuildServiceProvider().GetValidationOptions().Resolvers; + + // Assert + await Assert.That(resolvers[0].GetType().Name).EndsWith(GeneratedResolverName); + await Assert.That(resolvers[1]).IsTypeOf(); + await Assert.That(resolvers[2].GetType().Name).EndsWith(RuntimeParameterResolverName); + } + + [Test] + public async Task AddZodSharpValidation_GivenCalledTwice_RegistersOneResolverAndAppliesBothConfigurations() + { + // Arrange + ServiceCollection services = new(); + services.AddZodSharpValidation(static options => options.AlsoRunDataAnnotations = true); + services.AddZodSharpValidation(static options => options.PathNamer = ZodPathNamer.FromFunc(static n => n)); + var provider = services.BuildServiceProvider(); + + // Act + var resolvers = provider.GetValidationOptions().Resolvers; + var options = provider.GetRequiredService>().Value; + + // Assert + await Assert.That(resolvers.Count).IsEqualTo(1); + await Assert.That(resolvers[0]).IsSameReferenceAs(provider.GetRequiredService()); + await Assert.That(options.AlsoRunDataAnnotations).IsTrue(); + await Assert.That(options.PathNamer).IsNotNull(); + } + + [Test] + public async Task AddZodSharpValidation_GivenNoFactoryRegistered_RegistersSchemaFactory() + { + // Arrange + ServiceCollection services = new(); + services.AddZodSharpValidation(); + + // Act + var factory = services.BuildServiceProvider().GetService(); + + // Assert + await Assert.That(factory).IsNotNull(); + } + + [Test] + [Arguments(true, true)] + [Arguments(false, false)] + public async Task AddZodSharpValidation_GivenValidateParameters_ClaimsParametersOnlyWhenAsked( + bool validateParameters, + bool expected, + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddZodSharpFactory(static factory => factory.Register(new GeneratedPersonSchemaValidator())); + services.AddZodSharpValidation(configure: null, validateParameters); + await using var provider = services.BuildServiceProvider(); + var resolver = provider.GetRequiredService(); + var parameter = typeof(ZodSharpValidationServiceCollectionExtensionsTests) + .GetMethod( + nameof(Handler), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static + )! + .GetParameters()[0]; + + // Act + var claimed = resolver.TryGetValidatableParameterInfo(parameter, out _); + var typeClaimed = resolver.TryGetValidatableTypeInfo(typeof(GeneratedPerson), out _); + + // Assert - type validation (forms, ValidateAsync) is unaffected either way. + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(resolver.ValidatesParameters).IsEqualTo(expected); + await Assert.That(claimed).IsEqualTo(expected); + await Assert.That(typeClaimed).IsTrue(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task AddZodSharpValidation_GivenATypeOnlyRegistrationAndAParameterRequest_ClaimsParametersInEitherOrder( + bool typeOnlyFirst, + CancellationToken cancellationToken + ) + { + // Arrange - AddZodSharpBlazor registers type validation only; the application's own AddZodSharpValidation() + // still asks for parameters, whichever comes first. + ServiceCollection services = new(); + if (typeOnlyFirst) + { + services.AddZodSharpValidation(configure: null, validateParameters: false); + services.AddZodSharpValidation(); + } + else + { + services.AddZodSharpValidation(); + services.AddZodSharpValidation(configure: null, validateParameters: false); + } + + await using var provider = services.BuildServiceProvider(); + + // Act + var resolver = provider.GetRequiredService(); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(resolver.ValidatesParameters).IsTrue(); + } + + [Test] + [Arguments(true, false)] + [Arguments(false, true)] + public async Task ValidateParameters_GivenAnExplicitValue_OverridesTheRegistration( + bool registrationAsks, + bool configured, + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddZodSharpValidation(options => options.ValidateParameters = configured, registrationAsks); + await using var provider = services.BuildServiceProvider(); + + // Act + var resolver = provider.GetRequiredService(); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(resolver.ValidatesParameters).IsEqualTo(configured); + } + + static void Handler(GeneratedPerson person) => _ = person; +} diff --git a/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensionsTests.cs b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensionsTests.cs new file mode 100644 index 00000000..e48a4a47 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensionsTests.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace Microsoft.Extensions.Validation; + +public class ZodValidationOptionsExtensionsTests +{ + [Test] + public async Task ValidateAsync_GivenZodValidatedValue_ReturnsFullFidelityReport( + CancellationToken cancellationToken + ) + { + // Arrange + var expected = Error("too_small", "Email", M("Customer"), M("Email")); + using var provider = CreateProvider(factory => factory.Register(new ScriptedValidator(expected))); + + // Act + var report = await provider.GetValidationOptions().ValidateAsync(new Order(), provider, cancellationToken); + + // Assert + await Assert.That(report.HasErrors).IsTrue(); + await Assert.That(report.Errors.Single()).IsEqualTo(expected); + } + + [Test] + public async Task ValidateAsync_GivenValidValue_ReturnsEmptyReport(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(new ScriptedValidator())); + + // Act + var report = await provider.GetValidationOptions().ValidateAsync(new Order(), provider, cancellationToken); + + // Assert + await Assert.That(report.HasErrors).IsFalse(); + } + + [Test] + public async Task ValidateAsync_GivenUnknownType_ReturnsEmptyReport(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider(static _ => { }); + + // Act + var report = await provider.GetValidationOptions().ValidateAsync(new Order(), provider, cancellationToken); + + // Assert + await Assert.That(report.HasErrors).IsFalse(); + } + + [Test] + public async Task ValidateAsync_GivenDataAnnotationsErrors_ConvertsThemToCustomErrors( + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpValidation(); + using var provider = services.BuildServiceProvider(); + + // Act + var report = await provider.GetValidationOptions().ValidateAsync(new DualModel(), provider, cancellationToken); + + // Assert + var error = report.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(ZodIssueCategories.Custom); + await Assert.That(error.Message).IsEqualTo("DataAnnotations: Name is required."); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("Name"); + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs new file mode 100644 index 00000000..c6a5f2eb --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace ZodSharp.Extensions.Validation.Fixtures; + +[ZodSchema] +public class GeneratedPerson +{ + [MinLength(3)] + public string Name { get; set; } = string.Empty; + + public GeneratedAddress Address { get; set; } = new(); + + public List PreviousAddresses { get; set; } = []; +} + +[ZodSchema] +public class GeneratedAddress +{ + [Required] + public string? Street { get; set; } +} + +/// A struct member: its errors are located inside a value type, which cannot own a field. +[ZodSchema] +public readonly partial record struct GeneratedPostcode +{ + [Required] + [RegularExpression("^[0-9]{5}$")] + public string? Value { get; init; } +} + +[ZodSchema] +public class GeneratedDelivery +{ + public GeneratedPostcode Postcode { get; set; } + + public List Alternatives { get; set; } = []; +} diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs new file mode 100644 index 00000000..c3c794c5 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Validation; + +namespace ZodSharp.Extensions.Validation.Fixtures; + +public sealed class Order +{ + public string? Reference { get; set; } + + public Customer Customer { get; set; } = new(); + + public List Lines { get; set; } = []; + + public OrderLine[] LineArray { get; set; } = []; + + public IEnumerable LineSequence { get; set; } = []; + + public HashSet LineSet { get; set; } = []; + + public Dictionary ByCode { get; set; } = []; + + public Dictionary ById { get; set; } = []; + + public int? Quantity { get; set; } +} + +public sealed class Customer +{ + public string? Email { get; set; } + + public Address Address { get; set; } = new(); +} + +public sealed class Address +{ + public string? Street { get; set; } +} + +public sealed class OrderLine +{ + public string? Sku { get; set; } + + public int Quantity { get; set; } +} + +public readonly record struct Point(int X, int Y); + +/// +/// Known to both ZodSharp (registered in the tests) and the Microsoft.Extensions.Validation generator. +/// +[ValidatableType] +public sealed class DualModel +{ + [Required(ErrorMessage = "DataAnnotations: Name is required.")] + public string? Name { get; set; } +} + +/// +/// Known only to the Microsoft.Extensions.Validation generator; its is validated by ZodSharp. +/// +[ValidatableType] +public sealed class Envelope +{ + [Required] + public string? Title { get; set; } + + public Order Order { get; set; } = new(); +} diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs new file mode 100644 index 00000000..3c9141b6 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs @@ -0,0 +1,118 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation.Fixtures; + +static class TestHelpers +{ + public static ZodValidationError Error(string code, string message, params ZodPathSegment[] path) => + ZodValidationError.Create(code, message, ZodPath.From(path)); + + public static ZodPathSegment M(string name) => ZodPathSegment.Member(name); + + public static ZodPathSegment I(int index) => ZodPathSegment.At(index); + + public static ZodPathSegment K(TKey key) => ZodPathSegment.Key(key); + + public static ServiceProvider CreateProvider( + Action register, + Action? configure = null, + Action? options = null + ) + { + ServiceCollection services = new(); + services.AddZodSharpFactory(register); + services.AddZodSharpValidation(options); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } + + public static ValidationOptions GetValidationOptions(this IServiceProvider provider) => + provider.GetRequiredService>().Value; + + public static ValidateContext CreateContext(this IServiceProvider provider) => + new() { ValidationOptions = provider.GetValidationOptions(), ServiceProvider = provider }; + + public static IValidatableTypeInfo GetTypeInfo(this IServiceProvider provider) + { + // A conditional expression would turn the guard into a throw expression inside the return. +#pragma warning disable IDE0046 // Convert to conditional expression + if (!provider.GetValidationOptions().TryGetValidatableTypeInfo(typeof(T), out var info)) + throw new InvalidOperationException($"No type info for {typeof(T).Name}."); +#pragma warning restore IDE0046 + + return info; + } + + public static Microsoft.Extensions.Validation.ValidationError Single(this ValidateContext context) + { + var errors = context.ValidationErrors ?? throw new InvalidOperationException("No errors."); + return errors.SelectMany(static pair => pair.Value).Single(); + } + + public static ImmutableArray All(this ValidateContext context) => + [ + .. ( + context.ValidationErrors + ?? ImmutableDictionary>.Empty + ).SelectMany(static pair => pair.Value), + ]; +} + +sealed class RecordingSink : IZodValidationReportSink +{ + public List<(Type Type, string Prefix, ImmutableArray Errors)> Calls { get; } = []; + + public void Add(Type validatedType, string pathPrefix, ImmutableArray errors) => + Calls.Add((validatedType, pathPrefix, errors)); +} + +sealed class FakeStringLocalizerFactory(Dictionary resources) : IStringLocalizerFactory +{ + public List RequestedTypes { get; } = []; + + public IStringLocalizer Create(Type resourceSource) + { + RequestedTypes.Add(resourceSource); + return new FakeStringLocalizer(resources); + } + + public IStringLocalizer Create(string baseName, string location) => new FakeStringLocalizer(resources); +} + +sealed class FakeStringLocalizer(Dictionary resources) : IStringLocalizer +{ + public LocalizedString this[string name] + { + get + { + // Culture-specific entries are keyed "{culture}:{key}" so tests can prove the culture is honoured. + var culture = CultureInfo.CurrentUICulture.Name; + // A conditional expression would nest the culture-specific lookup inside the fallback lookup. +#pragma warning disable IDE0046 // Convert to conditional expression + if (resources.TryGetValue($"{culture}:{name}", out var specific)) + return new(name, specific, resourceNotFound: false); +#pragma warning restore IDE0046 + + return resources.TryGetValue(name, out var value) + ? new(name, value, resourceNotFound: false) + : new(name, name, resourceNotFound: true); + } + } + + public LocalizedString this[string name, params object[] arguments] => this[name]; + + public IEnumerable GetAllStrings(bool includeParentCultures) => + resources.Select(static pair => new LocalizedString(pair.Key, pair.Value)); +} + +sealed class FixedFormatter(string prefix) : IZodMessageFormatter +{ + public string Format(in ZodValidationError error, CultureInfo culture) => prefix + error.Message; +} diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/TestValidators.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestValidators.cs new file mode 100644 index 00000000..f6665a59 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestValidators.cs @@ -0,0 +1,95 @@ +using ZodSharp.Core; + +namespace ZodSharp.Extensions.Validation.Fixtures; + +/// +/// Returns the errors a test scripts, so paths can be asserted exactly. Implements neither member validation +/// nor member resolution, like a runtime schema registered with . +/// +sealed class ScriptedValidator(Func> errors) : IZodSchemaValidator +{ + public ScriptedValidator(params ValidationError[] errors) + : this(_ => errors) { } + + public bool RequiresAsync { get; init; } + + public bool RequiresAsyncValidation => RequiresAsync; + + public ValidationResult Validate(T value) + { + ValidationError[] produced = [.. errors(value)]; + return produced.Length == 0 ? ValidationResult.Success(value) : ValidationResult.Failure(produced); + } + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + new(Validate(value)); +} + +/// +/// Completes asynchronously and only gives a complete answer asynchronously. +/// +sealed class AsyncOnlyValidator(params ValidationError[] errors) : IZodSchemaValidator +{ + public IServiceProvider? ReceivedServices { get; private set; } + + public bool RequiresAsyncValidation => true; + + public ValidationResult Validate(T value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + ValidateAsync(value, null, cancellationToken); + + public async ValueTask> ValidateAsync( + T value, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) + { + ReceivedServices = services; + await Task.Yield(); + return errors.Length == 0 ? ValidationResult.Success(value) : ValidationResult.Failure(errors); + } +} + +/// +/// Supports member validation and reflection-free member resolution, like a generated validator. +/// +sealed class MemberAwareValidator( + Func> errors, + Func> memberErrors, + Func resolve +) : IZodSchemaValidator, IZodMemberValidator, IZodMemberResolver +{ + public HashSet ValidatableMembers { get; } = [with(StringComparer.Ordinal)]; + + public ValidationResult Validate(T value) + { + ValidationError[] produced = [.. errors(value)]; + return produced.Length == 0 ? ValidationResult.Success(value) : ValidationResult.Failure(produced); + } + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + new(Validate(value)); + + public bool IsValidatableMember(string memberName) => ValidatableMembers.Contains(memberName); + + public ValidationResult ValidateMember(T value, string memberName) + { + ValidationError[] produced = [.. memberErrors(value, memberName)]; + return produced.Length == 0 ? ValidationResult.Success(value) : ValidationResult.Failure(produced); + } + + public bool TryResolveMember(T value, ZodPath path, out object? container, out string memberName) + { + if (resolve(value, path) is { } resolved) + { + container = resolved.Container; + memberName = resolved.Member; + return true; + } + + container = value; + memberName = string.Empty; + return false; + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs new file mode 100644 index 00000000..11ae2a65 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs @@ -0,0 +1,6 @@ +namespace ZodSharp.Extensions.Validation.Fixtures; + +/// +/// A union with no validator of its own, whose case is validated by ZodSharp. +/// +public readonly union Shipment(Order, Address); diff --git a/src/tests/Extensions.Validation.UnitTests/StringLocalizerZodMessageFormatterTests.cs b/src/tests/Extensions.Validation.UnitTests/StringLocalizerZodMessageFormatterTests.cs new file mode 100644 index 00000000..9b5c1e37 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/StringLocalizerZodMessageFormatterTests.cs @@ -0,0 +1,284 @@ +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +public class StringLocalizerZodMessageFormatterTests +{ + static readonly ZodValidationError MinLengthError = ZodValidationError.Create( + "too_small", + "Sku must be at least 3 characters", + ZodPath.From(M("Lines"), I(0), M("Sku")), + parameters: new Dictionary { ["minimum"] = 3, ["displayName"] = "Sku" } + ) with + { + MessageFormat = "{displayName} must be at least {minimum} characters", + }; + + [Test] + public async Task Format_GivenMessageFormatKey_PrefersItOverEveryOtherKey() + { + // Arrange + var formatter = CreateFormatter( + new() + { + ["{displayName} must be at least {minimum} characters"] = "explicit {minimum}", + ["OrderLine_Sku_too_small"] = "member", + ["OrderLine_too_small"] = "type", + ["too_small"] = "code", + } + ); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("explicit 3"); + } + + [Test] + public async Task Format_GivenMemberKey_PrefersItOverTypeAndCodeKeys() + { + // Arrange + var formatter = CreateFormatter( + new() + { + ["OrderLine_Sku_too_small"] = "member {displayName}", + ["OrderLine_too_small"] = "type", + ["too_small"] = "code", + } + ); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("member Sku"); + } + + [Test] + public async Task Format_GivenTypeKey_PrefersItOverCodeKey() + { + // Arrange + var formatter = CreateFormatter(new() { ["OrderLine_too_small"] = "type", ["too_small"] = "code" }); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("type"); + } + + [Test] + public async Task Format_GivenOnlyCodeKey_UsesIt() + { + // Arrange + var formatter = CreateFormatter(new() { ["too_small"] = "At least {minimum}" }); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("At least 3"); + } + + [Test] + public async Task Format_GivenNoMatchingKey_ReturnsErrorMessage() + { + // Arrange + var formatter = CreateFormatter([]); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("Sku must be at least 3 characters"); + } + + [Test] + public async Task Format_GivenLiteralMessageKey_UsesMessageAsKey() + { + // Arrange + var formatter = CreateFormatter(new() { ["Order is invalid"] = "Commande invalide" }); + + // Act + var message = formatter.Format( + Error("custom", "Order is invalid"), + typeof(Order), + CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(message).IsEqualTo("Commande invalide"); + } + + [Test] + public async Task Format_GivenDisplayNameKey_LocalizesDisplayName() + { + // Arrange + var formatter = CreateFormatter( + new() { ["too_small"] = "{displayName} trop court", ["OrderLine_Sku_DisplayName"] = "Référence" } + ); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("Référence trop court"); + } + + [Test] + public async Task Format_GivenCulture_LooksUpInThatCulture() + { + // Arrange + var formatter = CreateFormatter(new() { ["too_small"] = "too short", ["fr-FR:too_small"] = "trop court" }); + + // Act + var message = formatter.Format(MinLengthError, typeof(OrderLine), CultureInfo.GetCultureInfo("fr-FR")); + + // Assert + await Assert.That(message).IsEqualTo("trop court"); + } + + [Test] + public async Task Format_GivenUnknownDeclaringTypeAndNoSharedResource_ReturnsErrorMessage() + { + // Arrange + var formatter = CreateFormatter(new() { ["too_small"] = "code" }); + + // Act + var message = formatter.Format(MinLengthError, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("Sku must be at least 3 characters"); + } + + [Test] + public async Task Format_GivenSharedResourceType_UsesItWhenDeclaringTypeIsUnknown() + { + // Arrange + FakeStringLocalizerFactory factory = new(new() { ["too_small"] = "code" }); + StringLocalizerZodMessageFormatter formatter = new(factory, sharedResourceType: typeof(SharedResource)); + + // Act + var message = formatter.Format(MinLengthError, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("code"); + await Assert.That(factory.RequestedTypes).IsEquivalentTo([typeof(SharedResource)]); + } + + [Test] + public async Task ValidateAsync_GivenStringLocalizerFactoryRegistered_LocalizesWithOwningTypeKeys( + CancellationToken cancellationToken + ) + { + // Arrange + FakeStringLocalizerFactory factory = new(new() { ["OrderLine_Sku_too_small"] = "Référence trop courte" }); + using var provider = CreateProvider( + static zod => zod.Register(new ScriptedValidator(MinLengthError)), + services => services.AddSingleton(factory) + ); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order { Lines = [new()] }, context, cancellationToken); + + // Assert + await Assert.That(context.Single().ErrorMessage).IsEqualTo("Référence trop courte"); + await Assert.That(factory.RequestedTypes).Contains(typeof(OrderLine)); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "TUnit's assertion builders count towards coupling; the test is one scenario." + )] + public async Task ValidateAsync_GivenLocalizerProvider_HonoursIt(CancellationToken cancellationToken) + { + // Arrange + FakeStringLocalizerFactory factory = new(new() { ["too_small"] = "shared" }); + ServiceCollection services = new(); + services.AddSingleton(factory); + services.AddZodSharpFactory(static zod => zod.Register(new ScriptedValidator(MinLengthError))); + services.AddZodSharpValidation(); + services.AddValidation(static options => + options.LocalizerProvider = static (_, f) => f.Create(typeof(SharedResource)) + ); + using var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order { Lines = [new()] }, context, cancellationToken); + + // Assert + await Assert.That(context.Single().ErrorMessage).IsEqualTo("shared"); + await Assert.That(factory.RequestedTypes).IsEquivalentTo([typeof(SharedResource)]); + } + + [Test] + public async Task ValidateAsync_GivenNoStringLocalizerFactory_UsesErrorMessage(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider(static zod => zod.Register(new ScriptedValidator(MinLengthError))); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order { Lines = [new()] }, context, cancellationToken); + + // Assert + await Assert.That(context.Single().ErrorMessage).IsEqualTo("Sku must be at least 3 characters"); + } + + [Test] + public async Task Format_GivenRuntimeSchemaErrors_LocalizesThemByTheRuleTemplates() + { + // Arrange + var minLength = FirstError(Z.String().Min(3), "a", "Sku"); + var range = FirstError(Z.Number().Range(1, 10), 11d, "Qty"); + var email = FirstError(Z.String().Email(), "nope", "Mail"); + var formatter = CreateFormatter( + new() + { + ["fr-FR:String must be at least {minimum} characters long, but got {count}"] = + "{displayName} : au moins {minimum} caractères (reçu {count})", + ["fr-FR:Value must be between {minimum} and {maximum}"] = + "{displayName} : entre {minimum} et {maximum}", + ["fr-FR:Invalid email format"] = "{displayName} : adresse e-mail invalide", + } + ); + var french = CultureInfo.GetCultureInfo("fr-FR"); + + // Act + string[] messages = + [ + formatter.Format(minLength, typeof(OrderLine), french), + formatter.Format(range, typeof(OrderLine), french), + formatter.Format(email, typeof(OrderLine), french), + ]; + + // Assert + await Assert + .That(messages) + .IsEquivalentTo([ + "Sku : au moins 3 caractères (reçu 1)", + "Qty : entre 1 et 10", + "Mail : adresse e-mail invalide", + ]); + } + + // A runtime schema's error, located under a member as an object schema would place it. + static ZodValidationError FirstError(ZodType schema, T value, string member) => + schema.Validate(value).Errors.Single().WithPathPrefix(ZodPathSegment.Member(member)); + + static StringLocalizerZodMessageFormatter CreateFormatter(Dictionary resources) => + new(new FakeStringLocalizerFactory(resources)); + + sealed class SharedResource; +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverTests.cs new file mode 100644 index 00000000..cfa777d5 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverTests.cs @@ -0,0 +1,247 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace ZodSharp.Extensions.Validation; + +public class ZodValidatableInfoResolverTests +{ + [Test] + public async Task TryGetValidatableTypeInfo_GivenRegisteredType_ReturnsZodTypeInfo() + { + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new ScriptedValidator()); + ZodValidatableInfoResolver resolver = new(factory); + + // Act + var found = resolver.TryGetValidatableTypeInfo(typeof(Order), out var info); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(info).IsTypeOf(); + await Assert.That(((ZodValidatableTypeInfo)info!).ValidatedType).IsEqualTo(typeof(Order)); + } + + [Test] + public async Task TryGetValidatableTypeInfo_GivenUnregisteredType_ReturnsFalse() + { + // Arrange + ZodValidatableInfoResolver resolver = new(new ZodSchemaFactory()); + + // Act + var found = resolver.TryGetValidatableTypeInfo(typeof(Order), out var info); + + // Assert + await Assert.That(found).IsFalse(); + await Assert.That(info).IsNull(); + } + + [Test] + public async Task TryGetValidatableTypeInfo_GivenRepeatedLookups_ReturnsCachedInstance() + { + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new ScriptedValidator()); + ZodValidatableInfoResolver resolver = new(factory); + + // Act + resolver.TryGetValidatableTypeInfo(typeof(Order), out var first); + resolver.TryGetValidatableTypeInfo(typeof(Order), out var second); + + // Assert + await Assert.That(second).IsSameReferenceAs(first); + } + + [Test] + public async Task TryGetValidatableTypeInfo_GivenRegistrationAfterNegativeLookup_KeepsNegativeResult() + { + // Arrange: registering into the factory after the first validation is documented as unsupported. + ZodSchemaFactory factory = new(); + ZodValidatableInfoResolver resolver = new(factory); + resolver.TryGetValidatableTypeInfo(typeof(Order), out _); + factory.Register(new ScriptedValidator()); + + // Act + var found = resolver.TryGetValidatableTypeInfo(typeof(Order), out _); + + // Assert + await Assert.That(found).IsFalse(); + } + + [Test] + public async Task TryGetValidatableTypeInfo_GivenNullableValueType_ResolvesUnderlyingType() + { + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new ScriptedValidator()); + ZodValidatableInfoResolver resolver = new(factory); + + // Act + var found = resolver.TryGetValidatableTypeInfo(typeof(Point?), out var info); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(((ZodValidatableTypeInfo)info!).ValidatedType).IsEqualTo(typeof(Point)); + } + + [Test] + [Arguments(typeof(int))] + [Arguments(typeof(string))] + [Arguments(typeof(Guid))] + [Arguments(typeof(DateTimeOffset))] + public async Task TryGetValidatableTypeInfo_GivenPrimitiveTypeEvenWhenRegistered_ReturnsFalse(Type type) + { + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new ScriptedValidator()); + factory.Register(new ScriptedValidator()); + ZodValidatableInfoResolver resolver = new(factory); + + // Act + var found = resolver.TryGetValidatableTypeInfo(type, out _); + + // Assert + await Assert.That(found).IsFalse(); + } + + [Test] + public async Task TryGetValidatableTypeInfo_GivenSource_PrefersSourceOverFactory() + { + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new ScriptedValidator()); + ZodUntypedValidator fromSource = new(new ScriptedValidator()); + ZodValidationOptions options = new(); + options.Sources.Add(new SingleSource(typeof(Order), fromSource)); + ZodValidatableInfoResolver resolver = new(factory, options); + + // Act + resolver.TryGetValidatableTypeInfo(typeof(Order), out var info); + + // Assert + await Assert.That(((ZodValidatableTypeInfo)info!).Validator).IsSameReferenceAs(fromSource); + } + + [Test] + public async Task TryGetValidatableTypeInfo_GivenNoFactory_UsesSourcesOnly() + { + // Arrange + ZodValidationOptions options = new(); + options.Sources.Add( + new SingleSource(typeof(Order), new ZodUntypedValidator(new ScriptedValidator())) + ); + ZodValidatableInfoResolver resolver = new(null, options); + + // Act + var found = resolver.TryGetValidatableTypeInfo(typeof(Order), out _); + + // Assert + await Assert.That(found).IsTrue(); + } + + [Test] + public async Task TryGetValidatableParameterInfo_GivenComplexParameter_ReturnsParameterInfo() + { + // Arrange + var resolver = CreateOrderResolver(); + + // Act + var found = resolver.TryGetValidatableParameterInfo(Parameter("order"), out var info); + + // Assert + await Assert.That(found).IsTrue(); + var parameter = (ZodValidatableParameterInfo)info!; + await Assert.That(parameter.Name).IsEqualTo("order"); + await Assert.That(parameter.IsCollection).IsFalse(); + await Assert.That(parameter.AllowsNull).IsFalse(); + } + + [Test] + public async Task TryGetValidatableParameterInfo_GivenCollectionOfRegisteredElements_ReturnsCollectionInfo() + { + // Arrange + var resolver = CreateOrderResolver(); + + // Act + var found = resolver.TryGetValidatableParameterInfo(Parameter("orders"), out var info); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(((ZodValidatableParameterInfo)info!).IsCollection).IsTrue(); + } + + [Test] + public async Task TryGetValidatableParameterInfo_GivenNullableOrOptionalParameter_AllowsNull() + { + // Arrange + var resolver = CreateOrderResolver(); + + // Act + resolver.TryGetValidatableParameterInfo(Parameter("optional"), out var optional); + resolver.TryGetValidatableParameterInfo(Parameter("nullable"), out var nullable); + + // Assert + await Assert.That(((ZodValidatableParameterInfo)optional!).AllowsNull).IsTrue(); + await Assert.That(((ZodValidatableParameterInfo)nullable!).AllowsNull).IsTrue(); + } + + [Test] + [Arguments("skipped")] + [Arguments("service")] + [Arguments("count")] + [Arguments("unregistered")] + public async Task TryGetValidatableParameterInfo_GivenExcludedParameter_ReturnsFalse(string name) + { + // Arrange + var resolver = CreateOrderResolver(); + + // Act + var found = resolver.TryGetValidatableParameterInfo(Parameter(name), out _); + + // Assert + await Assert.That(found).IsFalse(); + } + + static ZodValidatableInfoResolver CreateOrderResolver() + { + ZodSchemaFactory factory = new(); + factory.Register(new ScriptedValidator()); + return new(factory); + } + + static ParameterInfo Parameter(string name) => + typeof(ZodValidatableInfoResolverTests) + .GetMethod(nameof(Handler), BindingFlags.NonPublic | BindingFlags.Static)! + .GetParameters() + .Single(p => p.Name == name); + + [SuppressMessage("Usage", "IDE0060:Remove unused parameter", Justification = "Parameter metadata fixture.")] + [SuppressMessage("Style", "IDE0051:Remove unused private members", Justification = "Read by reflection.")] + static void Handler( + Order order, + List orders, + [SkipValidation] Order skipped, + [FromServices] Order service, + int count, + Customer unregistered, + Order? nullable, + Order? optional = null + ) { } + + sealed class SingleSource(Type type, IZodUntypedValidator validator) : IZodValidatableInfoSource + { + public bool TryGetValidator(Type requested, out IZodUntypedValidator? found) + { + found = requested == type ? validator : null; + return found is not null; + } + } + + /// Stands in for ASP.NET Core's attribute, which this package detects by name. + [AttributeUsage(AttributeTargets.Parameter)] + sealed class FromServicesAttribute : Attribute; +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs new file mode 100644 index 00000000..550c2135 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs @@ -0,0 +1,154 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace ZodSharp.Extensions.Validation; + +public class ZodValidatableParameterInfoTests +{ + [Test] + public async Task ValidateAsync_GivenComplexParameter_DoesNotPrefixWithParameterName( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new(); + var (provider, info) = Create("order", Error("too_small", "Street", M("Customer"), M("Address"), M("Street"))); + using var _ = provider; + var context = provider.CreateContext(); + + // Act + await info.ValidateAsync(order, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Customer.Address.Street"); + await Assert.That(error.Name).IsEqualTo("Street"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer.Address); + } + + [Test] + public async Task ValidateAsync_GivenRootError_KeysByParameterNameWithoutContainer( + CancellationToken cancellationToken + ) + { + // Arrange + var (provider, info) = Create("order", Error("custom", "Order is invalid")); + using var _ = provider; + var context = provider.CreateContext(); + + // Act + await info.ValidateAsync(new Order(), context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("order"); + await Assert.That(error.Name).IsEqualTo("order"); + await Assert.That(error.Container).IsNull(); + await Assert.That(context.ZodReport!.Errors[0].FullPath.ToDottedString()).IsEqualTo("order"); + } + + [Test] + public async Task ValidateAsync_GivenNullRequiredParameter_ReportsNullValueKeyedByParameterName( + CancellationToken cancellationToken + ) + { + // Arrange + var (provider, info) = Create("order", Error("custom", "Should not run")); + using var _ = provider; + var context = provider.CreateContext(); + + // Act + await info.ValidateAsync(null, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("order"); + await Assert.That(error.Name).IsEqualTo("order"); + await Assert.That(error.Container).IsNull(); + var reported = context.ZodReport!.Errors.Single(); + await Assert.That(reported.Code).IsEqualTo(ZodIssueCodes.InvalidType); + await Assert.That(error.ErrorMessage).IsEqualTo(reported.Message); + } + + [Test] + public async Task ValidateAsync_GivenNullOptionalParameter_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange + var (provider, info) = Create("optional", Error("invalid_type", "Should not run")); + using var _ = provider; + var context = provider.CreateContext(); + + // Act + await info.ValidateAsync(null, context, cancellationToken); + + // Assert + await Assert.That(context.ValidationErrors).IsNull(); + } + + [Test] + public async Task ValidateAsync_GivenCollectionParameter_PrefixesElementsWithParameterNameAndIndex( + CancellationToken cancellationToken + ) + { + // Arrange + List orders = [new(), new()]; + var (provider, info) = Create("orders", Error("too_small", "Email", M("Customer"), M("Email"))); + using var _ = provider; + var context = provider.CreateContext(); + + // Act + await info.ValidateAsync(orders, context, cancellationToken); + + // Assert + var errors = context.All(); + string[] paths = [.. errors.Select(static e => e.Path).Order(StringComparer.Ordinal)]; + await Assert.That(paths).IsEquivalentTo(["orders[0].Customer.Email", "orders[1].Customer.Email"]); + var second = errors.Single(static e => e.Path == "orders[1].Customer.Email"); + await Assert.That(second.Container).IsSameReferenceAs(orders[1].Customer); + await Assert.That(context.CurrentValidationPath).IsEqualTo(string.Empty); + } + + [Test] + public async Task Validate_GivenCollectionParameter_ValidatesSynchronously() + { + // Arrange + var (provider, info) = Create("orders", Error("too_small", "Email", M("Customer"), M("Email"))); + using var _ = provider; + var context = provider.CreateContext(); + + // Act + info.Validate(new List { new() }, context); + + // Assert + await Assert.That(context.Single().Path).IsEqualTo("orders[0].Customer.Email"); + } + + static (Microsoft.Extensions.DependencyInjection.ServiceProvider Provider, IValidatableParameterInfo Info) Create( + string parameter, + Core.ValidationError error + ) + { + var provider = CreateProvider(factory => factory.Register(new ScriptedValidator(error))); + // A conditional expression would turn the guard into a throw expression inside the return. +#pragma warning disable IDE0046 // Convert to conditional expression + if (!provider.GetValidationOptions().TryGetValidatableParameterInfo(Parameter(parameter), out var info)) + throw new InvalidOperationException($"Parameter '{parameter}' was not claimed."); +#pragma warning restore IDE0046 + + return (provider, info); + } + + static ParameterInfo Parameter(string name) => + typeof(ZodValidatableParameterInfoTests) + .GetMethod(nameof(Handler), BindingFlags.NonPublic | BindingFlags.Static)! + .GetParameters() + .Single(p => p.Name == name); + + [SuppressMessage("Usage", "IDE0060:Remove unused parameter", Justification = "Parameter metadata fixture.")] + [SuppressMessage("Style", "IDE0051:Remove unused private members", Justification = "Read by reflection.")] + static void Handler(Order order, List orders, Order? optional = null) { } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatablePropertyInfoTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatablePropertyInfoTests.cs new file mode 100644 index 00000000..11bd9b2c --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatablePropertyInfoTests.cs @@ -0,0 +1,116 @@ +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; + +namespace ZodSharp.Extensions.Validation; + +public class ZodValidatablePropertyInfoTests +{ + [Test] + public async Task TryFindProperty_GivenValidatableMember_ReturnsPropertyInfo() + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(CreateValidator())); + var info = provider.GetTypeInfo(); + + // Act + var found = info.TryFindProperty("Email", provider.GetValidationOptions(), out var property); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(((ZodValidatablePropertyInfo)property!).MemberName).IsEqualTo("Email"); + } + + [Test] + public async Task TryFindProperty_GivenMemberWithoutOwnRules_ReturnsFalse() + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(CreateValidator())); + var info = provider.GetTypeInfo(); + + // Act + var found = info.TryFindProperty("Address", provider.GetValidationOptions(), out var property); + + // Assert + await Assert.That(found).IsFalse(); + await Assert.That(property).IsNull(); + } + + [Test] + public async Task TryFindProperty_GivenValidatorWithoutMemberValidation_ReturnsFalse() + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(new ScriptedValidator())); + var info = provider.GetTypeInfo(); + + // Act + var found = info.TryFindProperty("Email", provider.GetValidationOptions(), out _); + + // Assert + await Assert.That(found).IsFalse(); + } + + [Test] + public async Task ValidateAsync_GivenInvalidMember_ReportsOnlyThatMembersErrors(CancellationToken cancellationToken) + { + // Arrange + Customer customer = new(); + using var provider = CreateProvider(static factory => factory.Register(CreateValidator())); + var context = provider.CreateContext(); + provider.GetTypeInfo().TryFindProperty("Email", context.ValidationOptions, out var property); + + // Act + await property!.ValidateAsync(customer, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Email"); + await Assert.That(error.Name).IsEqualTo("Email"); + await Assert.That(error.Container).IsSameReferenceAs(customer); + await Assert.That(error.ErrorMessage).IsEqualTo("Email is required"); + } + + [Test] + public async Task Validate_GivenInvalidMember_ReportsSynchronously() + { + // Arrange + Customer customer = new(); + using var provider = CreateProvider(static factory => factory.Register(CreateValidator())); + var context = provider.CreateContext(); + provider.GetTypeInfo().TryFindProperty("Email", context.ValidationOptions, out var property); + + // Act + property!.Validate(customer, context); + + // Assert + await Assert.That(context.Single().Path).IsEqualTo("Email"); + } + + [Test] + public async Task ValidateAsync_GivenValidMember_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(CreateValidator())); + var context = provider.CreateContext(); + provider.GetTypeInfo().TryFindProperty("Email", context.ValidationOptions, out var property); + + // Act + await property!.ValidateAsync(new Customer { Email = "a@b.c" }, context, cancellationToken); + + // Assert + await Assert.That(context.ValidationErrors).IsNull(); + } + + static MemberAwareValidator CreateValidator() + { + MemberAwareValidator validator = new( + static _ => [Error("custom", "Whole-object error")], + static (customer, member) => + member == "Email" && customer.Email is null + ? [Error("too_small", "Email is required", M("Email"))] + : [], + static (customer, path) => path.Length == 1 ? (customer, path[0].Name!) : null + ); + validator.ValidatableMembers.Add("Email"); + return validator; + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs new file mode 100644 index 00000000..ac17bf0e --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Extensions.Validation.Fixtures; + +namespace ZodSharp.Extensions.Validation; + +/// +/// End to end over validators produced by the ZodSharp source generator. +/// +public class ZodValidatableTypeInfoGeneratedSchemaTests +{ + [Test] + public async Task ValidateAsync_GivenGeneratedSchema_PlacesEveryErrorOnItsOwningObject( + CancellationToken cancellationToken + ) + { + // Arrange + GeneratedPerson person = new() + { + Name = "ab", + Address = new() { Street = null }, + PreviousAddresses = [new() { Street = "1 High St" }, new() { Street = null }], + }; + using var provider = CreatePersonProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(person, context, cancellationToken); + + // Assert + var errors = context.All(); + var name = errors.Single(static e => e.Path == "Name"); + await Assert.That(name.Name).IsEqualTo("Name"); + await Assert.That(name.Container).IsSameReferenceAs(person); + + var street = errors.Single(static e => e.Path == "Address.Street"); + await Assert.That(street.Name).IsEqualTo("Street"); + await Assert.That(street.Container).IsSameReferenceAs(person.Address); + + var previous = errors.Single(static e => e.Path == "PreviousAddresses[1].Street"); + await Assert.That(previous.Name).IsEqualTo("Street"); + await Assert.That(previous.Container).IsSameReferenceAs(person.PreviousAddresses[1]); + await Assert.That(errors.Length).IsEqualTo(3); + } + + [Test] + public async Task ValidateAsync_GivenValidGeneratedSchema_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange + GeneratedPerson person = new() + { + Name = "abc", + Address = new() { Street = "1 High St" }, + }; + using var provider = CreatePersonProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(person, context, cancellationToken); + + // Assert + await Assert.That(context.ValidationErrors).IsNull(); + } + + [Test] + public async Task ValidateAsync_GivenErrorInsideAStructMember_AttributesItToTheReferenceTypeOwner( + CancellationToken cancellationToken + ) + { + // Arrange - a struct cannot own a field (Blazor's FieldIdentifier rejects a value-type model, and a boxed copy is + // not the instance a form binds), so the error belongs to the class that holds the struct. + GeneratedDelivery delivery = new() + { + Postcode = new() { Value = "abc" }, + Alternatives = [new() { Value = "12345" }, new() { Value = "xyz" }], + }; + ServiceCollection services = new(); + services.AddZodSharpFactory(static factory => factory.Register(new GeneratedDeliverySchemaValidator())); + services.AddZodSharpValidation(); + using var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(delivery, context, cancellationToken); + + // Assert + var errors = context.All(); + var postcode = errors.Single(static e => e.Path == "Postcode.Value"); + await Assert.That(postcode.Container).IsSameReferenceAs(delivery); + await Assert.That(postcode.Name).IsEqualTo(nameof(GeneratedDelivery.Postcode)); + + var alternative = errors.Single(static e => e.Path == "Alternatives[1].Value"); + await Assert.That(alternative.Container).IsSameReferenceAs(delivery); + await Assert.That(alternative.Name).IsEqualTo(nameof(GeneratedDelivery.Alternatives)); + await Assert.That(errors.Length).IsEqualTo(2); + } + + static ServiceProvider CreatePersonProvider() + { + ServiceCollection services = new(); + // Registered through the generic overload: until generated validators implement IZodUntypedValidator, + // assembly scanning (the non-generic Register) does not make them resolvable by runtime type. + services.AddZodSharpFactory(static factory => factory.Register(new GeneratedPersonSchemaValidator())); + services.AddZodSharpValidation(); + return services.BuildServiceProvider(); + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoMemberResolutionTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoMemberResolutionTests.cs new file mode 100644 index 00000000..c5cc8b11 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoMemberResolutionTests.cs @@ -0,0 +1,326 @@ +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +public class ZodValidatableTypeInfoMemberResolutionTests +{ + [Test] + public async Task ValidateAsync_GivenNestedMemberError_ResolvesOwningObjectAndLeafName( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new(); + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Street is required", M("Customer"), M("Address"), M("Street")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("Customer.Address.Street"); + await Assert.That(error.Name).IsEqualTo("Street"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer.Address); + await Assert.That(error.ErrorMessage).IsEqualTo("Street is required"); + } + + [Test] + public async Task ValidateAsync_GivenListElementError_ResolvesElementAsContainer( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() { Lines = [new(), new()] }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("Lines"), I(1), M("Sku")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("Lines[1].Sku"); + await Assert.That(error.Name).IsEqualTo("Sku"); + await Assert.That(error.Container).IsSameReferenceAs(order.Lines[1]); + } + + [Test] + public async Task ValidateAsync_GivenArrayElementError_ResolvesElementAsContainer( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() { LineArray = [new(), new()] }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("LineArray"), I(0), M("Sku")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("LineArray[0].Sku"); + await Assert.That(error.Container).IsSameReferenceAs(order.LineArray[0]); + } + + [Test] + public async Task ValidateAsync_GivenEnumerableElementError_ResolvesElementAsContainer( + CancellationToken cancellationToken + ) + { + // Arrange + OrderLine first = new(); + OrderLine second = new(); + Order order = new() { LineSequence = Yield(first, second) }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("LineSequence"), I(1), M("Quantity")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("LineSequence[1].Quantity"); + await Assert.That(error.Name).IsEqualTo("Quantity"); + await Assert.That(error.Container).IsSameReferenceAs(second); + + static IEnumerable Yield(OrderLine a, OrderLine b) + { + yield return a; + yield return b; + } + } + + [Test] + public async Task ValidateAsync_GivenHashSetElementError_ResolvesElementInEnumerationOrder( + CancellationToken cancellationToken + ) + { + // Arrange + OrderLine line = new(); + Order order = new() { LineSet = [line] }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("LineSet"), I(0), M("Sku")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("LineSet[0].Sku"); + await Assert.That(error.Container).IsSameReferenceAs(line); + } + + [Test] + public async Task ValidateAsync_GivenStringKeyedDictionaryError_ResolvesEntryAsContainer( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() + { + ByCode = new() { ["abc"] = new(), ["def"] = new() }, + }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("ByCode"), K("def"), M("Sku")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("ByCode[def].Sku"); + await Assert.That(error.Name).IsEqualTo("Sku"); + await Assert.That(error.Container).IsSameReferenceAs(order.ByCode["def"]); + } + + [Test] + public async Task ValidateAsync_GivenIntKeyedDictionaryError_ResolvesEntryByInvariantKey( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() + { + ById = new() { [7] = new(), [1000] = new() }, + }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("ById"), K(1000), M("Sku")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("ById[\"1000\"].Sku"); + await Assert.That(error.Container).IsSameReferenceAs(order.ById[1000]); + } + + [Test] + public async Task ValidateAsync_GivenPathEndingInIndex_ResolvesOwnerOfCollection( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() { Lines = [new()] }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("invalid_type", "Null line", M("Lines"), I(0)), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("Lines[0]"); + await Assert.That(error.Name).IsEqualTo("Lines"); + await Assert.That(error.Container).IsSameReferenceAs(order); + } + + [Test] + public async Task ValidateAsync_GivenNullableValueTypeMemberError_ResolvesRootAsContainer( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() { Quantity = null }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("invalid_type", "Quantity is required", M("Quantity")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("Quantity"); + await Assert.That(error.Name).IsEqualTo("Quantity"); + await Assert.That(error.Container).IsSameReferenceAs(order); + } + + [Test] + public async Task ValidateAsync_GivenRootError_ReportsModelLevelError(CancellationToken cancellationToken) + { + // Arrange + Order order = new(); + + // Act + var (error, _) = await ValidateOrderAsync(order, Error("custom", "Order is invalid"), cancellationToken); + + // Assert + await Assert.That(error.Path).IsEqualTo(string.Empty); + await Assert.That(error.Name).IsEqualTo(string.Empty); + await Assert.That(error.Container).IsSameReferenceAs(order); + } + + [Test] + public async Task ValidateAsync_GivenIndexPastEndOfCollection_DegradesToNearestAncestor( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new() { Lines = [new()] }; + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("too_small", "Sku", M("Lines"), I(5), M("Sku")), + cancellationToken + ); + + // Assert + await Assert.That(error.Path).IsEqualTo("Lines[5].Sku"); + await Assert.That(error.Name).IsEqualTo("Lines"); + await Assert.That(error.Container).IsSameReferenceAs(order); + } + + [Test] + public async Task ValidateAsync_GivenSyntheticMemberPath_KeepsMemberNameOnNearestObject( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new(); + + // Act + var (error, _) = await ValidateOrderAsync( + order, + Error("custom", "Totals mismatch", M("Customer"), M("Totals")), + cancellationToken + ); + + // Assert + await Assert.That(error.Name).IsEqualTo("Totals"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer); + } + + [Test] + public async Task ValidateAsync_GivenValidatorWithMemberResolver_UsesResolverResult( + CancellationToken cancellationToken + ) + { + // Arrange + object sentinel = new(); + MemberAwareValidator validator = new( + static _ => [Error("too_small", "Street", M("Customer"), M("Address"), M("Street"))], + static (_, _) => [], + (_, _) => (sentinel, "Resolved") + ); + using var provider = CreateProvider(factory => factory.Register(validator)); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order(), context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Container).IsSameReferenceAs(sentinel); + await Assert.That(error.Name).IsEqualTo("Resolved"); + } + + [Test] + public async Task ValidateAsync_GivenDictionaryBackedRuntimeSchema_ReportsFullPathWithoutContainer( + CancellationToken cancellationToken + ) + { + // Arrange + var schema = Z.Object().Field("name", Z.String().Min(3)).Build(); + using var provider = CreateProvider(factory => + factory.Register(new ZodSchemaValidator>(schema)) + ); + var context = provider.CreateContext(); + Dictionary model = new() { ["name"] = "ab" }; + + // Act + await provider.GetTypeInfo>().ValidateAsync(model, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("name"); + await Assert.That(error.Name).IsEqualTo("name"); + await Assert.That(error.Container).IsNull(); + } + + static async Task<( + Microsoft.Extensions.Validation.ValidationError Error, + ValidateContext Context + )> ValidateOrderAsync(Order order, ZodValidationError error, CancellationToken cancellationToken) + { + using var provider = CreateProvider(factory => factory.Register(new ScriptedValidator(error))); + var context = provider.CreateContext(); + await provider.GetTypeInfo().ValidateAsync(order, context, cancellationToken); + return (context.Single(), context); + } +} diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoTests.cs new file mode 100644 index 00000000..671c2cc6 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoTests.cs @@ -0,0 +1,376 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Validation; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation.Fixtures; +using static ZodSharp.Extensions.Validation.Fixtures.TestHelpers; +using ZodValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.Extensions.Validation; + +public class ZodValidatableTypeInfoTests +{ + [Test] + public async Task ValidateAsync_GivenCurrentValidationPath_PrefixesPath(CancellationToken cancellationToken) + { + // Arrange + Order order = new(); + using var provider = CreateProvider(static factory => + factory.Register( + new ScriptedValidator(Error("too_small", "Street", M("Customer"), M("Address"), M("Street"))) + ) + ); + var context = provider.CreateContext(); + context.CurrentValidationPath = "Batch.Orders[2]"; + + // Act + await provider.GetTypeInfo().ValidateAsync(order, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Batch.Orders[2].Customer.Address.Street"); + await Assert.That(error.Name).IsEqualTo("Street"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer.Address); + await Assert + .That(context.ZodReport!.Errors[0].FullPath.ToDottedString()) + .IsEqualTo("Batch.Orders[2].Customer.Address.Street"); + } + + [Test] + public async Task ValidateAsync_GivenNestedUnderDataAnnotationsWalker_PrefixesWithMemberPath( + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpFactory(static factory => + factory.Register( + new ScriptedValidator(Error("too_small", "Street", M("Customer"), M("Address"), M("Street"))) + ) + ); + services.AddZodSharpValidation(); + using var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + Envelope envelope = new() { Title = "ok" }; + + // Act + await provider.GetTypeInfo().ValidateAsync(envelope, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Order.Customer.Address.Street"); + await Assert.That(error.Name).IsEqualTo("Street"); + await Assert.That(error.Container).IsSameReferenceAs(envelope.Order.Customer.Address); + await Assert.That(context.CurrentValidationPath).IsEqualTo(string.Empty); + } + + [Test] + public async Task ValidateAsync_GivenMaxDepthReached_ThrowsLikeMicrosoftExtensionsValidation( + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpFactory(static factory => factory.Register(new ScriptedValidator())); + services.AddZodSharpValidation(); + using var provider = services.BuildServiceProvider(); + var zodContext = provider.CreateContext(); + zodContext.CurrentDepth = zodContext.ValidationOptions.MaxDepth; + var generatedContext = provider.CreateContext(); + generatedContext.CurrentDepth = generatedContext.ValidationOptions.MaxDepth; + + // Act + var zod = await Assert + .That(() => provider.GetTypeInfo().ValidateAsync(new Order(), zodContext, cancellationToken)) + .Throws(); + var generated = await Assert + .That(() => + provider.GetTypeInfo().ValidateAsync(new Envelope(), generatedContext, cancellationToken) + ) + .Throws(); + + // Assert + await Assert.That(zod!.Message).StartsWith("Maximum validation depth of 32 exceeded at '' in 'Order'."); + await Assert + .That(zod.Message.Replace("'Order'", "'Envelope'", StringComparison.Ordinal)) + .IsEqualTo(generated!.Message); + } + + [Test] + public async Task ValidateAsync_GivenDepthBelowMax_Validates(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider(static factory => + factory.Register(new ScriptedValidator(Error("custom", "Invalid"))) + ); + var context = provider.CreateContext(); + context.CurrentDepth = context.ValidationOptions.MaxDepth - 1; + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order(), context, cancellationToken); + + // Assert + await Assert.That(context.All().Length).IsEqualTo(1); + await Assert.That(context.CurrentDepth).IsEqualTo(context.ValidationOptions.MaxDepth - 1); + } + + [Test] + public async Task Validate_GivenAsyncOnlyRules_Throws() + { + // Arrange + using var provider = CreateProvider(static factory => + factory.Register(new AsyncOnlyValidator(Error("custom", "Taken"))) + ); + var context = provider.CreateContext(); + + // Act + var exception = await Assert + .That(() => provider.GetTypeInfo().Validate(new Order(), context)) + .Throws(); + + // Assert + await Assert.That(exception!.Message).Contains(typeof(Order).FullName!); + await Assert.That(context.ValidationErrors).IsNull(); + } + + [Test] + public async Task ValidateAsync_GivenAsyncOnlyRules_AwaitsAndPassesServices(CancellationToken cancellationToken) + { + // Arrange + AsyncOnlyValidator validator = new(Error("custom", "Taken", M("Reference"))); + using var provider = CreateProvider(factory => factory.Register(validator)); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order(), context, cancellationToken); + + // Assert + await Assert.That(context.Single().Path).IsEqualTo("Reference"); + await Assert.That(validator.ReceivedServices).IsSameReferenceAs(provider); + } + + [Test] + public async Task Validate_GivenSyncValidator_ReportsErrors() + { + // Arrange + Order order = new(); + using var provider = CreateProvider(static factory => + factory.Register(new ScriptedValidator(Error("too_small", "Email", M("Customer"), M("Email")))) + ); + var context = provider.CreateContext(); + + // Act + provider.GetTypeInfo().Validate(order, context); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Customer.Email"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer); + } + + [Test] + public async Task ValidateAsync_GivenValidValue_CompletesSynchronouslyWithoutErrors( + CancellationToken cancellationToken + ) + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(new ScriptedValidator())); + var context = provider.CreateContext(); + + // Act + var task = provider.GetTypeInfo().ValidateAsync(new Order(), context, cancellationToken); + + // Assert + await Assert.That(task.IsCompletedSuccessfully).IsTrue(); + await Assert.That(context.ValidationErrors).IsNull(); + await Assert.That(context.ZodReport).IsNull(); + } + + [Test] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "TUnit's assertion builders count towards coupling; the test is one scenario." + )] + public async Task ValidateAsync_GivenErrors_ReportAndSinkReceiveIdenticalFullFidelityErrors( + CancellationToken cancellationToken + ) + { + // Arrange + var original = ZodValidationError.Create( + "too_small", + "Sku must be 3 characters", + ZodPath.From(M("Lines"), I(0), M("Sku")), + parameters: new Dictionary { ["minimum"] = 3 }, + origin: "string", + minimum: 3, + category: ZodIssueCategories.TooSmall + ); + RecordingSink sink = new(); + using var provider = CreateProvider( + factory => factory.Register(new ScriptedValidator(original)), + services => services.AddSingleton(sink) + ); + var context = provider.CreateContext(); + context.CurrentValidationPath = "Root"; + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order { Lines = [new()] }, context, cancellationToken); + + // Assert + var report = context.ZodReport; + await Assert.That(report).IsNotNull(); + await Assert.That(report!.HasErrors).IsTrue(); + var call = sink.Calls.Single(); + await Assert.That(call.Type).IsEqualTo(typeof(Order)); + await Assert.That(call.Prefix).IsEqualTo("Root"); + await Assert.That(call.Errors.SequenceEqual(report.Errors)).IsTrue(); + + var reported = report.Errors.Single(); + await Assert.That(reported).IsEqualTo(original.WithPathPrefix(M("Root"))); + await Assert.That(reported.Code).IsEqualTo("too_small"); + await Assert.That(reported.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(reported.Origin).IsEqualTo("string"); + await Assert.That(reported.Minimum).IsEqualTo(3); + await Assert.That(reported.FullPath.ToDottedString()).IsEqualTo(context.Single().Path); + } + + [Test] + public async Task ValidateAsync_GivenMessageFormatter_UsesIt(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider( + static factory => + factory.Register(new ScriptedValidator(Error("custom", "Invalid", M("Reference")))), + options: static options => options.MessageFormatter = new FixedFormatter("formatted: ") + ); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new Order(), context, cancellationToken); + + // Assert + await Assert.That(context.Single().ErrorMessage).IsEqualTo("formatted: Invalid"); + } + + [Test] + public async Task ValidateAsync_GivenPathNamer_RendersPathWithNamerButKeepsMemberName( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new(); + using var provider = CreateProvider( + static factory => + factory.Register( + new ScriptedValidator(Error("too_small", "Street", M("Customer"), M("Address"), M("Street"))) + ), + options: static options => + options.PathNamer = ZodPathNamer.FromFunc(static name => char.ToLowerInvariant(name[0]) + name[1..]) + ); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(order, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("customer.address.street"); + await Assert.That(error.Name).IsEqualTo("Street"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer.Address); + } + + [Test] + public async Task ValidateAsync_GivenUnionWithoutValidator_FollowsActiveCaseUnderValue( + CancellationToken cancellationToken + ) + { + // Arrange + Order order = new(); + using var provider = CreateProvider(static factory => + factory.Register( + new ScriptedValidator(Error("too_small", "Street", M("Customer"), M("Address"), M("Street"))) + ) + ); + var context = provider.CreateContext(); + Shipment shipment = order; + + // Act + await provider.GetTypeInfo().ValidateAsync(shipment, context, cancellationToken); + + // Assert + var error = context.Single(); + await Assert.That(error.Path).IsEqualTo("Value.Customer.Address.Street"); + await Assert.That(error.Name).IsEqualTo("Street"); + await Assert.That(error.Container).IsSameReferenceAs(order.Customer.Address); + await Assert.That(context.CurrentValidationPath).IsEqualTo(string.Empty); + await Assert.That(context.CurrentDepth).IsEqualTo(0); + } + + [Test] + public async Task ValidateAsync_GivenUnionCaseWithoutValidator_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange + using var provider = CreateProvider(static factory => factory.Register(new ScriptedValidator())); + var context = provider.CreateContext(); + Shipment shipment = new Address(); + + // Act + await provider.GetTypeInfo().ValidateAsync(shipment, context, cancellationToken); + + // Assert + await Assert.That(context.ValidationErrors).IsNull(); + } + + [Test] + public async Task ValidateAsync_GivenAlsoRunDataAnnotations_ReportsBothResolversErrors( + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpFactory(static factory => + factory.Register( + new ScriptedValidator(Error("custom", "ZodSharp: Name is required.", M("Name"))) + ) + ); + services.AddZodSharpValidation(static options => options.AlsoRunDataAnnotations = true); + using var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new DualModel(), context, cancellationToken); + + // Assert + string[] messages = [.. context.All().Select(static e => e.ErrorMessage).Order(StringComparer.Ordinal)]; + await Assert + .That(messages) + .IsEquivalentTo(["DataAnnotations: Name is required.", "ZodSharp: Name is required."]); + } + + [Test] + public async Task ValidateAsync_GivenAlsoRunDataAnnotationsWithIdenticalMessage_ReportsItOnce( + CancellationToken cancellationToken + ) + { + // Arrange + ServiceCollection services = new(); + services.AddValidation(); + services.AddZodSharpFactory(static factory => + factory.Register( + new ScriptedValidator(Error("custom", "DataAnnotations: Name is required.", M("Name"))) + ) + ); + services.AddZodSharpValidation(static options => options.AlsoRunDataAnnotations = true); + using var provider = services.BuildServiceProvider(); + var context = provider.CreateContext(); + + // Act + await provider.GetTypeInfo().ValidateAsync(new DualModel(), context, cancellationToken); + + // Assert + await Assert.That(context.Single().ErrorMessage).IsEqualTo("DataAnnotations: Name is required."); + } +} diff --git a/src/tests/Http.UnitTests/Fixtures/Order.cs b/src/tests/Http.UnitTests/Fixtures/Order.cs new file mode 100644 index 00000000..d09d846b --- /dev/null +++ b/src/tests/Http.UnitTests/Fixtures/Order.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.Http.Fixtures; + +[ZodSchema] +public partial class Order +{ + [MinLengthZod(3)] + public string Reference { get; set; } = string.Empty; + + [MinValue(1)] + public int Quantity { get; set; } +} + +/// The references already taken; what the service-aware rule on resolves. +public interface IReferenceRegistry +{ + bool IsTaken(string reference); +} + +sealed class ReferenceRegistry(params string[] taken) : IReferenceRegistry +{ + public bool IsTaken(string reference) => taken.Contains(reference, StringComparer.Ordinal); +} + +/// A model with a service-aware async rule: its reference must not be taken. +[ZodSchema] +public sealed partial class Booking +{ + public const string ReferenceTakenCode = "reference_taken"; + + [MinLengthZod(3)] + public string Reference { get; set; } = string.Empty; + + [ZodAsyncMember(nameof(Reference))] + internal static ValueTask> CustomValidationAsync( + Booking value, + IServiceProvider services, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + return + services.GetService(typeof(IReferenceRegistry)) is IReferenceRegistry registry + && registry.IsTaken(value.Reference) + ? ValueTask.FromResult( + ValidationResult.Failure( + ValidationError.Create(ReferenceTakenCode, "Reference is taken", BookingSchema.Paths.Reference) + ) + ) + : ValueTask.FromResult(ValidationResult.Success(value)); + } +} + +/// The contract a WebAssembly client would supply: source-generated, never reflective. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(Order))] +[JsonSerializable(typeof(Booking))] +sealed partial class HttpTestJsonContext : JsonSerializerContext; + +/// Records each request (method, URI and body) and answers with a fixed response. +sealed class RecordingHandler(HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler +{ + public List<(HttpMethod Method, Uri? Uri, string? MediaType, string Body)> Requests { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + Requests.Add((request.Method, request.RequestUri, request.Content?.Headers.ContentType?.MediaType, body)); + return new HttpResponseMessage(status) { RequestMessage = request }; + } +} diff --git a/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs b/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs new file mode 100644 index 00000000..fcddeef7 --- /dev/null +++ b/src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZodSharp.Core; + +// A stand-in for the Purview.ValueObjects [Scalar] attribute, which the generator recognizes by name and namespace, so +// these fixtures are shaped exactly like value objects without referencing that package. +#pragma warning disable IDE0130 // The stand-in must live in the namespace the generator recognizes. +namespace Purview.ValueObjects.Serialization +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute(string propertyName = "Value") : Attribute + { + public string PropertyName { get; } = propertyName; + } +} + +#pragma warning restore IDE0130 + +namespace ZodSharp.Http.Fixtures +{ + /// A scalar value object read by a strict converter, which rejects an invalid value while reading. + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [JsonConverter(typeof(EmailAddressJsonConverter))] + public readonly partial record struct EmailAddress + { + public EmailAddress(string value) => Value = value; + + [Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Value { get; } + } + + [ZodSchema] + public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, int Quantity); + + /// + /// What a Purview.ValueObjects strict converter does: turn a validation failure into a + /// carrying the , so the serializer records where it was read. + /// + public sealed class EmailAddressJsonConverter : JsonConverter + { + public override EmailAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var result = EmailAddressSchema.Validate(new EmailAddress(reader.GetString() ?? string.Empty)); + return result.IsSuccess + ? result.Value + : throw new JsonException( + "Cannot deserialize EmailAddress: the value failed validation.", + new ZodException(result.Errors) + ); + } + + public override void Write(Utf8JsonWriter writer, EmailAddress value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] + [JsonSerializable(typeof(PlaceOrderRequest))] + sealed partial class StrictValueObjectJsonContext : JsonSerializerContext; +} diff --git a/src/tests/Http.UnitTests/Http.UnitTests.csproj b/src/tests/Http.UnitTests/Http.UnitTests.csproj new file mode 100644 index 00000000..ee35549e --- /dev/null +++ b/src/tests/Http.UnitTests/Http.UnitTests.csproj @@ -0,0 +1,26 @@ + + + ZodSharp.Http + Purview.ZodSharp.Http.UnitTests + + $(NoWarn);CA1506 + + + + + + + + + + + + + + + diff --git a/src/tests/Http.UnitTests/ServiceAwareValidationTests.cs b/src/tests/Http.UnitTests/ServiceAwareValidationTests.cs new file mode 100644 index 00000000..cfd703f6 --- /dev/null +++ b/src/tests/Http.UnitTests/ServiceAwareValidationTests.cs @@ -0,0 +1,173 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.Http.Fixtures; + +namespace ZodSharp.Http; + +/// +/// The overloads: service-aware rules run before a body is sent and as one is read. +/// +public class ServiceAwareValidationTests +{ + [Test] + public async Task PostAsJsonValidatedAsync_GivenServicesAndATakenReference_SendsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + await using var services = CreateServices("TAKEN-1"); + + // Act + var result = await client.PostAsJsonValidatedAsync( + "bookings", + new Booking { Reference = "TAKEN-1" }, + new BookingSchemaValidator(), + HttpTestJsonContext.Default.Booking, + services, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(Booking.ReferenceTakenCode); + await Assert.That(result.Errors.Single().FullPath).IsEqualTo(BookingSchema.Paths.Reference); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task PostAsJsonValidatedAsync_GivenServicesAndAFreeReference_Sends(CancellationToken cancellationToken) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + await using var services = CreateServices("TAKEN-1"); + + // Act + var result = await client.PostAsJsonValidatedAsync( + new Uri("bookings", UriKind.Relative), + new Booking { Reference = "FREE-1" }, + new BookingSchemaValidator(), + HttpTestJsonContext.Default.Booking, + services, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert + .That(handler.Requests.Single().Body) + .IsEqualTo( /*lang=json,strict*/ + """{"reference":"FREE-1"}""" + ); + } + + [Test] + public async Task PutAndPatchAsJsonValidatedAsync_GivenServicesAndATakenReference_SendNothing( + CancellationToken cancellationToken + ) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + await using var services = CreateServices("TAKEN-1"); + Booking booking = new() { Reference = "TAKEN-1" }; + BookingSchemaValidator validator = new(); + + // Act + var put = await client.PutAsJsonValidatedAsync( + "bookings/1", + booking, + validator, + HttpTestJsonContext.Default.Booking, + services, + cancellationToken + ); + var patch = await client.PatchAsJsonValidatedAsync( + "bookings/1", + booking, + validator, + HttpTestJsonContext.Default.Booking, + services, + cancellationToken + ); + + // Assert + await Assert.That(put.IsSuccess).IsFalse(); + await Assert.That(patch.IsSuccess).IsFalse(); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task ReadFromJsonAndValidateAsync_GivenServicesAndATakenReference_ReturnsTheRuleError( + CancellationToken cancellationToken + ) + { + // Arrange + await using var services = CreateServices("TAKEN-1"); + using StringContent content = new( + /*lang=json,strict*/"""{"reference":"TAKEN-1"}""", + Encoding.UTF8, + "application/json" + ); + + // Act + var result = await content.ReadFromJsonAndValidateAsync( + new BookingSchemaValidator(), + HttpTestJsonContext.Default.Booking, + services, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(Booking.ReferenceTakenCode); + } + + [Test] + public async Task ReadFromJsonAndValidateAsync_GivenServicesAndMalformedJson_ReturnsInvalidJson( + CancellationToken cancellationToken + ) + { + // Arrange + await using var services = CreateServices(); + using StringContent content = new("{not json", Encoding.UTF8, "application/json"); + + // Act + var result = await content.ReadFromJsonAndValidateAsync( + new BookingSchemaValidator(), + HttpTestJsonContext.Default.Booking, + services, + cancellationToken + ); + + // Assert + await Assert.That(result.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + } + + [Test] + public async Task CreateAsync_GivenServicesAndATakenReference_ReturnsTheRuleError( + CancellationToken cancellationToken + ) + { + // Arrange + await using var services = CreateServices("TAKEN-1"); + + // Act + var result = await ZodJsonContent.CreateAsync( + new BookingSchemaValidator(), + new Booking { Reference = "TAKEN-1" }, + HttpTestJsonContext.Default.Booking, + services, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(Booking.ReferenceTakenCode); + } + + static ServiceProvider CreateServices(params string[] taken) => + new ServiceCollection().AddSingleton(new ReferenceRegistry(taken)).BuildServiceProvider(); +} diff --git a/src/tests/Http.UnitTests/ZodHttpClientExtensionsTests.cs b/src/tests/Http.UnitTests/ZodHttpClientExtensionsTests.cs new file mode 100644 index 00000000..f9253071 --- /dev/null +++ b/src/tests/Http.UnitTests/ZodHttpClientExtensionsTests.cs @@ -0,0 +1,116 @@ +using ZodSharp.Http.Fixtures; +using ZodSharp.Rules; + +namespace ZodSharp.Http; + +/// +/// PostAsJsonValidatedAsync, PutAsJsonValidatedAsync and PatchAsJsonValidatedAsync. +/// +public class ZodHttpClientExtensionsTests +{ + [Test] + public async Task PostAsJsonValidatedAsync_GivenValidValue_SendsTheValidatedJson( + CancellationToken cancellationToken + ) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + Order order = new() { Reference = "ABC-1", Quantity = 2 }; + + // Act + var result = await client.PostAsJsonValidatedAsync( + "orders", + order, + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value!.IsSuccessStatusCode).IsTrue(); + var request = handler.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.Uri).IsEqualTo(new Uri("https://api.test/orders")); + await Assert.That(request.MediaType).IsEqualTo("application/json"); + await Assert + .That(request.Body) + .IsEqualTo( /*lang=json,strict*/ + """{"reference":"ABC-1","quantity":2}""" + ); + } + + [Test] + public async Task PostAsJsonValidatedAsync_GivenInvalidValue_ReturnsErrorsAndSendsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + Order order = new() { Reference = "A", Quantity = 0 }; + + // Act + var result = await client.PostAsJsonValidatedAsync( + new Uri("orders", UriKind.Relative), + order, + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert + .That(result.Errors.Select(static error => error.FullPath.ToDottedString())) + .IsEquivalentTo(["Reference", "Quantity"]); + await Assert + .That(result.Errors.Select(static error => error.Code)) + .IsEquivalentTo([MinLengthRule.ErrorCode, MinValueRule.ErrorCode]); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task PutAsJsonValidatedAsync_GivenValidValue_SendsAPut(CancellationToken cancellationToken) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + + // Act + var result = await client.PutAsJsonValidatedAsync( + "orders/1", + new Order { Reference = "ABC-1", Quantity = 1 }, + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Put); + } + + [Test] + public async Task PatchAsJsonValidatedAsync_GivenInvalidValue_SendsNothing(CancellationToken cancellationToken) + { + // Arrange + using RecordingHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://api.test/") }; + + // Act + var result = await client.PatchAsJsonValidatedAsync( + "orders/1", + new Order { Reference = "ABC-1", Quantity = 0 }, + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(MinValueRule.ErrorCode); + await Assert.That(handler.Requests).IsEmpty(); + } +} diff --git a/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs b/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs new file mode 100644 index 00000000..bbc9f76a --- /dev/null +++ b/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs @@ -0,0 +1,154 @@ +using System.Text; +using ZodSharp.Http.Fixtures; +using ZodSharp.Rules; + +namespace ZodSharp.Http; + +/// +/// HttpContent.ReadFromJsonAndValidateAsync and . +/// +public class ZodHttpContentExtensionsTests +{ + [Test] + public async Task ReadFromJsonAndValidateAsync_GivenValidBody_ReturnsTheValue(CancellationToken cancellationToken) + { + // Arrange + using StringContent content = new( + /*lang=json,strict*/"""{"reference":"ABC-1","quantity":2}""", + Encoding.UTF8, + "application/json" + ); + + // Act + var result = await content.ReadFromJsonAndValidateAsync( + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value!.Reference).IsEqualTo("ABC-1"); + await Assert.That(result.Value!.Quantity).IsEqualTo(2); + } + + [Test] + public async Task ReadFromJsonAndValidateAsync_GivenInvalidValue_ReturnsTheSchemaErrors( + CancellationToken cancellationToken + ) + { + // Arrange + using StringContent content = new( + /*lang=json,strict*/"""{"reference":"ABC-1","quantity":0}""", + Encoding.UTF8, + "application/json" + ); + + // Act + var result = await content.ReadFromJsonAndValidateAsync( + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(MinValueRule.ErrorCode); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("Quantity"); + } + + [Test] + [Arguments("{not json")] + [Arguments("null")] + [Arguments( /*lang=json,strict*/ + """{"reference":"ABC-1","quantity":"two"}""" + )] + public async Task ReadFromJsonAndValidateAsync_GivenUnreadableBody_ReturnsInvalidJson( + string body, + CancellationToken cancellationToken + ) + { + // Arrange + using StringContent content = new(body, Encoding.UTF8, "application/json"); + + // Act + var result = await content.ReadFromJsonAndValidateAsync( + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + } + + [Test] + public async Task ReadFromJsonAndValidateAsync_GivenStrictValueObjectRejection_ReturnsTheRerootedErrors( + CancellationToken cancellationToken + ) + { + // Arrange: a strict value object converter throws JsonException(inner: ZodException) while reading. + using StringContent content = new( + /*lang=json,strict*/"""{"customerEmail":"not-an-email","quantity":5}""", + Encoding.UTF8, + "application/json" + ); + + // Act + var result = await content.ReadFromJsonAndValidateAsync( + new PlaceOrderRequestSchemaValidator(), + StrictValueObjectJsonContext.Default.PlaceOrderRequest, + cancellationToken + ); + + // Assert: the errors validating the hydrated model would report, not invalid_json. + await Assert.That(result.IsSuccess).IsFalse(); + var expected = PlaceOrderRequestSchema + .Validate(new PlaceOrderRequest(new EmailAddress("not-an-email"), 5)) + .Errors.Single(); + var error = result.Errors.Single(); + await Assert.That(error).IsEqualTo(expected); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("CustomerEmail.Value"); + await Assert.That(error.Message).IsEqualTo("Field 'CustomerEmail' must be a valid email address."); + } + + [Test] + public async Task Create_GivenValidValue_ReturnsJsonContent(CancellationToken cancellationToken) + { + // Arrange + Order order = new() { Reference = "ABC-1", Quantity = 2 }; + + // Act + var result = ZodJsonContent.Create(new OrderSchemaValidator(), order, HttpTestJsonContext.Default.Order); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + using var content = result.Value!; + await Assert + .That(await content.ReadAsStringAsync(cancellationToken)) + .IsEqualTo( /*lang=json,strict*/ + """{"reference":"ABC-1","quantity":2}""" + ); + } + + [Test] + public async Task CreateAsync_GivenInvalidValue_ReturnsTheErrors(CancellationToken cancellationToken) + { + // Arrange + Order order = new() { Reference = "A", Quantity = 2 }; + + // Act + var result = await ZodJsonContent.CreateAsync( + new OrderSchemaValidator(), + order, + HttpTestJsonContext.Default.Order, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(MinLengthRule.ErrorCode); + } +} diff --git a/src/tests/Http.UnitTests/ZodValidationProblemDetailsTests.cs b/src/tests/Http.UnitTests/ZodValidationProblemDetailsTests.cs new file mode 100644 index 00000000..e65acebc --- /dev/null +++ b/src/tests/Http.UnitTests/ZodValidationProblemDetailsTests.cs @@ -0,0 +1,253 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ZodSharp.AspNetCore; +using ZodSharp.Core; +using ZodSharp.Http.Fixtures; + +namespace ZodSharp.Http; + +/// +/// Reading validation problems: TryReadZodValidationProblemAsync, , and +/// parity with what Purview.ZodSharp.AspNetCore writes. +/// +public class ZodValidationProblemDetailsTests +{ + [Test] + public async Task TryReadZodValidationProblemAsync_GivenServerProblem_RoundTripsEveryErrorField( + CancellationToken cancellationToken + ) + { + // Arrange + ImmutableArray written = + [ + ValidationError.Create( + "too_small", + "Quantity must be at least 1", + ZodPath.From(ZodPathSegment.Member("Lines"), ZodPathSegment.At(1), ZodPathSegment.Member("Quantity")), + origin: "number", + minimum: 1, + inclusive: true, + category: "too_small" + ), + ValidationError.Create( + "too_big", + "Price must be less than 10.5", + ZodPath.Member("Price"), + origin: "number", + category: "too_big" + ) with + { + Upper = new ErrorBound(10.5, Inclusive: false), + }, + ValidationError.Create( + "invalid_tenant", + "Tenant is unknown", + ZodPath.Member("Tenant"), + new Dictionary { ["tenant"] = "acme" }, + category: "invalid_value" + ), + ValidationError.Create("custom", "Order is invalid", ZodPath.Root), + ZodIssues.InvalidUnion( + [ + [ + ValidationError.Create( + "invalid_type", + "Required", + ZodPath.Member("Name"), + category: "invalid_type" + ), + ], + ], + path: ZodPath.Member("Pet") + ), + ]; + await using var app = await StartServerAsync(written, cancellationToken); + using var client = app.GetTestClient(); + using var response = await client.PostAsync( + new Uri("/orders", UriKind.Relative), + content: null, + cancellationToken + ); + + // Act + var problem = await response.TryReadZodValidationProblemAsync(cancellationToken); + var read = problem!.ToValidationErrors(); + + // Assert + await Assert.That(problem.Status).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert + .That(read.Select(Describe)) + .IsEquivalentTo(written.Select(Describe), TUnit.Assertions.Enums.CollectionOrdering.Matching); + var options = read.Single(static error => error.FullPath.ToDottedString() == "Pet").GetUnionErrors(); + await Assert + .That(options.Single().Select(Describe)) + .IsEquivalentTo(written[^1].GetUnionErrors().Single().Select(Describe)); + await Assert + .That(read.Single(static error => error.Code == "invalid_tenant").Parameters?["tenant"]) + .IsEqualTo("acme"); + } + + [Test] + public async Task PostAsJsonValidatedAsync_GivenServerRejection_ReadsTheProblem(CancellationToken cancellationToken) + { + // Arrange + var taken = ValidationError.Create("reference_taken", "Reference is taken", ZodPath.Member("Reference")); + await using var app = await StartServerAsync([taken], cancellationToken); + using var client = app.GetTestClient(); + + // Act + var sent = await client.PostAsJsonValidatedAsync( + "/orders", + new Order { Reference = "ABC-1", Quantity = 1 }, + new OrderSchemaValidator(), + HttpTestJsonContext.Default.Order, + cancellationToken + ); + using var response = sent.Value!; + var problem = await response.TryReadZodValidationProblemAsync(cancellationToken); + var result = problem!.ToValidationResult(); + + // Assert + await Assert.That(sent.IsSuccess).IsTrue(); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo("reference_taken"); + await Assert.That(result.Errors.Single().FullPath).IsEqualTo(ZodPath.Member("Reference")); + } + + [Test] + public async Task TryReadZodValidationProblemAsync_GivenUnionIssue_ReadsTheOptions( + CancellationToken cancellationToken + ) + { + // Arrange + using HttpResponseMessage response = new(HttpStatusCode.BadRequest) + { + Content = new StringContent( + /*lang=json,strict*/""" + {"status":400,"errors":{"Pet":["Invalid input"]},"issues":[{"path":"Pet","segments":["Pet"], + "code":"invalid_union","category":"invalid_union","message":"Invalid input","unionErrors":[[{"path":"Name", + "segments":["Name"],"code":"invalid_type","message":"Required"}]]}],"traceId":"00-abc"} + """, + Encoding.UTF8, + "application/problem+json" + ), + }; + + // Act + var problem = await response.TryReadZodValidationProblemAsync(cancellationToken); + + // Assert + await Assert.That(problem).IsNotNull(); + await Assert.That(problem!.TraceId).IsEqualTo("00-abc"); + var error = problem.ToValidationErrors().Single(); + var options = error.GetUnionErrors(); + await Assert.That(options.Length).IsEqualTo(1); + await Assert.That(options[0][0].Code).IsEqualTo("invalid_type"); + } + + [Test] + [Arguments( + HttpStatusCode.OK, + "application/json", /*lang=json,strict*/ + """{"errors":{"a":["b"]}}""" + )] + [Arguments(HttpStatusCode.BadRequest, "text/plain", "not json")] + [Arguments(HttpStatusCode.BadRequest, "application/json", "{not json")] + [Arguments( + HttpStatusCode.NotFound, + "application/problem+json", /*lang=json,strict*/ + """{"title":"Not Found","status":404}""" + )] + public async Task TryReadZodValidationProblemAsync_GivenNonValidationResponse_ReturnsNull( + HttpStatusCode status, + string mediaType, + string body, + CancellationToken cancellationToken + ) + { + // Arrange + using HttpResponseMessage response = new(status) + { + Content = new StringContent(body, Encoding.UTF8, mediaType), + }; + + // Act + var problem = await response.TryReadZodValidationProblemAsync(cancellationToken); + + // Assert + await Assert.That(problem).IsNull(); + } + + [Test] + public async Task ToValidationErrors_GivenOnlyDottedErrors_ParsesTheKeysAsCustomErrors() + { + // Arrange + ZodValidationProblemDetails problem = new() + { + Errors = new Dictionary + { + ["Lines[0].Sku"] = ["Sku is required"], + [""] = ["Order is invalid"], + }, + }; + + // Act + var errors = problem.ToValidationErrors(); + + // Assert + await Assert + .That(errors.Select(static error => (error.FullPath, error.Code, error.Message))) + .IsEquivalentTo([ + ( + ZodPath.From(ZodPathSegment.Member("Lines"), ZodPathSegment.At(0), ZodPathSegment.Member("Sku")), + "custom", + "Sku is required" + ), + (ZodPath.Root, "custom", "Order is invalid"), + ]); + } + + // Every field a client can observe, normalized so a numeric bound compares by value whatever its CLR type. + static string Describe(ValidationError error) => + string.Join( + '|', + error.Code, + error.Category, + error.Message, + error.FullPath.ToDottedString(), + error.Origin, + DescribeBound(error.Lower), + DescribeBound(error.Upper) + ); + + static string DescribeBound(ErrorBound? bound) => + bound is { } value + ? string.Create( + CultureInfo.InvariantCulture, + $"{Convert.ToDecimal(value.Value, CultureInfo.InvariantCulture)}:{value.Inclusive}" + ) + : "-"; + + static async Task StartServerAsync( + ImmutableArray errors, + CancellationToken cancellationToken + ) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddZodSharpProblemDetails(); + var app = builder.Build(); + app.MapPost("/orders", (HttpContext context) => + ZodTypedResults.ValidationProblem(ValidationResult.Failure(errors), context) + ); + await app.StartAsync(cancellationToken); + return app; + } +} diff --git a/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs b/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs new file mode 100644 index 00000000..5d76d6ee --- /dev/null +++ b/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs @@ -0,0 +1,93 @@ +using System.Text; +using Newtonsoft.Json; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// Both JSON packages declare their extensions in namespace ZodSharp. These calls compile only when the +/// argument-light forms resolve to one overload (System.Text.Json's) instead of failing with CS0121, so this file +/// is itself the regression test; the assertions check where each call went. +/// +public class DualPackageCallTests +{ + const string PersonJson = /*lang=json,strict*/ + """{"Name":"Ada"}"""; + + [Test] + public async Task DeserializeAndValidate_GivenBothPackagesAndNoOptions_ResolvesToSystemTextJson( + CancellationToken cancellationToken + ) + { + // Arrange + PersonSchema schema = new(); + using MemoryStream first = new(Encoding.UTF8.GetBytes(PersonJson)); + using MemoryStream second = new(Encoding.UTF8.GetBytes(PersonJson)); + + // Act + var fromString = schema.DeserializeAndValidate(PersonJson); + var fromStream = await schema.DeserializeAndValidateAsync(first); + var fromStreamWithToken = await schema.DeserializeAndValidateAsync(second, cancellationToken); + var invalid = schema.DeserializeAndValidate("{bad"); + + // Assert + await Assert.That(fromString.Value?.Name).IsEqualTo("Ada"); + await Assert.That(fromStream.Value?.Name).IsEqualTo("Ada"); + await Assert.That(fromStreamWithToken.Value?.Name).IsEqualTo("Ada"); + await Assert.That(invalid.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + } + + [Test] + public async Task ValidateAndSerialize_GivenBothPackagesAndNoOptions_ResolvesToSystemTextJson( + CancellationToken cancellationToken + ) + { + // Arrange + PersonSchema schema = new(); + Person person = new() { Name = "Ada" }; + using MemoryStream first = new(); + using MemoryStream second = new(); + + // Act + var json = schema.ValidateAndSerialize(person); + var toStream = await schema.ValidateAndSerializeAsync(person, first); + var toStreamWithToken = await schema.ValidateAndSerializeAsync(person, second, cancellationToken); + + // Assert — System.Text.Json writes compact JSON; Newtonsoft's async default is indented. + await Assert.That(json.Value).IsEqualTo(PersonJson); + await Assert.That(toStream.IsSuccess).IsTrue(); + await Assert.That(toStreamWithToken.IsSuccess).IsTrue(); + await Assert.That(Encoding.UTF8.GetString(first.ToArray())).IsEqualTo(PersonJson); + } + + [Test] + public async Task DeserializeAndValidate_GivenNewtonsoftSettings_StillResolvesToNewtonsoft() + { + // Arrange + PersonSchema schema = new(); + JsonSerializerSettings settings = new(); + + // Act + var result = schema.DeserializeAndValidate(PersonJson, settings); + var converter = NewtonsoftJsonExtensions.CreateValidatingConverter(schema); + + // Assert + await Assert.That(result.Value?.Name).IsEqualTo("Ada"); + await Assert.That(converter).IsAssignableTo(); + } + + public sealed class Person + { + public string? Name { get; set; } + } + + sealed class PersonSchema : IZodSchema + { + public ValidationResult Validate(Person value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + Person value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } +} diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs new file mode 100644 index 00000000..56b45f8d --- /dev/null +++ b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs @@ -0,0 +1,247 @@ +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// Covers how Newtonsoft.Json failures become validation errors: one invalid_json code, the JSON +/// location kept as a typed path plus line and position, a generic message, full errors from the validating +/// converter, and asynchronous validation on the stream path. +/// +public class NewtonsoftJsonErrorTests +{ + [Test] + public async Task DeserializeAndValidate_GivenUnconvertibleMemberValue_ReportsPathLineAndPositionWithGenericMessage() + { + // Defect J5 + // Arrange + PassThroughSchema schema = new(); + const string json = /*lang=json,strict*/ + """{"Name":"Ada","Age":"forty-two"}"""; + + // Act + var result = schema.DeserializeAndValidate(json); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(NewtonsoftJsonExtensions.InvalidJsonCode); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.Member("Age")); + await Assert.That(error.Message).IsEqualTo("The JSON value could not be converted to number."); + await Assert.That(error.Parameters!["line"]).IsEqualTo(1); + await Assert.That(error.Parameters!["position"]).IsNotNull(); + await Assert.That(error.Message).DoesNotContain("System."); + await Assert.That(error.Message).DoesNotContain("forty-two"); + } + + [Test] + public async Task DeserializeAndValidate_GivenBadArrayElement_ReportsIndexedPath() + { + // Defect J5 + // Arrange + PassThroughSchema schema = new(); + const string json = /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}"""; + + // Act + var result = schema.DeserializeAndValidate(json); + + // Assert + var error = result.Errors.Single(); + await Assert + .That(error.FullPath) + .IsEqualTo( + ZodPath.From(ZodPathSegment.Member("Members"), ZodPathSegment.At(1), ZodPathSegment.Member("Age")) + ); + await Assert.That(error.Message).IsEqualTo("The JSON value could not be converted to number."); + } + + [Test] + [Arguments( /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}""", + "Members[1].Age" + )] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":}]}""", "Members[1].Age")] + public async Task DeserializeAndValidate_GivenUnreadableValueUnderAValidatingConverter_ReportsTheFullPath( + string json, + string expectedPath + ) + { + // Arrange + PassThroughSchema schema = new(); + JsonSerializerSettings settings = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; + + // Act + var error = schema.DeserializeAndValidate(json, settings).Errors.Single(); + + // Assert + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo(expectedPath); + } + + [Test] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":}]}""", "Members[1].Age")] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":12x}]}""", "Members[1].Age")] + public async Task DeserializeAndValidate_GivenMalformedJsonInsideAnElement_ReportsTheReadersPath( + string json, + string expectedPath + ) + { + // Arrange + PassThroughSchema schema = new(); + + // Act + var error = schema.DeserializeAndValidate(json).Errors.Single(); + + // Assert + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo(expectedPath); + } + + [Test] + public async Task DeserializeAndValidate_GivenMalformedOrNullJson_ReportsSingleInvalidJsonCode() + { + // Defect J2 + // Arrange + PassThroughSchema schema = new(); + + // Act + var malformed = schema.DeserializeAndValidate("{bad json"); + var nullDocument = schema.DeserializeAndValidate("null"); + var nullToken = schema.DeserializeAndValidate(JValue.CreateNull()); + + // Assert + await Assert.That(malformed.Errors.Single().Code).IsEqualTo(NewtonsoftJsonExtensions.InvalidJsonCode); + await Assert.That(nullDocument.Errors.Single().Code).IsEqualTo(NewtonsoftJsonExtensions.InvalidJsonCode); + await Assert.That(nullToken.Errors.Single().Code).IsEqualTo(NewtonsoftJsonExtensions.InvalidJsonCode); + await Assert + .That(nullDocument.Errors.Single().Message) + .IsEqualTo("The JSON value could not be converted to object."); + } + + [Test] + public async Task DeserializeAndValidateAsync_GivenSchemaWithAsyncOnlyFailure_RunsAsyncValidation() + { + // Defect J6 + // Arrange + AsyncOnlyFailingSchema schema = new(); + using MemoryStream stream = new( + Encoding.UTF8.GetBytes( /*lang=json,strict*/ + """{"Name":"Ada","Age":1}""" + ) + ); + + // Act + var result = await schema.DeserializeAndValidateAsync(stream); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(AsyncOnlyFailingSchema.AsyncCode); + } + + [Test] + public async Task DeserializeAndValidate_GivenNestedValidatingConverterFailure_ReturnsFullErrorsUnderConverterPath() + { + // Defect J4 + // Arrange + PassThroughSchema schema = new(); + JsonSerializerSettings settings = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; + + // Act + var result = schema.DeserializeAndValidate(json, settings); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(PersonSchema.NameRequiredCode); + await Assert.That(error.Message).IsEqualTo("Name is required"); + await Assert.That(error.Origin).IsEqualTo("string"); + await Assert.That(error.Minimum).IsEqualTo(1); + await Assert + .That(error.FullPath) + .IsEqualTo(ZodPath.From(ZodPathSegment.Member("Owner"), ZodPathSegment.Member("Name"))); + } + + [Test] + public async Task DeserializeObject_GivenValidatingConverterFailure_ThrowsExceptionWithErrors() + { + // Defect J4 + // Arrange + JsonSerializerSettings settings = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; + + // Act + var exception = Assert.Throws(() => + JsonConvert.DeserializeObject(json, settings) + ); + + // Assert + await Assert.That(exception!.Message).Contains("Validation failed"); + await Assert.That(exception.Path).IsEqualTo("Owner"); + await Assert.That(exception.Errors.Single().FullPath.ToDottedString()).IsEqualTo("Owner.Name"); + } + + public sealed class Person + { + public string? Name { get; set; } + + public int Age { get; set; } + } + + public sealed class Team + { + public List? Members { get; set; } + } + + public sealed class Household + { + public Person? Owner { get; set; } + } + + sealed class PassThroughSchema : IZodSchema + { + public ValidationResult Validate(T value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + new(Validate(value)); + } + + sealed class AsyncOnlyFailingSchema : IZodSchema + { + public const string AsyncCode = "async_failure"; + + public ValidationResult Validate(Person value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + Person value, + CancellationToken cancellationToken = default + ) => new(ValidationResult.Failure(new ValidationError(AsyncCode, "Failed asynchronously"))); + } + + sealed class PersonSchema : IZodSchema + { + public const string NameRequiredCode = "name_required"; + + public ValidationResult Validate(Person value) => + string.IsNullOrEmpty(value.Name) + ? ValidationResult.Failure( + new ValidationError( + NameRequiredCode, + "Name is required", + [nameof(Person.Name)], + origin: "string", + minimum: 1, + category: ZodIssueCategories.TooSmall + ) + ) + : ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + Person value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } +} diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonExtensionsTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonExtensionsTests.cs index ec97034e..b4d39a07 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonExtensionsTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonExtensionsTests.cs @@ -19,14 +19,14 @@ public async Task DeserializeAndValidate_GivenValidJson_ReturnsSuccess() } [Test] - public async Task DeserializeAndValidate_GivenMalformedJson_ReturnsJsonErrorFailure() + public async Task DeserializeAndValidate_GivenMalformedJson_ReturnsInvalidJsonFailure() { TestUserSchema schema = new(); var result = schema.DeserializeAndValidate("{bad json"); await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Code).IsEqualTo("json_error"); + await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_json"); } [Test] diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs new file mode 100644 index 00000000..4f01d504 --- /dev/null +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs @@ -0,0 +1,162 @@ +using Newtonsoft.Json.Linq; +using ZodSharp.Core; +using ZodSharp.JsonSchema.NewtonsoftJson; + +namespace ZodSharp.Json.Schema; + +/// +/// Covers the errors produced by schemas imported from JSON Schema: typed index and key paths that keep +/// every error field, minItems/maxItems failures shaped like ZodArray's, and type +/// mismatches described with Zod's type names. +/// +public class FromJsonSchemaParserErrorTests +{ + [Test] + public async Task Validate_GivenInvalidArrayElement_PrefixesTypedIndexAndKeepsEveryField() + { + // Defect J7 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","items":{"type":"array","minItems":2}}""" + ); + + // Act + var result = schema.Validate(JToken.Parse("[[1,2],[1]]")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.From(ZodPathSegment.At(1))); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + [Test] + public async Task Validate_GivenTooFewItems_MirrorsZodArrayLengthFailure() + { + // Defect J8 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","minItems":2,"maxItems":3}""" + ); + + // Act + var result = schema.Validate(JToken.Parse("[1]")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("too_small"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + await Assert.That(error.Maximum).IsEqualTo(3); + await Assert.That(error.Inclusive).IsTrue(); + await Assert.That(error.Message).IsEqualTo("Array must have at least 2 elements, but got 1"); + } + + [Test] + public async Task Validate_GivenTooManyItems_MirrorsZodArrayLengthFailure() + { + // Defect J8 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","maxItems":1}""" + ); + + // Act + var result = schema.Validate(JToken.Parse("[1,2]")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("too_big"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Maximum).IsEqualTo(1); + await Assert.That(error.Message).IsEqualTo("Array must have at most 1 elements, but got 2"); + } + + [Test] + [Arguments( /*lang=json,strict*/ + """{"type":"string"}""", + "42", + "Expected string, but got number" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"number"}""", + "\"x\"", + "Expected number, but got string" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"boolean"}""", + "{}", + "Expected boolean, but got object" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"object"}""", + "[]", + "Expected object, but got array" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"array"}""", + "true", + "Expected array, but got boolean" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"null"}""", + "1", + "Expected null, but got number" + )] + public async Task Validate_GivenWrongJsonKind_ReportsZodTypeNames(string jsonSchema, string json, string message) + { + // Defect J9 + // Arrange + var schema = FromJsonSchemaParser.Parse(jsonSchema); + + // Act + var result = schema.Validate(JToken.Parse(json)); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_type"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(error.Message).IsEqualTo(message); + } + + [Test] + public async Task Validate_GivenLiteralMismatch_DoesNotEchoTheReceivedValue() + { + // Defect J9 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"const":"admin"}""" + ); + + // Act + var result = schema.Validate(JToken.Parse("\"secret-value\"")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_literal"); + await Assert.That(error.Message).IsEqualTo("Expected literal value 'admin', but got string"); + } + + [Test] + public async Task Validate_GivenInvalidRecordValue_PrefixesKeySegmentAndKeepsEveryField() + { + // Defect J10 + // Arrange + var schema = FromJsonSchemaParser.Parse( + /*lang=json,strict*/ + """{"type":"object","additionalProperties":{"type":"array","minItems":2}}""" + ); + + // Act + var result = schema.Validate(JToken.Parse("""{"a.b":[1]}""")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.From(ZodPathSegment.Key("a.b"))); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + } +} diff --git a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs index aaf0be2d..a88758b9 100644 --- a/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs +++ b/src/tests/Repository.UnitTests/CrossPlatformVitestTests.cs @@ -74,6 +74,7 @@ public static IEnumerable> Suites() => () => new VitestSuite("tests/ts/cross-platform.test.ts", "src/tests/cross-platform/output"), () => new VitestSuite("tests/ts/guid-uuid-parity.test.ts", "src/tests/cross-platform/guid-parity"), () => new VitestSuite("tests/ts/temporal-parity.test.ts", "src/tests/cross-platform/temporal-parity"), + () => new VitestSuite("tests/ts/client-validators.test.ts", "src/tests/cross-platform/client-rules"), ]; [Test] diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs new file mode 100644 index 00000000..bd07fa62 --- /dev/null +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json; + +namespace ZodSharp.Repository; + +/// +/// Guards the skills installed from other repositories. They are installed by the skills CLI into the +/// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json +/// are committed, so the two must agree and no per-agent copy or instruction file may appear beside +/// them. +/// +/// +/// SkillTests validates this repository's own skills in skills/; these are someone else's, +/// so their content is not checked here, only that the installed set is the recorded set. +/// +public sealed class InstalledSkillTests +{ + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + [Test] + public async Task Lockfile_GivenEveryEntry_HasItsSkillInstalledUnderAgents(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var missing = recorded + .Where(static name => !File.Exists(Path.Combine(AgentsSkillsFolder(), name, "SKILL.md"))) + .ToArray(); + + // Assert + await Assert.That(recorded).IsNotEmpty().Because("skills-lock.json records no installed skills"); + await Assert + .That(missing) + .IsEmpty() + .Because( + "skills-lock.json records skills that are not installed in .agents/skills/. Restore them with " + + $"`npx -y skills@1 experimental_install`: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenEveryInstalledSkill_IsRecordedInTheLockfile(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var unrecorded = Directory + .GetDirectories(AgentsSkillsFolder()) + .Select(static path => Path.GetFileName(path)) + .Where(name => !recorded.Contains(name, StringComparer.Ordinal)) + .ToArray(); + + // Assert + await Assert + .That(unrecorded) + .IsEmpty() + .Because( + "every folder in .agents/skills/ must be written by the skills CLI, which records it in " + + $"skills-lock.json: {string.Join(", ", unrecorded)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenTheAuthoredSkills_HoldsNoCopyOfThem(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var authored = Directory + .GetDirectories(Path.Combine(RepositoryRoot.Value, "skills")) + .Select(static path => Path.GetFileName(path)) + .ToArray(); + + // Act + var copied = authored + .Where(static name => Directory.Exists(Path.Combine(AgentsSkillsFolder(), name))) + .ToArray(); + + // Assert + await Assert + .That(copied) + .IsEmpty() + .Because( + "this repository's own skills live in skills/ only; a copy in .agents/skills/ would drift from " + + $"the authored one: {string.Join(", ", copied)}" + ); + } + + [Test] + public async Task ClaudeFolder_GivenTheInstalledSkills_HoldsNoCopy(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var claudeSkills = Path.Combine(RepositoryRoot.Value, ".claude", "skills"); + + // Act + var exists = Directory.Exists(claudeSkills); + + // Assert + await Assert + .That(exists) + .IsFalse() + .Because( + "skills are installed into .agents/skills/ only. A .claude/skills/ folder means the CLI ran without " + + "`-a universal`; delete it and reinstall with `-a universal`" + ); + } + + [Test] + public async Task Repository_GivenAgentInstructions_HasNoClaudeMd(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + string[] candidates = + [ + Path.Combine(RepositoryRoot.Value, "CLAUDE.md"), + Path.Combine(RepositoryRoot.Value, ".github", "CLAUDE.md"), + ]; + + // Act + var present = candidates.Where(File.Exists).ToArray(); + + // Assert + await Assert + .That(present) + .IsEmpty() + .Because( + "this repository is not agent-specific: AGENTS.md is its one instruction file, so a CLAUDE.md " + + $"must not be added: {string.Join(", ", present)}" + ); + } + + static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); + + static async Task ReadLockfileSkillsAsync(CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(Path.Combine(RepositoryRoot.Value, "skills-lock.json")); + using var lockfile = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + return + [ + .. lockfile + .RootElement.GetProperty("skills") + .EnumerateObject() + .Select(static property => property.Name) + .Order(StringComparer.Ordinal), + ]; + } + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "skills-lock.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, skills-lock.json and src/src." + ); + } +} diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs new file mode 100644 index 00000000..cbabdf71 --- /dev/null +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -0,0 +1,463 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using CliWrap; +using CliWrap.Buffered; + +namespace ZodSharp.Repository; + +/// +/// Guards the release gates themselves: the compiler-host leak scan must catch every kind of leak it claims to, +/// every CI job that publishes must run it, and pack validation must keep forbidding compiler-host content outside +/// analyzers/. +/// +/// +/// +/// A gate that passes vacuously is worse than none, because it is trusted. The leak scan +/// (scripts/assert-no-compiler-host-assemblies.ps1) exists because +/// Purview.SourceGeneratorFramework.dll once reached application output while every publish gate stayed +/// green; these tests run it against crafted output, so a regex or file-pattern change that stops it seeing a leak +/// fails here rather than in a consumer's Blazor WebAssembly AOT build. +/// +/// +/// The scan is a PowerShell script, as the CI jobs run it, so these tests run it through pwsh as a child +/// process. They do not skip when pwsh is missing: the gate cannot run without it either. +/// +/// +public sealed partial class ReleaseGateTests +{ + const string LeakScanScript = "scripts/assert-no-compiler-host-assemblies.ps1"; + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + /// A file written into otherwise clean build output. + public sealed record OutputFile(string Description, string RelativePath, string Content, bool Brotli = false) + { + public override string ToString() => Description; + } + + public static IEnumerable> Leaks() => + [ + () => new("the generator framework assembly", "Purview.SourceGeneratorFramework.dll", "MZ"), + () => new("a Roslyn assembly in a subfolder", "refs/Microsoft.CodeAnalysis.CSharp.dll", "MZ"), + () => + new( + "a fingerprinted WebAssembly generator", + "wwwroot/_framework/Purview.ZodSharp.SourceGenerators.ab12cd34ef.wasm", + "wasm" + ), + () => + new( + "a compressed WebAssembly code-fix assembly", + "wwwroot/_framework/Purview.ZodSharp.CodeFixes.ab12cd34ef.wasm.br", + "br" + ), + () => + new( + "a deps.json library entry", + "PackedApp.deps.json", + /*lang=json,strict*/"""{"libraries":{"Purview.SourceGeneratorFramework/1.2.2":{"type":"package"}}}""" + ), + () => + new( + "a runtimeconfig.json reference", + "PackedApp.runtimeconfig.json", + /*lang=json,strict*/"""{"runtimeOptions":{"configProperties":{"Microsoft.CodeAnalysis.Leak":true}}}""" + ), + () => + new( + "a blazor.boot.json asset", + "wwwroot/_framework/blazor.boot.json", + /*lang=json,strict*/"""{"resources":{"assembly":{"Purview.ZodSharp.SourceGenerators.wasm":"sha256-x"}}}""" + ), + () => + new( + "a fingerprinted dotnet.boot.js asset", + "wwwroot/_framework/dotnet.boot.ab12cd34ef.js", + """export const config = {"assembly":[{"name":"Purview.ZodSharp.CodeFixes.wasm"}]};""" + ), + () => + new( + "a Brotli-compressed dotnet.js boot manifest", + "wwwroot/_framework/dotnet.js.br", + """const config = {"assembly":[{"name":"Purview.SourceGeneratorFramework.wasm"}]};""", + Brotli: true + ), + ]; + + [Test] + [MethodDataSource(nameof(Leaks))] + public async Task LeakScan_GivenACompilerHostLeak_FailsAndNamesTheFile( + OutputFile leak, + CancellationToken cancellationToken + ) + { + // Arrange + using var output = CleanOutput(); + Write(output.Path, leak); + + // Act + var result = await RunLeakScanAsync([output.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"the scan missed {leak}:{Environment.NewLine}{result}"); + await Assert + .That(result.StandardOutput) + .Contains(Path.GetFileName(leak.RelativePath)) + .Because("a failure must name the leaking file"); + } + + [Test] + public async Task LeakScan_GivenCleanBuildAndPublishOutput_Passes(CancellationToken cancellationToken) + { + // Arrange + using var output = CleanOutput(); + + // Act + var result = await RunLeakScanAsync([output.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(0) + .Because($"clean output was rejected:{Environment.NewLine}{result}"); + await Assert.That(result.StandardOutput).Contains("3 manifest(s)"); + } + + [Test] + public async Task LeakScan_GivenSeveralPaths_ScansEveryOne(CancellationToken cancellationToken) + { + // Arrange + using var clean = CleanOutput(); + using var leaking = CleanOutput(); + Write(leaking.Path, new("a leak in the second path", "Microsoft.CodeAnalysis.dll", "MZ")); + + // Act - space-separated, exactly as the Justfile recipes pass them through pwsh -File. + var result = await RunLeakScanAsync([clean.Path, leaking.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"the second path was not scanned:{Environment.NewLine}{result}"); + } + + /// + /// A Blazor server publish carries Microsoft's WebAssembly debugging proxy, whose expression evaluator ships Roslyn. + /// -Allow exempts that folder alone: a leak anywhere else in the same output must still fail. + /// + [Test] + public async Task LeakScan_GivenAnAllowedToolFolder_SkipsItButStillFailsALeakElsewhere( + CancellationToken cancellationToken + ) + { + // Arrange + using var allowedOnly = CleanOutput(); + Write(allowedOnly.Path, new("the debug proxy's Roslyn", "BlazorDebugProxy/Microsoft.CodeAnalysis.dll", "MZ")); + using var alsoLeaking = CleanOutput(); + Write(alsoLeaking.Path, new("the debug proxy's Roslyn", "BlazorDebugProxy/Microsoft.CodeAnalysis.dll", "MZ")); + Write(alsoLeaking.Path, new("a real leak", "Purview.SourceGeneratorFramework.dll", "MZ")); + + // Act + var allowed = await RunLeakScanAsync([allowedOnly.Path], cancellationToken, ["*BlazorDebugProxy/*"]); + var leaking = await RunLeakScanAsync([alsoLeaking.Path], cancellationToken, ["*BlazorDebugProxy/*"]); + + // Assert + await Assert + .That(allowed.ExitCode) + .IsEqualTo(0) + .Because($"the allowed folder was not skipped:{Environment.NewLine}{allowed}"); + await Assert.That(allowed.StandardOutput).Contains("1 allowed by -Allow"); + await Assert + .That(leaking.ExitCode) + .IsEqualTo(1) + .Because($"-Allow hid a leak outside its folder:{Environment.NewLine}{leaking}"); + await Assert.That(leaking.StandardOutput).Contains("Purview.SourceGeneratorFramework.dll"); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task LeakScan_GivenNothingToScan_FailsRatherThanPassingVacuously( + bool directoryExists, + CancellationToken cancellationToken + ) + { + // Arrange + using TemporaryDirectory output = new(); + var path = directoryExists ? output.Path : Path.Combine(output.Path, "missing"); + + // Act + var result = await RunLeakScanAsync([path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"scanning nothing passed:{Environment.NewLine}{result}"); + } + + /// + /// Every CI job that publishes an application must scan what it published. A job added later without the scan + /// would otherwise be a publish gate that cannot see the leak this repository has already shipped once. + /// + [Test] + public async Task Workflows_GivenAJobThatPublishes_ScanItsOutputForCompilerHostAssemblies( + CancellationToken cancellationToken + ) + { + // Arrange + var workflows = Directory.GetFiles(Path.Combine(RepositoryRoot.Value, ".github", "workflows"), "*.yml"); + + // Act + List publishing = []; + List unscanned = []; + foreach (var workflow in workflows) + { + var text = await File.ReadAllTextAsync(workflow, cancellationToken); + foreach (var (job, body) in Jobs(text)) + { + if (!body.Contains("dotnet publish", StringComparison.Ordinal)) + continue; + + var name = $"{Path.GetFileName(workflow)}:{job}"; + publishing.Add(name); + if ( + !body.Contains( + LeakScanScript.Replace("scripts/", "./scripts/", StringComparison.Ordinal), + StringComparison.Ordinal + ) + ) + unscanned.Add(name); + } + } + + // Assert + await Assert.That(publishing).IsNotEmpty().Because("no workflow job publishes, so the job parsing is broken"); + await Assert + .That(unscanned) + .IsEmpty() + .Because($"these jobs publish without running {LeakScanScript}: {string.Join(", ", unscanned)}"); + } + + [Test] + public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateAndTheRuleProof( + CancellationToken cancellationToken + ) + { + // Arrange + var text = await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + cancellationToken + ); + + // Act + var job = Jobs(text).SingleOrDefault(static j => j.Name == "packed-consumers").Body ?? string.Empty; + + // Assert + await Assert.That(job).Contains("./scripts/test-packed-consumers.ps1 -PreflightOnly"); + await Assert.That(job).Contains("-WasmAot Require"); + await Assert.That(job).Contains("./scripts/test-pack-validation-rules.ps1"); + await Assert.That(job).Contains("dotnet workload install wasm-tools"); + } + + /// + /// Compiler-host assemblies must stay forbidden everywhere NuGet hands files to an application, and the generator + /// and code fixes everywhere an application loads assemblies from. scripts/test-pack-validation-rules.ps1 + /// proves each rule rejects a package; this proves nobody removes one. + /// + [Test] + public async Task PackValidation_GivenEveryPackage_ForbidsCompilerHostContentOutsideAnalyzers( + CancellationToken cancellationToken + ) + { + // Arrange + var json = await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), + cancellationToken + ); + using var document = JsonDocument.Parse( + json, + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true } + ); + string[] hostOnly = ["Purview.SourceGeneratorFramework*", "Microsoft.CodeAnalysis*"]; + string[] generatorOnly = ["Purview.ZodSharp.SourceGenerators*", "Purview.ZodSharp.CodeFixes*"]; + string[] applicationFolders = + [ + "lib", + "ref", + "runtimes", + "build", + "buildTransitive", + "buildMultiTargeting", + "tools", + "content", + "contentFiles", + "staticwebassets", + ]; + string[] assemblyFolders = ["lib", "ref", "runtimes"]; + + // Act + var rules = document + .RootElement.GetProperty("PackValidation") + .GetProperty("ForbiddenContent") + .GetProperty("*") + .EnumerateArray() + .Select(static rule => rule.GetString()) + .ToHashSet(StringComparer.Ordinal); + + // Assert + string[] expected = + [ + .. hostOnly, + .. hostOnly.Select(static name => $"analyzers/**/{name}"), + .. applicationFolders.SelectMany(folder => hostOnly.Select(name => $"{folder}/**/{name}")), + .. assemblyFolders.SelectMany(folder => generatorOnly.Select(name => $"{folder}/**/{name}")), + ]; + var missing = expected.Where(rule => !rules.Contains(rule)).ToArray(); + await Assert + .That(missing) + .IsEmpty() + .Because($"ForbiddenContent[\"*\"] no longer forbids: {string.Join(", ", missing)}"); + } + + static TemporaryDirectory CleanOutput() + { + TemporaryDirectory output = new(); + Write(output.Path, new("the app", "PackedApp.dll", "MZ")); + Write( + output.Path, + new( + "clean deps.json", + "PackedApp.deps.json", + /*lang=json,strict*/"""{"libraries":{"Purview.ZodSharp/3.0.0":{"type":"package"},"Microsoft.Extensions.Options/11.0.0":{"type":"package"}}}""" + ) + ); + Write( + output.Path, + new( + "clean runtimeconfig", + "PackedApp.runtimeconfig.json", /*lang=json,strict*/ + """{"runtimeOptions":{}}""" + ) + ); + Write( + output.Path, + new( + "clean boot manifest", + "wwwroot/_framework/dotnet.js", + """const config = {"assembly":[{"name":"Purview.ZodSharp.wasm"},{"name":"Purview.ZodSharp.Blazor.wasm"}]};""" + ) + ); + Write(output.Path, new("the runtime", "wwwroot/_framework/dotnet.native.ab12cd34ef.js", "// runtime")); + return output; + } + + static void Write(string root, OutputFile file) + { + var path = Path.Combine(root, file.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var bytes = Encoding.UTF8.GetBytes(file.Content); + if (!file.Brotli) + { + File.WriteAllBytes(path, bytes); + return; + } + + using var stream = File.Create(path); + using BrotliStream brotli = new(stream, CompressionLevel.Optimal); + brotli.Write(bytes); + } + + static async Task RunLeakScanAsync( + string[] paths, + CancellationToken cancellationToken, + string[]? allow = null + ) + { + var result = await Cli.Wrap("pwsh") + .WithArguments([ + "-NoProfile", + "-NonInteractive", + "-File", + Path.Combine(RepositoryRoot.Value, LeakScanScript), + "-Path", + .. paths, + .. AllowArguments(allow), + ]) + .WithEnvironmentVariables(static env => env.Set("GITHUB_ACTIONS", null)) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + return new(result.ExitCode, result.StandardOutput, result.StandardError); + } + + static string[] AllowArguments(string[]? allow) => allow is null ? [] : ["-Allow", .. allow]; + + sealed record ScanResult(int ExitCode, string StandardOutput, string StandardError) + { + public override string ToString() => $"exit {ExitCode}{Environment.NewLine}{StandardOutput}{StandardError}"; + } + + /// The jobs of a workflow, split on their two-space-indented keys under jobs:. + static IEnumerable<(string Name, string Body)> Jobs(string workflow) + { + var jobs = workflow.IndexOf("\njobs:", StringComparison.Ordinal); + if (jobs < 0) + yield break; + + var headers = JobHeader().Matches(workflow[jobs..]); + for (var i = 0; i < headers.Count; i++) + { + var start = headers[i].Index; + var end = i + 1 < headers.Count ? headers[i + 1].Index : workflow.Length - jobs; + yield return (headers[i].Groups["name"].Value, workflow.Substring(jobs + start, end - start)); + } + } + + [GeneratedRegex(@"^ (?[A-Za-z0-9_-]+):[ \t]*\r?$", RegexOptions.Multiline)] + private static partial Regex JobHeader(); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, LeakScanScript)) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries both AGENTS.md and {LeakScanScript}." + ); + } + + sealed class TemporaryDirectory : IDisposable + { + public string Path { get; } = + Directory + .CreateDirectory( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"zs-leak-scan-{Guid.NewGuid():N}") + ) + .FullName; + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) { } + } + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index 5f270851..5a3777a5 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -1,6 +1,6 @@ - net10.0 diff --git a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs index a42ce502..13ad59fb 100644 --- a/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs +++ b/src/tests/SourceGenerators.UnitTests/AnalyzerReleaseTrackingTests.cs @@ -11,7 +11,8 @@ namespace ZodSharp.SourceGenerators; /// /// /// ZODSGEN037–ZODSGEN044 are recorded under a ## Release 2.0.2 block, -/// ZODSGEN045 under ## Release 2.1.0, and ZODSGEN046 under ## Release 2.2.0. A +/// ZODSGEN045 under ## Release 2.1.0, ZODSGEN046 under ## Release 2.2.0, and +/// ZODSGEN047–ZODSGEN054 under ## Release 3.0.0. A /// rule stays recorded under the release that introduced it, so an existing block is never rewritten. The /// unshipped file declares no rules; the next diagnostic added goes there and moves across when it ships. /// @@ -19,7 +20,7 @@ namespace ZodSharp.SourceGenerators; /// accepts a rule row with or without a leading pipe, because the two files are /// written in different table styles — AnalyzerReleases.Shipped.md delimits rows with pipes and /// AnalyzerReleases.Unshipped.md does not. Matching only the pipe-delimited form made -/// vacuous: a rule parked in the +/// vacuous: a rule parked in the /// unshipped file parsed as no rules at all, so the guard passed while the catalogue was wrong. /// /// @@ -87,11 +88,20 @@ public partial class AnalyzerReleaseTrackingTests "ZODSGEN045", // Release 2.2.0. "ZODSGEN046", + // Release 3.0.0. + "ZODSGEN047", + "ZODSGEN048", + "ZODSGEN049", + "ZODSGEN050", + "ZODSGEN051", + "ZODSGEN052", + "ZODSGEN053", + "ZODSGEN054", ]; /// - /// Rules added since the last release. Empty: every rule is recorded as shipped. A new or changed - /// diagnostic is added here and moves into AnalyzerReleases.Shipped.md when it ships. + /// Rules added since the last release. A new or changed diagnostic is added here and moves into + /// AnalyzerReleases.Shipped.md when it ships. /// static readonly string[] ExpectedUnshippedRuleIds = []; @@ -108,11 +118,15 @@ public async Task ShippedReleases_GivenEveryRelease_DeclareEveryDiagnosticId(Can await Assert.That(markdown).Contains("## Release 2.0.0"); await Assert.That(markdown).Contains("## Release 2.0.2"); await Assert.That(markdown).Contains("## Release 2.1.0"); + await Assert.That(markdown).Contains("## Release 2.2.0"); + await Assert.That(markdown).Contains("## Release 3.0.0"); await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedShippedRuleIds)); } [Test] - public async Task UnshippedRelease_GivenEveryRuleHasShipped_DeclaresNoRules(CancellationToken cancellationToken) + public async Task UnshippedRelease_GivenUnreleasedRules_DeclaresExactlyThoseRules( + CancellationToken cancellationToken + ) { // Arrange var markdown = await ReadReleaseFileAsync("AnalyzerReleases.Unshipped.md", cancellationToken); @@ -120,7 +134,7 @@ public async Task UnshippedRelease_GivenEveryRuleHasShipped_DeclaresNoRules(Canc // Act var ruleIds = ReadRuleIds(markdown); - // Assert — the header stays so the file keeps its purpose; it just declares nothing right now. + // Assert — the header stays so the file keeps its purpose, and it declares exactly the unreleased rules. await Assert.That(markdown).Contains("; Unshipped analyzer release"); await Assert.That(SortedRuleIds(ruleIds)).IsEqualTo(SortedRuleIds(ExpectedUnshippedRuleIds)); } diff --git a/src/tests/SourceGenerators.UnitTests/CollectionCountAttributeAnalyzerTests.cs b/src/tests/SourceGenerators.UnitTests/CollectionCountAttributeAnalyzerTests.cs new file mode 100644 index 00000000..9ce58aea --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/CollectionCountAttributeAnalyzerTests.cs @@ -0,0 +1,177 @@ +using System.Collections.Immutable; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public class CollectionCountAttributeAnalyzerTests : CollectionCountAnalyzerTestBase +{ + static ZodAnalyzerTestOptions TrimmedOptions(string property = "PublishAot") => + new() + { + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add($"build_property.{property}", "true"), + }; + + [Test] + [Arguments("PublishAot")] + [Arguments("PublishTrimmed")] + [Arguments("IsAotCompatible")] + [Arguments("IsTrimmable")] + public async Task Analyze_GivenMinLengthOnCollectionInTrimmedProject_ReportsZODSGEN053( + string property, + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public class Order + { + [MinLength(1)] + public List Lines { get; set; } = []; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, TrimmedOptions(property), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UseTrimSafeCollectionCountAttribute); + } + + [Test] + public async Task Analyze_GivenEachSizeAttributeOnCollections_ReportsOnePerAttribute( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public class Order + { + [MinLength(1)] + [MaxLength(5)] + public string[] Lines { get; set; } = []; + + [Length(1, 3)] + public Dictionary Counts { get; set; } = []; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, TrimmedOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostics(DiagnosticLibrary.UseTrimSafeCollectionCountAttribute, 3); + } + + [Test] + public async Task Analyze_GivenPositionalRecordParameter_ReportsZODSGEN053(CancellationToken cancellationToken) + { + // Arrange — the attribute lands on the primary-constructor parameter. + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public sealed record Order([MaxLength(5)] IReadOnlyList Lines); + } + """; + + // Act + var result = await AnalyzeAsync(source, TrimmedOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UseTrimSafeCollectionCountAttribute); + } + + [Test] + public async Task Analyze_GivenProjectThatIsNotTrimmed_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public class Order + { + [MinLength(1)] + public List Lines { get; set; } = []; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, new ZodAnalyzerTestOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UseTrimSafeCollectionCountAttribute); + } + + [Test] + public async Task Analyze_GivenMinLengthOnString_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange — a string length is not a collection count, and [MinLength] on a string has no trim-safe twin. + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public class Order + { + [MinLength(1)] + public string Name { get; set; } = ""; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, TrimmedOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UseTrimSafeCollectionCountAttribute); + } + + [Test] + public async Task Analyze_GivenTypeWithoutZodSchema_ReportsNothing(CancellationToken cancellationToken) + { + // Arrange — only the generator validates the count attributes, so they are suggested on [ZodSchema] types only. + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + public class Order + { + [MinLength(1)] + public List Lines { get; set; } = []; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, TrimmedOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UseTrimSafeCollectionCountAttribute); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/CollectionCountAttributeCodeFixTests.cs b/src/tests/SourceGenerators.UnitTests/CollectionCountAttributeCodeFixTests.cs new file mode 100644 index 00000000..9df013ed --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/CollectionCountAttributeCodeFixTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Immutable; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public class CollectionCountAttributeCodeFixTests : CollectionCountCodeFixTestBase +{ + static ZodCodeFixTestOptions TrimmedOptions() => + new() + { + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add("build_property.PublishAot", "true"), + }; + + [Test] + [Arguments("MinLength(1)", "MinCount(1)")] + [Arguments("MaxLength(5)", "MaxCount(5)")] + [Arguments("Length(1, 3)", "CountRange(1, 3)")] + [Arguments("MinLength(2, ErrorMessage = \"Too few\")", "MinCount(2, ErrorMessage = \"Too few\")")] + public async Task CodeFix_GivenSizeAttributeOnCollection_ReplacesWithCountAttribute( + string original, + string replacement, + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public class Order + { + [{{original}}] + public List Lines { get; set; } = []; + } + } + """; + + // Act + var result = await ApplyCodeFixAsync(source, TrimmedOptions(), cancellationToken); + + // Assert + await Assert.That(result.FixedSource).Contains($"[{replacement}]"); + await Assert.That(result.FixedSource).Contains("using ZodSharp.Rules;"); + await Assert.That(result.FixedSource).DoesNotContain($"[{original}]"); + } + + [Test] + public async Task CodeFix_GivenRulesNamespaceAlreadyImported_DoesNotDuplicateTheUsing( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public class Order + { + [MinLength(1)] + public List Lines { get; set; } = []; + } + } + """; + + // Act + var result = await ApplyCodeFixAsync(source, TrimmedOptions(), cancellationToken); + + // Assert + await Assert.That(result.FixedSource).Contains("[MinCount(1)]"); + await Assert.That(result.FixedSource.Split("using ZodSharp.Rules;").Length - 1).IsEqualTo(1); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs b/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs new file mode 100644 index 00000000..1606f1d5 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs @@ -0,0 +1,405 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZodSharp.SourceGenerators; + +/// +/// Two compilations, the second referencing the first, both using [ZodSchema] and [ErrorType] - the +/// shape of every multi-project solution. Each compilation runs both generators, as a package consumer's build does. +/// +/// +/// The generators used to emit ZodSchemaAttribute and ErrorTypeAttribute, public and documented, into +/// every compilation. Every project in a solution then exposed the same type and XML documentation ID, and the +/// ASP.NET Core OpenAPI XML-comment generator - which reads a project's own documented types and its referenced +/// projects' XML files - added the same key twice and crashed at runtime. Both attributes are now defined once, in +/// Purview.ZodSharp, and the generators emit no marker type at all. +/// +public sealed class CrossAssemblyMarkerAttributeTests +{ + const string ContractsAssemblyName = "Contracts"; + const string ConsumerAssemblyName = "Consumer"; + + const string ContractsSource = """ + using System.ComponentModel.DataAnnotations; + using System.Runtime.CompilerServices; + using ZodSharp; + using ZodSharp.Core; + + // A test project is the usual friend assembly; it must not see a second copy of any marker attribute. + [assembly: InternalsVisibleTo("Consumer")] + + namespace Contracts + { + /// A customer. + [ZodSchema] + public class Customer + { + /// The email address. + [Required] + [EmailAddress] + public string? Email { get; set; } + } + + /// Errors raised by the contracts. + public static partial class ContractErrors + { + /// The customer already exists. + [ErrorType] + public static readonly ErrorType CustomerExists = new( + Code: "customer_exists", + Description: "The customer already exists.", + HttpStatus: 409, + MessageFormat: "Customer '{Email}' already exists", + Parameters: [new ErrorTypeParameter("Email", typeof(string))] + ); + } + } + """; + + const string ConsumerSource = """ + using System.ComponentModel.DataAnnotations; + using Contracts; + using ZodSharp; + using ZodSharp.Core; + + namespace Consumer + { + /// An order. + [ZodSchema] + public class Order + { + /// The customer. + [Required] + public Customer Customer { get; set; } = new(); + + /// The quantity. + [Range(1, 10)] + public int Quantity { get; set; } = 1; + } + + /// Errors raised by the consumer. + public static partial class ConsumerErrors + { + /// The order was rejected. + [ErrorType] + public static readonly ErrorType OrderRejected = new( + Code: "order_rejected", + Description: "The order was rejected.", + HttpStatus: 422, + MessageFormat: "Order for '{Email}' was rejected", + Parameters: [new ErrorTypeParameter("Email", typeof(string))] + ); + } + + public static class Probe + { + public static string Run() + { + var invalid = OrderSchema.Validate(new Order { Customer = new Customer { Email = "nope" }, Quantity = 0 }); + var valid = OrderSchema.Validate(new Order { Customer = new Customer { Email = "a@example.com" } }); + var contractError = ContractErrors.CreateCustomerExists("a@example.com"); + var consumerError = ConsumerErrors.CreateOrderRejected("a@example.com"); + + return string.Join( + "|", + invalid.IsSuccess, + valid.IsSuccess, + contractError.Code, + contractError.Message, + consumerError.Code, + consumerError.Message + ); + } + } + } + """; + + // What ZodSharp 2.4.x generated into every compilation, verbatim in shape: public, documented and [Embedded]. + const string LegacyGeneratedMarkersSource = """ + namespace Microsoft.CodeAnalysis + { + internal sealed partial class EmbeddedAttribute : global::System.Attribute { } + } + + namespace ZodSharp + { + /// Attribute to mark a class for automatic schema generation. + [global::Microsoft.CodeAnalysis.Embedded] + [global::System.AttributeUsage(global::System.AttributeTargets.Class | global::System.AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] + public sealed partial class ZodSchemaAttribute : global::System.Attribute + { + public string? SchemaName { get; init; } + } + } + + namespace ZodSharp.Core + { + /// Attribute to mark a static ErrorType field for strongly-typed helper generation. + [global::Microsoft.CodeAnalysis.Embedded] + [global::System.AttributeUsage(global::System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] + public sealed partial class ErrorTypeAttribute : global::System.Attribute { } + } + """; + + // The duplicate-definition diagnostics a second copy of a marker type would raise. + static readonly string[] DuplicateTypeDiagnosticIds = ["CS0433", "CS0434", "CS0435", "CS0436", "CS0437", "CS0438"]; + + static readonly string[] MarkerTypeNames = ["ZodSchemaAttribute", "ErrorTypeAttribute", "EmbeddedAttribute"]; + + [Test] + public async Task Generators_GivenAnyCompilation_EmitNoMarkerAttributeTypes(CancellationToken cancellationToken) + { + // Arrange + var compilation = CreateCompilation(ContractsAssemblyName, ContractsSource, [], cancellationToken); + + // Act + var (_, generated) = RunGenerators(compilation, cancellationToken); + + // Assert + var declared = DeclaredTypeNames(generated, cancellationToken); + await Assert.That(declared).IsNotEmpty(); + await Assert.That(declared.Intersect(MarkerTypeNames, StringComparer.Ordinal)).IsEmpty(); + } + + [Test] + public async Task Generators_GivenAnyCompilation_BindTheMarkerAttributesToPurviewZodSharp( + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = CreateCompilation(ContractsAssemblyName, ContractsSource, [], cancellationToken); + + // Act + var (output, _) = RunGenerators(compilation, cancellationToken); + + // Assert + var zodSchema = output.GetTypeByMetadataName("ZodSharp.ZodSchemaAttribute"); + var errorType = output.GetTypeByMetadataName("ZodSharp.Core.ErrorTypeAttribute"); + await Assert + .That(zodSchema?.ContainingAssembly.Name) + .IsEqualTo(typeof(ZodSchemaAttribute).Assembly.GetName().Name); + await Assert + .That(errorType?.ContainingAssembly.Name) + .IsEqualTo(typeof(ZodSchemaAttribute).Assembly.GetName().Name); + await Assert.That(zodSchema!.DeclaredAccessibility).IsEqualTo(Accessibility.Public); + await Assert.That(errorType!.DeclaredAccessibility).IsEqualTo(Accessibility.Public); + } + + [Test] + public async Task Generators_GivenConsumerReferencingContracts_CompileBothWithoutDuplicateTypes( + CancellationToken cancellationToken + ) + { + // Arrange + var (contracts, contractsDiagnostics, _) = BuildContracts(ContractsSource, cancellationToken); + var consumer = CreateCompilation( + ConsumerAssemblyName, + ConsumerSource, + [contracts.Reference], + cancellationToken + ); + + // Act + var (output, generated) = RunGenerators(consumer, cancellationToken); + var consumerDiagnostics = output.GetDiagnostics(cancellationToken); + + // Assert + await Assert.That(DuplicateTypeDiagnostics(contractsDiagnostics)).IsEmpty(); + await Assert.That(DuplicateTypeDiagnostics(consumerDiagnostics)).IsEmpty(); + await Assert.That(Errors(consumerDiagnostics)).IsEmpty(); + await Assert + .That(DeclaredTypeNames(generated, cancellationToken).Intersect(MarkerTypeNames, StringComparer.Ordinal)) + .IsEmpty(); + } + + [Test] + public async Task Generators_GivenConsumerReferencingContracts_ValidateAndCreateErrorsAcrossAssemblies( + CancellationToken cancellationToken + ) + { + // Arrange + var (contracts, _, _) = BuildContracts(ContractsSource, cancellationToken); + var consumer = CreateCompilation( + ConsumerAssemblyName, + ConsumerSource, + [contracts.Reference], + cancellationToken + ); + var (output, _) = RunGenerators(consumer, cancellationToken); + var consumerImage = Emit(output, cancellationToken); + + // Act + var result = RunProbe(contracts.Image, consumerImage); + + // Assert + await Assert + .That(result) + .IsEqualTo( + "False|True|customer_exists|Customer 'a@example.com' already exists|order_rejected|" + + "Order for 'a@example.com' was rejected" + ); + } + + [Test] + public async Task Generators_GivenContractsBuiltWithLegacyGeneratedMarkers_ConsumerBindsToPurviewZodSharp( + CancellationToken cancellationToken + ) + { + // Arrange - a library compiled by ZodSharp 2.4.x carries its own public, [Embedded] copies of the markers. + var (contracts, _, _) = BuildContracts( + ContractsSource + Environment.NewLine + LegacyGeneratedMarkersSource, + cancellationToken + ); + var consumer = CreateCompilation( + ConsumerAssemblyName, + ConsumerSource, + [contracts.Reference], + cancellationToken + ); + + // Act + var (output, _) = RunGenerators(consumer, cancellationToken); + var diagnostics = output.GetDiagnostics(cancellationToken); + + // Assert - [Embedded] hides the legacy copies from the consumer, so there is no ambiguity with the real ones. + await Assert.That(DuplicateTypeDiagnostics(diagnostics)).IsEmpty(); + await Assert.That(Errors(diagnostics)).IsEmpty(); + await Assert + .That(output.GetTypeByMetadataName("ZodSharp.ZodSchemaAttribute")?.ContainingAssembly.Name) + .IsEqualTo(typeof(ZodSchemaAttribute).Assembly.GetName().Name); + } + + static ( + (MetadataReference Reference, byte[] Image) Contracts, + ImmutableArray Diagnostics, + ImmutableArray Generated + ) BuildContracts(string source, CancellationToken cancellationToken) + { + var compilation = CreateCompilation(ContractsAssemblyName, source, [], cancellationToken); + var (output, generated) = RunGenerators(compilation, cancellationToken); + var image = Emit(output, cancellationToken); + + return ((MetadataReference.CreateFromImage(image), image), output.GetDiagnostics(cancellationToken), generated); + } + + static (Compilation Output, ImmutableArray Generated) RunGenerators( + CSharpCompilation compilation, + CancellationToken cancellationToken + ) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new ZodSchemaGenerator().AsSourceGenerator(), new ErrorTypeGenerator().AsSourceGenerator()], + parseOptions: ParseOptions + ); + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var output, + out var generatorDiagnostics, + cancellationToken + ); + + // A conditional expression would turn the guard into a throw expression inside the tuple return. +#pragma warning disable IDE0046 // Convert to conditional expression + if (generatorDiagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)) + throw new InvalidOperationException(string.Join(Environment.NewLine, generatorDiagnostics)); +#pragma warning restore IDE0046 + + return (output, driver.GetRunResult().GeneratedTrees); + } + + static byte[] Emit(Compilation compilation, CancellationToken cancellationToken) + { + using MemoryStream stream = new(); + var result = compilation.Emit(stream, cancellationToken: cancellationToken); + // A conditional expression would turn the block into a throw expression inside the return. +#pragma warning disable IDE0046 // Convert to conditional expression + if (!result.Success) + { + throw new InvalidOperationException( + $"{compilation.AssemblyName} did not compile:{Environment.NewLine}{string.Join(Environment.NewLine, Errors(result.Diagnostics))}" + ); + } +#pragma warning restore IDE0046 + + return stream.ToArray(); + } + + static string RunProbe(byte[] contractsImage, byte[] consumerImage) + { + ProbeLoadContext context = new(contractsImage); + try + { + var consumer = context.LoadFromStream(new MemoryStream(consumerImage)); + var probe = consumer.GetType("Consumer.Probe", throwOnError: true)!; + return (string)probe.GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, null)!; + } + finally + { + context.Unload(); + } + } + + static List DeclaredTypeNames(ImmutableArray trees, CancellationToken cancellationToken) => + [ + .. trees + .SelectMany(tree => tree.GetRoot(cancellationToken).DescendantNodes()) + .OfType() + .Select(static declaration => declaration.Identifier.ValueText), + ]; + + static List DuplicateTypeDiagnostics(IEnumerable diagnostics) => + [.. diagnostics.Where(static diagnostic => DuplicateTypeDiagnosticIds.Contains(diagnostic.Id))]; + + static List Errors(IEnumerable diagnostics) => + [.. diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)]; + + static readonly CSharpParseOptions ParseOptions = CSharpParseOptions.Default.WithLanguageVersion( + LanguageVersion.Preview + ); + + static CSharpCompilation CreateCompilation( + string assemblyName, + string source, + MetadataReference[] additionalReferences, + CancellationToken cancellationToken + ) => + CSharpCompilation.Create( + assemblyName, + [ + CSharpSyntaxTree.ParseText( + source, + ParseOptions, + path: $"/repo/{assemblyName}/Source.cs", + cancellationToken: cancellationToken + ), + ], + [.. References.Value, .. additionalReferences], + new(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable) + ); + + static readonly Lazy> References = new(static () => + { + var trusted = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?.Split(Path.PathSeparator) ?? []; + return + [ + .. trusted + .Append(typeof(ZodSchemaAttribute).Assembly.Location) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(static path => MetadataReference.CreateFromFile(path)), + ]; + }); + + /// + /// Loads the consumer and the contracts it references into an isolated, collectible context; everything else - + /// Purview.ZodSharp and the framework - comes from the default context. + /// + sealed class ProbeLoadContext(byte[] contractsImage) : AssemblyLoadContext(isCollectible: true) + { + protected override Assembly? Load(AssemblyName assemblyName) => + assemblyName.Name == ContractsAssemblyName ? LoadFromStream(new MemoryStream(contractsImage)) : null; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs b/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs index 79a62785..53df7032 100644 --- a/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs +++ b/src/tests/SourceGenerators.UnitTests/ErrorTypeGeneratorTests.cs @@ -27,6 +27,32 @@ public static partial class ConcurrentErrorType } """; + [Test] + public async Task GivenErrorTypeField_DoesNotDeclareTheErrorTypeAttribute(CancellationToken cancellationToken) + { + // Arrange + var source = Source; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert - [ErrorType] is defined once in Purview.ZodSharp; a copy per project duplicated its documentation ID. + await Assert.That(result).HasNoErrorDiagnostics(); + var query = result.Generated(); + await Assert.That(query.HasClass("ConcurrentErrorType")).IsTrue(); + await Assert.That(query.HasClass("ErrorTypeAttribute")).IsFalse(); + await Assert.That(query.HasClass("EmbeddedAttribute")).IsFalse(); + + var field = result + .CompilationResult.Compilation.GetTypeByMetadataName("Testing.ConcurrentErrorType")! + .GetMembers("SaveFailed") + .Single(); + var attributeClass = field.GetAttributes().Single().AttributeClass!; + await Assert + .That(attributeClass.ContainingAssembly.Name) + .IsEqualTo(typeof(ErrorTypeAttribute).Assembly.GetName().Name); + } + [Test] public async Task GivenErrorTypeFieldWithParameters_GeneratesCreateAndThrowMethods( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/Infra/CollectionCountAnalyzerTestBase.cs b/src/tests/SourceGenerators.UnitTests/Infra/CollectionCountAnalyzerTestBase.cs new file mode 100644 index 00000000..ea279773 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/CollectionCountAnalyzerTestBase.cs @@ -0,0 +1,7 @@ +namespace ZodSharp.SourceGenerators.Infra; + +public abstract class CollectionCountAnalyzerTestBase + : TUnitDiagnosticAnalyzerTestBase +{ + // Empty +} diff --git a/src/tests/SourceGenerators.UnitTests/Infra/CollectionCountCodeFixTestBase.cs b/src/tests/SourceGenerators.UnitTests/Infra/CollectionCountCodeFixTestBase.cs new file mode 100644 index 00000000..8eb23d97 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/CollectionCountCodeFixTestBase.cs @@ -0,0 +1,13 @@ +using ZodSharp.CodeFixes; + +namespace ZodSharp.SourceGenerators.Infra; + +public abstract class CollectionCountCodeFixTestBase + : TUnitCodeFixTestBase< + CollectionCountAttributeAnalyzer, + CollectionCountAttributeCodeFixProvider, + ZodCodeFixTestOptions + > +{ + // Empty +} diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs index d0717548..fb32fe4f 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeAnalyzerTestOptions.cs @@ -8,16 +8,5 @@ public ErrorTypeAnalyzerTestOptions() { AdditionalNamespaces = ["ZodSharp.Core"]; AdditionalAssemblyTypes = [typeof(ErrorType), typeof(ErrorTypeParameter)]; - // The generator emits this attribute at compile time; the analyzer-only harness - // provides the same surface so the analyzer can resolve it. - AdditionalSources = - [ - """ - namespace ZodSharp.Core; - - [global::System.AttributeUsage(global::System.AttributeTargets.Field, AllowMultiple = false, Inherited = false)] - public sealed class ErrorTypeAttribute : global::System.Attribute { } - """, - ]; } } diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs index 8624420f..011ecc3d 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ErrorTypeGeneratorTestOptions.cs @@ -18,7 +18,6 @@ public ErrorTypeGeneratorTestOptions() typeof(ZodException), typeof(ImmutableArray), ]; - ExcludeGeneratedSourceHintNames = ["ErrorTypeAttribute", "EmbeddedAttribute"]; DisableSourceGeneratorPropertyName = PropertyLibrary.DisableErrorTypeGeneratorProperty; } } diff --git a/src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs b/src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs new file mode 100644 index 00000000..3bbf4b3e --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using System.Reflection; +using ZodSharp.Core; + +namespace ZodSharp.SourceGenerators.Infra; + +/// +/// Invokes generated schemas compiled into a test assembly, so behavioural tests read as plain calls. +/// +static class GeneratedSchemaRuntime +{ + public static object Create(Assembly assembly, string typeName, params (string Name, object? Value)[] properties) + { + var type = assembly.GetType(typeName, throwOnError: true)!; + var instance = Activator.CreateInstance(type)!; + foreach (var (name, value) in properties) + type.GetProperty(name)!.SetValue(instance, value); + + return instance; + } + + public static ImmutableArray RunValidate(Assembly assembly, string schemaTypeName, object? value) + { + var result = GetStaticMethod(assembly, schemaTypeName, "Validate", 1).Invoke(null, [value])!; + return GetErrors(result); + } + + public static ImmutableArray RunValidateMember( + Assembly assembly, + string schemaTypeName, + object value, + string memberName + ) + { + var result = GetStaticMethod(assembly, schemaTypeName, "ValidateMember", 2).Invoke(null, [value, memberName])!; + return GetErrors(result); + } + + public static async Task> RunValidationAsync( + Assembly assembly, + string schemaTypeName, + object? value, + IServiceProvider? services = null, + CancellationToken cancellationToken = default + ) + { + var pending = GetStaticMethod(assembly, schemaTypeName, "ValidateAsync", 3) + .Invoke(null, [value, services, cancellationToken])!; + var task = (Task)pending.GetType().GetMethod("AsTask")!.Invoke(pending, null)!; + await task.ConfigureAwait(false); + var result = task.GetType().GetProperty("Result")!.GetValue(task)!; + return GetErrors(result); + } + + public static async Task> RunMemberValidationAsync( + Assembly assembly, + string schemaTypeName, + object value, + string memberName, + IServiceProvider? services = null, + CancellationToken cancellationToken = default + ) + { + var pending = GetStaticMethod(assembly, schemaTypeName, "ValidateMemberAsync", 4) + .Invoke(null, [value, memberName, services, cancellationToken])!; + var task = (Task)pending.GetType().GetMethod("AsTask")!.Invoke(pending, null)!; + await task.ConfigureAwait(false); + var result = task.GetType().GetProperty("Result")!.GetValue(task)!; + return GetErrors(result); + } + + public static bool RunTryResolveMember( + Assembly assembly, + string schemaTypeName, + object value, + ZodPath path, + out object? container, + out string memberName + ) + { + object?[] arguments = [value, path, null, null]; + var resolved = (bool)GetStaticMethod(assembly, schemaTypeName, "TryResolveMember", 4).Invoke(null, arguments)!; + container = arguments[2]; + memberName = (string)arguments[3]!; + return resolved; + } + + public static T InvokeStatic(Assembly assembly, string typeName, string name, params object?[] arguments) => + (T)GetStaticMethod(assembly, typeName, name, arguments.Length).Invoke(null, arguments)!; + + public static object? GetStaticField(Assembly assembly, string typeName, string name) => + assembly + .GetType(typeName, throwOnError: true)! + .GetField(name, BindingFlags.Public | BindingFlags.Static)! + .GetValue(null); + + /// Unwraps the exception a reflected call threw. + public static Exception? Unwrap(Action action) + { + try + { + action(); + return null; + } + catch (TargetInvocationException ex) + { + return ex.InnerException; + } + } + + public static ImmutableArray GetErrors(object result) + { + var errors = (ImmutableArray)result.GetType().GetProperty("Errors")!.GetValue(result)!; + return errors.IsDefault ? [] : errors; + } + + public static MethodInfo GetStaticMethod(Assembly assembly, string typeName, string name, int parameterCount) + { + var type = assembly.GetType(typeName, throwOnError: true)!; + return type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == name && method.GetParameters().Length == parameterCount); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs index ccacb12e..9505e552 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodAnalyzerTestOptions.cs @@ -19,26 +19,5 @@ public ZodAnalyzerTestOptions() typeof(Microsoft.Extensions.Options.ValidateOptionsResult), ]; AdditionalNamespaces = ["ZodSharp"]; - AdditionalSources = - [ - """ - namespace ZodSharp; - - [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] - public sealed class ZodSchemaAttribute : System.Attribute - { - public string? SchemaName { get; init; } - public bool GenerateValidateMethod { get; init; } = true; - public bool GenerateParseMethod { get; init; } = true; - public bool EnableComposition { get; init; } = true; - public string? CustomValidationMethodName { get; init; } - public bool GenerateIValidateOptions { get; init; } = false; - public bool SuppressIValidateOptions { get; init; } = false; - public bool ValidateEnumValues { get; init; } = true; - public bool GenerateJsonSchema { get; init; } = true; - public bool AllowNulls { get; init; } - } - """, - ]; } } diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs index edf6777d..6ec4bd9b 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodCodeFixTestOptions.cs @@ -19,23 +19,5 @@ public ZodCodeFixTestOptions() typeof(Microsoft.Extensions.Options.ValidateOptionsResult), ]; AdditionalNamespaces = ["ZodSharp"]; - AdditionalSources = - [ - """ - namespace ZodSharp; - - [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] - public sealed class ZodSchemaAttribute : System.Attribute - { - public string? SchemaName { get; init; } - public bool GenerateValidateMethod { get; init; } = true; - public bool GenerateParseMethod { get; init; } = true; - public bool EnableComposition { get; init; } = true; - public string? CustomValidationMethodName { get; init; } - public bool GenerateIValidateOptions { get; init; } = false; - public bool SuppressIValidateOptions { get; init; } = false; - } - """, - ]; } } diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodSharpSourceGeneratorTestBase.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodSharpSourceGeneratorTestBase.cs index 302fb681..6e7984d5 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodSharpSourceGeneratorTestBase.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodSharpSourceGeneratorTestBase.cs @@ -6,7 +6,12 @@ public abstract class ZodSharpSourceGeneratorTestBase : TUnitSourceGeneratorTestBase where TGenerator : class, IIncrementalGenerator, new() { - public static readonly string[] GeneratedAttributes = ["EmbeddedAttribute.cs", "ZodSchemaAttribute.g.cs"]; + /// + /// The sources the generator emits into every compilation, whatever it contains. None: [ZodSchema] and + /// [ErrorType] are defined once in Purview.ZodSharp, so no marker type is duplicated across the + /// projects of a solution. + /// + public static readonly string[] GeneratedAttributes = []; public static readonly int ExpectedFileCount = GeneratedAttributes.Length; diff --git a/src/tests/SourceGenerators.UnitTests/Infra/ZodSourceGeneratorTestOptions.cs b/src/tests/SourceGenerators.UnitTests/Infra/ZodSourceGeneratorTestOptions.cs index 93724f60..db1e80dc 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodSourceGeneratorTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodSourceGeneratorTestOptions.cs @@ -20,11 +20,31 @@ public ZodSourceGeneratorTestOptions() typeof(Microsoft.Extensions.Options.ValidateOptionsResult), ]; AdditionalNamespaces = [TypeLibraryGenerator.ZodSharpNamespace]; - ExcludeGeneratedSourceHintNames = ["EmbeddedAttribute", "ZodSchemaAttribute"]; DisableSourceGeneratorPropertyName = PropertyLibrary.DisableZodSharpSourceGeneratorProperty; } public static readonly ZodSourceGeneratorTestOptions NoValidation = new() { ThrowOnGenerationException = false }; public static readonly ZodSourceGeneratorTestOptions Compile = new() { CompileToAssembly = true }; + + /// + /// Compiles to an assembly with Microsoft.Extensions.DependencyInjection.Abstractions referenced, so the + /// generated DI registration extension is emitted. + /// + public static readonly ZodSourceGeneratorTestOptions CompileWithDependencyInjection = new() + { + CompileToAssembly = true, + AdditionalAssemblyTypes = + [ + typeof(Z), + typeof(ImmutableArray), + typeof(RequiredAttribute), + typeof(System.Text.Json.JsonSerializer), + typeof(System.Text.RegularExpressions.Regex), + typeof(Schemas.RefineCtx<>), + typeof(Microsoft.Extensions.Options.IValidateOptions<>), + typeof(Microsoft.Extensions.Options.ValidateOptionsResult), + typeof(Microsoft.Extensions.DependencyInjection.IServiceCollection), + ], + }; } diff --git a/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs b/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs new file mode 100644 index 00000000..afece00a --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs @@ -0,0 +1,229 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ZodSharp.SourceGenerators; + +/// +/// Drives the ZodSharp MSBuild switches through a real globalconfig, parsed by Roslyn's own +/// , rather than through a hand-built options dictionary. MSBuild's +/// GenerateMSBuildEditorConfigFile writes every CompilerVisibleProperty into exactly this +/// shape (is_global = true, then build_property.Name = value), so a key the compiler would not +/// expose fails here even when the dictionary-driven tests pass. +/// +public sealed class MSBuildSwitchGlobalConfigTests +{ + const string Source = """ + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Widget + { + public string Name { get; set; } = ""; + } + } + """; + + [Test] + public async Task GenerateJsonSchemas_GivenGlobalConfigBuildPropertyFalse_OmitsBuildJsonSchema( + CancellationToken cancellationToken + ) + { + // Arrange + const string globalConfig = """ + is_global = true + build_property.ZodSharpGenerateJsonSchemas = false + """; + + // Act + var generated = RunGenerator(globalConfig, cancellationToken); + + // Assert + await Assert.That(generated).DoesNotContain("BuildJsonSchema"); + } + + [Test] + public async Task GenerateJsonSchemas_GivenGlobalConfigWithoutSwitch_EmitsBuildJsonSchema( + CancellationToken cancellationToken + ) + { + // Arrange + const string globalConfig = """ + is_global = true + build_property.RootNamespace = Testing + """; + + // Act + var generated = RunGenerator(globalConfig, cancellationToken); + + // Assert + await Assert.That(generated).Contains("BuildJsonSchema"); + } + + [Test] + public async Task AutoGenerateOptionsValidators_GivenGlobalConfigBuildPropertyFalse_DoesNotEmitValidator( + CancellationToken cancellationToken + ) + { + // Arrange + const string globalConfig = """ + is_global = true + build_property.ZodSharpAutoGenerateOptionsValidators = false + """; + const string source = """ + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public sealed class AdminPortalOptions + { + public string? RoutePrefix { get; set; } + } + } + """; + + // Act + var disabled = RunGenerator(globalConfig, cancellationToken, source); + var enabled = RunGenerator("is_global = true", cancellationToken, source); + + // Assert + await Assert.That(enabled).Contains("AdminPortalOptionsValidator"); + await Assert.That(disabled).DoesNotContain("AdminPortalOptionsValidator"); + } + + [Test] + public async Task BuildTransitiveProps_GivenEverySwitchTheGeneratorReads_DeclaresItCompilerVisible( + CancellationToken cancellationToken + ) + { + // Arrange — a switch missing from the props never reaches the globalconfig, so it silently does nothing. + var switches = typeof(Helpers.PropertyLibrary) + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Where(field => field.IsLiteral && field.Name.EndsWith("Property", StringComparison.Ordinal)) + .Select(field => (string)field.GetRawConstantValue()!) + .ToArray(); + var propsPath = Path.Combine( + FindRepositoryRoot(), + "src", + "src", + "ZodSharp", + "Sdk", + "buildTransitive", + "Purview.ZodSharp.props" + ); + + // Act + var props = System.Xml.Linq.XDocument.Parse(await File.ReadAllTextAsync(propsPath, cancellationToken)); + var declared = props + .Descendants("CompilerVisibleProperty") + .Select(element => (string?)element.Attribute("Include")) + .ToHashSet(StringComparer.Ordinal); + + // Assert + await Assert.That(switches).IsNotEmpty(); + await Assert.That(switches.Where(name => !declared.Contains(name))).IsEmpty(); + } + + static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AGENTS.md"))) + directory = directory.Parent; + + return directory?.FullName ?? throw new InvalidOperationException("Could not locate the repository root."); + } + + static string RunGenerator(string globalConfig, CancellationToken cancellationToken, string source = Source) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var compilation = CreateCompilation(source, parseOptions, cancellationToken); + + var driver = CSharpGeneratorDriver.Create( + [new ZodSchemaGenerator().AsSourceGenerator()], + parseOptions: parseOptions, + optionsProvider: new GlobalConfigOptionsProvider(ParseGlobalOptions(globalConfig)) + ); + var result = driver.RunGenerators(compilation, cancellationToken).GetRunResult(); + + return string.Join( + Environment.NewLine, + result + .GeneratedTrees + // Only the schema outputs are inspected; a generated rule attribute documents its own options. + .Where(tree => !tree.FilePath.EndsWith("Attribute.g.cs", StringComparison.Ordinal)) + .Select(tree => tree.GetText(cancellationToken).ToString()) + ); + } + + static ImmutableDictionary ParseGlobalOptions(string globalConfig) + { + var config = AnalyzerConfig.Parse(globalConfig, "/repo/obj/Testing.GeneratedMSBuildEditorConfig.editorconfig"); + var configSet = AnalyzerConfigSet.Create(ImmutableArray.Create(config), out var configDiagnostics); + // A conditional expression would turn the guard into a throw expression inside the return. +#pragma warning disable IDE0046 // Convert to conditional expression + if (!configDiagnostics.IsEmpty) + throw new InvalidOperationException(string.Join(Environment.NewLine, configDiagnostics)); +#pragma warning restore IDE0046 + + return configSet.GlobalConfigOptions.AnalyzerOptions; + } + + static CSharpCompilation CreateCompilation( + string source, + CSharpParseOptions parseOptions, + CancellationToken cancellationToken + ) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + source, + parseOptions, + path: "/repo/Widget.cs", + cancellationToken: cancellationToken + ); + + return CSharpCompilation.Create( + "Testing", + [syntaxTree], + GetReferences(), + new(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable) + ); + } + + static ImmutableArray GetReferences() + { + var trusted = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?.Split(Path.PathSeparator) ?? []; + return + [ + .. trusted.Select(path => MetadataReference.CreateFromFile(path)), + MetadataReference.CreateFromFile(typeof(Z).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.Options.IValidateOptions<>).Assembly.Location), + ]; + } + + /// + /// Exposes the global section of a parsed globalconfig the way the compiler does, through the + /// case-insensitive dictionary produced. + /// + sealed class GlobalConfigOptionsProvider(ImmutableDictionary globalOptions) + : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new DictionaryOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => DictionaryOptions.Empty; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => DictionaryOptions.Empty; + } + + sealed class DictionaryOptions(ImmutableDictionary options) : AnalyzerConfigOptions + { + public static readonly DictionaryOptions Empty = new([]); + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) => + options.TryGetValue(key, out value); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs index 33a00673..108071be 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionBehavior.cs @@ -4,6 +4,50 @@ namespace ZodSharp.SourceGenerators; public partial class ZodSchemaAnalyzerTests { + [Test] + public async Task CollectionBehavior_GivenDefaultArrayOptionOnReferenceCollection_ProducesZODSGEN046( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + [ZodSchema] + public class Widget + { + [CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty)] + public System.Collections.Generic.List Items { get; set; } = []; + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.CollectionBehaviorNotApplicable); + } + + [Test] + public async Task CollectionBehavior_GivenValueElementImmutableArrayWithDefaultBehavior_ProducesNoZODSGEN046( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + [ZodSchema] + public record Widget( + [CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty)] + System.Collections.Immutable.ImmutableArray Items); + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CollectionBehaviorNotApplicable); + } + [Test] public async Task CollectionBehavior_GivenNonCollectionProperty_ProducesZODSGEN046( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionCount.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionCount.cs new file mode 100644 index 00000000..de09c83e --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CollectionCount.cs @@ -0,0 +1,67 @@ +using ZodSharp.SourceGenerators.Helpers; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaAnalyzerTests +{ + [Test] + [Arguments("[MinCount(1)]")] + [Arguments("[MaxCount(1)]")] + [Arguments("[CountRange(1, 2)]")] + public async Task CollectionCount_GivenNonCollectionMember_ProducesZODSGEN030( + string attribute, + CancellationToken cancellationToken + ) + { + // Arrange — a count has no meaning on a scalar; the count rules validate IEnumerable. + var source = $$""" + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public class Widget + { + {{attribute}} + public int Value { get; set; } + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } + + [Test] + public async Task CollectionCount_GivenCollectionMember_ProducesNoZODSGEN030(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public class Widget + { + [MinCount(1)] + [MaxCount(3)] + public List Values { get; set; } = []; + + [CountRange(1, 2)] + public Dictionary Counts { get; set; } = []; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.UnsupportedCustomRuleTarget); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomValidation.cs index 117d0c71..d2ad2755 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.CustomValidation.cs @@ -4,6 +4,67 @@ namespace ZodSharp.SourceGenerators; public partial class ZodSchemaAnalyzerTests { + [Test] + public async Task ZodAsyncMemberAttribute_GivenItsXmlDocExample_CompilesWithoutCustomValidationDiagnostics( + CancellationToken cancellationToken + ) + { + // Arrange — the example is taken from the attribute's own XML doc, so a broken example fails here. + var example = await ReadXmlDocExampleAsync( + Path.Combine("src", "src", "ZodSharp", "ZodAsyncMemberAttribute.cs"), + cancellationToken + ); + var member = example.Replace( + "{ ... }", + "{ await Task.Yield(); return ValidationResult.Success(value); }", + StringComparison.Ordinal + ); + var source = $$""" + using System; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public partial class User + { + public string? Email { get; set; } + + {{member}} + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CustomValidationInaccessible); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CustomValidationMethodNotFound); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CustomValidationInvalidServicesParameter); + } + + static async Task ReadXmlDocExampleAsync(string relativePath, CancellationToken cancellationToken) + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "AGENTS.md"))) + root = root.Parent; + + var lines = await File.ReadAllLinesAsync( + Path.Combine(root?.FullName ?? throw new InvalidOperationException("No repository root."), relativePath), + cancellationToken + ); + var code = lines + .SkipWhile(static line => !line.Contains("", StringComparison.Ordinal)) + .Skip(1) + .TakeWhile(static line => !line.Contains("", StringComparison.Ordinal)) + .Select(static line => line.TrimStart().TrimStart('/').TrimStart()); + + return System.Net.WebUtility.HtmlDecode(string.Join(Environment.NewLine, code)); + } + [Test] public async Task CustomValidation_GivenExplicitNameMissing_ProducesZODSGEN007(CancellationToken cancellationToken) { @@ -127,6 +188,86 @@ internal static ValueTask> CustomValidationAsync( await Assert.That(result).HasDiagnostic(DiagnosticLibrary.CustomValidationInvalidCancellationToken); } + [Test] + public async Task CustomValidation_GivenWrongCancellationTokenType_SaysItMustBeTheLastParameter( + CancellationToken cancellationToken + ) + { + // Arrange + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public class WrongServiceAwareCT + { + public string? Name { get; set; } + + internal static ValueTask> CustomValidationAsync( + WrongServiceAwareCT value, IServiceProvider services, string ct) => + ValueTask.FromResult(ValidationResult.Success(value)); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.CustomValidationInvalidCancellationToken); + await Assert + .That( + DiagnosticLibrary.CustomValidationInvalidCancellationToken.MessageFormat.ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ) + .Contains("last parameter"); + await Assert + .That( + DiagnosticLibrary.CustomValidationInvalidCancellationToken.MessageFormat.ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ) + .DoesNotContain("second parameter"); + } + + [Test] + public async Task CustomValidation_GivenServiceAwareMethodWithWrongServicesType_ProducesZODSGEN054( + CancellationToken cancellationToken + ) + { + // Arrange + var source = """ + using System.Threading; + using System.Threading.Tasks; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public class WrongServices + { + public string? Name { get; set; } + + internal static ValueTask> CustomValidationAsync( + WrongServices value, string services, CancellationToken ct) => + ValueTask.FromResult(ValidationResult.Success(value)); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.CustomValidationInvalidServicesParameter); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.CustomValidationInvalidParameterCount); + } + [Test] public async Task CustomValidation_GivenGenericMethod_ProducesZODSGEN012(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs new file mode 100644 index 00000000..69b8b235 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaAnalyzerTests.ValidationInputs.cs @@ -0,0 +1,278 @@ +using ZodSharp.CodeFixes; +using ZodSharp.SourceGenerators.Helpers; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +public partial class ZodSchemaAnalyzerTests +{ + [Test] + public async Task ValidatableType_GivenZodSchemaType_ProducesZODSGEN047(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Microsoft.Extensions.Validation + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class ValidatableTypeAttribute : System.Attribute + { + } + } + + namespace Testing + { + [ZodSchema] + [Microsoft.Extensions.Validation.ValidatableType] + public class Customer + { + public string? Name { get; set; } + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ValidatableTypeRedundant); + } + + [Test] + public async Task ClosedHierarchy_GivenDerivedTypeWithoutSchema_ProducesZODSGEN049( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing + { + public closed class Shape + { + } + + public sealed class Square : Shape + { + } + + [ZodSchema] + public class Drawing + { + public Shape? Shape { get; set; } + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new ZodAnalyzerTestOptions { LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.Preview }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ClosedHierarchyDerivedTypeWithoutSchema); + } + + [Test] + public async Task OptionsValidator_GivenHandAuthoredOptionsValidator_ProducesZODSGEN050( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using Microsoft.Extensions.Options; + + namespace Testing + { + [ZodSchema(GenerateIValidateOptions = true)] + public sealed class QueueOptions + { + public int Workers { get; set; } + } + + [OptionsValidator] + public partial class QueueOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, QueueOptions options) => ValidateOptionsResult.Success; + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.OptionsValidatorConflict); + } + + [Test] + public async Task ErrorMessage_GivenUnsuppliedPlaceholder_ProducesZODSGEN052(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public sealed class Ticket + { + [Required(ErrorMessage = "{0} needs {1}")] + public string? Title { get; set; } + + [StringLength(10, ErrorMessage = "{0} must be {2}-{1}")] + public string? Summary { get; set; } + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert — {1} is not supplied by [Required]; [StringLength] supplies {0}-{2}. + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.UnresolvableMessagePlaceholder); + await Assert.That(result.Diagnostics.Count(static diagnostic => diagnostic.Id == "ZODSGEN052")).IsEqualTo(1); + } + + [Test] + [Arguments("{0} is required", 0, null)] + [Arguments("{displayName} is required", 0, null)] + [Arguments("{{literal}}", 0, null)] + [Arguments("{0} needs {1}", 0, "{1}")] + [Arguments("{0} between {1,5:N0} and {2}", 2, null)] + [Arguments("broken { brace", 0, "{")] + [Arguments("broken } brace", 0, "}")] + public async Task FindUnresolvablePlaceholder_GivenMessage_ReturnsTheFirstProblem( + string message, + int maxOrdinal, + string? expected + ) + { + // Act + var problem = SourceGenLibrary.FindUnresolvablePlaceholder(message, maxOrdinal); + + // Assert + await Assert.That(problem).IsEqualTo(expected); + } +} + +public class ZodSchemaAsyncAnalyzerTests : ZodAnalyzerTestBase +{ + [Test] + public async Task AsyncValidatableObject_GivenGeneratedValidate_ProducesZODSGEN048( + CancellationToken cancellationToken + ) + { + // Arrange — the .NET 11 interface is declared here so the diagnostic is testable on every TFM. + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.Threading; + + namespace System.ComponentModel.DataAnnotations + { + public interface IAsyncValidatableObject : IValidatableObject + { + IAsyncEnumerable ValidateAsync(ValidationContext validationContext, CancellationToken cancellationToken); + } + } + + namespace Testing + { + [ZodSchema] + public sealed class Reservation : IAsyncValidatableObject + { + public int Nights { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) => []; + + public IAsyncEnumerable ValidateAsync(ValidationContext validationContext, CancellationToken cancellationToken) => + throw new System.NotImplementedException(); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AsyncOnlyRulesWithSyncValidate); + } +} + +public class ZodSchemaRazorAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + [Arguments("Pages/Counter.razor", true)] + [Arguments("obj/Generated/Pages_Counter_razor.g.cs", true)] + [Arguments("Counter.razor.g.cs", true)] + [Arguments("Models/Counter.cs", false)] + [Arguments(null, false)] + public async Task IsRazorFile_GivenPath_DetectsRazorSources(string? path, bool expected) + { + // Act + var isRazor = ZodSchemaRazorAnalyzer.IsRazorFile(path); + + // Assert + await Assert.That(isRazor).IsEqualTo(expected); + } + + [Test] + public async Task ZodSchemaType_GivenRegularSourceFile_ProducesNoZODSGEN051(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing + { + [ZodSchema] + public sealed class Counter + { + public int Count { get; set; } + } + } + """; + + // Act + var result = await AnalyzeAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodSchemaInRazorFile); + } +} + +public class RedundantValidatableTypeCodeFixTests + : TUnitCodeFixTestBase +{ + [Test] + public async Task CodeFix_GivenRedundantValidatableType_RemovesTheAttribute(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Microsoft.Extensions.Validation + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class ValidatableTypeAttribute : System.Attribute + { + } + } + + namespace Testing + { + [ZodSharp.ZodSchema] + [Microsoft.Extensions.Validation.ValidatableType] + public class Customer + { + public string? Name { get; set; } + } + } + """; + + // Act + var result = await ApplyCodeFixAsync(source, new ZodCodeFixTestOptions(), cancellationToken); + + // Assert + await Assert.That(result.FixedSource).DoesNotContain("[Microsoft.Extensions.Validation.ValidatableType]"); + await Assert.That(result.FixedSource).Contains("[ZodSharp.ZodSchema]"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs new file mode 100644 index 00000000..8dbec309 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorCacheTests.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using ZodSharp.SourceGenerators.Infra; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +namespace ZodSharp.SourceGenerators; + +/// +/// Proves the [ZodSchema] pipeline caches stage by stage: an identical rerun and an unrelated file are +/// cached, and adding a [JsonPropertyName] (which feeds the generated JSON-name table) re-runs only the type +/// that changed. +/// +public class ZodSchemaGeneratorCacheTests : ZodSharpSourceGeneratorTestBase +{ + const string CustomerSource = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing; + + [ZodSchema] + public class Customer + { + [Required] + public string? Name { get; set; } + } + """; + + const string CustomerWithJsonNameSource = """ + using System.ComponentModel.DataAnnotations; + using System.Text.Json.Serialization; + using ZodSharp; + + namespace Testing; + + [ZodSchema] + public class Customer + { + [Required] + [JsonPropertyName("full_name")] + public string? Name { get; set; } + } + """; + + const string OrderSource = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing; + + [ZodSchema] + public class Order + { + [Range(1, 10)] + public int Quantity { get; set; } + } + """; + + const string UnrelatedSource = """ + namespace Testing; + + public class Unrelated + { + public int Value { get; set; } + } + """; + + const string AttributeStage = "ForAttribute_ZodSchemaAttribute"; + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) + { + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = + [ + .. pair.Value.SelectMany(static step => step.Outputs.Select(static output => output.Reason)), + ]; + + return builder.ToImmutable(); + } + + static bool IsCachedOrUnchanged(StepReason reason) => reason is StepReason.Cached or StepReason.Unchanged; + + [Test] + public async Task IdenticalRerun_GivenSameSources_KeepsTheAttributeStageCached(CancellationToken cancellationToken) + { + // Act + var result = await GenerateIncrementalAsync( + [CustomerSource, OrderSource], + cancellationToken: cancellationToken + ); + + // Assert + var second = StepReasons(result.Runs[1]); + await Assert.That(second.TryGetValue(AttributeStage, out var reasons)).IsTrue(); + await Assert.That(reasons.All(IsCachedOrUnchanged)).IsTrue(); + } + + [Test] + public async Task UnrelatedFile_GivenNewSource_KeepsPerTypeOutputsCached(CancellationToken cancellationToken) + { + // Arrange + IncrementalRunInput[] inputs = + [ + new([CustomerSource, OrderSource]), + new([CustomerSource, OrderSource, UnrelatedSource]), + ]; + + // Act + var result = await GenerateIncrementalAsync(inputs, cancellationToken: cancellationToken); + + // Assert + var second = StepReasons(result.Runs[1]); + await Assert.That(second[AttributeStage].All(IsCachedOrUnchanged)).IsTrue(); + } + + [Test] + public async Task JsonPropertyName_GivenAddedToOneType_ReRunsOnlyThatType(CancellationToken cancellationToken) + { + // Arrange + IncrementalRunInput[] inputs = + [ + new([CustomerSource, OrderSource]), + new([CustomerWithJsonNameSource, OrderSource]), + ]; + + // Act + var result = await GenerateIncrementalAsync(inputs, cancellationToken: cancellationToken); + + // Assert — Customer's output is modified, Order's stays cached. + var second = StepReasons(result.Runs[1]); + await Assert.That(second[AttributeStage]).Contains(StepReason.Modified); + await Assert.That(second[AttributeStage].Count(IsCachedOrUnchanged)).IsEqualTo(1); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs new file mode 100644 index 00000000..1faaf161 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs @@ -0,0 +1,237 @@ +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +/// +/// Service-aware custom validation (Change 9), [ZodAsyncMember] targeting, the synchronous fast path and the +/// async depth guard. +/// +partial class ZodSchemaGeneratorTests +{ + const string ServiceAwareSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public class Account + { + [Required] + public string? Handle { get; set; } + + [MinLength(2)] + public string? DisplayName { get; set; } + + [ZodAsyncMember(nameof(Handle))] + internal static ValueTask> CustomValidationAsync( + Account value, + IServiceProvider services, + CancellationToken ct + ) + { + var taken = services.GetService(typeof(string)) as string; + return ValueTask.FromResult( + value.Handle == taken + ? ValidationResult.Failure( + new[] + { + ValidationError.Create("handle_taken", "Handle is taken", AccountSchema.Paths.Handle), + ValidationError.Create("audit", "Logged", ZodPath.Root), + } + ) + : ValidationResult.Success(value) + ); + } + } + } + """; + + [Test] + public async Task ValidateAsync_GivenServiceAwareMethod_HandsTheCallersServicesToIt( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + ServiceAwareSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var account = Create(assembly, "Testing.Account", ("Handle", "ada"), ("DisplayName", "Ada")); + StubServiceProvider services = new("ada"); + + // Act + var errors = await RunValidationAsync(assembly, "Testing.AccountSchema", account, services, cancellationToken); + + // Assert + await Assert + .That(errors.Select(static error => error.Code).ToArray()) + .IsEquivalentTo(["handle_taken", "audit"]); + } + + [Test] + public async Task ValidateAsync_GivenServiceAwareMethodAndNoServices_ThrowsNamingTheType( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + ServiceAwareSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var account = Create(assembly, "Testing.Account", ("Handle", "ada")); + + // Act + async Task Act() => + await RunValidationAsync(assembly, "Testing.AccountSchema", account, null, cancellationToken); + + // Assert + await Assert.That(Act).Throws().WithMessageContaining("Testing.Account"); + } + + [Test] + public async Task ValidateMemberAsync_GivenZodAsyncMember_RunsTheCustomMethodOnlyForThatMemberAndKeepsItsErrors( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + ServiceAwareSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var account = Create(assembly, "Testing.Account", ("Handle", "ada"), ("DisplayName", "A")); + StubServiceProvider services = new("ada"); + + // Act + var handle = await RunMemberValidationAsync( + assembly, + "Testing.AccountSchema", + account, + "Handle", + services, + cancellationToken + ); + var displayName = await RunMemberValidationAsync( + assembly, + "Testing.AccountSchema", + account, + "DisplayName", + services, + cancellationToken + ); + + // Assert — the root-level "audit" error is not the Handle field's, so it is not reported for it. + await Assert.That(handle.Select(static error => error.Code).ToArray()).IsEquivalentTo(["handle_taken"]); + await Assert.That(displayName.Select(static error => error.Code).ToArray()).IsEquivalentTo(["too_small"]); + await Assert.That(services.Requests).IsEqualTo(1); + } + + [Test] + public async Task ValidateAsync_GivenNoAsynchronousRules_CompletesSynchronously(CancellationToken cancellationToken) + { + // Arrange + using var result = await GenerateAsync( + FlatMembersSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var registration = Create(assembly, "Testing.Registration", ("UserName", "valid")); + + // Act + var pending = GetStaticMethod(assembly, "Testing.RegistrationSchema", "ValidateAsync", 3) + .Invoke(null, [registration, null, cancellationToken])!; + var completed = (bool)pending.GetType().GetProperty("IsCompletedSuccessfully")!.GetValue(pending)!; + var requiresAsync = (bool)GetStaticField(assembly, "Testing.RegistrationSchema", "RequiresAsyncValidation")!; + + // Assert + await Assert.That(completed).IsTrue(); + await Assert.That(requiresAsync).IsFalse(); + } + + [Test] + [Arguments("FlatMembers", "RegistrationSchema", "so this call never blocks", "a synchronous call cannot")] + [Arguments("ServiceAware", "AccountSchema", "a synchronous call cannot", "so this call never blocks")] + public async Task Validate_GivenRequiresAsyncValidation_DocumentsWhichCallIsRightInAsyncCode( + string sourceName, + string schemaName, + string expected, + string unexpected, + CancellationToken cancellationToken + ) + { + // Arrange - CA1849 pairs Validate with ValidateAsync; ZodSharp's analyzer suppresses it for both, so the + // generated remark states which call the type actually needs. + var source = sourceName == "FlatMembers" ? FlatMembersSource : ServiceAwareSource; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + var generated = result.GetSource(schemaName); + + // Assert + await Assert.That(generated).Contains(expected); + await Assert.That(generated).DoesNotContain(unexpected); + } + + [Test] + public async Task ValidateAsync_GivenCyclicGraph_ThrowsAtTheMaximumDepth(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public class Link + { + public Link? Next { get; set; } + + internal static ValueTask> CustomValidationAsync(Link value, CancellationToken ct) => + ValueTask.FromResult(ValidationResult.Success(value)); + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + dynamic link = Create(assembly, "Testing.Link"); + link.Next = link; + object cyclic = link; + + // Act + async Task Act() => await RunValidationAsync(assembly, "Testing.LinkSchema", cyclic, null, cancellationToken); + + // Assert + await Assert.That(Act).Throws().WithMessageContaining("Maximum validation depth"); + } + + sealed class StubServiceProvider(string taken) : IServiceProvider + { + public int Requests { get; private set; } + + public object? GetService(Type serviceType) + { + Requests++; + return serviceType == typeof(string) ? taken : null; + } + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs index 6e9f30e9..5628e7ad 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeGen.cs @@ -1,5 +1,5 @@ using System.Diagnostics.CodeAnalysis; -using ZodSharp.SourceGenerators.Infra; +using Microsoft.CodeAnalysis; namespace ZodSharp.SourceGenerators; @@ -11,7 +11,7 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGeneratorTests { [Test] - public async Task Generate_GivenEmptySource_GeneratesAttributesOnly(CancellationToken cancellationToken) + public async Task Generate_GivenEmptySource_GeneratesNothing(CancellationToken cancellationToken) { // Arrange const string source = @@ -25,62 +25,101 @@ public class Empty { } // Act using var driverResult = await GenerateAsync(source, cancellationToken); - // Assert - await Assert.That(driverResult.AllSyntaxTrees.Length).IsEqualTo(ExpectedFileCount); + // Assert - [ZodSchema] is defined in Purview.ZodSharp, so there is no marker source to emit. + var generated = driverResult.DriverResult.Results.SelectMany(static result => result.GeneratedSources); + await Assert.That(generated).IsEmpty(); + await Assert.That(ExpectedFileCount).IsEqualTo(0); } [Test] - public async Task Generate_GivenAttributeFiles_ContainsGenerateZodAttributes(CancellationToken cancellationToken) + public async Task Generate_GivenSchemaType_DoesNotDeclareMarkerAttributeTypes(CancellationToken cancellationToken) { // Arrange const string source = @" +using ZodSharp; + namespace Testing { - public class Empty { } + [ZodSchema] + public class Widget + { + public string? Name { get; set; } + } } "; // Act - using var driverResult = await GenerateAsync( - source, - new ZodSourceGeneratorTestOptions().Compile(), - cancellationToken - ); - var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); - - // Assert — attribute files are generated - var attributeSources = driverResult.AllSyntaxTrees.Select(static t => t.GetText().ToString()).ToList(); - - await Assert.That(attributeSources).Count().IsEqualTo(ExpectedFileCount); + using var driverResult = await GenerateAsync(source, cancellationToken); - var allAttributeSource = string.Join("\n", attributeSources); - await Assert.That(allAttributeSource).Contains("class EmbeddedAttribute"); - await Assert.That(allAttributeSource).Contains("class ZodSchemaAttribute"); + // Assert - a generated copy in every project gave each one the same public type and XML documentation ID. + var query = driverResult.Generated(); + await Assert.That(query.HasClass("WidgetSchema")).IsTrue(); + await Assert.That(query.HasClass("ZodSchemaAttribute")).IsFalse(); + await Assert.That(query.HasClass("EmbeddedAttribute")).IsFalse(); } [Test] - public async Task Generate_GivenAttributeFiles_DoesNotContainRetiredRefinementMethodNameProperty( - CancellationToken cancellationToken - ) + public async Task Generate_GivenSchemaType_BindsZodSchemaToPurviewZodSharp(CancellationToken cancellationToken) { // Arrange const string source = @" +using ZodSharp; + namespace Testing { - public class Empty { } + [ZodSchema] + public class Widget + { + public string? Name { get; set; } + } } "; // Act using var driverResult = await GenerateAsync(source, cancellationToken); - // Assert — the refinement name is no longer configured by convention; the OnZodValidate hook - // is declared by the generator instead. - var attributeSources = driverResult.AllSyntaxTrees.Select(static t => t.GetText().ToString()).ToList(); - var allAttributeSource = string.Join("\n", attributeSources); + // Assert + var compilation = driverResult.CompilationResult.Compilation; + var widget = compilation.GetTypeByMetadataName("Testing.Widget")!; + var attributeClass = widget.GetAttributes().Single().AttributeClass!; + await Assert.That(attributeClass.ToDisplayString()).IsEqualTo("ZodSharp.ZodSchemaAttribute"); + await Assert + .That(attributeClass.ContainingAssembly.Name) + .IsEqualTo(typeof(ZodSchemaAttribute).Assembly.GetName().Name); + await Assert.That(attributeClass.DeclaredAccessibility).IsEqualTo(Accessibility.Public); + await Assert + .That(compilation.GetDiagnostics(cancellationToken).Any(static diagnostic => diagnostic.Id == "CS0436")) + .IsFalse(); + } + + [Test] + public async Task ZodSchemaAttribute_GivenDefaults_MatchesTheDocumentedDefaults(CancellationToken cancellationToken) + { + // Arrange + ZodSchemaAttribute attribute = new(); + + // Act + var usage = (AttributeUsageAttribute) + Attribute.GetCustomAttribute(typeof(ZodSchemaAttribute), typeof(AttributeUsageAttribute))!; - await Assert.That(allAttributeSource).DoesNotContain("RefinementMethodName", StringComparison.Ordinal); + // Assert - the shape the generator emitted before the attribute moved into Purview.ZodSharp. + await Assert.That(usage.ValidOn).IsEqualTo(AttributeTargets.Class | AttributeTargets.Struct); + await Assert.That(usage.Inherited).IsFalse(); + await Assert.That(usage.AllowMultiple).IsFalse(); + await Assert.That(attribute.SchemaName).IsNull(); + await Assert.That(attribute.GenerateValidateMethod).IsTrue(); + await Assert.That(attribute.GenerateParseMethod).IsTrue(); + await Assert.That(attribute.EnableComposition).IsTrue(); + await Assert.That(attribute.CustomValidationMethodName).IsNull(); + await Assert.That(attribute.GenerateIValidateOptions).IsFalse(); + await Assert.That(attribute.SuppressIValidateOptions).IsFalse(); + await Assert.That(attribute.ValidateEnumValues).IsTrue(); + await Assert.That(attribute.GenerateJsonSchema).IsTrue(); + await Assert.That(attribute.AllowNulls).IsFalse(); + // The refinement name is no longer configured by convention; the OnZodValidate hook replaced it. + await Assert.That(typeof(ZodSchemaAttribute).GetProperty("RefinementMethodName")).IsNull(); + cancellationToken.ThrowIfCancellationRequested(); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs index 25a90c0c..9f4d49d5 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AttributeOptions.cs @@ -94,7 +94,7 @@ public class User } [Test] - public async Task LengthValidation_GivenCollectionProperty_ReportsCollectionOrigin( + public async Task LengthValidation_GivenCollectionProperty_ReportsArrayOriginLikeTheRuntimeSchemas( CancellationToken cancellationToken ) { @@ -121,8 +121,8 @@ public class Basket var schema = driverResult.GetSource("BasketSchema.g.cs", HintNameMatchMode.Suffix); // Assert - await Assert.That(schema).ContainsGeneratedCode("origin: \"collection\""); - await Assert.That(schema).DoesNotContain("origin: \"array\""); + await Assert.That(schema).ContainsGeneratedCode("origin: \"array\""); + await Assert.That(schema).DoesNotContain("origin: \"collection\""); } [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 5c53c575..ab9055c3 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -110,7 +110,7 @@ await Assert await Assert.That(generated).ContainsGeneratedCode("EmptyPath"); // The property is synthesized from the attribute (name defaults to Value). - await Assert.That(generated).ContainsGeneratedCode("ImmutableArray.Create(\"Value\")"); + await Assert.That(generated).ContainsGeneratedCode("ZodPath.Member(\"Value\")"); } [Test] @@ -247,8 +247,8 @@ public readonly partial record struct Score { } var generated = driverResult.GetSource("ScoreSchema"); // Assert — the synthesized nullable property is guarded, so null is accepted. - await Assert.That(generated).ContainsGeneratedCode("value.Value != null"); - await Assert.That(generated).ContainsGeneratedCode("ImmutableArray.Create(\"Value\")"); + await Assert.That(generated).DoesNotContain("value.Value is null"); + await Assert.That(generated).ContainsGeneratedCode("ZodPath.Member(\"Value\")"); } [Test] @@ -295,8 +295,8 @@ public readonly partial record struct TenantId { } var typeofGenerated = typeofResult.GetSource("TenantIdSchema"); // Assert — the synthesized property is named Id for both forms. - await Assert.That(generic).ContainsGeneratedCode("ImmutableArray.Create(\"Id\")"); - await Assert.That(typeofGenerated).ContainsGeneratedCode("ImmutableArray.Create(\"Id\")"); + await Assert.That(generic).ContainsGeneratedCode("ZodPath.Member(\"Id\")"); + await Assert.That(typeofGenerated).ContainsGeneratedCode("ZodPath.Member(\"Id\")"); } [Test] @@ -390,7 +390,11 @@ await Assert .ContainsGeneratedCode( "((global::ZodSharp.Core.IZodRule)externalIdCustomRuleInner0).Code ?? \"validation_failed\"" ); - await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + await Assert + .That(generated) + .ContainsGeneratedCode("EmptyPath, (global::ZodSharp.Core.IZodRule)externalIdCustomRuleInner0)"); + // A runtime type test on the boxed rule crashes the Mono AOT compiler; the cast is known to succeed. + await Assert.That(generated).DoesNotContain("(object)"); } [Test] @@ -644,8 +648,8 @@ public readonly partial record struct OptionalTimestamp { } // Assert — the synthesized property is DateTimeOffset?, so null is accepted and exported. foreach (var generated in new[] { generic, typeofGenerated }) { - await Assert.That(generated).ContainsGeneratedCode("value.Value != null"); - await Assert.That(generated).ContainsGeneratedCode("ImmutableArray.Create(\"Value\")"); + await Assert.That(generated).DoesNotContain("value.Value is null"); + await Assert.That(generated).ContainsGeneratedCode("ZodPath.Member(\"Value\")"); await Assert.That(generated).ContainsGeneratedCode("AnyOf = new()"); await Assert.That(generated).ContainsGeneratedCode("Type = \"null\""); } @@ -850,7 +854,7 @@ public readonly partial record struct RequiredCount await Assert.That(errors.Any(error => error.Code == Rules.RequiredRule.ErrorCode)).IsTrue(); await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); - await Assert.That(generated).ContainsGeneratedCode("value.Value == null"); + await Assert.That(generated).ContainsGeneratedCode("value.Value is null"); await Assert.That(generated).DoesNotContain("Type = \"null\""); } @@ -992,6 +996,6 @@ await Assert await Assert.That((bool)validResult.GetType().GetProperty("IsSuccess")!.GetValue(validResult)!).IsTrue(); var generated = driverResult.GetSource("ScoreHolderSchema"); - await Assert.That(generated).ContainsGeneratedCode("value.Score == null || value.Score.Value.Value == null"); + await Assert.That(generated).ContainsGeneratedCode("value.Score is null || value.Score.Value.Value is null"); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionCount.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionCount.cs new file mode 100644 index 00000000..b8ae43a0 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionCount.cs @@ -0,0 +1,204 @@ +using System.Collections.Immutable; +using ZodSharp.Core; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + /// + /// A model with one member validated by a DataAnnotations size attribute and one by the matching ZodSharp + /// collection-count attribute, so the two members' errors can be compared field by field. + /// + const string CollectionCountSource = + @" +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [MinLength(2)] + public List MinLegacy { get; set; } = []; + + [MinCount(2)] + public List MinCounted { get; set; } = []; + + [MaxLength(1)] + public List MaxLegacy { get; set; } = [1, 2]; + + [MaxCount(1)] + public List MaxCounted { get; set; } = [1, 2]; + + [Length(1, 3)] + public Dictionary RangeLegacy { get; set; } = []; + + [CountRange(1, 3)] + public Dictionary RangeCounted { get; set; } = []; + } +}"; + + [Test] + [Arguments("MinLegacy", "MinCounted")] + [Arguments("MaxLegacy", "MaxCounted")] + [Arguments("RangeLegacy", "RangeCounted")] + public async Task CollectionCount_GivenCountAttribute_ReportsTheSameErrorIdentityAsTheLengthAttribute( + string legacyMember, + string countedMember, + CancellationToken cancellationToken + ) + { + // Arrange + using var driverResult = await GenerateAsync( + CollectionCountSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var widget = Activator.CreateInstance(assembly!.GetType("Testing.Widget")!)!; + + // Act + var errors = ValidateWidget(assembly, widget); + var legacy = errors.Single(error => error.Path[0] == legacyMember); + var counted = errors.Single(error => error.Path[0] == countedMember); + + // Assert — everything but the member name is identical. + await Assert.That(counted.Code).IsEqualTo(legacy.Code); + await Assert.That(counted.Category).IsEqualTo(legacy.Category); + await Assert.That(counted.Origin).IsEqualTo(legacy.Origin); + await Assert.That(counted.Minimum).IsEqualTo(legacy.Minimum); + await Assert.That(counted.Maximum).IsEqualTo(legacy.Maximum); + await Assert.That(counted.Inclusive).IsEqualTo(legacy.Inclusive); + await Assert.That(counted.MessageFormat).IsEqualTo(legacy.MessageFormat); + await Assert + .That(counted.Message.Replace(countedMember, legacyMember, StringComparison.Ordinal)) + .IsEqualTo(legacy.Message); + } + + [Test] + public async Task CollectionCount_GivenSatisfiedCounts_ReportsNoCountErrors(CancellationToken cancellationToken) + { + // Arrange + const string source = + @" +using System.Collections.Generic; +using ZodSharp; +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [MinCount(1)] + [MaxCount(2)] + public int[] Values { get; set; } = [1, 2]; + + [CountRange(0, 1)] + public IReadOnlyCollection Tags { get; set; } = [""a""]; + } +}"; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var widget = Activator.CreateInstance(assembly!.GetType("Testing.Widget")!)!; + + // Act + var errors = ValidateWidget(assembly, widget); + + // Assert + await Assert.That(errors.IsDefaultOrEmpty).IsTrue(); + } + + [Test] + public async Task CollectionCount_GivenCustomMessage_UsesTheDataAnnotationsPlaceholders( + CancellationToken cancellationToken + ) + { + // Arrange — Message is an alias of ErrorMessage: {0} is the display name, {1} the bound. + const string source = + @" +using System.Collections.Generic; +using ZodSharp; +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [MinCount(2, Message = ""{0} needs {1} or more"")] + public List Values { get; set; } = []; + } +}"; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var widget = Activator.CreateInstance(assembly!.GetType("Testing.Widget")!)!; + + // Act + var errors = ValidateWidget(assembly, widget); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Values needs 2 or more"); + } + + [Test] + public async Task CollectionCount_JsonSchema_GivenCountAttributes_ExportsItemBounds( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = + @" +using System.Collections.Generic; +using ZodSharp; +using ZodSharp.Rules; + +namespace Testing +{ + [ZodSchema] + public class Widget + { + [MinCount(1)] + [MaxCount(4)] + public List Values { get; set; } = []; + + [CountRange(2, 5)] + public Dictionary Counts { get; set; } = []; + } +}"; + + // Act + var definition = await BuildDefinitionAsync(source, "WidgetSchema", cancellationToken); + + // Assert + var values = definition.Properties!["values"]; + await Assert.That(values.MinItems).IsEqualTo(1); + await Assert.That(values.MaxItems).IsEqualTo(4); + + var counts = definition.Properties!["counts"]; + await Assert.That(counts.MinProperties).IsEqualTo(2); + await Assert.That(counts.MaxProperties).IsEqualTo(5); + } + + static ImmutableArray ValidateWidget(System.Reflection.Assembly assembly, object widget) + { + var result = assembly.GetType("Testing.WidgetSchema")!.GetMethod("Validate")!.Invoke(null, [widget])!; + dynamic dynResult = result; + return (ImmutableArray)dynResult.Errors; + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionNullItems.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionNullItems.cs index 8e3f5236..d5343d84 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionNullItems.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionNullItems.cs @@ -334,7 +334,7 @@ public async Task CollectionNullItems_GivenMsBuildAllowNullsTrue_SkipsNullItem(C { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpAllowNulls", + "build_property.ZodSharpAllowNulls", "true" ), }; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionOrigin.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionOrigin.cs new file mode 100644 index 00000000..2caeb666 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CollectionOrigin.cs @@ -0,0 +1,56 @@ +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task CollectionSize_GivenTheDocumentedBasket_ReportsTheZodOriginOfEachCollectionShape( + CancellationToken cancellationToken + ) + { + // Arrange — the Basket example from Source-Generator-DataAnnotations.md, plus a map and a set. + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using ZodSharp.Rules; + + namespace Testing + { + [ZodSchema] + public sealed class Basket + { + [Required] + [Length(2, 5)] + public List? Items { get; set; } + + [MinLength(1)] + public Dictionary Prices { get; set; } = []; + + [MaxCount(1)] + public HashSet Tags { get; set; } = ["a", "b"]; + } + } + """; + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var basket = Create(assembly, "Testing.Basket", ("Items", new List { "apple" })); + + // Act + var errors = RunValidate(assembly, "Testing.BasketSchema", basket) + .ToDictionary(static error => error.FullPath.ToDottedString()); + + // Assert + await Assert.That(errors["Items"].Code).IsEqualTo("too_small"); + await Assert.That(errors["Items"].Minimum).IsEqualTo(2); + await Assert.That(errors["Items"].Inclusive ?? false).IsTrue(); + await Assert.That(errors["Items"].Origin).IsEqualTo("array"); + await Assert.That(errors["Prices"].Origin).IsEqualTo("record"); + await Assert.That(errors["Tags"].Origin).IsEqualTo("array"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs index 62b62794..d4b10e8e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ComparableRanges.cs @@ -52,11 +52,7 @@ await Assert .ContainsGeneratedCode( "static readonly global::System.TimeSpan RangeMaximum_MaxTimeRange = global::System.TimeSpan.Parse(\"365.00:00:00\", global::System.Globalization.CultureInfo.InvariantCulture);" ); - await Assert - .That(generatedSource) - .ContainsGeneratedCode( - "maxTimeRangeValue < RangeMinimum_MaxTimeRange || maxTimeRangeValue > RangeMaximum_MaxTimeRange" - ); + await Assert.That(generatedSource).ContainsGeneratedCode("maxTimeRangeValue > RangeMaximum_MaxTimeRange"); await Assert .That(generatedSource) @@ -71,9 +67,7 @@ await Assert ); await Assert .That(generatedSource) - .ContainsGeneratedCode( - "apiVersionValue.CompareTo(RangeMinimum_ApiVersion) < 0 || apiVersionValue.CompareTo(RangeMaximum_ApiVersion) > 0" - ); + .ContainsGeneratedCode("apiVersionValue.CompareTo(RangeMaximum_ApiVersion) > 0"); await Assert .That(generatedSource) diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs index 444635bd..48f9ec18 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomRules.cs @@ -49,7 +49,7 @@ public async Task CustomRule_GivenZodRuleMappedAttribute_EmitsRuleValidation(Can await Assert.That(generated).ContainsGeneratedCode("\"No spaces allowed.\""); await Assert.That(generated).ContainsGeneratedCode(".IsValid("); await Assert.That(generated).ContainsGeneratedCode("\"invalid_string\""); - await Assert.That(generated).ContainsGeneratedCode("origin: \"string\""); + await Assert.That(generated).ContainsGeneratedCode("fallbackOrigin: \"string\""); } [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs index 1f610ce3..f52176ab 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.CustomValidation.cs @@ -21,7 +21,7 @@ public class NoCustom { public string? Name { get; set; } } // No async state machine await Assert .That(generated) - .ContainsGeneratedCode("ValueTask.FromResult") + .ContainsGeneratedCode("=> new(ValidateCore(value, depth));") .Because("Should use FromResult, not async"); await Assert .That(generated) @@ -60,7 +60,7 @@ await Assert .Because("Should generate async when custom method exists"); await Assert.That(generated).ContainsGeneratedCode("await global::Testing.WithDefault.CustomValidationAsync"); await Assert.That(generated).ContainsGeneratedCode(".ConfigureAwait(false)"); - await Assert.That(generated).ContainsGeneratedCode(".Merge(syncResult, customResult)"); + await Assert.That(generated).ContainsGeneratedCode("ZodGeneratedValidation.WithResult(errors, customResult)"); } [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs index c7f1800a..9c4ef60a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs @@ -91,16 +91,14 @@ CancellationToken cancellationToken var generatedSource = driverResult.GetSource("UserSchema"); - await Assert.That(generatedSource).ContainsGeneratedCode("value.Name == null"); + await Assert.That(generatedSource).ContainsGeneratedCode("string.IsNullOrWhiteSpace(value.Name)"); await Assert.That(generatedSource).ContainsGeneratedCode("var nameValue = value.Name;"); await Assert.That(generatedSource).ContainsGeneratedCode("var nameLength = nameValue.Length;"); await Assert.That(generatedSource).ContainsGeneratedCode("nameLength < 3"); await Assert.That(generatedSource).ContainsGeneratedCode("nameLength > 50"); await Assert.That(generatedSource).ContainsGeneratedCode("static readonly int RangeMinimum_Age = 0;"); await Assert.That(generatedSource).ContainsGeneratedCode("static readonly int RangeMaximum_Age = 120;"); - await Assert - .That(generatedSource) - .ContainsGeneratedCode("ageValue < RangeMinimum_Age || ageValue > RangeMaximum_Age"); + await Assert.That(generatedSource).ContainsGeneratedCode("ageValue < RangeMinimum_Age"); await Assert.That(generatedSource).ContainsGeneratedCode("EmailRegex.IsMatch(emailValue)"); } @@ -228,7 +226,7 @@ public sealed class BackslashPatternModel await Assert .That(generatedSource) .ContainsGeneratedCode( - @"new(""^[\\w\\-.]+$"", global::System.Text.RegularExpressions.RegexOptions.CultureInvariant, global::ZodSharp.Rules.RegexRule.DefaultMatchTimeout)" + @"new(""^[\\w\\-.]+$"", global::System.Text.RegularExpressions.RegexOptions.CultureInvariant, global::System.TimeSpan.FromMilliseconds(2000))" ); } @@ -466,7 +464,8 @@ public sealed class FullAttributeCoverageModel var codesByPath = errors.ToDictionary(static error => error.Path[0], static error => error.Code); await Assert.That(codesByPath["Name"]).IsEqualTo("missing_field"); - await Assert.That(codesByPath["Value"]).IsEqualTo("too_small"); + // Defect B3: [StringLength] reports the runtime StringLengthRule's code; the direction is the category. + await Assert.That(codesByPath["Value"]).IsEqualTo("invalid_length"); await Assert.That(codesByPath["MinOnly"]).IsEqualTo("too_small"); await Assert.That(codesByPath["MaxOnly"]).IsEqualTo("too_big"); await Assert.That(codesByPath["Items"]).IsEqualTo("too_small"); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs new file mode 100644 index 00000000..74a67fe1 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs @@ -0,0 +1,891 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using ZodSharp.Core; +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +/// +/// One regression test per generator defect in the error-model register (G1-G24). Each test failed before +/// its fix. +/// +partial class ZodSchemaGeneratorTests +{ + const string RequiredSemanticsSource = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Contact + { + [Required] + public string? Name { get; set; } + + [Required(AllowEmptyStrings = true)] + public string? Nickname { get; set; } + } + } + """; + + // Defect G1 + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" ")] + public async Task Required_GivenDefaultAllowEmptyStringsAndMissingText_ReportsMissingField( + string? name, + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + RequiredSemanticsSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var contact = Create(assembly, "Testing.Contact", ("Name", name), ("Nickname", "nick")); + + // Act + var errors = RunValidate(assembly, "Testing.ContactSchema", contact); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("missing_field"); + await Assert.That(errors[0].Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(errors[0].Message).IsEqualTo("Name is required"); + } + + // Defect G1 + [Test] + [Arguments("", 0)] + [Arguments(" ", 0)] + [Arguments(null, 1)] + public async Task Required_GivenAllowEmptyStrings_RejectsOnlyNull( + string? nickname, + int expectedErrors, + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + RequiredSemanticsSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var contact = Create(assembly, "Testing.Contact", ("Name", "Ada"), ("Nickname", nickname)); + + // Act + var errors = RunValidate(assembly, "Testing.ContactSchema", contact); + + // Assert + await Assert.That(errors.Length).IsEqualTo(expectedErrors); + } + + // Defect G2 + [Test] + public async Task Required_GivenErrorMessageWithUnknownPlaceholder_FormatsAtRuntimeWithoutThrowing( + CancellationToken cancellationToken + ) + { + // Arrange — the generator used to string.Format this message at generation time, which threw. + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Ticket + { + [Required(ErrorMessage = "{0} needs {1} and a { brace")] + public string? Title { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var ticket = Create(assembly, "Testing.Ticket"); + + // Act + var errors = RunValidate(assembly, "Testing.TicketSchema", ticket); + + // Assert — {0} binds the display name, the unknown {1} and the stray brace are written literally, and the + // named template is kept for hosts that re-format it. + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Title needs {1} and a { brace"); + await Assert.That(errors[0].MessageFormat).IsEqualTo("{displayName} needs {1} and a { brace"); + await Assert.That(errors[0].Parameters!["displayName"]).IsEqualTo("Title"); + await Assert.That(errors[0].Parameters!["member"]).IsEqualTo("Title"); + await Assert.That(errors[0].Parameters!["declaringType"]).IsEqualTo("Testing.Ticket"); + } + + // Defect G3 + [Test] + public async Task JsonNames_GivenJsonPropertyName_RendersJsonNamedPathWithoutReflection( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.Text.Json.Serialization; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Order + { + [JsonPropertyName("order_lines")] + public List Lines { get; set; } = new(); + } + + [ZodSchema] + public class Line + { + [Required] + public string? ProductCode { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var path = ZodPath.From( + ZodPathSegment.Member("Lines"), + ZodPathSegment.At(1), + ZodPathSegment.Member("ProductCode") + ); + + // Act + var jsonName = InvokeStatic(assembly, "Testing.OrderSchema+JsonNames", "Get", "Lines"); + var renamed = InvokeStatic( + assembly, + "Testing.OrderSchema", + "ToJsonNamedPath", + path, + (Func)System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName + ); + + // Assert + await Assert.That(jsonName).IsEqualTo("order_lines"); + await Assert.That(renamed.ToDottedString()).IsEqualTo("order_lines[1].productCode"); + } + + // Defect G4 + [Test] + public async Task Length_GivenCustomMessage_FormatsLikeLengthAttribute(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Code + { + [Length(2, 5, ErrorMessage = "{0}:{1}:{2}")] + public string? Value { get; set; } + } + } + """; + var expected = new LengthAttribute(2, 5) { ErrorMessage = "{0}:{1}:{2}" }.FormatErrorMessage("Value"); + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var code = Create(assembly, "Testing.Code", ("Value", "A")); + + // Act + var errors = RunValidate(assembly, "Testing.CodeSchema", code); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo(expected); + } + + // Defect G5 + [Test] + public async Task StringLength_GivenTooShort_ReportsBothBoundsAndDirectionCategory( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Code + { + [StringLength(5, MinimumLength = 2)] + public string? Value { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var code = Create(assembly, "Testing.Code", ("Value", "A")); + + // Act + var errors = RunValidate(assembly, "Testing.CodeSchema", code); + + // Assert — the runtime StringLengthRule's code (defect B3), both bounds, the count. + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo(Rules.StringLengthRule.ErrorCode); + await Assert.That(errors[0].Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(errors[0].Lower).IsEqualTo(ErrorBound.Including(2)); + await Assert.That(errors[0].Upper).IsEqualTo(ErrorBound.Including(5)); + await Assert.That(errors[0].Parameters!["count"]).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Field 'Value' must contain at least 2 characters."); + } + + // Defect G6 + [Test] + public async Task Range_GivenExclusiveMinimum_ReportsTooSmallWithExclusiveLowerBound( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Booking + { + [Range(1, 10, MinimumIsExclusive = true)] + public int Nights { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var booking = Create(assembly, "Testing.Booking", ("Nights", 1)); + + // Act + var errors = RunValidate(assembly, "Testing.BookingSchema", booking); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo("invalid_range"); + await Assert.That(errors[0].Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(errors[0].Origin).IsEqualTo("number"); + await Assert.That(errors[0].Lower).IsEqualTo(ErrorBound.Excluding(1)); + await Assert.That(errors[0].Upper).IsEqualTo(ErrorBound.Including(10)); + await Assert + .That(errors[0].Message) + .IsEqualTo("Field 'Nights' must be greater than 1 and less than or equal to 10."); + } + + const string NestedCollectionsSource = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Order + { + [Required] + public string? Number { get; set; } + + public Customer Customer { get; set; } = new(); + + public List Lines { get; set; } = new(); + + public Dictionary ByCode { get; set; } = new(); + } + + [ZodSchema] + public class Customer + { + [Required] + [MinLength(2)] + public string? Name { get; set; } + } + + [ZodSchema] + public class Line + { + [Range(1, 10)] + public int Quantity { get; set; } = 1; + } + } + """; + + static object CreateInvalidOrder(System.Reflection.Assembly assembly, out dynamic order) + { + order = Create(assembly, "Testing.Order"); + order.Customer.Name = "A"; + order.Lines.Add((dynamic)Create(assembly, "Testing.Line")); + order.Lines.Add((dynamic)Create(assembly, "Testing.Line", ("Quantity", 0))); + order.Lines.Add(null); + order.ByCode.Add("a.b", (dynamic)Create(assembly, "Testing.Line", ("Quantity", 11))); + return order; + } + + // Defect G7, G8, G9, G10 + [Test] + public async Task NestedCollections_GivenInvalidElements_UseTypedIndexAndKeySegmentsAndKeepEveryField( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + NestedCollectionsSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var order = CreateInvalidOrder(assembly, out _); + + // Act + var errors = RunValidate(assembly, "Testing.OrderSchema", order); + var byPath = errors.ToDictionary(static error => error.FullPath.ToDottedString(), StringComparer.Ordinal); + + // Assert — G7 index segments, G8 escaped key segments, G9 null-element origin, G10 every field kept. + await Assert + .That(byPath.Keys.Order(StringComparer.Ordinal).ToArray()) + .IsEquivalentTo(["ByCode[\"a.b\"].Quantity", "Customer.Name", "Lines[1].Quantity", "Lines[2]", "Number"]); + await Assert.That(byPath["Lines[1].Quantity"].FullPath[1].Kind).IsEqualTo(ZodPathSegmentKind.Index); + await Assert.That(byPath["Lines[1].Quantity"].Path.ToArray()).IsEquivalentTo(["Lines", "1", "Quantity"]); + await Assert.That(byPath["ByCode[\"a.b\"].Quantity"].FullPath[1].Kind).IsEqualTo(ZodPathSegmentKind.Key); + await Assert.That(byPath["Lines[2]"].Origin).IsEqualTo("array"); + await Assert.That(byPath["Lines[2]"].Code).IsEqualTo("invalid_type"); + await Assert.That(byPath["Customer.Name"].Origin).IsEqualTo("string"); + await Assert.That(byPath["Customer.Name"].Lower).IsEqualTo(ErrorBound.Including(2)); + await Assert.That(byPath["Customer.Name"].Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + // Defect G8 + [Test] + public async Task Dictionary_GivenNumericKeysUnderAnotherCulture_RendersKeysInvariantly( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Prices + { + public Dictionary ByWeight { get; set; } = new(); + } + + [ZodSchema] + public class Price + { + [Range(0, 100)] + public int Amount { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + dynamic prices = Create(assembly, "Testing.Prices"); + prices.ByWeight.Add(1.5, (dynamic)Create(assembly, "Testing.Price", ("Amount", 500))); + + var culture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + try + { + // Act + var errors = RunValidate(assembly, "Testing.PricesSchema", (object)prices); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath[1].Name).IsEqualTo("1.5"); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } + + // Defect G11, G12 + [Test] + public async Task ValidateAsync_GivenNestedTypeWithCustomAsyncMethod_AwaitsNestedAndPrefixesItsErrors( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + [ZodSchema] + public class Parent + { + public Child Child { get; set; } = new(); + } + + [ZodSchema] + public class Child + { + public string? Code { get; set; } + + internal static ValueTask> CustomValidationAsync(Child value, CancellationToken ct) => + ValueTask.FromResult( + ValidationResult.Failure(ValidationError.Create("taken", "Code is taken", ChildSchema.Paths.Code)) + ); + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var parent = Create(assembly, "Testing.Parent"); + + // Act + var asyncErrors = await RunValidationAsync(assembly, "Testing.ParentSchema", parent, null, cancellationToken); + var syncErrors = RunValidate(assembly, "Testing.ParentSchema", parent); + var requiresAsync = (bool)GetStaticField(assembly, "Testing.ParentSchema", "RequiresAsyncValidation")!; + + // Assert — the nested custom error used to be lost; it is now awaited and placed under Child. + await Assert.That(asyncErrors.Length).IsEqualTo(1); + await Assert.That(asyncErrors[0].Code).IsEqualTo("taken"); + await Assert.That(asyncErrors[0].FullPath.ToDottedString()).IsEqualTo("Child.Code"); + await Assert.That(syncErrors).IsEmpty(); + await Assert.That(requiresAsync).IsTrue(); + } + + // Defect G13 + [Test] + public async Task Paths_GivenSchema_ExposesMemberPathConstantsForRefinements(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using ZodSharp; + using ZodSharp.Schemas; + + namespace Testing + { + [ZodSchema] + public partial class Signup + { + public string? Password { get; set; } + + public string? Confirm { get; set; } + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.Password != context.Value.Confirm) + context.AddIssue("mismatch", "Passwords differ", SignupSchema.Paths.Confirm); + } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var signup = Create(assembly, "Testing.Signup", ("Password", "a"), ("Confirm", "b")); + + // Act + var errors = RunValidate(assembly, "Testing.SignupSchema", signup); + var path = (ZodPath)GetStaticField(assembly, "Testing.SignupSchema+Paths", "Confirm")!; + + // Assert + await Assert.That(path).IsEqualTo(ZodPath.Member("Confirm")); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath).IsEqualTo(path); + } + + const string DefectCustomRuleSource = """ + using System; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + using ZodSharp.Core; + + namespace Testing + { + public readonly struct NoSpacesRule : IValidationRule + { + public const string ErrorCode = "no_spaces"; + public const string MessageFormat = "Spaces are not allowed"; + + public string Code => ErrorCode; + + public bool IsValid(in string value) => !value.Contains(' '); + + public string GetErrorMessage(in string value) => MessageFormat; + } + + [ZodRule(typeof(NoSpacesRule))] + [AttributeUsage(AttributeTargets.Property)] + public sealed class NoSpacesAttribute : ValidationAttribute + { + } + + [ZodSchema] + public class Handle + { + [NoSpaces] + public string? Plain { get; set; } + + [NoSpaces(ErrorMessage = "{0} has {1} spaces")] + public string? Custom { get; set; } + } + } + """; + + // Defect G14, G15, G16 + [Test] + public async Task CustomRule_GivenMessagesAndNoOrigin_FormatsSafelyWithDisplayNameAndStringOrigin( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + DefectCustomRuleSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var handle = Create(assembly, "Testing.Handle", ("Plain", "a b"), ("Custom", "c d")); + + // Act + var errors = RunValidate(assembly, "Testing.HandleSchema", handle) + .ToDictionary(static error => error.FullPath.ToDottedString(), StringComparer.Ordinal); + + // Assert — G14: "{1}" used to throw FormatException at runtime; G15: the display name is a parameter; + // G16: a string rule without an origin reports "string". + await Assert.That(errors["Custom"].Message).IsEqualTo("Custom has {1} spaces"); + await Assert.That(errors["Plain"].Message).IsEqualTo("Spaces are not allowed"); + await Assert.That(errors["Plain"].Parameters!["displayName"]).IsEqualTo("Plain"); + await Assert.That(errors["Plain"].Origin).IsEqualTo("string"); + await Assert.That(errors["Plain"].Category).IsEqualTo(ZodIssueCategories.Custom); + } + + // Defect G17, G18 + [Test] + public async Task Enums_GivenAllowedValuesAndEnumRule_ReportMemberNamesAndEnumOrigin( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public enum Status { Draft, Active, Retired } + + [ZodSchema] + public class Item + { + [AllowedValues(Status.Draft, Status.Active)] + public Status Allowed { get; set; } + + public Status Defined { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var statusType = assembly.GetType("Testing.Status")!; + var item = Create( + assembly, + "Testing.Item", + ("Allowed", Enum.ToObject(statusType, 2)), + ("Defined", Enum.ToObject(statusType, 42)) + ); + + // Act + var errors = RunValidate(assembly, "Testing.ItemSchema", item) + .ToDictionary(static error => error.FullPath.ToDottedString(), StringComparer.Ordinal); + + // Assert + await Assert.That(errors["Allowed"].Parameters!["options"]).IsEqualTo("Draft, Active"); + await Assert + .That(errors["Allowed"].Message) + .IsEqualTo("Field 'Allowed' must be one of the following values: Draft, Active."); + await Assert.That(errors["Allowed"].Category).IsEqualTo(ZodIssueCategories.InvalidValue); + await Assert.That(errors["Defined"].Origin).IsEqualTo("enum"); + } + + // Defect G19 + [Test] + public async Task Display_GivenResourceType_ReadsTheLocalizedNameAtRuntime(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public static class Labels + { + public static string Current { get; set; } = "Full name"; + + public static string FullName => Current; + } + + [ZodSchema] + public class Person + { + [Required] + [Display(Name = nameof(Labels.FullName), ResourceType = typeof(Labels))] + public string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var person = Create(assembly, "Testing.Person"); + + // Act + var first = RunValidate(assembly, "Testing.PersonSchema", person); + assembly.GetType("Testing.Labels")!.GetProperty("Current")!.SetValue(null, "Nom complet"); + var second = RunValidate(assembly, "Testing.PersonSchema", person); + + // Assert — the resource is read per failure (not baked in at compile time, not cached). + await Assert.That(first[0].Message).IsEqualTo("Full name is required"); + await Assert.That(second[0].Message).IsEqualTo("Nom complet is required"); + } + + // Defect G20 + [Test] + public async Task NonNullableMember_GivenNull_ReportsTheSharedNullValueIssue(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Profile + { + [MinLength(1)] + public string Bio { get; set; } = string.Empty; + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var profile = Create(assembly, "Testing.Profile", ("Bio", null)); + + // Act + var errors = RunValidate(assembly, "Testing.ProfileSchema", profile); + var rootErrors = RunValidate(assembly, "Testing.ProfileSchema", null); + + // Assert — the same identity the runtime schemas report. + await Assert.That(errors[0].Message).IsEqualTo("Expected string, received null"); + await Assert.That(errors[0].Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(errors[0].MessageFormat).IsEqualTo(ZodMessages.NullValue); + await Assert.That(rootErrors[0].Message).IsEqualTo("Expected object, received null"); + } + + // Defect G21 + [Test] + public async Task OptionsValidator_GivenFailure_ReportsPathMessageAndCode(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema(GenerateIValidateOptions = true)] + public class SmtpOptions + { + [Range(1, 65535)] + public int Port { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var options = Create(assembly, "Testing.SmtpOptions"); + dynamic validator = Activator.CreateInstance(assembly.GetType("Testing.SmtpOptionsValidator")!)!; + + // Act + Microsoft.Extensions.Options.ValidateOptionsResult outcome = validator.Validate(null, (dynamic)options); + + // Assert + await Assert.That(outcome.Failed).IsTrue(); + await Assert + .That(outcome.Failures!.Single()) + .IsEqualTo( + "Port: Field 'Port' must be greater than or equal to 1 and less than or equal to 65535. (invalid_range)" + ); + } + + // Defect G23 + [Test] + public async Task ValidatableObject_GivenMemberErrors_RunsOnlyAfterMembersPass(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Stay : IValidatableObject + { + [Range(1, 30)] + public int Nights { get; set; } = 1; + + public int Guests { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Guests > Nights * 4) + yield return new ValidationResult("Too many guests", new[] { nameof(Guests) }); + } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + + // Act + var objectErrors = RunValidate(assembly, "Testing.StaySchema", Create(assembly, "Testing.Stay", ("Guests", 9))); + var memberErrors = RunValidate( + assembly, + "Testing.StaySchema", + Create(assembly, "Testing.Stay", ("Nights", 0), ("Guests", 9)) + ); + + // Assert — the validatable object is honoured (it was ignored), placed at its member names, and runs after + // the members like DataAnnotations and Microsoft.Extensions.Validation. + await Assert.That(objectErrors.Length).IsEqualTo(1); + await Assert.That(objectErrors[0].FullPath.ToDottedString()).IsEqualTo("Guests"); + await Assert.That(objectErrors[0].Message).IsEqualTo("Too many guests"); + await Assert.That(memberErrors.Length).IsEqualTo(1); + await Assert.That(memberErrors[0].FullPath.ToDottedString()).IsEqualTo("Nights"); + } + + // Defect G24 + [Test] + public async Task Validate_GivenCyclicGraph_ThrowsAtTheMaximumDepth(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Node + { + [Required] + public string? Name { get; set; } = "node"; + + public Node? Next { get; set; } + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + dynamic node = Create(assembly, "Testing.Node"); + node.Next = node; + object cyclic = node; + + // Act + var exception = Unwrap(() => RunValidate(assembly, "Testing.NodeSchema", cyclic)); + + // Assert — a cycle used to overflow the stack; it now fails like Microsoft.Extensions.Validation. + await Assert.That(exception).IsTypeOf(); + await Assert + .That(exception!.Message) + .Contains($"Maximum validation depth of {ZodValidationDepth.MaxDepth} exceeded"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs index bb42133d..a72c9e53 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Dictionaries.cs @@ -50,7 +50,7 @@ public class WebAppKitOptions var generated = driverResult.GetSource("WebAppKitOptionsSchema"); await Assert.That(generated).IsNotNull(); await Assert.That(generated!).Contains("variantsEntry.Value"); - await Assert.That(generated!).Contains("variantsEntry.Key.ToString()"); + await Assert.That(generated!).Contains("ZodPathSegment.Key(variantsEntry.Key)"); } [Test] @@ -124,7 +124,7 @@ public class Container var generated = driverResult.GetSource("ContainerSchema"); await Assert.That(generated).IsNotNull(); - await Assert.That(generated!).ContainsGeneratedCode("ChildSchema.Validate("); + await Assert.That(generated!).ContainsGeneratedCode("ChildSchema.ValidateCore("); // Act var (isSuccess, errors) = ValidateDictionary(assembly!, "Container", "Items", ("a", "Name", "x")); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DuplicateNames.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DuplicateNames.cs new file mode 100644 index 00000000..177632c2 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DuplicateNames.cs @@ -0,0 +1,128 @@ +using ZodSharp.Core; +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + const string DuplicateSimpleNamesSource = """ + using System.ComponentModel.DataAnnotations; + + namespace Alpha + { + [ZodSchema] + public class Widget + { + [Required] + public string? Name { get; set; } + } + + public partial class Outer + { + [ZodSchema] + public partial class Item + { + [Range(1, 5)] + public int Size { get; set; } + } + } + + public partial class Other + { + [ZodSchema] + public partial class Item + { + [MinLength(2)] + public string Code { get; set; } = ""; + } + } + } + + namespace Beta + { + [ZodSchema] + public class Widget + { + [Range(1, 5)] + public int Size { get; set; } + } + } + + namespace Gamma + { + [ZodSchema] + public class Box + { + public Alpha.Widget? First { get; set; } + + public Beta.Widget? Second { get; set; } + } + } + """; + + [Test] + public async Task DuplicateSimpleNames_GivenTypesInDifferentNamespaces_GeneratesEveryOutput( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + DuplicateSimpleNamesSource, + ZodSourceGeneratorTestOptions.CompileWithDependencyInjection, + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + ZodSchemaFactory factory = new(); + + // Act + InvokeStatic( + assembly, + "ZodSharp.Generated.TestAssemblyZodSharpSchemas", + "Register", + factory + ); + var alpha = RunValidate(assembly, "Alpha.WidgetSchema", Create(assembly, "Alpha.Widget")); + var beta = RunValidate(assembly, "Beta.WidgetSchema", Create(assembly, "Beta.Widget")); + var outerItem = RunValidate(assembly, "Alpha.Outer+ItemSchema", Create(assembly, "Alpha.Outer+Item")); + var otherItem = RunValidate(assembly, "Alpha.Other+ItemSchema", Create(assembly, "Alpha.Other+Item")); + + // Assert + await Assert.That(result.DriverResult.Diagnostics.Where(static d => d.Id == "ZODSGEN001")).IsEmpty(); + await Assert.That(factory.TryGetUntypedValidator(assembly.GetType("Alpha.Widget")!, out _)).IsTrue(); + await Assert.That(factory.TryGetUntypedValidator(assembly.GetType("Beta.Widget")!, out _)).IsTrue(); + await Assert.That(factory.TryGetUntypedValidator(assembly.GetType("Gamma.Box")!, out _)).IsTrue(); + await Assert.That(alpha.Single().FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(beta.Single().FullPath.ToDottedString()).IsEqualTo("Size"); + await Assert.That(outerItem.Single().FullPath.ToDottedString()).IsEqualTo("Size"); + await Assert.That(otherItem.Single().FullPath.ToDottedString()).IsEqualTo("Code"); + } + + [Test] + public async Task DuplicateSimpleNames_GivenTheSameSource_UsesDeterministicDistinctHintNames( + CancellationToken cancellationToken + ) + { + // Arrange + using var first = await GenerateAsync(DuplicateSimpleNamesSource, cancellationToken); + using var second = await GenerateAsync(DuplicateSimpleNamesSource, cancellationToken); + + // Act + string[] firstHints = + [ + .. first.DriverResult.GeneratedTrees.Select(static tree => Path.GetFileName(tree.FilePath)), + ]; + string[] secondHints = + [ + .. second.DriverResult.GeneratedTrees.Select(static tree => Path.GetFileName(tree.FilePath)), + ]; + + // Assert + await Assert.That(firstHints.Distinct(StringComparer.OrdinalIgnoreCase).Count()).IsEqualTo(firstHints.Length); + await Assert.That(firstHints).IsEquivalentTo(secondHints, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(firstHints).Contains("Alpha.WidgetSchema.g.cs"); + await Assert.That(firstHints).Contains("Beta.WidgetSchema.g.cs"); + await Assert.That(firstHints).Contains("Alpha.Outer.ItemSchema.g.cs"); + await Assert.That(firstHints).Contains("Alpha.Other.ItemSchema.g.cs"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs index 927c3ed0..d3b2a9ae 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.EnumValidation.cs @@ -49,7 +49,7 @@ await Assert await Assert .That(generated) .ContainsGeneratedCode( - "((global::ZodSharp.Core.IZodRule)statusEnumRule).Code ?? global::ZodSharp.Rules.EnumRule.ErrorCode" + "global::ZodSharp.Core.ZodRuleErrors.Create(statusEnumRule, statusEnumRuleValue, Paths.Status, \"enum\")" ); } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs index 1a57e465..34684975 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ExternalSchemas.cs @@ -133,7 +133,9 @@ CancellationToken cancellationToken // Assert var generated = driverResult.GetSource("AppOptionsSchema"); await Assert.That(generated).IsNotNull(); - await Assert.That(generated!).ContainsGeneratedCode("global::Testing.Contracts.ServiceOptionsSchema.Validate("); + await Assert + .That(generated!) + .ContainsGeneratedCode("global::Testing.Contracts.ServiceOptionsSchema.ValidateCore("); } [Test] @@ -174,7 +176,9 @@ CancellationToken cancellationToken var generated = driverResult.GetSource("AppOptionsSchema"); await Assert.That(generated).IsNotNull(); - await Assert.That(generated!).ContainsGeneratedCode("global::Testing.Contracts.ServiceOptionsSchema.Validate("); + await Assert + .That(generated!) + .ContainsGeneratedCode("global::Testing.Contracts.ServiceOptionsSchema.ValidateCore("); } [Test] @@ -241,7 +245,7 @@ CancellationToken cancellationToken await Assert.That(generated).IsNotNull(); await Assert .That(generated!) - .ContainsGeneratedCode("global::Testing.Local.LocalServiceOptionsSchema.Validate("); + .ContainsGeneratedCode("global::Testing.Local.LocalServiceOptionsSchema.ValidateCore("); } const string NestedAppOptionsSource = """ @@ -282,19 +286,34 @@ CancellationToken cancellationToken var generated = driverResult.GetSource("ChangeOpsAppOptionsSchema"); await Assert.That(generated).IsNotNull(); - await Assert.That(generated!).ContainsGeneratedCode("global::Testing.Contracts.ServiceOptionsSchema.Validate("); + await Assert + .That(generated!) + .ContainsGeneratedCode("global::Testing.Contracts.ServiceOptionsSchema.ValidateCore("); var diagnostics = driverResult.CompilationResult.Compilation.GetDiagnostics(cancellationToken); await Assert.That(diagnostics.Any(static diagnostic => diagnostic.Id == "CS0436")).IsFalse(); } + // Schema hint names are namespace-qualified (Testing.ServiceOptionsSchema.g.cs). Every dot-separated suffix is + // listed too, so a test can ask for the simple-name form whatever namespace the type is declared in. static List GeneratedHintNames(DriverRunResult driverResult) => [ .. driverResult .DriverResult.Results.SelectMany(static result => result.GeneratedSources) - .Select(static source => source.HintName), + .SelectMany(static source => DotSuffixes(source.HintName)), ]; + static IEnumerable DotSuffixes(string hintName) + { + yield return hintName; + for ( + var index = hintName.IndexOf('.', StringComparison.Ordinal); + index >= 0; + index = hintName.IndexOf('.', index + 1) + ) + yield return hintName[(index + 1)..]; + } + async Task CompileContractAsync(string source, CancellationToken cancellationToken) { ZodSourceGeneratorTestOptions options = new() { CompilationAssemblyName = "Testing.Contracts" }; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs index dfb54331..5bc4960c 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.GenericRules.cs @@ -73,7 +73,9 @@ public async Task GenericRule_GivenRuleOwningIdentity_EmitsRuleCodeExpression(Ca await Assert .That(generated) .ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)valueCustomRule0).Code ?? \"invalid_asset_id\""); - await Assert.That(generated).ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)valueCustomRule0).Origin"); + await Assert + .That(generated) + .ContainsGeneratedCode("ZodRuleErrors.Create(valueCustomRule0, valueCustomRuleValue0"); } [Test] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs index e9d7f660..2e1f7b19 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchema.cs @@ -381,7 +381,7 @@ CancellationToken cancellationToken { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpEnumSerialization", + "build_property.ZodSharpEnumSerialization", "String" ), }; @@ -405,8 +405,8 @@ CancellationToken cancellationToken { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary - .Empty.Add("build_property:ZodSharpEnumSerialization", "String") - .Add("build_property:ZodSharpEnumNamingPolicy", "CamelCase"), + .Empty.Add("build_property.ZodSharpEnumSerialization", "String") + .Add("build_property.ZodSharpEnumNamingPolicy", "CamelCase"), }; // Act diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs index 2341b7c5..dbe4975e 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaCrossPlatform.cs @@ -182,7 +182,7 @@ await WriteParityArtifactAsync( } static string GuidHolderPayload(string plain, string nonEmpty) => - $$""" + /*lang=json,strict*/$$""" { "plain": "{{plain}}", "nonEmpty": "{{nonEmpty}}", @@ -193,7 +193,7 @@ static string GuidHolderPayload(string plain, string nonEmpty) => """; static string UuidHolderPayload(string any, string v4, string v7) => - $$""" + /*lang=json,strict*/$$""" { "any": "{{any}}", "v4": "{{v4}}", diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs index f0de9a77..d929fd23 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.JsonSchemaTemporal.cs @@ -98,7 +98,7 @@ CancellationToken cancellationToken { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpDateTimeOffsetSerialization", + "build_property.ZodSharpDateTimeOffsetSerialization", "Utc" ), }; @@ -194,7 +194,7 @@ public sealed class Stamp { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpDateTimeOffsetSerialization", + "build_property.ZodSharpDateTimeOffsetSerialization", "Utc" ), }; @@ -240,7 +240,7 @@ public sealed class Stamp { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpDateTimeOffsetSerialization", + "build_property.ZodSharpDateTimeOffsetSerialization", "Utc" ), }; @@ -289,7 +289,7 @@ public sealed record Stamp( { CompileToAssembly = true, AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpDateTimeOffsetSerialization", + "build_property.ZodSharpDateTimeOffsetSerialization", "Utc" ), }; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.MemberValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.MemberValidation.cs new file mode 100644 index 00000000..3263692c --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.MemberValidation.cs @@ -0,0 +1,333 @@ +using ZodSharp.Core; +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +/// +/// Member validation (Change 2), member resolution (Change 3), the generated per-assembly registration and +/// the untyped validator contract (Change 5). +/// +partial class ZodSchemaGeneratorTests +{ + const string FlatMembersSource = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Registration + { + [Required] + [StringLength(20, MinimumLength = 3)] + public string? UserName { get; set; } + + [EmailAddress] + public string? Email { get; set; } + + [Range(18, 120)] + public int Age { get; set; } + + public string? Password { get; set; } + + [Compare(nameof(Password))] + public string? ConfirmPassword { get; set; } + + public string? Notes { get; set; } + } + } + """; + + [Test] + public async Task ValidateMember_GivenEveryMember_MatchesValidateFilteredToThatMember( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + FlatMembersSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var registration = Create( + assembly, + "Testing.Registration", + ("UserName", "ab"), + ("Email", "not-an-email"), + ("Age", 12), + ("Password", "secret"), + ("ConfirmPassword", "different") + ); + var all = RunValidate(assembly, "Testing.RegistrationSchema", registration); + string[] members = ["UserName", "Email", "Age", "Password", "ConfirmPassword", "Notes"]; + + foreach (var member in members) + { + // Act + var memberErrors = RunValidateMember(assembly, "Testing.RegistrationSchema", registration, member); + + // Assert — property-based parity: the member's errors are exactly Validate's errors at that member. + var expected = all.Where(error => error.FullPath[0].Name == member).ToArray(); + await Assert.That(memberErrors.ToArray()).IsEquivalentTo(expected).Because(member); + } + + await Assert.That(all.Length).IsEqualTo(4); + } + + [Test] + public async Task IsValidatableMember_GivenMembers_IsTrueOnlyForMembersWithRules( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + FlatMembersSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + + // Act + bool IsValidatable(string member) => + InvokeStatic(assembly, "Testing.RegistrationSchema", "IsValidatableMember", member); + + // Assert — matching is ordinal on the C# member name. + await Assert.That(IsValidatable("UserName")).IsTrue(); + await Assert.That(IsValidatable("ConfirmPassword")).IsTrue(); + await Assert.That(IsValidatable("Notes")).IsFalse(); + await Assert.That(IsValidatable("username")).IsFalse(); + await Assert.That(IsValidatable("Unknown")).IsFalse(); + } + + [Test] + public async Task ValidateMember_GivenNestedAndCollectionMembers_DoesNotRecurseButKeepsNullElementChecks( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + NestedCollectionsSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var order = CreateInvalidOrder(assembly, out _); + + // Act + var customer = RunValidateMember(assembly, "Testing.OrderSchema", order, "Customer"); + var lines = RunValidateMember(assembly, "Testing.OrderSchema", order, "Lines"); + var unknown = RunValidateMember(assembly, "Testing.OrderSchema", order, "DoesNotExist"); + + // Assert — Blazor attributes field-level errors to the changed field, so a nested type's own errors are + // left to its own field changes; the null element belongs to the collection member. + await Assert.That(customer).IsEmpty(); + await Assert + .That(lines.Select(static error => error.FullPath.ToDottedString()).ToArray()) + .IsEquivalentTo(["Lines[2]"]); + await Assert.That(unknown).IsEmpty(); + } + + [Test] + public async Task TryResolveMember_GivenEveryValidateError_ResolvesTheOwningObjectAndMember( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + NestedCollectionsSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var order = CreateInvalidOrder(assembly, out var typed); + var errors = RunValidate(assembly, "Testing.OrderSchema", order); + Dictionary expected = new(StringComparer.Ordinal) + { + ["Number"] = (order, "Number"), + ["Customer.Name"] = ((object)typed.Customer, "Name"), + ["Lines[1].Quantity"] = ((object)typed.Lines[1], "Quantity"), + ["Lines[2]"] = (order, "Lines"), + ["ByCode[\"a.b\"].Quantity"] = ((object)typed.ByCode["a.b"], "Quantity"), + }; + + foreach (var error in errors) + { + // Act + var resolved = RunTryResolveMember( + assembly, + "Testing.OrderSchema", + order, + error.FullPath, + out var container, + out var memberName + ); + + // Assert — round trip, by object identity. + var (expectedContainer, expectedMember) = expected[error.FullPath.ToDottedString()]; + await Assert.That(resolved).IsTrue(); + await Assert + .That(ReferenceEquals(container, expectedContainer)) + .IsTrue() + .Because(error.FullPath.ToDottedString()); + await Assert.That(memberName).IsEqualTo(expectedMember); + } + + await Assert.That(errors.Length).IsEqualTo(expected.Count); + } + + [Test] + public async Task TryResolveMember_GivenPathThatCannotBeFollowed_ResolvesTheNearestObject( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + NestedCollectionsSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var order = CreateInvalidOrder(assembly, out _); + var missingElement = ZodPath.From( + ZodPathSegment.Member("Lines"), + ZodPathSegment.At(9), + ZodPathSegment.Member("Quantity") + ); + + // Act + var resolved = RunTryResolveMember( + assembly, + "Testing.OrderSchema", + order, + missingElement, + out var container, + out var member + ); + var root = RunTryResolveMember( + assembly, + "Testing.OrderSchema", + order, + ZodPath.Root, + out var rootContainer, + out var rootMember + ); + + // Assert + await Assert.That(resolved).IsFalse(); + await Assert.That(ReferenceEquals(container, order)).IsTrue(); + await Assert.That(member).IsEqualTo("Lines"); + await Assert.That(root).IsTrue(); + await Assert.That(ReferenceEquals(rootContainer, order)).IsTrue(); + await Assert.That(rootMember).IsEqualTo(string.Empty); + } + + [Test] + public async Task Registration_GivenGeneratedSchemas_RegistersUntypedValidatorsWithoutReflection( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + NestedCollectionsSource, + ZodSourceGeneratorTestOptions.CompileWithDependencyInjection, + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + ZodSchemaFactory factory = new(); + var orderType = assembly.GetType("Testing.Order")!; + + // Act + InvokeStatic( + assembly, + "ZodSharp.Generated.TestAssemblyZodSharpSchemas", + "Register", + factory + ); + var found = factory.TryGetUntypedValidator(orderType, out var untyped); + var errors = untyped!.Validate(CreateInvalidOrder(assembly, out _)); + + // Assert — the generated adapter is the untyped validator itself, not a reflection-built wrapper. + await Assert.That(found).IsTrue(); + await Assert.That(untyped.GetType().Name).IsEqualTo("OrderSchemaValidator"); + await Assert.That(untyped.ValidatedType).IsEqualTo(orderType); + await Assert.That(untyped.IsValidatableMember("Number")).IsTrue(); + await Assert.That(errors.Length).IsEqualTo(5); + await Assert.That(factory.TryGetUntypedValidator(assembly.GetType("Testing.Line")!, out _)).IsTrue(); + } + + [Test] + public async Task Registration_GivenDependencyInjection_AddsADeferredFactoryRegistration( + CancellationToken cancellationToken + ) + { + // Arrange + using var result = await GenerateAsync( + NestedCollectionsSource, + ZodSourceGeneratorTestOptions.CompileWithDependencyInjection, + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + TestServiceCollection services = []; + + // Act + InvokeStatic( + assembly, + "Microsoft.Extensions.DependencyInjection.TestAssemblyZodSharpServiceCollectionExtensions", + "AddZodSharpTestAssemblySchemas", + services + ); + var registration = (ZodSchemaRegistration) + services + .Single(static descriptor => descriptor.ServiceType == typeof(ZodSchemaRegistration)) + .ImplementationInstance!; + ZodSchemaFactory factory = new(); + registration.Register(factory); + + // Assert + await Assert + .That(services.Any(static descriptor => descriptor.ServiceType == typeof(IZodSchemaFactory))) + .IsTrue(); + await Assert.That(factory.TryGetUntypedValidator(assembly.GetType("Testing.Order")!, out _)).IsTrue(); + } + + [Test] + public async Task Registration_GivenSchemas_EmitsOneFactoryRegistrationPerAssembly( + CancellationToken cancellationToken + ) + { + // Act + using var result = await GenerateAsync(NestedCollectionsSource, cancellationToken); + var generated = result.GetSource("ZodSharpAssemblyRegistration")!; + + // Assert + await Assert.That(generated).ContainsGeneratedCode("public static partial class TestAssemblyZodSharpSchemas"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "factory.Register(new global::Testing.OrderSchemaValidator());" + ); + } + + [Test] + [Arguments("Contoso.Orders.Api", "ContosoOrdersApi")] + [Arguments("my-app", "MyApp")] + [Arguments("1st.assembly", "_1stAssembly")] + [Arguments("", "Assembly")] + public async Task GetAssemblyIdentifier_GivenAssemblyName_ReturnsPascalCaseIdentifier( + string assemblyName, + string expected + ) + { + // Act + var identifier = ZodSchemaGenerator.GetAssemblyIdentifier(assemblyName); + + // Assert + await Assert.That(identifier).IsEqualTo(expected); + } + + sealed class TestServiceCollection + : List, + Microsoft.Extensions.DependencyInjection.IServiceCollection; +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs new file mode 100644 index 00000000..4da9b28d --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Net11.cs @@ -0,0 +1,363 @@ +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; +#if NET11_0_OR_GREATER +using Microsoft.CodeAnalysis.CSharp; +using ZodSharp.Core; +#endif + +namespace ZodSharp.SourceGenerators; + +/// +/// .NET 11 and C# 15 inputs: async DataAnnotations (Change 8), IAsyncValidateOptions (Change 6), unions and +/// closed hierarchies (Change 7). +/// +partial class ZodSchemaGeneratorTests +{ + [Test] + public async Task OptionsValidator_GivenAsyncRulesAndIAsyncValidateOptions_ImplementsBothInterfaces( + CancellationToken cancellationToken + ) + { + // Arrange — IAsyncValidateOptions ships with .NET 11's Microsoft.Extensions.Options; the generator + // detects it by name, so a declared copy stands in for it here. + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + using ZodSharp.Core; + + namespace Microsoft.Extensions.Options + { + public interface IAsyncValidateOptions : IValidateOptions + where TOptions : class + { + Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default); + } + } + + namespace Testing + { + [ZodSchema(GenerateIValidateOptions = true)] + public class StorageOptions + { + [Required] + public string? Bucket { get; set; } + + internal static ValueTask> CustomValidationAsync( + StorageOptions value, + CancellationToken ct + ) => + ValueTask.FromResult( + ValidationResult.Failure( + ValidationError.Create("bucket_missing", "Bucket does not exist", StorageOptionsSchema.Paths.Bucket) + ) + ); + } + } + """; + + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var validatorType = assembly.GetType("Testing.StorageOptionsValidator")!; + dynamic validator = Activator.CreateInstance(validatorType)!; + var options = Create(assembly, "Testing.StorageOptions", ("Bucket", "media")); + + // Act + Microsoft.Extensions.Options.ValidateOptionsResult syncOutcome = validator.Validate(null, (dynamic)options); + Task pending = validator.ValidateAsync( + null, + (dynamic)options, + cancellationToken + ); + var asyncOutcome = await pending; + + // Assert — the synchronous path runs the synchronous rules only; the async path (ValidateOnStart) runs all. + await Assert + .That(validatorType.GetInterfaces().Any(static type => type.Name == "IAsyncValidateOptions`1")) + .IsTrue(); + await Assert.That(syncOutcome.Succeeded).IsTrue(); + await Assert.That(asyncOutcome.Failures!.Single()).IsEqualTo("Bucket: Bucket does not exist (bucket_missing)"); + } + + [Test] + public async Task OptionsValidator_GivenHandAuthoredOptionsValidator_SkipsTheGeneratedValidator( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using Microsoft.Extensions.Options; + using ZodSharp; + + namespace Testing + { + [ZodSchema(GenerateIValidateOptions = true)] + public class QueueOptions + { + [Range(1, 10)] + public int Workers { get; set; } + } + + [OptionsValidator] + public partial class QueueOptionsValidator : IValidateOptions + { + public ValidateOptionsResult Validate(string? name, QueueOptions options) => ValidateOptionsResult.Success; + } + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + var generated = result.GetSource("QueueOptionsSchema")!; + + // Assert — the generated validator would duplicate the hand-authored one (SYSLIB1205); ZODSGEN050 reports it. + await Assert.That(generated).DoesNotContain("class QueueOptionsValidator"); + await Assert.That(generated).ContainsGeneratedCode("class QueueOptionsSchemaValidator"); + } + +#if NET11_0_OR_GREATER + static readonly ZodSourceGeneratorTestOptions PreviewCompileOptions = new() + { + CompileToAssembly = true, + LanguageVersion = LanguageVersion.Preview, + }; + + [Test] + public async Task AsyncValidationAttribute_GivenMember_RunsInValidateAsyncAndSyncValidateThrows( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + + namespace Testing + { + public sealed class NotTakenAttribute : AsyncValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => + throw new System.NotSupportedException("Validated asynchronously."); + + protected override async Task IsValidAsync( + object? value, + ValidationContext validationContext, + CancellationToken cancellationToken + ) + { + await Task.Yield(); + return value as string == "taken" + ? new ValidationResult($"{validationContext.DisplayName} is taken") + : ValidationResult.Success; + } + } + + [ZodSchema] + public class Signup + { + [NotTaken] + [Display(Name = "User name")] + public string? Name { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var signup = Create(assembly, "Testing.Signup", ("Name", "taken")); + + // Act + var asyncErrors = await RunValidationAsync(assembly, "Testing.SignupSchema", signup, null, cancellationToken); + var memberErrors = await RunMemberValidationAsync( + assembly, + "Testing.SignupSchema", + signup, + "Name", + null, + cancellationToken + ); + var syncException = Unwrap(() => RunValidate(assembly, "Testing.SignupSchema", signup)); + + // Assert + await Assert.That(asyncErrors.Length).IsEqualTo(1); + await Assert.That(asyncErrors[0].Message).IsEqualTo("User name is taken"); + await Assert.That(asyncErrors[0].FullPath.ToDottedString()).IsEqualTo("Name"); + await Assert.That(memberErrors.Length).IsEqualTo(1); + await Assert.That(syncException).IsTypeOf(); + } + + [Test] + public async Task AsyncValidatableObject_GivenType_RunsAfterMembersInValidateAsync( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.Runtime.CompilerServices; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Reservation : IAsyncValidatableObject + { + public int Nights { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) => []; + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + await Task.Yield(); + if (Nights > 5) + yield return new ValidationResult("Too long", [nameof(Nights)]); + } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var reservation = Create(assembly, "Testing.Reservation", ("Nights", 9)); + + // Act + var errors = await RunValidationAsync( + assembly, + "Testing.ReservationSchema", + reservation, + null, + cancellationToken + ); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Nights"); + await Assert.That(errors[0].Code).IsEqualTo(ZodGeneratedValidation.DataAnnotationsErrorCode); + } + + [Test] + public async Task Union_GivenMemberTypedAsUnion_DispatchesToTheActiveCaseWithoutValueInThePath( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Cat + { + [MinLength(2)] + public string? Name { get; set; } + } + + [ZodSchema] + public class Dog + { + [Range(1, 100)] + public int Weight { get; set; } = 1; + } + + public union Pet(Cat, Dog); + + [ZodSchema] + public class Owner + { + public Pet Pet { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var cat = Create(assembly, "Testing.Cat", ("Name", "A")); + var pet = Activator.CreateInstance(assembly.GetType("Testing.Pet")!, cat)!; + var owner = Create(assembly, "Testing.Owner", ("Pet", pet)); + + // Act + var errors = RunValidate(assembly, "Testing.OwnerSchema", owner); + var resolved = RunTryResolveMember( + assembly, + "Testing.OwnerSchema", + owner, + errors[0].FullPath, + out var container, + out var member + ); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Pet.Name"); + await Assert.That(resolved).IsTrue(); + await Assert.That(ReferenceEquals(container, cat)).IsTrue(); + await Assert.That(member).IsEqualTo("Name"); + } + + [Test] + public async Task ClosedHierarchy_GivenMemberTypedAsClosedBase_DispatchesToTheDerivedSchema( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.ComponentModel.DataAnnotations; + using ZodSharp; + + namespace Testing + { + public closed class Shape + { + } + + [ZodSchema] + public sealed class Circle : Shape + { + [Range(1, 10)] + public int Radius { get; set; } + } + + [ZodSchema] + public sealed class Square : Shape + { + [Range(1, 10)] + public int Side { get; set; } = 1; + } + + [ZodSchema] + public class Drawing + { + public Shape? Shape { get; set; } + } + } + """; + + using var result = await GenerateAsync(source, PreviewCompileOptions, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + var drawing = Create(assembly, "Testing.Drawing", ("Shape", Create(assembly, "Testing.Circle"))); + + // Act + var errors = RunValidate(assembly, "Testing.DrawingSchema", drawing); + + // Assert + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Shape.Radius"); + } +#endif +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OperatorSafety.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OperatorSafety.cs new file mode 100644 index 00000000..e25c01f1 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OperatorSafety.cs @@ -0,0 +1,171 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + /// + /// Member types that define their own equality operators and conversions, as Purview.ValueObjects scalars do: an + /// operator against the underlying value (==(string?, EmailAddress)) plus implicit conversions both ways. + /// A generated value.Member == null is ambiguous between those operators and the type's own equality + /// (CS9342 for a Nullable<T> member, CS0034 for a reference type), so the generator must test for + /// null with a pattern, which never binds a user-defined operator. + /// + const string OperatorSafetySource = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + public readonly partial record struct EmailAddress + : Purview.ValueObjects.IScalarValueObject + { + public EmailAddress(string value) => Value = value; + + [Required] + [MinLength(3)] + public string Value { get; init; } + + public static bool operator ==(EmailAddress left, string? right) => left.Value == right; + + public static bool operator !=(EmailAddress left, string? right) => !(left == right); + + public static bool operator ==(string? left, EmailAddress right) => right.Value == left; + + public static bool operator !=(string? left, EmailAddress right) => !(left == right); + + public static implicit operator EmailAddress(string? value) => new(value!); + + public static implicit operator string(EmailAddress value) => value.Value; + } + + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + [ZodSchema] + public readonly partial record struct Score : Purview.ValueObjects.IScalarValueObject + { + public int? Value { get; init; } + + public static bool operator ==(Score left, int? right) => left.Value == right; + + public static bool operator !=(Score left, int? right) => !(left == right); + + public static bool operator ==(int? left, Score right) => right.Value == left; + + public static bool operator !=(int? left, Score right) => !(left == right); + + public static implicit operator Score(int? value) => new() { Value = value }; + + public static implicit operator int?(Score value) => value.Value; + } + + [ZodSchema] + public sealed class Token + { + [MinLength(1)] + public string Text { get; set; } = "token"; + + public static bool operator ==(Token? left, Token? right) => + ReferenceEquals(left, right) || (left is not null && right is not null && left.Text == right.Text); + + public static bool operator !=(Token? left, Token? right) => !(left == right); + + public static bool operator ==(Token? left, string? right) => left?.Text == right; + + public static bool operator !=(Token? left, string? right) => !(left == right); + + public static bool operator ==(string? left, Token? right) => right?.Text == left; + + public static bool operator !=(string? left, Token? right) => !(left == right); + + public static implicit operator Token(string? text) => new() { Text = text ?? string.Empty }; + + public static implicit operator string?(Token? token) => token?.Text; + + public override bool Equals(object? obj) => obj is Token other && other.Text == Text; + + public override int GetHashCode() => Text.GetHashCode(); + } + + [ZodSchema] + public partial class Contact + { + [Required] + public EmailAddress? Email { get; set; } + + public EmailAddress? Secondary { get; set; } + + [RequiredZod] + public Score? Score { get; set; } + + [Required] + public Token? Backup { get; set; } + + public Token Primary { get; set; } = new(); + } + } + """; + + [Test] + public async Task NullChecks_GivenMemberTypesWithUserDefinedEqualityOperators_CompileWithoutDiagnostics( + CancellationToken cancellationToken + ) + { + // Act + using var driverResult = await GenerateAsync(OperatorSafetySource, cancellationToken); + var generated = driverResult.GetSource("ContactSchema"); + + // Assert - every null test is a pattern, so none of them binds an operator the member type declares. + await Assert.That(generated).DoesNotContain("== null"); + await Assert.That(generated).DoesNotContain("!= null"); + await Assert.That(generated).ContainsGeneratedCode("value.Email is null"); + await Assert.That(generated).ContainsGeneratedCode("value.Secondary is not null"); + await Assert.That(generated).ContainsGeneratedCode("value.Score is null || value.Score.Value.Value is null"); + await Assert.That(generated).ContainsGeneratedCode("value.Backup is null"); + await Assert.That(generated).ContainsGeneratedCode("value.Primary is null"); + + var diagnostics = driverResult.CompilationResult.Compilation.GetDiagnostics(cancellationToken); + // No error anywhere, and nothing at all in the generated sources (the test source's own usings repeat the + // harness's global ones, which is noise outside the generated code). + Microsoft.CodeAnalysis.Diagnostic[] problems = + [ + .. diagnostics.Where(static diagnostic => + diagnostic.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error + || ( + diagnostic.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Warning + && diagnostic.Location.SourceTree?.FilePath.EndsWith(".g.cs", StringComparison.Ordinal) == true + ) + ), + ]; + await Assert.That(problems).IsEmpty(); + } + + [Test] + public async Task NullChecks_GivenMemberTypesWithUserDefinedEqualityOperators_RejectMissingValuesAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange + using var driverResult = await GenerateAsync( + OperatorSafetySource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var contactType = assembly.GetType("Testing.Contact")!; + var validate = assembly.GetType("Testing.ContactSchema")!.GetMethod("Validate")!; + + // Act - nothing set: Email, Score and Backup are required, and Primary keeps its default. + var result = validate.Invoke(null, [Activator.CreateInstance(contactType)!])!; + + // Assert + await Assert.That((bool)result.GetType().GetProperty("IsSuccess")!.GetValue(result)!).IsFalse(); + var errors = (System.Collections.Immutable.ImmutableArray) + result.GetType().GetProperty("Errors")!.GetValue(result)!; + string[] paths = + [ + .. errors.Select(static error => error.FullPath.ToDottedString()).Order(StringComparer.Ordinal), + ]; + await Assert.That(paths).IsEquivalentTo(["Backup", "Email", "Score"]); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs index c6267665..70fd1325 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs @@ -49,7 +49,11 @@ await Assert "public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Testing.AdminPortal options)" ); await Assert.That(generated).ContainsGeneratedCode("var schemaResult = AdminPortalSchema.Validate(options);"); - await Assert.That(generated).ContainsGeneratedCode("schemaResult.Errors.Select(static error => error.Message)"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "schemaResult.Errors.Select(static error => global::ZodSharp.Core.ZodGeneratedValidation.FormatOptionsFailure(error))" + ); } [Test] @@ -123,7 +127,7 @@ CancellationToken cancellationToken ZodSourceGeneratorTestOptions options = new() { AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpAutoGenerateOptionsValidators", + "build_property.ZodSharpAutoGenerateOptionsValidators", "false" ), }; @@ -157,7 +161,7 @@ public sealed class AdminPortalOptions ZodSourceGeneratorTestOptions options = new() { AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property:ZodSharpAutoGenerateOptionsValidatorSuffixes", + "build_property.ZodSharpAutoGenerateOptionsValidatorSuffixes", "Config" ), }; @@ -213,4 +217,50 @@ public sealed class AdminPortalOptions var validResult = (ValidateOptionsResult)validateMethod.Invoke(validator, [null, valid])!; await Assert.That(validResult.Succeeded).IsTrue(); } + + [Test] + public async Task OptionsValidator_GivenMissingRequiredMember_ReportsTheMissingFieldCodeTheDocsShow( + CancellationToken cancellationToken + ) + { + // Arrange — the shape the AddZodSchemaValidator/BindAndValidateWithZod XML docs describe. + var source = """ + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + + namespace Testing + { + [ZodSchema] + public sealed class SmtpServer + { + [Required] + public string? Host { get; set; } + } + + [ZodSchema] + public sealed class SmtpOptions + { + public List Servers { get; set; } = []; + } + } + """; + + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var options = Activator.CreateInstance(assembly.GetType("Testing.SmtpOptions")!)!; + var servers = (System.Collections.IList)options.GetType().GetProperty("Servers")!.GetValue(options)!; + servers.Add(Activator.CreateInstance(assembly.GetType("Testing.SmtpServer")!)); + var validatorType = assembly.GetType("Testing.SmtpOptionsValidator")!; + + // Act + var result = (ValidateOptionsResult) + validatorType.GetMethod("Validate")!.Invoke(Activator.CreateInstance(validatorType), [null, options])!; + + // Assert + await Assert.That(result.Failures).IsEquivalentTo(["Servers[0].Host: Host is required (missing_field)"]); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs index c2b9445b..04bc4085 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -206,7 +206,7 @@ DateTimeOffset Window // Assert - the display name, the length bounds and the parsed DateTimeOffset bounds all come from the // parameters, so the emitted validation is the one a declared property would get. - await Assert.That(generated).ContainsGeneratedCode("Required field 'Account name' is null"); + await Assert.That(generated).ContainsGeneratedCode("\"Name\", \"Account name\", Paths.Name"); await Assert.That(generated).ContainsGeneratedCode("if (handleLength < 3)"); await Assert.That(generated).ContainsGeneratedCode("if (handleLength > 10)"); await Assert diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs index 7f148844..4a3ac942 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -198,7 +198,11 @@ await Assert "new global::Purview.ValueObjects.ScalarRuleAdapter>(" ); await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); - await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + await Assert + .That(generated) + .ContainsGeneratedCode("EmptyPath, (global::ZodSharp.Core.IZodRule)tenantIdCustomRuleInner0)"); + // A runtime type test on the boxed rule crashes the Mono AOT compiler; the cast is known to succeed. + await Assert.That(generated).DoesNotContain("(object)"); // The wrapped rule owns the identity, not the adapter. await Assert .That(generated) @@ -398,4 +402,40 @@ await Assert "new global::Purview.ValueObjects.ScalarRuleAdapter>(" ); } + + [Test] + public async Task ScalarAdaptation_GivenWrappedRuleWithoutZodIdentity_PassesNoIdentityAndFailsAtRuntime( + CancellationToken cancellationToken + ) + { + // Arrange - the wrapped rule does not implement IZodRule, so the generator knows statically that there is no + // identity to copy and passes null, rather than testing the boxed rule at runtime. + var source = ScalarAdaptationSource + .Replace(": IValidationRule, IZodRule", ": IValidationRule", StringComparison.Ordinal) + .Replace("string? IZodRule.Code => \"invalid_value\";", string.Empty, StringComparison.Ordinal) + .Replace("string? IZodRule.Origin => \"value_object\";", string.Empty, StringComparison.Ordinal); + + // Act + using var driverResult = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var generated = driverResult.GetSource("TenantIdSchema"); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.TenantId")!; + var validate = assembly.GetType("Testing.TenantIdSchema")!.GetMethod("Validate")!; + var emptyResult = validate.Invoke(null, [Activator.CreateInstance(modelType)!])!; + + // Assert + await Assert.That(generated).ContainsGeneratedCode("EmptyPath, null)"); + await Assert.That(generated).DoesNotContain("(object)"); + await Assert.That(generated).DoesNotContain("IZodRule)tenantIdCustomRuleInner0"); + + var errors = (System.Collections.Immutable.ImmutableArray) + emptyResult.GetType().GetProperty("Errors")!.GetValue(emptyResult)!; + await Assert.That(errors).HasSingleItem(); + await Assert.That(errors[0].Code).IsEqualTo("validation_failed"); + await Assert.That(errors[0].Message).IsEqualTo("TenantId must not be the default."); + } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructCollections.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructCollections.cs new file mode 100644 index 00000000..d851a7fc --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructCollections.cs @@ -0,0 +1,303 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using ZodSharp.Core; +using ZodSharp.SourceGenerators.Infra; +using static ZodSharp.SourceGenerators.Infra.GeneratedSchemaRuntime; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + [Test] + [Arguments("ImmutableArray", "ImmutableArray.Create((string)null!)", "invalid_type")] + [Arguments("ImmutableArray?", "ImmutableArray.Create((string)null!)", "invalid_type")] + [Arguments("ImmutableArray?", "null", "")] + [Arguments("ImmutableArray?", "default(ImmutableArray)", "")] + [Arguments("ImmutableArray", "default", "invalid_type")] + [Arguments("ImmutableArray", "ImmutableArray.Empty", "too_small")] + [Arguments("ImmutableArray", "ImmutableArray.Create(new Child())", "missing_field")] + [Arguments("ImmutableArray?", "ImmutableArray.Create(new Child())", "missing_field")] + [Arguments("ImmutableArray", "ImmutableArray.Create((Child?)null)", "")] + [Arguments("List?", "null", "")] + [Arguments("List", "new List { null! }", "invalid_type")] + [Arguments("Bag", "new Bag(new[] { new Child() })", "missing_field")] + [Arguments("Bag?", "new Bag(new[] { new Child() })", "missing_field")] + [Arguments("Bag?", "null", "")] + public async Task Collections_GivenStructAndReferenceNullability_CompileWithoutWarningsAndApplyNullRules( + string collectionType, + string initializer, + string expectedCode, + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + using System.Collections.Generic; + using System.Collections.Immutable; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + namespace Testing; + + [ZodSchema] + public class Container + { + [MinLength(1)] + public {{collectionType}} Items { get; set; } = {{initializer}}; + } + + [ZodSchema] + public class Child + { + [Required] + public string? Name { get; set; } + } + + public readonly struct Bag(T[] items) : IReadOnlyCollection + { + int IReadOnlyCollection.Count => items.Length; + public IEnumerator GetEnumerator() => ((IEnumerable)items).GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + """; + + // Act + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions { IncludeDefaultNamespaces = false }.Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var container = Create(assembly, "Testing.Container"); + var errors = RunValidate(assembly, "Testing.ContainerSchema", container); + var memberErrors = RunValidateMember(assembly, "Testing.ContainerSchema", container, "Items"); + + // Assert + await AssertWarningFreeCompilation(result.CompilationResult.Compilation, cancellationToken); + await Assert.That(result.Generated().HasClass("ContainerSchema")).IsTrue(); + await Assert + .That(errors.Select(static error => error.Code).ToArray()) + .IsEquivalentTo(expectedCode.Length == 0 ? [] : new[] { expectedCode }); + await Assert.That(memberErrors.Length).IsEqualTo(expectedCode is "" or "missing_field" ? 0 : 1); + } + + [Test] + public async Task ImmutableArray_GivenDefaultValues_UsesRequiredAndNullableSemantics( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Immutable; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + namespace Testing; + + [ZodSchema] + public class Container + { + public ImmutableArray Missing { get; set; } + + [Required(ErrorMessage = "Supply items")] + public ImmutableArray Required { get; set; } + + [Required] + public ImmutableArray? RequiredNullable { get; set; } = default(ImmutableArray); + + [Required] + public ImmutableArray? RequiredNullWrapper { get; set; } + + public ImmutableArray? Optional { get; set; } = default(ImmutableArray); + + [Required] + public ImmutableArray Empty { get; set; } = ImmutableArray.Empty; + + [ZodSharp.Rules.RequiredZod] + public ImmutableArray RequiredRule { get; set; } + + [ZodSharp.Rules.RequiredZod] + public ImmutableArray? RequiredNullableRule { get; set; } = default(ImmutableArray); + } + """; + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions { IncludeDefaultNamespaces = false }.Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + + // Act + var errors = RunValidate(assembly, "Testing.ContainerSchema", Create(assembly, "Testing.Container")); + + // Assert + await AssertWarningFreeCompilation(result.CompilationResult.Compilation, cancellationToken); + await Assert + .That(errors.Select(static error => $"{error.Path[0]}:{error.Code}").ToArray()) + .IsEquivalentTo([ + "Missing:invalid_type", + "Required:missing_field", + "RequiredNullable:missing_field", + "RequiredNullWrapper:missing_field", + "RequiredRule:missing_field", + "RequiredNullableRule:missing_field", + ]); + await Assert.That(errors.Single(static error => error.Path[0] == "Required").Message).IsEqualTo("Supply items"); + } + + [Test] + [Arguments("", "", "", "invalid_type")] + [Arguments("TreatAsEmpty", "", "", "too_small")] + [Arguments("TreatAsEmpty", "ValidationError", "", "invalid_type")] + [Arguments("ValidationError", "TreatAsEmpty", "", "too_small")] + [Arguments("TreatAsEmpty", "TreatAsEmpty", "ValidationError", "invalid_type")] + [Arguments("ValidationError", "ValidationError", "TreatAsEmpty", "too_small")] + public async Task ImmutableArray_GivenConfiguredDefaultBehavior_ResolvesPropertyAssemblyAndBuildPrecedence( + string buildBehavior, + string assemblyBehavior, + string propertyBehavior, + string expectedCode, + CancellationToken cancellationToken + ) + { + // Arrange + var assemblyAttribute = + assemblyBehavior.Length == 0 + ? "" + : $"[assembly: ZodSharpSchemaDefaults(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.{assemblyBehavior})]"; + var propertyAttribute = + propertyBehavior.Length == 0 + ? "" + : $"[CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.{propertyBehavior})]"; + var source = $$""" + using System.Collections.Immutable; + using System.ComponentModel.DataAnnotations; + using ZodSharp; + {{assemblyAttribute}} + namespace Testing; + + [ZodSchema] + public class Container + { + {{propertyAttribute}} + [Required] + [MinLength(1)] + public ImmutableArray? Items { get; set; } = default(ImmutableArray); + } + """; + ZodSourceGeneratorTestOptions options = new() + { + CompileToAssembly = true, + IncludeDefaultNamespaces = false, + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property.ZodSharpDefaultImmutableArrayBehavior", + buildBehavior + ), + }; + using var result = await GenerateAsync(source, options, cancellationToken); + var assembly = result.CompilationResult.Assembly!; + + // Act + var errors = RunValidate(assembly, "Testing.ContainerSchema", Create(assembly, "Testing.Container")); + + // Assert + await AssertWarningFreeCompilation(result.CompilationResult.Compilation, cancellationToken); + await Assert.That(errors.Length).IsEqualTo(1); + await Assert.That(errors[0].Code).IsEqualTo(expectedCode == "invalid_type" ? "missing_field" : expectedCode); + } + + [Test] + [Arguments(false, false, false)] + [Arguments(true, false, false)] + [Arguments(false, true, false)] + [Arguments(true, true, false)] + [Arguments(false, true, true)] + [Arguments(true, true, true)] + public async Task ImmutableArray_GivenAsyncNestedSchemasAndNullableCollection_ValidatesAndResolvesMembers( + bool nullableCollection, + bool defaultArray, + bool treatAsEmpty, + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + using System.Collections.Immutable; + using System.Threading; + using System.Threading.Tasks; + using ZodSharp; + using ZodSharp.Core; + namespace Testing; + + [ZodSchema] + public class Container + { + [CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.{{( + treatAsEmpty ? "TreatAsEmpty" : "ValidationError" + )}})] + public ImmutableArray{{(nullableCollection ? "?" : "")}} Items { get; set; } = + {{( + defaultArray ? "default(ImmutableArray)" : "ImmutableArray.Create(null, new Child())" + )}}; + } + + [ZodSchema] + public struct Child + { + public string? Name { get; set; } + + internal static ValueTask> CustomValidationAsync(Child value, CancellationToken cancellationToken) + => ValueTask.FromResult(ValidationResult.Failure( + new[] { ValidationError.Create("child_async", "Invalid child", ChildSchema.Paths.Name) })); + } + """; + using var result = await GenerateAsync( + source, + new ZodSourceGeneratorTestOptions { IncludeDefaultNamespaces = false }.Compile(), + cancellationToken + ); + var assembly = result.CompilationResult.Assembly!; + var container = Create(assembly, "Testing.Container"); + + // Act + var errors = await RunValidationAsync( + assembly, + "Testing.ContainerSchema", + container, + cancellationToken: cancellationToken + ); + var resolved = RunTryResolveMember( + assembly, + "Testing.ContainerSchema", + container, + ZodPath.ParseDotted("Items[1].Name"), + out var owner, + out var member + ); + + // Assert + await AssertWarningFreeCompilation(result.CompilationResult.Compilation, cancellationToken); + string[] expectedCodes = defaultArray + ? nullableCollection || treatAsEmpty + ? [] + : ["invalid_type"] + : ["child_async"]; + await Assert.That(errors.Select(static error => error.Code).ToArray()).IsEquivalentTo(expectedCodes); + await Assert.That(resolved).IsEqualTo(!defaultArray); + if (!defaultArray) + { + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Items[1].Name"); + await Assert.That(owner).IsSameReferenceAs(container); + await Assert.That(member).IsEqualTo("Items"); + } + } + + static async Task AssertWarningFreeCompilation(Compilation compilation, CancellationToken cancellationToken) + { + var strict = compilation.WithOptions(compilation.Options.WithGeneralDiagnosticOption(ReportDiagnostic.Error)); + var problems = strict + .GetDiagnostics(cancellationToken) + .Where(static diagnostic => diagnostic.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning) + .Select(static diagnostic => diagnostic.ToString()) + .ToArray(); + await Assert.That(problems).IsEmpty(); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructMembers.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructMembers.cs new file mode 100644 index 00000000..680e7f21 --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructMembers.cs @@ -0,0 +1,127 @@ +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +partial class ZodSchemaGeneratorTests +{ + const string StructMemberSource = + AutomaticScalarPrelude + + """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + public readonly partial record struct EmailAddress + { + [Required] + [MinLength(3)] + public string? Value { get; init; } + } + + [ZodSchema] + public partial record struct Coordinates + { + [Range(-90, 90)] + public double Latitude { get; set; } + } + + [ZodSchema] + public sealed class SignUp + { + public EmailAddress Email { get; set; } + + [Display(Name = "Backup email")] + public EmailAddress? BackupEmail { get; set; } + + public Coordinates Location { get; set; } + + public System.Collections.Generic.List Stops { get; set; } = []; + } + } + """; + + [Test] + [Arguments("Email.Value", "Email")] + [Arguments("BackupEmail.Value", "BackupEmail")] + [Arguments("Location.Latitude", "Location")] + [Arguments("Stops[1].Latitude", "Stops")] + public async Task TryResolveMember_GivenPathInsideAStructMember_ResolvesToTheReferenceTypeOwner( + string path, + string expectedMember, + CancellationToken cancellationToken + ) + { + // Arrange - a boxed copy of a struct cannot own a field, so the nearest reference-type owner does. + using var driverResult = await GenerateAsync( + StructMemberSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.SignUp")!; + var model = Activator.CreateInstance(modelType)!; + var coordinatesType = assembly.GetType("Testing.Coordinates")!; + var stops = (System.Collections.IList)modelType.GetProperty("Stops")!.GetValue(model)!; + stops.Add(Activator.CreateInstance(coordinatesType)); + stops.Add(Activator.CreateInstance(coordinatesType)); + var emailType = assembly.GetType("Testing.EmailAddress")!; + modelType.GetProperty("BackupEmail")!.SetValue(model, Activator.CreateInstance(emailType)); + var resolve = assembly + .GetType("Testing.SignUpSchema")! + .GetMethods() + .Single(static method => method.Name == "TryResolveMember" && method.GetParameters().Length == 4); + object?[] arguments = [model, Core.ZodPath.ParseDotted(path), null, null]; + + // Act + var resolved = (bool)resolve.Invoke(null, arguments)!; + + // Assert + await Assert.That(resolved).IsTrue(); + await Assert.That(arguments[2]).IsSameReferenceAs(model); + await Assert.That(arguments[3]).IsEqualTo(expectedMember); + } + + [Test] + public async Task Validate_GivenInvalidScalarValueObjectMember_NamesTheErrorAfterTheOwningMember( + CancellationToken cancellationToken + ) + { + // Arrange + using var driverResult = await GenerateAsync( + StructMemberSource, + new ZodSourceGeneratorTestOptions().Compile(), + cancellationToken + ); + var assembly = await Assert.That(driverResult.CompilationResult.Assembly).IsNotNull(); + var modelType = assembly.GetType("Testing.SignUp")!; + var emailType = assembly.GetType("Testing.EmailAddress")!; + var coordinatesType = assembly.GetType("Testing.Coordinates")!; + var model = Activator.CreateInstance(modelType)!; + var shortEmail = Activator.CreateInstance(emailType)!; + emailType.GetProperty("Value")!.SetValue(shortEmail, "ab"); + modelType.GetProperty("Email")!.SetValue(model, shortEmail); + modelType.GetProperty("BackupEmail")!.SetValue(model, Activator.CreateInstance(emailType)); + var location = Activator.CreateInstance(coordinatesType)!; + coordinatesType.GetProperty("Latitude")!.SetValue(location, 120d); + modelType.GetProperty("Location")!.SetValue(model, location); + var validate = assembly.GetType("Testing.SignUpSchema")!.GetMethod("Validate")!; + + // Act + var result = validate.Invoke(null, [model])!; + + // Assert + var errors = (System.Collections.Immutable.ImmutableArray) + result.GetType().GetProperty("Errors")!.GetValue(result)!; + var email = errors.Single(static error => error.FullPath.ToDottedString() == "Email.Value"); + var backup = errors.Single(static error => error.FullPath.ToDottedString() == "BackupEmail.Value"); + var latitude = errors.Single(static error => error.FullPath.ToDottedString() == "Location.Latitude"); + + // A scalar's own member is an implementation detail: the message and displayName name the owning member, while + // member and declaringType still identify the rule's member for localization. + await Assert.That(email.Message).Contains("'Email'"); + await Assert.That(email.Parameters![Core.ZodIssueParameters.DisplayName]).IsEqualTo("Email"); + await Assert.That(email.Parameters![Core.ZodIssueParameters.Member]).IsEqualTo("Value"); + await Assert.That(backup.Message).IsEqualTo("Backup email is required"); + await Assert.That(latitude.Message).Contains("'Latitude'"); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs index a90e2a50..38f82a59 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.SyncValidation.cs @@ -43,7 +43,7 @@ await Assert ); await Assert.That(generated).ContainsGeneratedCode("InvokeZodRefinementHook(value, refineContext)"); await Assert.That(generated).ContainsGeneratedCode("refineContext.HasIssues"); - await Assert.That(generated).ContainsGeneratedCode("AddError(ref errors, refinementIssue)"); + await Assert.That(generated).ContainsGeneratedCode("ZodGeneratedValidation.Add(ref errors, refinementIssue)"); var hooks = driverResult.GetSource("PagingSchema.Hooks.g.cs", HintNameMatchMode.Suffix); await Assert.That(hooks).Contains("partial class Paging"); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs index 041a7e51..d5f4d045 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.TypeRules.cs @@ -61,7 +61,7 @@ await Assert .That(generated) .ContainsGeneratedCode("new global::Testing.NotEmptyRule("); await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); - await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + await Assert.That(generated).ContainsGeneratedCode("ZodRuleErrors.Create(assetIdCustomRule0, value, EmptyPath"); await Assert .That(generated) .ContainsGeneratedCode("((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? \"invalid_asset_id\""); @@ -134,7 +134,7 @@ await Assert ); await Assert .That(generated) - .ContainsGeneratedCode("origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin"); + .ContainsGeneratedCode("ZodRuleErrors.Create(assetIdCustomRule0, value, EmptyPath, code:"); } /// diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs index 6c24579a..ea213d12 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidationMethods.cs @@ -38,6 +38,6 @@ internal static ValueTask> CustomValidationAsync( // ...and the async custom method is dropped so the validator does not reference both. await Assert.That(generated).DoesNotContain("CustomValidationAsync", StringComparison.Ordinal); - await Assert.That(generated).ContainsGeneratedCode("ValueTask.FromResult"); + await Assert.That(generated).ContainsGeneratedCode("=> new(ValidateCore(value, depth));"); } } diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs index d7c71a2f..23c272af 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ValidatorAdapter.cs @@ -149,7 +149,11 @@ async ValueTask> CustomValidationAsync(Gizmo value, Canc var gizmo = Activator.CreateInstance(gizmoType); var gizmoSchemaValidator = Activator.CreateInstance(gizmoSchemaType); - var method = gizmoSchemaType.GetMethod(nameof(IZodSchemaValidator<>.ValidateAsync)); + // The adapter also exposes the service-aware ValidateAsync overload, so select by signature. + var method = gizmoSchemaType.GetMethod( + nameof(IZodSchemaValidator<>.ValidateAsync), + [gizmoType!, typeof(CancellationToken)] + ); await Assert.That(method).IsNotNull(); var valueTaskResult = method!.Invoke(gizmoSchemaValidator, [gizmo, cancellationToken])!; diff --git a/src/tests/SourceGenerators.UnitTests/ZodSyncValidationSuppressorTests.cs b/src/tests/SourceGenerators.UnitTests/ZodSyncValidationSuppressorTests.cs new file mode 100644 index 00000000..5dd79eca --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSyncValidationSuppressorTests.cs @@ -0,0 +1,209 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using ZodSharp.SourceGenerators.Infra; + +namespace ZodSharp.SourceGenerators; + +/// +/// CA1849 is reported at the call site, so a package can only answer it with a suppressor. These tests pair +/// with a stand-in for the SDK's analyzer (which is not referenced here, +/// and whose async-context condition would only add setup noise) that reports CA1849 for every invocation of a +/// method that has an Async sibling. +/// +public class ZodSyncValidationSuppressorTests : ZodSharpSourceGeneratorTestBase +{ + static string SourceWith(string call) => + $$""" + using System.Threading.Tasks; + using ZodSharp; + + namespace Testing + { + [ZodSchema] + public class Person + { + public string Name { get; set; } = string.Empty; + } + + public class Widget + { + public static int Validate() => 0; + + public static ValueTask ValidateAsync() => ValueTask.FromResult(0); + } + + public static class Holder + { + public static string Result => string.Empty; + } + + public static class Caller + { + public static object Call(Person person) => {{call}}; + } + } + """; + + [Test] + [Arguments("PersonSchema.Validate(person)")] + [Arguments("PersonSchema.ValidateMember(person, nameof(Person.Name))")] + [Arguments("new PersonSchemaValidator().Validate(person)")] + [Arguments("Z.String().Validate(\"value\")")] + public async Task Ca1849_GivenZodValidationCall_IsSuppressed(string call, CancellationToken cancellationToken) + { + // Arrange + var compilation = await CompileAsync(SourceWith(call), cancellationToken); + + // Act + var diagnostics = await RunAnalyzersAsync(compilation, cancellationToken); + + // Assert + await Assert.That(diagnostics.Length).IsEqualTo(1); + await Assert.That(diagnostics[0].Id).IsEqualTo(ZodSyncValidationSuppressor.SuppressedDiagnosticId); + await Assert.That(diagnostics[0].IsSuppressed).IsTrue(); + } + + [Test] + public async Task Ca1849_GivenForeignValidateCall_IsNotSuppressed(CancellationToken cancellationToken) + { + // Arrange — a type outside ZodSharp that pairs Validate with ValidateAsync. + var compilation = await CompileAsync(SourceWith("Widget.Validate()"), cancellationToken); + + // Act + var diagnostics = await RunAnalyzersAsync(compilation, cancellationToken); + + // Assert + await Assert.That(diagnostics.Length).IsEqualTo(1); + await Assert.That(diagnostics[0].IsSuppressed).IsFalse(); + } + + [Test] + public async Task Ca1849_GivenBlockingMemberInsideAZodValidationCall_IsNotSuppressed( + CancellationToken cancellationToken + ) + { + // Arrange — CA1849 also reports blocking members (Task.Wait, Task.Result), which can sit inside the + // arguments of a Validate call; only the call itself is ours to suppress. + var compilation = await CompileAsync(SourceWith("Z.String().Validate(Holder.Result)"), cancellationToken); + + // Act + var diagnostics = await RunAnalyzersAsync(compilation, cancellationToken); + var unsuppressed = diagnostics.Where(static diagnostic => !diagnostic.IsSuppressed).ToArray(); + + // Assert + await Assert.That(diagnostics.Length).IsEqualTo(2); + await Assert.That(unsuppressed.Length).IsEqualTo(1); + var text = await unsuppressed[0].Location.SourceTree!.GetTextAsync(cancellationToken); + await Assert.That(text.ToString(unsuppressed[0].Location.SourceSpan)).IsEqualTo("Holder.Result"); + } + + [Test] + public async Task SupportedSuppressions_GivenEverySuppression_CoversCa1849Only() + { + // Arrange + ZodSyncValidationSuppressor suppressor = new(); + + // Act + var suppressions = suppressor.SupportedSuppressions; + + // Assert + await Assert.That(suppressions.Length).IsEqualTo(1); + await Assert.That(suppressions[0].Id).IsEqualTo(ZodSyncValidationSuppressor.SuppressionId); + await Assert + .That(suppressions[0].SuppressedDiagnosticId) + .IsEqualTo(ZodSyncValidationSuppressor.SuppressedDiagnosticId); + } + + async Task CompileAsync(string source, CancellationToken cancellationToken) + { + using var result = await GenerateAsync(source, cancellationToken); + return result.CompilationResult.Compilation; + } + + static async Task> RunAnalyzersAsync( + Compilation compilation, + CancellationToken cancellationToken + ) + { + CompilationWithAnalyzersOptions options = new( + new AnalyzerOptions([]), + onAnalyzerException: null, + concurrentAnalysis: true, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: true + ); + + var analyzers = ImmutableArray.Create( + new FakeCa1849Analyzer(), + new ZodSyncValidationSuppressor() + ); + + return await compilation.WithAnalyzers(analyzers, options).GetAnalyzerDiagnosticsAsync(cancellationToken); + } + + /// + /// Reports CA1849 wherever a method has an Async sibling, so the tests do not depend on the real + /// analyzer's Task-returning-context condition. + /// + /// + /// It reuses the real rule's id on purpose, and a test assembly is neither a packaged analyzer nor an + /// netstandard2.0 one, so the analyzer-authoring rules that would police a shipped component are + /// silenced here. + /// +#pragma warning disable RS1026, RS1029, RS1032, RS1036, RS1038, RS1041, RS2008 + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class FakeCa1849Analyzer : DiagnosticAnalyzer + { + static readonly DiagnosticDescriptor Rule = new( + ZodSyncValidationSuppressor.SuppressedDiagnosticId, + "Call async methods when in an async method", + "In async method, call '{0}' instead of '{1}'", + "Performance", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics { get; } = [Rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterOperationAction(Analyze, OperationKind.Invocation); + context.RegisterOperationAction(AnalyzeBlockingMember, OperationKind.PropertyReference); + } + + static void AnalyzeBlockingMember(OperationAnalysisContext context) + { + var reference = (IPropertyReferenceOperation)context.Operation; + if (reference.Property.Name != "Result") + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, reference.Syntax.GetLocation(), "Result", "Result")); + } + + static void Analyze(OperationAnalysisContext context) + { + var invocation = (IInvocationOperation)context.Operation; + var name = invocation.TargetMethod.Name; + if (!HasAsyncSibling(invocation.TargetMethod.ContainingType, name + "Async")) + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), name + "Async", name)); + } + + static bool HasAsyncSibling(INamedTypeSymbol? type, string name) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current.GetMembers(name).OfType().Any()) + return true; + } + + return false; + } + } +#pragma warning restore RS1026, RS1029, RS1032, RS1036, RS1038, RS1041, RS2008 +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs new file mode 100644 index 00000000..168328eb --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using ZodSharp.Core; +using ZodSharp.JsonSchema.SystemTextJson; + +namespace ZodSharp.Json.Schema; + +/// +/// Covers the errors produced by schemas imported from JSON Schema: typed index and key paths that keep +/// every error field, minItems/maxItems failures shaped like ZodArray's, and type +/// mismatches described with Zod's type names. +/// +public class FromJsonSchemaParserErrorTests +{ + [Test] + public async Task Validate_GivenInvalidArrayElement_PrefixesTypedIndexAndKeepsEveryField() + { + // Defect J7 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","items":{"type":"array","minItems":2}}""" + ); + + // Act + var result = schema.Validate(Parse("[[1,2],[1]]")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.From(ZodPathSegment.At(1))); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + [Test] + public async Task Validate_GivenTooFewItems_MirrorsZodArrayLengthFailure() + { + // Defect J8 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","minItems":2,"maxItems":3}""" + ); + + // Act + var result = schema.Validate(Parse("[1]")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("too_small"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + await Assert.That(error.Maximum).IsEqualTo(3); + await Assert.That(error.Inclusive).IsTrue(); + await Assert.That(error.Message).IsEqualTo("Array must have at least 2 elements, but got 1"); + } + + [Test] + public async Task Validate_GivenTooManyItems_MirrorsZodArrayLengthFailure() + { + // Defect J8 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","maxItems":1}""" + ); + + // Act + var result = schema.Validate(Parse("[1,2]")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("too_big"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Maximum).IsEqualTo(1); + await Assert.That(error.Message).IsEqualTo("Array must have at most 1 elements, but got 2"); + } + + [Test] + [Arguments( /*lang=json,strict*/ + """{"type":"string"}""", + "42", + "Expected string, but got number" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"number"}""", + "\"x\"", + "Expected number, but got string" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"boolean"}""", + "{}", + "Expected boolean, but got object" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"object"}""", + "[]", + "Expected object, but got array" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"array"}""", + "true", + "Expected array, but got boolean" + )] + [Arguments( /*lang=json,strict*/ + """{"type":"null"}""", + "1", + "Expected null, but got number" + )] + public async Task Validate_GivenWrongJsonKind_ReportsZodTypeNames(string jsonSchema, string json, string message) + { + // Defect J9 + // Arrange + var schema = FromJsonSchemaParser.Parse(jsonSchema); + + // Act + var result = schema.Validate(Parse(json)); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_type"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(error.Message).IsEqualTo(message); + } + + [Test] + public async Task Validate_GivenLiteralMismatch_DoesNotEchoTheReceivedValue() + { + // Defect J9 + // Arrange + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"const":"admin"}""" + ); + + // Act + var result = schema.Validate(Parse("\"secret-value\"")); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_literal"); + await Assert.That(error.Message).IsEqualTo("Expected literal value 'admin', but got string"); + } + + [Test] + public async Task Validate_GivenInvalidRecordValue_PrefixesKeySegmentAndKeepsEveryField() + { + // Defect J10 + // Arrange + var schema = FromJsonSchemaParser.Parse( + /*lang=json,strict*/"""{"type":"object","additionalProperties":{"type":"array","minItems":2}}""" + ); + + // Act + var result = schema.Validate( + Parse( /*lang=json,strict*/ + """{"a.b":[1]}""" + ) + ); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.From(ZodPathSegment.Key("a.b"))); + await Assert.That(error.Origin).IsEqualTo("array"); + await Assert.That(error.Minimum).IsEqualTo(2); + } + + static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonConverterTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonConverterTests.cs index 75498330..1b671b7e 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonConverterTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonConverterTests.cs @@ -14,7 +14,10 @@ public async Task CreateValidatingConverter_GivenValidJson_DeserializesSuccessfu TestUserSchema schema = new(); var options = CreateOptions(schema); - var deserialized = JsonSerializer.Deserialize("""{"Name":"John","Age":30}""", options); + var deserialized = JsonSerializer.Deserialize( /*lang=json,strict*/ + """{"Name":"John","Age":30}""", + options + ); await Assert.That(deserialized).IsNotNull(); await Assert.That(deserialized!.Name).IsEqualTo("John"); @@ -41,7 +44,10 @@ public async Task CreateValidatingConverter_GivenInvalidData_ThrowsJsonException var options = CreateOptions(schema); var exception = Assert.Throws(() => - JsonSerializer.Deserialize("""{"Name":"","Age":30}""", options) + JsonSerializer.Deserialize( /*lang=json,strict*/ + """{"Name":"","Age":30}""", + options + ) ); await Assert.That(exception).IsNotNull(); diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs new file mode 100644 index 00000000..42f5b09e --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs @@ -0,0 +1,341 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// Covers how System.Text.Json failures become validation errors: one invalid_json code, the JSON +/// location kept as a typed path plus line and position, a generic message, and full errors from the +/// validating converter. +/// +public class SystemTextJsonErrorTests +{ + [Test] + public async Task DeserializeAndValidate_GivenUnconvertibleMemberValue_ReportsPathLineAndPositionWithGenericMessage() + { + // Defect J1 + // Arrange + PassThroughSchema schema = new(); + const string json = /*lang=json,strict*/ + """{"Name":"Ada","Age":"forty-two"}"""; + + // Act + var result = schema.DeserializeAndValidate(json); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.Member("Age")); + await Assert.That(error.Message).IsEqualTo("The JSON value could not be converted to number."); + await Assert.That(error.Parameters!["line"]).IsEqualTo(0L); + await Assert.That(error.Parameters!["position"]).IsNotNull(); + await Assert.That(error.Message).DoesNotContain("System."); + await Assert.That(error.Message).DoesNotContain("forty-two"); + } + + [Test] + public async Task DeserializeAndValidate_GivenJsonTypeInfoAndBadElement_ReportsIndexedPath() + { + // Defect J1 + // Arrange + PassThroughSchema schema = new(); + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + var typeInfo = (JsonTypeInfo)options.TypeInfoResolver.GetTypeInfo(typeof(Team), options)!; + const string json = /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":true}]}"""; + + // Act + var result = schema.DeserializeAndValidate(json, typeInfo); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + await Assert + .That(error.FullPath) + .IsEqualTo( + ZodPath.From(ZodPathSegment.Member("Members"), ZodPathSegment.At(1), ZodPathSegment.Member("Age")) + ); + await Assert.That(error.Message).DoesNotContain("System."); + } + + [Test] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":}]}""", "Members[1].Age")] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":12x}]}""", "Members[1].Age")] + [Arguments( /*lang=json*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":1,}]}""", + "Members[1]" + )] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age" 1}]}""", "Members[1]")] + public async Task DeserializeAndValidate_GivenMalformedJsonInsideAnElement_ReportsTheSerializersPath( + string json, + string expectedPath, + CancellationToken cancellationToken + ) + { + // Arrange — the path is the serializer's: the value being read when a syntax error is a value error, + // and the containing element when the reader fails between members. + PassThroughSchema schema = new(); + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + + // Act + var fromString = schema.DeserializeAndValidate(json).Errors.Single(); + var fromStream = ( + await schema.DeserializeAndValidateAsync(stream, cancellationToken: cancellationToken) + ).Errors.Single(); + + // Assert + await Assert.That(fromString.FullPath.ToDottedString()).IsEqualTo(expectedPath); + await Assert.That(fromStream.FullPath.ToDottedString()).IsEqualTo(expectedPath); + await Assert.That(fromString.Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + } + + [Test] + // A value that cannot be converted is located inside the converted element. A syntax error is located at the + // element itself: the converter (and, for a stream, the serializer's read-ahead) buffers the whole value, so + // the position inside it is not known. + [Arguments( /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}""", + "Members[1].Age" + )] + [Arguments( /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":true}]}""", + "Members[1].Age" + )] + [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":}]}""", "Members[1]")] + [Arguments( /*lang=json*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":1,}]}""", + "Members[1]" + )] + public async Task DeserializeAndValidate_GivenMalformedJsonUnderAValidatingConverter_ReportsTheConvertedValuePath( + string json, + string expectedPath + ) + { + // Arrange + PassThroughSchema schema = new(); + JsonSerializerOptions options = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; + + // Act + var error = schema.DeserializeAndValidate(json, options).Errors.Single(); + + // Assert + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo(expectedPath); + } + + [Test] + [Arguments( /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":true}]}""", + "number" + )] + [Arguments( /*lang=json,strict*/ + """{"Members":[{"Name":true,"Age":1}]}""", + "string" + )] + [Arguments( /*lang=json,strict*/ + """{"Scores":{"a":"x"}}""", + "number" + )] + [Arguments( /*lang=json,strict*/ + """{"Grid":[["x"]]}""", + "number" + )] + [Arguments( /*lang=json,strict*/ + """{"Payload":5}""", + "string" + )] + [Arguments( /*lang=json,strict*/ + """{"Members":5}""", + "array" + )] + public async Task DeserializeAndValidate_GivenUnconvertibleNestedValue_NamesTheExpectedJsonType( + string json, + string expected + ) + { + // Arrange + PassThroughSchema schema = new(); + + // Act + var error = schema.DeserializeAndValidate(json).Errors.Single(); + + // Assert + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Expected)).IsEqualTo(expected); + await Assert.That(error.Message).IsEqualTo($"The JSON value could not be converted to {expected}."); + } + + [Test] + public async Task DeserializeAndValidate_GivenMalformedOrNullJson_ReportsSingleInvalidJsonCode() + { + // Defect J2 + // Arrange + PassThroughSchema schema = new(); + + // Act + var malformed = schema.DeserializeAndValidate("{bad json"); + var nullDocument = schema.DeserializeAndValidate("null"); + + // Assert + await Assert.That(malformed.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + await Assert.That(nullDocument.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + await Assert.That(nullDocument.Errors.Single().Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert + .That(nullDocument.Errors.Single().Message) + .IsEqualTo("The JSON value could not be converted to object."); + } + + [Test] + public async Task DeserializeAndValidate_GivenTypeTheSerializerCannotHandle_ReturnsInvalidJsonInsteadOfThrowing() + { + // Defect J3 + // Arrange + PassThroughSchema schema = new(); + + // Act + var result = schema.DeserializeAndValidate( /*lang=json,strict*/ + """{"Sides":3}""" + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + await Assert.That(result.Errors.Single().Message).DoesNotContain("System."); + } + + [Test] + public async Task DeserializeAndValidate_GivenNestedValidatingConverterFailure_ReturnsFullErrorsUnderConverterPath() + { + // Defect J4 + // Arrange + PassThroughSchema schema = new(); + JsonSerializerOptions options = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; + + // Act + var result = schema.DeserializeAndValidate(json, options); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(PersonSchema.NameRequiredCode); + await Assert.That(error.Message).IsEqualTo("Name is required"); + await Assert.That(error.Origin).IsEqualTo("string"); + await Assert.That(error.Minimum).IsEqualTo(1); + await Assert + .That(error.FullPath) + .IsEqualTo(ZodPath.From(ZodPathSegment.Member("Owner"), ZodPathSegment.Member("Name"))); + } + + [Test] + public async Task Deserialize_GivenValidatingConverterFailure_ThrowsZodJsonValidationExceptionWithErrors() + { + // Defect J4 + // Arrange + JsonSerializerOptions options = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; + + // Act + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, options) + ); + + // Assert + await Assert.That(exception!.Message).Contains("Validation failed"); + await Assert.That(exception.Errors.Single().FullPath.ToDottedString()).IsEqualTo("Owner.Name"); + } + + [Test] + public async Task DeserializeAndValidateAsync_GivenUnconvertibleValueInStream_ReportsPathAndCode() + { + // Defect J1 + // Arrange + PassThroughSchema schema = new(); + using MemoryStream stream = new( + Encoding.UTF8.GetBytes( /*lang=json,strict*/ + """{"Name":"Ada","Age":[1]}""" + ) + ); + + // Act + var result = await schema.DeserializeAndValidateAsync(stream); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.Member("Age")); + } + + public sealed class Person + { + public string? Name { get; set; } + + public int Age { get; set; } + } + + public sealed class Team + { + public List? Members { get; set; } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1819:Properties should not return arrays", + Justification = "The fixture exercises how the serializer reads arrays." + )] + public sealed class Roster + { + public List? Members { get; set; } + + public Dictionary? Scores { get; set; } + + public int[][]? Grid { get; set; } + + public byte[]? Payload { get; set; } + } + + public sealed class Household + { + public Person? Owner { get; set; } + } + + public interface IShape + { + int Sides { get; } + } + + sealed class PassThroughSchema : IZodSchema + { + public ValidationResult Validate(T value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + new(Validate(value)); + } + + sealed class PersonSchema : IZodSchema + { + public const string NameRequiredCode = "name_required"; + + public ValidationResult Validate(Person value) => + string.IsNullOrEmpty(value.Name) + ? ValidationResult.Failure( + new ValidationError( + NameRequiredCode, + "Name is required", + [nameof(Person.Name)], + origin: "string", + minimum: 1, + category: ZodIssueCategories.TooSmall + ) + ) + : ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + Person value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonExtensionsTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonExtensionsTests.cs index 3f89f0c8..225e51e4 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonExtensionsTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonExtensionsTests.cs @@ -36,14 +36,14 @@ public async Task DeserializeAndValidate_GivenValidJson_WithCamelCaseOptions_Ret } [Test] - public async Task DeserializeAndValidate_GivenMalformedJson_ReturnsJsonErrorFailure() + public async Task DeserializeAndValidate_GivenMalformedJson_ReturnsInvalidJsonFailure() { TestUserSchema schema = new(); var result = schema.DeserializeAndValidate("{bad json"); await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Code).IsEqualTo("json_error"); + await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_json"); } [Test] @@ -96,7 +96,7 @@ public async Task DeserializeAndValidateAsync_GivenValidJsonStream_ReturnsSucces } [Test] - public async Task DeserializeAndValidateAsync_GivenMalformedJsonStream_ReturnsJsonErrorFailure() + public async Task DeserializeAndValidateAsync_GivenMalformedJsonStream_ReturnsInvalidJsonFailure() { TestUserSchema schema = new(); var jsonBytes = Encoding.UTF8.GetBytes("{bad json"); @@ -105,7 +105,7 @@ public async Task DeserializeAndValidateAsync_GivenMalformedJsonStream_ReturnsJs var result = await schema.DeserializeAndValidateAsync(stream); await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Code).IsEqualTo("json_error"); + await Assert.That(result.Errors[0].Code).IsEqualTo("invalid_json"); } [Test] diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonServiceAwareTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonServiceAwareTests.cs new file mode 100644 index 00000000..6592a871 --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonServiceAwareTests.cs @@ -0,0 +1,138 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// The overloads of DeserializeAndValidateAsync and +/// ValidateAndSerializeAsync: the services reach the validator. +/// +public class SystemTextJsonServiceAwareTests +{ + [Test] + public async Task DeserializeAndValidateAsync_GivenServices_PassesThemToTheValidator( + CancellationToken cancellationToken + ) + { + // Arrange + BannedNames banned = new("Mallory"); + using MemoryStream stream = new( + Encoding.UTF8.GetBytes( /*lang=json,strict*/ + """{"name":"Mallory"}""" + ) + ); + + // Act + var result = await new ServiceAwareValidator().DeserializeAndValidateAsync( + stream, + ServiceAwareJsonContext.Default.Person, + banned, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Errors.Single().Code).IsEqualTo(ServiceAwareValidator.BannedCode); + } + + [Test] + public async Task DeserializeAndValidateAsync_GivenServicesAndMalformedJson_ReturnsInvalidJson( + CancellationToken cancellationToken + ) + { + // Arrange + using MemoryStream stream = new(Encoding.UTF8.GetBytes("{not json")); + + // Act + var result = await new ServiceAwareValidator().DeserializeAndValidateAsync( + stream, + ServiceAwareJsonContext.Default.Person, + new BannedNames(), + cancellationToken + ); + + // Assert + await Assert.That(result.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + } + + [Test] + public async Task ValidateAndSerializeAsync_GivenServices_WritesOnlyWhenTheServiceAwareRulePasses( + CancellationToken cancellationToken + ) + { + // Arrange + BannedNames banned = new("Mallory"); + ServiceAwareValidator validator = new(); + using MemoryStream rejected = new(); + using MemoryStream accepted = new(); + + // Act + var rejectedResult = await validator.ValidateAndSerializeAsync( + new Person { Name = "Mallory" }, + rejected, + ServiceAwareJsonContext.Default.Person, + banned, + cancellationToken + ); + var acceptedResult = await validator.ValidateAndSerializeAsync( + new Person { Name = "Alice" }, + accepted, + ServiceAwareJsonContext.Default.Person, + banned, + cancellationToken + ); + + // Assert + await Assert.That(rejectedResult.IsSuccess).IsFalse(); + await Assert.That(rejected.Length).IsEqualTo(0); + await Assert.That(acceptedResult.IsSuccess).IsTrue(); + await Assert + .That(Encoding.UTF8.GetString(accepted.ToArray())) + .IsEqualTo( /*lang=json,strict*/ + """{"name":"Alice"}""" + ); + } + + internal sealed class Person + { + public string? Name { get; set; } + } + + /// A service provider that is its own only service: the names that are not allowed. + sealed class BannedNames(params string[] names) : IServiceProvider + { + public bool Contains(string? name) => name is not null && names.Contains(name, StringComparer.Ordinal); + + public object? GetService(Type serviceType) => serviceType == typeof(BannedNames) ? this : null; + } + + /// Fails a banned name, which it can only know through the services it is given. + sealed class ServiceAwareValidator : IZodSchemaValidator + { + public const string BannedCode = "banned"; + + public ValidationResult Validate(Person value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + Person value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + + public ValueTask> ValidateAsync( + Person value, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) => + new( + services?.GetService(typeof(BannedNames)) is BannedNames banned && banned.Contains(value.Name) + ? ValidationResult.Failure(new ValidationError(BannedCode, "Name is banned")) + : ValidationResult.Success(value) + ); + } +} + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(SystemTextJsonServiceAwareTests.Person))] +sealed partial class ServiceAwareJsonContext : JsonSerializerContext; diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonStrictValueObjectTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonStrictValueObjectTests.cs new file mode 100644 index 00000000..9c49c43c --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonStrictValueObjectTests.cs @@ -0,0 +1,146 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using ZodSharp.Core; + +namespace ZodSharp.Json; + +/// +/// A value rejected by a strict value object converter while it is read (JsonException(inner: ZodException)) +/// is reported as its ZodSharp errors under the member that held it, not as invalid_json. +/// +public partial class SystemTextJsonStrictValueObjectTests +{ + const string InvalidEmailBody = /*lang=json,strict*/ + """{"customerEmail":"not-an-email","quantity":5}"""; + + [Test] + public async Task DeserializeAndValidateAsync_GivenStrictConverterRejection_ReturnsTheRerootedErrors( + CancellationToken cancellationToken + ) + { + // Arrange + PassThroughSchema schema = new(); + using MemoryStream stream = new(Encoding.UTF8.GetBytes(InvalidEmailBody)); + + // Act + var result = await schema.DeserializeAndValidateAsync( + stream, + StrictJsonContext.Default.PlaceOrderRequest, + cancellationToken + ); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_string"); + await Assert.That(error.Origin).IsEqualTo("string"); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("CustomerEmail.Value"); + await Assert.That(error.Message).IsEqualTo("Field 'CustomerEmail' must be a valid email address."); + } + + [Test] + public async Task DeserializeAndValidate_GivenStrictConverterRejection_ReturnsTheRerootedErrors() + { + // Arrange + PassThroughSchema schema = new(); + + // Act + var result = schema.DeserializeAndValidate(InvalidEmailBody, StrictJsonContext.Default.PlaceOrderRequest); + + // Assert + await Assert.That(result.Errors.Single().FullPath.ToDottedString()).IsEqualTo("CustomerEmail.Value"); + } + + [Test] + public async Task DeserializeAndValidate_GivenStrictConverterRejectionInsideValidatingConverter_ReportsTheFullPath() + { + // Arrange: the validating converter reads its value through a second serializer call, so the location is joined. + PassThroughSchema schema = new(); + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + options.Converters.Add(new PassThroughSchema().CreateValidatingConverter()); + var typeInfo = (JsonTypeInfo)options.TypeInfoResolver.GetTypeInfo(typeof(Wrapper), options)!; + + // Act + var result = schema.DeserializeAndValidate( /*lang=json,strict*/ + """{"Order":{"CustomerEmail":"not-an-email","Quantity":5}}""", + typeInfo + ); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_string"); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("Order.CustomerEmail.Value"); + } + + [Test] + public async Task DeserializeAndValidate_GivenJsonExceptionWithoutZodErrors_StillReportsInvalidJson() + { + // Arrange + PassThroughSchema schema = new(); + + // Act + var result = schema.DeserializeAndValidate( + """{"customerEmail":""", + StrictJsonContext.Default.PlaceOrderRequest + ); + + // Assert + await Assert.That(result.Errors.Single().Code).IsEqualTo(SystemTextJsonExtensions.InvalidJsonCode); + } + + public sealed record Wrapper(PlaceOrderRequest? Order); + + public sealed record PlaceOrderRequest(EmailAddress CustomerEmail, int Quantity); + + [JsonConverter(typeof(EmailAddressConverter))] + public readonly record struct EmailAddress(string Value); + + /// What a Purview.ValueObjects strict converter does when the value object's schema rejects the value. + public sealed class EmailAddressConverter : JsonConverter + { + public override EmailAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString() ?? string.Empty; + if (value.Contains('@', StringComparison.Ordinal)) + return new(value); + + Dictionary parameters = new(StringComparer.Ordinal) + { + [ZodIssueParameters.DisplayName] = "Value", + }; + var error = ValidationError.Create( + "invalid_string", + "Field 'Value' must be a valid email address.", + ZodPath.Member("Value"), + parameters, + origin: "string", + category: ZodIssueCategories.InvalidFormat + ) with + { + MessageFormat = "Field '{displayName}' must be a valid email address.", + }; + + throw new JsonException( + "Cannot deserialize EmailAddress: the value failed validation.", + new ZodException([error]) + ); + } + + public override void Write(Utf8JsonWriter writer, EmailAddress value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + sealed class PassThroughSchema : IZodSchema + { + public ValidationResult Validate(T value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync(T value, CancellationToken cancellationToken = default) => + new(Validate(value)); + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] + [JsonSerializable(typeof(PlaceOrderRequest))] + internal sealed partial class StrictJsonContext : JsonSerializerContext; +} diff --git a/src/tests/TrimSafety.Verification/CoreChecks.cs b/src/tests/TrimSafety.Verification/CoreChecks.cs new file mode 100644 index 00000000..0acb66f8 --- /dev/null +++ b/src/tests/TrimSafety.Verification/CoreChecks.cs @@ -0,0 +1,90 @@ +using ZodSharp.Core; + +namespace ZodSharp.TrimSafety; + +/// Runtime schemas, discriminated unions and generated registration in Purview.ZodSharp. +static class CoreChecks +{ + public static void RuntimeSchemas(Harness h) + { + var email = Z.String().Min(3).Email(); + h.Expect(email.Validate("someone@example.com").IsSuccess, "a valid email passes"); + h.Expect(!email.Validate("x").IsSuccess, "an invalid email fails"); + + var person = Z.Object().Field("name", Z.String().Min(2)).Field("age", Z.Number().Min(0).Int()).Build(); + var valid = person.Validate(new Dictionary { ["name"] = "Ada", ["age"] = 36d }); + h.Expect(valid.IsSuccess, "a valid object passes"); + + var invalid = person.Validate(new Dictionary { ["name"] = "A", ["age"] = -1d }); + h.ExpectPaths(invalid.Errors, ["name", "age"], "an invalid object reports each field"); + + var pets = Z.DiscriminatedUnion("kind") + .Option("cat", Z.Object().Field("kind", Z.String()).Field("lives", Z.Number().Max(9)).Build()) + .Option("dog", Z.Object().Field("kind", Z.String()).Field("bark", Z.String().Min(1)).Build()) + .Build(); + h.Expect( + pets.Validate(new Dictionary { ["kind"] = "cat", ["lives"] = 9d }).IsSuccess, + "a discriminated union over a dictionary selects its option" + ); + h.Expect( + !pets.Validate(new Dictionary { ["kind"] = "cow" }).IsSuccess, + "an unknown discriminator fails" + ); + + var stringOrNumber = Z.String().Min(2).Or(Z.Number().Min(0)); + h.Expect(stringOrNumber.Validate("ab").IsSuccess, "a typed union accepts its first option"); + h.Expect(stringOrNumber.Validate(3d).IsSuccess, "a typed union accepts its second option"); + h.Expect(!stringOrNumber.Validate("a").IsSuccess, "a typed union rejects a value no option accepts"); + + var native = Z.NativeUnion(Z.String(), Z.Number()); + h.Expect(native.Validate("x").IsSuccess, "a native union accepts an option"); + + var compiled = Expressions.CompiledValidator.Compile(email); + h.Expect(compiled("someone@example.com").IsSuccess, "CompiledValidator binds the schema without codegen"); + } + + public static void PocoDiscriminatedUnion(Harness h) + { + var shapes = Z.DiscriminatedUnion("kind") + .Option("circle", new CircleShapeSchemaValidator()) + .Option("square", new SquareShapeSchemaValidator()) + .Build(); + + h.Expect(shapes.Validate(new CircleShape { Radius = 3 }).IsSuccess, "Option reads a POCO discriminator"); + h.ExpectPaths( + shapes.Validate(new SquareShape { Side = 0 }).Errors, + ["Side"], + "the POCO is validated by its option's schema" + ); + + // A POCO of a type the union was not told about needs the reflection fallback, which trimming removes + // (ZodSharp.Reflection.IsSupported is a feature guard): published, the value is reported as missing its + // discriminator rather than read through metadata that may be gone. + var untyped = Z.DiscriminatedUnion("kind").Option("circle", new AcceptAll()).Build(); + var result = untyped.Validate(new UnregisteredShape()); + + // A native image compiles nothing at run time (dotnet run of a PublishAot project reports dynamic code as + // unsupported too, so RuntimeFeature cannot tell the two apart). + var isNative = System.Runtime.JitInfo.GetCompiledMethodCount() == 0; + h.Expect( + !isNative ? result.IsSuccess : !result.IsSuccess, + !isNative + ? "untrimmed, the reflection fallback reads an unregistered POCO's discriminator" + : "trimmed, the reflection fallback is removed and the union fails closed" + ); + } + + public static void Registration(Harness h) + { + ZodSchemaFactory generated = new(); + Generated.ZodSharpTrimSafetyVerificationZodSharpSchemas.Register(generated); + h.Expect(generated.Resolve() is not null, "the generated Register names every validator"); + + ZodSchemaFactory scanned = new(); + scanned.RegisterFromAssembly(typeof(Order).Assembly); + h.Expect( + scanned.Resolve() is not null && scanned.TryGetUntypedValidator(typeof(Address), out _), + "RegisterFromAssembly reads [ZodSchemaGenerated] metadata, which the trimmer keeps" + ); + } +} diff --git a/src/tests/TrimSafety.Verification/GeneratedChecks.cs b/src/tests/TrimSafety.Verification/GeneratedChecks.cs new file mode 100644 index 00000000..41c62070 --- /dev/null +++ b/src/tests/TrimSafety.Verification/GeneratedChecks.cs @@ -0,0 +1,113 @@ +using ZodSharp.Core; + +namespace ZodSharp.TrimSafety; + +/// The code the [ZodSchema] generator emits into a trimmed application. +static class GeneratedChecks +{ + public static Order ValidOrder() => + new() + { + CustomerEmail = "buyer@example.com", + ShippingAddress = new() { Street = "1 Main St", City = "Springfield" }, + Lines = [new() { Sku = "ABC-1234", Quantity = 2 }], + AdditionalAddresses = new() + { + ["billing"] = new() { Street = "2 Side St", City = "Shelbyville" }, + }, + Pet = new Cat(), + Status = OrderStatus.Placed, + }; + + public static Order InvalidOrder() => + new() + { + CustomerEmail = "not-an-email", + ShippingAddress = new() { Street = "", City = "Springfield" }, + Lines = [new() { Sku = "abc", Quantity = 0 }], + AdditionalAddresses = new() + { + ["billing"] = new() { Street = "2 Side St", City = null }, + }, + Pet = new Cat { Name = "T" }, + Status = (OrderStatus)42, + Notes = "forbidden", + }; + + public static void GeneratedSchema(Harness h) + { + h.Expect(OrderSchema.Validate(ValidOrder()).IsSuccess, "a valid order passes"); + + var result = OrderSchema.Validate(InvalidOrder()); + h.ExpectPaths( + result.Errors, + [ + "CustomerEmail", + "ShippingAddress.Street", + "Lines[0].Sku", + "Lines[0].Quantity", + "AdditionalAddresses[billing].City", + "Pet.Name", + "Status", + ], + "an invalid order reports nested, collection, dictionary, union and enum errors" + ); + + // IValidatableObject runs once the member rules pass, as it does under DataAnnotations. + var forbidden = ValidOrder(); + forbidden.Notes = "forbidden"; + h.ExpectPaths( + OrderSchema.Validate(forbidden).Errors, + ["Notes"], + "IValidatableObject.Validate runs through the generated schema" + ); + + var street = result.Errors.FirstOrDefault(static e => e.FullPath.ToDottedString() == "ShippingAddress.Street"); + h.Expect( + street.Message?.Contains("Street line", StringComparison.Ordinal) == true, + $"the display name comes from DisplayAttribute.ResourceType (got '{street.Message}')" + ); + } + + public static void MemberValidation(Harness h) + { + var order = InvalidOrder(); + + var member = OrderSchema.ValidateMember(order, nameof(Order.CustomerEmail)); + h.ExpectPaths(member.Errors, ["CustomerEmail"], "ValidateMember reports only the member"); + + IZodUntypedValidator validator = new OrderSchemaValidator(); + var errors = validator.Validate(order); + var city = errors.First(static e => e.FullPath.ToDottedString() == "AdditionalAddresses[billing].City"); + var resolved = validator.TryResolveMember(order, city.FullPath, out var container, out var memberName); + h.Expect( + resolved && ReferenceEquals(container, order.AdditionalAddresses["billing"]) && memberName == "City", + "TryResolveMember resolves the owning object without reflection" + ); + } + + public static void CollectionCounts(Harness h) + { + var empty = ValidOrder(); + empty.Lines = []; + var tooFew = OrderSchema.Validate(empty).Errors.Single(); + h.Expect( + tooFew.Code == "too_small" && tooFew.FullPath.ToDottedString() == "Lines" && tooFew.Minimum == 1, + $"[MinCount] reports too_small at the member (got {tooFew.Code} at {tooFew.FullPath.ToDottedString()})" + ); + + var full = ValidOrder(); + full.Lines = [.. Enumerable.Range(0, 4).Select(static _ => new OrderLine { Sku = "ABC-1234" })]; + var tooMany = OrderSchema.Validate(full).Errors.Single(); + h.Expect(tooMany.Code == "too_big" && tooMany.Maximum == 3, "[MaxCount] reports too_big with the maximum"); + + var crowded = ValidOrder(); + for (var i = 0; i < 3; i++) + crowded.AdditionalAddresses[$"extra{i}"] = new() { Street = "s", City = "c" }; + var range = OrderSchema.Validate(crowded).Errors.Single(); + h.Expect( + range.Code == "too_big" && range.Maximum == 2 && range.FullPath.ToDottedString() == "AdditionalAddresses", + $"[CountRange] counts dictionary entries (got {range.Code}/{range.Origin} at {range.FullPath.ToDottedString()})" + ); + } +} diff --git a/src/tests/TrimSafety.Verification/Harness.cs b/src/tests/TrimSafety.Verification/Harness.cs new file mode 100644 index 00000000..5708563c --- /dev/null +++ b/src/tests/TrimSafety.Verification/Harness.cs @@ -0,0 +1,103 @@ +using System.Collections.Immutable; +using ZodSharp.Core; +using ValidationError = ZodSharp.Core.ValidationError; + +namespace ZodSharp.TrimSafety; + +/// An object-typed option that accepts anything, so it carries no CLR type for the union to read. +sealed class AcceptAll : IZodSchema +{ + public ValidationResult Validate(object value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + object value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); +} + +/// +/// Collects check results. A check that throws is a failure too, so an exception from trimmed-away metadata or +/// missing AOT code is reported rather than ending the run. +/// +sealed class Harness +{ + readonly List _failures = []; + string _section = string.Empty; + int _passed; + + public void Run(string section, Action check) + { + _section = section; + try + { + check(this); + } + catch (Exception ex) + { + Fail($"threw {ex.GetType().Name}: {ex.Message}"); + } + } + + public async Task RunAsync(string section, Func check) + { + _section = section; + try + { + await check(this); + } + catch (Exception ex) + { + Fail($"threw {ex.GetType().Name}: {ex.Message}"); + } + } + + public void Expect(bool condition, string description) + { + if (condition) + { + _passed++; + Console.WriteLine($" pass [{_section}] {description}"); + } + else + { + Fail(description); + } + } + + public void ExpectPaths(ImmutableArray errors, string[] expected, string description) + { + var actual = errors.IsDefault + ? [] + : errors.Select(static e => e.FullPath.ToDottedString()).Distinct().ToArray(); + var missing = expected.Except(actual).ToArray(); + var unexpected = actual.Except(expected).ToArray(); + Expect( + missing.Length == 0 && unexpected.Length == 0, + missing.Length == 0 && unexpected.Length == 0 + ? description + : $"{description} (missing: [{string.Join(", ", missing)}], unexpected: [{string.Join(", ", unexpected)}])" + ); + } + + public void Fail(string description) + { + _failures.Add($"[{_section}] {description}"); + Console.WriteLine($" FAIL [{_section}] {description}"); + } + + public int Complete() + { + Console.WriteLine(); + if (_failures.Count == 0) + { + Console.WriteLine($"Trim and AOT verification passed: {_passed} checks."); + return 0; + } + + Console.WriteLine($"Trim and AOT verification FAILED: {_failures.Count} failure(s), {_passed} passed."); + foreach (var failure in _failures) + Console.WriteLine($" {failure}"); + + return 1; + } +} diff --git a/src/tests/TrimSafety.Verification/HttpChecks.cs b/src/tests/TrimSafety.Verification/HttpChecks.cs new file mode 100644 index 00000000..6d0a823f --- /dev/null +++ b/src/tests/TrimSafety.Verification/HttpChecks.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Text; + +namespace ZodSharp.TrimSafety; + +/// Purview.ZodSharp.Http: validated request and response bodies, and reading validation problems. +static class HttpChecks +{ + public static async Task HttpClient(Harness h) + { + using EchoHandler handler = new(); + using HttpClient client = new(handler) { BaseAddress = new("https://harness.test/") }; + CustomerSchemaValidator schema = new(); + var typeInfo = HarnessJsonContext.Default.Customer; + + var sent = await client.PostAsJsonValidatedAsync( + "customers", + new Customer { Name = "Grace", Age = 85 }, + schema, + typeInfo + ); + h.Expect( + sent.IsSuccess && handler.Requests == 1, + "a valid value is sent through the source-generated contract" + ); + + using var response = sent.Value!; + var read = await response.Content.ReadFromJsonAndValidateAsync(schema, typeInfo); + h.Expect(read.IsSuccess && read.Value!.Name == "Grace", "the response body reads back and validates"); + + var rejected = await client.PostAsJsonValidatedAsync( + "customers", + new Customer { Name = "A", Age = 200 }, + schema, + typeInfo + ); + h.Expect(!rejected.IsSuccess && handler.Requests == 1, "an invalid value is never sent"); + + using HttpResponseMessage problemResponse = new(HttpStatusCode.BadRequest) + { + Content = new StringContent( + /*lang=json,strict*/""" + {"status":400,"issues":[{"path":"Name","segments":["Name"],"code":"too_small","message":"Too short", + "minimum":2,"inclusive":true}]} + """, + Encoding.UTF8, + "application/problem+json" + ), + }; + var problem = await problemResponse.TryReadZodValidationProblemAsync(); + var errors = problem?.ToValidationErrors() ?? []; + h.Expect( + errors.Length == 1 && errors[0].Code == "too_small" && errors[0].Lower?.Value is 2L, + "a validation problem reads through the package's source-generated context" + ); + } + + /// Answers every request with its own body, so a round trip needs no network. + sealed class EchoHandler : HttpMessageHandler + { + public int Requests { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Requests++; + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + } + } +} diff --git a/src/tests/TrimSafety.Verification/IntegrationChecks.cs b/src/tests/TrimSafety.Verification/IntegrationChecks.cs new file mode 100644 index 00000000..b54b9874 --- /dev/null +++ b/src/tests/TrimSafety.Verification/IntegrationChecks.cs @@ -0,0 +1,192 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using ZodSharp.AspNetCore; +using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.SystemTextJson; + +namespace ZodSharp.TrimSafety; + +/// The System.Text.Json, JSON Schema, Microsoft.Extensions.Validation, ProblemDetails and options integrations. +static class IntegrationChecks +{ + public static void AssemblyScanning(Harness h) + { + ServiceCollection services = new(); + services.AddZodSharp(static options => options.ScanAssemblies.Add(typeof(Order).Assembly)); + using var provider = services.BuildServiceProvider(); + h.Expect( + provider.GetRequiredService().Resolve() is not null, + "ScanAssemblies registers generated validators from [ZodSchemaGenerated] metadata" + ); + + ServiceCollection loaded = new(); + loaded.AddZodSharp(static options => options.ScanLoadedAssemblies = true); + using var loadedProvider = loaded.BuildServiceProvider(); + h.Expect( + loadedProvider.GetRequiredService().Resolve() is not null, + "ScanLoadedAssemblies finds the generated validators" + ); + } + + public static void SystemTextJson(Harness h) + { + CustomerSchemaValidator schema = new(); + var typeInfo = HarnessJsonContext.Default.Customer; + + var valid = schema.DeserializeAndValidate( /*lang=json,strict*/ + """{"name":"Ada","age":36,"tags":["a"]}""", + typeInfo + ); + h.Expect(valid.IsSuccess && valid.Value.Name == "Ada", "a valid document deserializes and validates"); + + var invalid = schema.DeserializeAndValidate( /*lang=json,strict*/ + """{"name":"A","age":200,"tags":["a","b","c"]}""", + typeInfo + ); + h.ExpectPaths(invalid.Errors, ["Name", "Age", "Tags"], "an invalid document reports each member"); + + var malformed = schema.DeserializeAndValidate("""{"name":""", typeInfo); + h.Expect( + !malformed.IsSuccess && malformed.Errors[0].Code == SystemTextJsonExtensions.InvalidJsonCode, + "malformed JSON is reported as invalid_json" + ); + + var serialized = schema.ValidateAndSerialize(new Customer { Name = "Grace", Age = 85 }, typeInfo); + h.Expect( + serialized.IsSuccess && serialized.Value.Contains("\"Grace\"", StringComparison.Ordinal), + "ValidateAndSerialize writes through the source-generated contract" + ); + } + + public static void JsonSchemaRoundTrip(Harness h) + { + var typeInfo = JsonSchemaSerializerOptions.Default.GetTypeInfo(); + + var generated = OrderSchema.BuildJsonSchema(); + var json = JsonSerializer.Serialize(generated, typeInfo); + h.Expect(json.Contains("\"minItems\": 1", StringComparison.Ordinal), "the generated schema exports minItems"); + h.Expect( + json.Contains("\"maxProperties\": 2", StringComparison.Ordinal), + "the generated schema exports maxProperties" + ); + + var runtime = Z.ToJsonSchema( + Z.Object() + .Field("name", Z.String().Min(2).Max(10)) + .Field("kind", Z.Enum("a", "b")) + .Field("tags", Z.Array(Z.String()).Min(1)) + .Build() + ); + var runtimeJson = JsonSerializer.Serialize(runtime, typeInfo); + h.Expect( + runtimeJson.Contains("\"minLength\": 2", StringComparison.Ordinal) + && runtimeJson.Contains("\"enum\"", StringComparison.Ordinal), + "a runtime schema exports its constraints and enum values" + ); + + var imported = Z.FromJsonSchema(runtimeJson); + h.Expect( + imported + .Validate( + new Dictionary + { + ["name"] = "Ada", + ["kind"] = "a", + ["tags"] = new object?[] { "x" }, + } + ) + .IsSuccess, + "an exported schema imports and validates" + ); + h.Expect( + !imported + .Validate( + new Dictionary + { + ["name"] = "A", + ["kind"] = "z", + ["tags"] = new object?[] { "x" }, + } + ) + .IsSuccess, + "the imported schema enforces the exported constraints" + ); + } + + public static async Task ValidationBridge(Harness h) + { + ServiceCollection services = new(); + services.AddZodSharpValidation(); + services.AddZodSharpZodSharpTrimSafetyVerificationSchemas(); + await using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + var report = await options.ValidateAsync(GeneratedChecks.InvalidOrder(), provider); + h.Expect( + report.Errors.Any(static e => e.FullPath.ToDottedString() == "Lines[0].Sku"), + "ValidationOptions validates through the ZodSharp resolver with full-fidelity errors" + ); + + var clean = await options.ValidateAsync(GeneratedChecks.ValidOrder(), provider); + h.Expect(clean.Errors.Count == 0, "a valid order produces no errors through the bridge"); + } + + public static async Task ProblemDetails(Harness h) + { + ZodProblemDetailsWriter writer = new(new ZodProblemDetailsOptions()); + var errors = OrderSchema.Validate(GeneratedChecks.InvalidOrder()).Errors; + + var problem = writer.Create(errors.AsSpan()); + h.Expect(problem.Status == StatusCodes.Status400BadRequest, "the problem is a 400"); + h.Expect(problem.Errors.ContainsKey("Lines[0].Sku"), "errors are keyed by path"); + + DefaultHttpContext context = new(); + using MemoryStream body = new(); + context.Response.Body = body; + await writer.WriteAsync(context, errors.AsSpan()); + + var written = Encoding.UTF8.GetString(body.ToArray()); + using var document = JsonDocument.Parse(written); + h.Expect( + document.RootElement.TryGetProperty("issues", out var issues) && issues.GetArrayLength() > 0, + "the response body is written through the source-generated ProblemDetails contract" + ); + h.Expect( + context.Response.ContentType?.StartsWith("application/problem+json", StringComparison.Ordinal) == true, + "the response is application/problem+json" + ); + } + + public static void OptionsValidation(Harness h) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Smtp:Port"] = "70000" }) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddZodSharpZodSharpTrimSafetyVerificationSchemas(); + services.AddOptions().BindConfiguration("Smtp").ValidateWithZod("Smtp", validateOnStart: false); + using var provider = services.BuildServiceProvider(); + + try + { + _ = provider.GetRequiredService>().Value; + h.Fail("invalid options were accepted"); + } + catch (OptionsValidationException ex) + { + h.Expect( + ex.Failures.Any(static f => f.StartsWith("Smtp:Host:", StringComparison.Ordinal)) + && ex.Failures.Any(static f => f.StartsWith("Smtp:Port:", StringComparison.Ordinal)), + $"failures are named by configuration key (got: {string.Join(" | ", ex.Failures)})" + ); + } + } +} diff --git a/src/tests/TrimSafety.Verification/Models.cs b/src/tests/TrimSafety.Verification/Models.cs new file mode 100644 index 00000000..f535bb58 --- /dev/null +++ b/src/tests/TrimSafety.Verification/Models.cs @@ -0,0 +1,151 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using ZodSharp.Rules; + +namespace ZodSharp.TrimSafety; + +/// The order status; validated as a defined enum member. +enum OrderStatus +{ + Draft, + Placed, + Shipped, +} + +/// Display names looked up through . +static class Labels +{ + public static string Street => "Street line"; +} + +/// +/// An order: a nested object, a counted collection, a dictionary, a union member, an enum, a display-name +/// resource and an rule - the generated-code features a trimmed application uses. +/// +[ZodSchema] +sealed class Order : IValidatableObject +{ + [Required] + [EmailAddress] + public string? CustomerEmail { get; set; } + + [Required] + public Address? ShippingAddress { get; set; } + + [MinCount(1)] + [MaxCount(3)] + public List Lines { get; set; } = []; + + [CountRange(0, 2)] + public Dictionary AdditionalAddresses { get; set; } = []; + + public Pet Pet { get; set; } + + public OrderStatus Status { get; set; } + + public string? Notes { get; set; } + + public IEnumerable Validate( + ValidationContext validationContext + ) + { + if (string.Equals(Notes, "forbidden", StringComparison.Ordinal)) + yield return new("Notes may not be 'forbidden'.", [nameof(Notes)]); + } +} + +[ZodSchema] +sealed class OrderLine +{ + [Required] + [RegularExpression("^[A-Z]{3}-[0-9]{4}$")] + public string? Sku { get; set; } + + [Range(1, 100)] + public int Quantity { get; set; } = 1; +} + +[ZodSchema] +sealed class Address +{ + [Required] + [StringLength(100, MinimumLength = 1)] + [Display(Name = nameof(Labels.Street), ResourceType = typeof(Labels))] + public string? Street { get; set; } + + [Required] + public string? City { get; set; } +} + +[ZodSchema] +sealed class Cat +{ + // [StringLength], not [MinLength]: MinLengthAttribute's constructor is [RequiresUnreferencedCode] on any member. + [StringLength(50, MinimumLength = 2)] + public string? Name { get; set; } = "Tom"; +} + +[ZodSchema] +sealed class Dog +{ + [Range(1, 100)] + public int Weight { get; set; } = 1; +} + +/// A C# union member, dispatched to the case's generated schema. +readonly union Pet(Cat, Dog); + +/// A JSON request model, deserialized through a source-generated . +[ZodSchema] +sealed class Customer +{ + [Required] + [StringLength(50, MinimumLength = 2)] + public string? Name { get; set; } + + [Range(0, 150)] + public int Age { get; set; } + + [MaxCount(2)] + public List Tags { get; set; } = []; +} + +/// Options validated at startup with ValidateWithZod, bound by the configuration binder generator. +[ZodSchema] +sealed class SmtpOptions +{ + [Required] + public string? Host { get; set; } + + [Range(1, 65535)] + public int Port { get; set; } = 25; +} + +/// A discriminated-union option over a CLR type, supplied through Option<T>. +[ZodSchema] +sealed class CircleShape +{ + public string Kind { get; set; } = "circle"; + + [Range(1, 10)] + public int Radius { get; set; } = 1; +} + +[ZodSchema] +sealed class SquareShape +{ + public string Kind { get; set; } = "square"; + + [Range(1, 10)] + public int Side { get; set; } = 1; +} + +/// A shape whose type the union is not told about, so reading its discriminator needs the reflection fallback. +sealed class UnregisteredShape +{ + public string Kind { get; set; } = "circle"; +} + +[JsonSerializable(typeof(Customer))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +sealed partial class HarnessJsonContext : JsonSerializerContext { } diff --git a/src/tests/TrimSafety.Verification/Program.cs b/src/tests/TrimSafety.Verification/Program.cs new file mode 100644 index 00000000..0fa32e28 --- /dev/null +++ b/src/tests/TrimSafety.Verification/Program.cs @@ -0,0 +1,24 @@ +// The trim and Native AOT verification harness. Each check exercises one part of the trim-safe surface and records a +// failure instead of throwing, so one run reports every mismatch; any failure makes the process exit non-zero. +Harness harness = new(); + +harness.Run("runtime schemas", CoreChecks.RuntimeSchemas); +harness.Run("generated schema", GeneratedChecks.GeneratedSchema); +harness.Run("discriminated unions over CLR types", CoreChecks.PocoDiscriminatedUnion); +harness.Run("member validation and resolution", GeneratedChecks.MemberValidation); +harness.Run("collection-count attributes", GeneratedChecks.CollectionCounts); +harness.Run("generated registration and assembly metadata", CoreChecks.Registration); +harness.Run("assembly scanning through AddZodSharp", IntegrationChecks.AssemblyScanning); +harness.Run("System.Text.Json with a source-generated context", IntegrationChecks.SystemTextJson); +harness.Run("JSON Schema export and import", IntegrationChecks.JsonSchemaRoundTrip); +await harness.RunAsync("HttpClient with validated bodies and validation problems", HttpChecks.HttpClient); +await harness.RunAsync("Microsoft.Extensions.Validation bridge", IntegrationChecks.ValidationBridge); +await harness.RunAsync("ZodProblemDetailsWriter without a host", IntegrationChecks.ProblemDetails); +harness.Run("options validation with ValidateWithZod", IntegrationChecks.OptionsValidation); +await harness.RunAsync("strict value object deserialization in a minimal API", StrictDeserializationChecks.MinimalAPI); +await harness.RunAsync( + "strict value object deserialization through the JSON integrations", + StrictDeserializationChecks.JsonIntegrations +); + +return harness.Complete(); diff --git a/src/tests/TrimSafety.Verification/StrictDeserializationChecks.cs b/src/tests/TrimSafety.Verification/StrictDeserializationChecks.cs new file mode 100644 index 00000000..884fc478 --- /dev/null +++ b/src/tests/TrimSafety.Verification/StrictDeserializationChecks.cs @@ -0,0 +1,181 @@ +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ZodSharp.Core; + +// A stand-in for the Purview.ValueObjects [Scalar] attribute, which the generator recognizes by name and namespace. +#pragma warning disable IDE0130 // The stand-in must live in the namespace the generator recognizes. +namespace Purview.ValueObjects.Serialization +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + sealed class ScalarAttribute(string propertyName = "Value") : Attribute + { + public string PropertyName { get; } = propertyName; + } +} + +#pragma warning restore IDE0130 + +namespace ZodSharp.TrimSafety +{ + /// A scalar value object read by a strict converter, as Purview.ValueObjects generates it. + [Purview.ValueObjects.Serialization.Scalar] + [ZodSchema] + [JsonConverter(typeof(ContactEmailJsonConverter))] + readonly partial record struct ContactEmail + { + public ContactEmail(string value) => Value = value; + + [Required] + [EmailAddress] + public string Value { get; } + } + + [ZodSchema] + sealed partial record PlaceOrderRequest(ContactEmail CustomerEmail, int Quantity); + + /// Rejects an invalid value with JsonException(inner: ZodException). + sealed class ContactEmailJsonConverter : JsonConverter + { + public override ContactEmail Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var result = ContactEmailSchema.Validate(new ContactEmail(reader.GetString() ?? string.Empty)); + return result.IsSuccess + ? result.Value + : throw new JsonException( + "Cannot deserialize ContactEmail: the value failed validation.", + new ZodException(result.Errors) + ); + } + + public override void Write(Utf8JsonWriter writer, ContactEmail value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] + [JsonSerializable(typeof(PlaceOrderRequest))] + sealed partial class StrictJsonContext : JsonSerializerContext; + + /// + /// A body rejected by a strict value object converter while it is deserialized: located under the member that held + /// the value, in a Native AOT minimal API (request delegate generator, slim builder, source-generated contract) and + /// through the System.Text.Json and HttpClient integrations. + /// + static class StrictDeserializationChecks + { + const string InvalidEmailBody = /*lang=json,strict*/ + """{"customerEmail":"not-an-email","quantity":5}"""; + const string ExpectedKey = "CustomerEmail.Value"; + const string ExpectedMessage = "Field 'CustomerEmail' must be a valid email address."; + + static readonly HttpClient Client = new(); + + public static async Task MinimalAPI(Harness h) + { + foreach (var handleBindingFailures in new[] { true, false }) + { + var mode = handleBindingFailures ? "HandleBindingFailures" : "the .NET 11 binding problem"; + await using var app = await StartAsync(handleBindingFailures); + + using var rejected = await PostAsync(app, InvalidEmailBody); + var root = rejected.Document?.RootElement; + h.Expect( + rejected.Status == 400 && rejected.MediaType == "application/problem+json", + $"a rejected value object is a 400 problem ({mode})" + ); + h.Expect( + root is { } r + && r.TryGetProperty("errors", out var errors) + && errors.TryGetProperty(ExpectedKey, out var messages) + && messages[0].GetString() == ExpectedMessage, + $"the error is keyed by the member path and named after the member ({mode}; got {rejected.Text})" + ); + h.Expect( + root is { } issueRoot + && issueRoot.TryGetProperty("issues", out var issues) + && issues[0].GetProperty("code").GetString() == "invalid_string" + && string.Join( + '|', + issues[0].GetProperty("segments").EnumerateArray().Select(static s => s.GetString()) + ) == "CustomerEmail|Value", + $"the issue keeps its code and carries the member segments ({mode})" + ); + } + + await using var optedIn = await StartAsync(handleBindingFailures: true); + using var malformed = await PostAsync(optedIn, """{"customerEmail":"""); + h.Expect( + malformed.Status == 400 + && malformed.MediaType == "application/problem+json" + && malformed.Document?.RootElement.TryGetProperty("issues", out _) == false, + "malformed JSON is a standard 400 problem without issues" + ); + } + + public static async Task JsonIntegrations(Harness h) + { + var typeInfo = StrictJsonContext.Default.PlaceOrderRequest; + PlaceOrderRequestSchemaValidator validator = new(); + + var deserialized = validator.DeserializeAndValidate(InvalidEmailBody, typeInfo); + h.Expect( + !deserialized.IsSuccess + && deserialized.Errors.Single().FullPath.ToDottedString() == ExpectedKey + && deserialized.Errors.Single().Message == ExpectedMessage, + "DeserializeAndValidate reports the rerooted error, not invalid_json" + ); + + using StringContent content = new(InvalidEmailBody, Encoding.UTF8, "application/json"); + var read = await content.ReadFromJsonAndValidateAsync(validator, typeInfo); + h.Expect( + !read.IsSuccess && read.Errors.Single().FullPath.ToDottedString() == ExpectedKey, + "ReadFromJsonAndValidateAsync reports the rerooted error, not invalid_json" + ); + } + + static async Task StartAsync(bool handleBindingFailures) + { + var builder = WebApplication.CreateSlimBuilder( + new WebApplicationOptions { EnvironmentName = Environments.Production } + ); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, StrictJsonContext.Default) + ); + builder.Services.AddZodSharpProblemDetails(o => o.HandleBindingFailures = handleBindingFailures); + + var app = builder.Build(); + app.UseExceptionHandler(); + app.MapPost("/orders", static (PlaceOrderRequest request) => TypedResults.Ok()); + await app.StartAsync(); + + return app; + } + + static async Task PostAsync(WebApplication app, string body) + { + using StringContent content = new(body, Encoding.UTF8, "application/json"); + using var response = await Client.PostAsync(new Uri(new Uri(app.Urls.First()), "/orders"), content); + var text = await response.Content.ReadAsStringAsync(); + return new( + (int)response.StatusCode, + response.Content.Headers.ContentType?.MediaType, + text, + text.Length == 0 ? null : JsonDocument.Parse(text) + ); + } + + sealed record Response(int Status, string? MediaType, string Text, JsonDocument? Document) : IDisposable + { + public void Dispose() => Document?.Dispose(); + } + } +} diff --git a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj new file mode 100644 index 00000000..fbef804f --- /dev/null +++ b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj @@ -0,0 +1,127 @@ + + + + + net11.0 + + Exe + false + false + + true + ZodSharp.TrimSafety + ZodSharp.TrimSafety.Verification + + true + + $(NoWarn);CS1591;CA1506;CA1849 + + $(NoWarn);IL2101;IL2104;IL3053 + false + true + + true + + false + + true + true + true + true + + 11.0.0-rc.3.26522.148 + + + + + + + + + + + + + + + + + + + + + + + + + Rooted + $(NoWarn);IL3054 + + + + + + + + + + + + + + + + + + diff --git a/src/tests/ZodSharp.UnitTests/Core/DefaultZodMessageFormatterTests.cs b/src/tests/ZodSharp.UnitTests/Core/DefaultZodMessageFormatterTests.cs new file mode 100644 index 00000000..57b54b97 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/DefaultZodMessageFormatterTests.cs @@ -0,0 +1,52 @@ +using System.Globalization; + +namespace ZodSharp.Core; + +public class DefaultZodMessageFormatterTests +{ + [Test] + public async Task Format_GivenCompleteMessageFormat_FormatsInTheRequestedCulture() + { + // Arrange + var error = ValidationError.Create("too_small", "Must be at least 1.5.", ZodPath.Member("Price")) with + { + MessageFormat = "Must be at least {minimum}.", + Lower = ErrorBound.Including(1.5), + }; + + // Act + var message = DefaultZodMessageFormatter.Instance.Format(error, CultureInfo.GetCultureInfo("fr-FR")); + + // Assert + await Assert.That(message).IsEqualTo("Must be at least 1,5."); + } + + [Test] + public async Task Format_GivenUnresolvedPlaceholder_ReturnsTheMessage() + { + // Arrange + var error = ValidationError.Create("custom", "Original.", ZodPath.Root) with + { + MessageFormat = "Needs {unknown}.", + }; + + // Act + var message = DefaultZodMessageFormatter.Instance.Format(error, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("Original."); + } + + [Test] + public async Task Format_GivenNoMessageFormat_ReturnsTheMessage() + { + // Arrange + var error = ValidationError.Create("custom", "Literal {braces}.", ZodPath.Root); + + // Act + var message = DefaultZodMessageFormatter.Instance.Format(error, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("Literal {braces}."); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs b/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs index 9acc7be0..0322b989 100644 --- a/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs +++ b/src/tests/ZodSharp.UnitTests/Core/MultiTargetingTests.cs @@ -6,17 +6,11 @@ namespace ZodSharp.Core; /// /// Verifies the multi-targeting guarantee for the core package: every test run resolves a /// for the core assembly that matches one of the supported target -/// frameworks (net8.0, net9.0, net10.0, net11.0). +/// frameworks (net10.0, net11.0). /// public class MultiTargetingTests { - static readonly string[] SupportedTargetFrameworkNames = - [ - ".NETCoreApp,Version=v8.0", - ".NETCoreApp,Version=v9.0", - ".NETCoreApp,Version=v10.0", - ".NETCoreApp,Version=v11.0", - ]; + static readonly string[] SupportedTargetFrameworkNames = [".NETCoreApp,Version=v10.0", ".NETCoreApp,Version=v11.0"]; [Test] public async Task CoreAssembly_GivenMultiTargetedBuild_DeclaresSupportedTargetFramework() diff --git a/src/tests/ZodSharp.UnitTests/Core/SchemaCacheTests.cs b/src/tests/ZodSharp.UnitTests/Core/SchemaCacheTests.cs index 12584dfe..728d083d 100644 --- a/src/tests/ZodSharp.UnitTests/Core/SchemaCacheTests.cs +++ b/src/tests/ZodSharp.UnitTests/Core/SchemaCacheTests.cs @@ -10,14 +10,11 @@ public async Task GetOrCreate_GivenMissingKey_InvokesFactory() var key = CreateKey(); var invocationCount = 0; - var schema = SchemaCache.GetOrCreate( - key, - () => - { - invocationCount++; - return Z.String(); - } - ); + var schema = SchemaCache.GetOrCreate(key, () => + { + invocationCount++; + return Z.String(); + }); try { @@ -38,23 +35,17 @@ public async Task GetOrCreate_GivenExistingKey_ReturnsCachedInstanceWithoutInvok try { - var first = SchemaCache.GetOrCreate( - key, - () => - { - invocationCount++; - return Z.String(); - } - ); - - var second = SchemaCache.GetOrCreate( - key, - () => - { - invocationCount++; - return Z.String().Min(10); - } - ); + var first = SchemaCache.GetOrCreate(key, () => + { + invocationCount++; + return Z.String(); + }); + + var second = SchemaCache.GetOrCreate(key, () => + { + invocationCount++; + return Z.String().Min(10); + }); await Assert.That(ReferenceEquals(first, second)).IsTrue(); await Assert.That(invocationCount).IsEqualTo(1); diff --git a/src/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs b/src/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs new file mode 100644 index 00000000..70c6ba39 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs @@ -0,0 +1,152 @@ +using ZodSharp.Rules; + +namespace ZodSharp.Core; + +public class ValidationErrorModelTests +{ + // Defect A23 + [Test] + public async Task Equals_GivenSeparatelyBuiltIdenticalErrors_AreEqual() + { + // Arrange + ValidationError first = new( + "too_small", + "Too small", + ["Items", "0"], + new Dictionary { ["min"] = 1 } + ); + ValidationError second = new( + "too_small", + "Too small", + ["Items", "0"], + new Dictionary { ["min"] = 1 } + ); + + // Assert + await Assert.That(first).IsEqualTo(second); + await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode()); + } + + [Test] + public async Task WithPathPrefix_GivenStructuredError_KeepsEveryField() + { + // Arrange + var error = ValidationError.Create( + "too_small", + "Too small", + ZodPath.Member("Quantity"), + new Dictionary { ["count"] = 0 }, + origin: "number", + minimum: 1, + maximum: 9, + inclusive: true, + category: ZodIssueCategories.TooSmall + ) with + { + MessageFormat = "{member} too small", + }; + + // Act + var prefixed = error.WithPathPrefix(ZodPathSegment.At(2)).WithPathPrefix(ZodPathSegment.Member("Items")); + + // Assert + await Assert.That(prefixed.FullPath.ToDottedString()).IsEqualTo("Items[2].Quantity"); + await Assert.That(prefixed.Origin).IsEqualTo("number"); + await Assert.That(prefixed.Minimum).IsEqualTo(1); + await Assert.That(prefixed.Maximum).IsEqualTo(9); + await Assert.That(prefixed.Inclusive == true).IsTrue(); + await Assert.That(prefixed.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(prefixed.MessageFormat).IsEqualTo("{member} too small"); + await Assert.That(prefixed.Parameters!["count"]).IsEqualTo(0); + } + + [Test] + public async Task Lower_GivenOnlyIntegerMinimum_FallsBackToMinimum() + { + // Act + var error = ValidationError.Create("too_small", "x", ZodPath.Root, minimum: 3, inclusive: false); + + // Assert + await Assert.That(error.Lower).IsEqualTo(new ErrorBound(3, false)); + await Assert.That(error.Upper).IsNull(); + } + + [Test] + public async Task Lower_GivenExplicitBound_PrefersIt() + { + // Act + var error = ValidationError.Create("too_small", "x", ZodPath.Root) with { Lower = ErrorBound.Excluding(0.5) }; + + // Assert + await Assert.That(error.Lower).IsEqualTo(new ErrorBound(0.5, false)); + } + + // Defect A22 + [Test] + public async Task Merge_GivenDefaultResult_DoesNotThrow() + { + // Arrange + var failure = ValidationResult.Failure(ValidationError.Create("x", "y", ZodPath.Root)); + + // Act + var merged = ValidationResult.Merge(default, failure); + + // Assert + await Assert.That(merged.IsSuccess).IsFalse(); + await Assert.That(merged.Errors.Length).IsEqualTo(1); + } + + // Defects O2, O3 + [Test] + public async Task ZodException_GivenErrors_DescribesIssuesWithDottedPaths() + { + // Arrange + ZodException exception = new([ + ValidationError.Create("too_small", "Too small", ZodPath.ParseDotted("Items[0].Quantity")), + ValidationError.Create("custom", "Root failed", ZodPath.Root), + ]); + + // Act + var message = exception.Message; + var text = exception.ToString(); + + // Assert + await Assert.That(message).Contains("Items[0].Quantity: Too small (too_small)"); + await Assert.That(message).Contains("(root): Root failed (custom)"); + await Assert.That(text).DoesNotContain(".[0]"); + } + + [Test] + public async Task ZodException_GivenManyErrors_SummarisesTheRest() + { + // Arrange + var errors = Enumerable.Range(0, 8).Select(i => ValidationError.Create("c", $"m{i}", ZodPath.Root)); + + // Act + ZodException exception = new(errors); + + // Assert + await Assert.That(exception.Message).Contains("and 3 more"); + } + + // Defect B5 + [Test] + public async Task RuleMessageFormat_GivenCustomMessageWithStrayBraces_DoesNotThrow() + { + // Act + var message = RuleMessage.Format("Use {name} or { here", "value"); + + // Assert + await Assert.That(message).IsEqualTo("Use {name} or { here"); + } + + [Test] + public async Task RuleMessageFormat_GivenPlaceholderBeyondArguments_DoesNotThrow() + { + // Act + var message = RuleMessage.Format("{0} and {1}", "value"); + + // Assert + await Assert.That(message).IsEqualTo("value and {1}"); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodGeneratedValidationTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodGeneratedValidationTests.cs new file mode 100644 index 00000000..b1ec06da --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ZodGeneratedValidationTests.cs @@ -0,0 +1,93 @@ +namespace ZodSharp.Core; + +public class ZodGeneratedValidationTests +{ + static readonly ZodGeneratedMember ValueMember = new( + "Testing.EmailAddress", + "Value", + "Value", + ZodPath.ParseDotted("Value") + ); + + static readonly ZodGeneratedMember EmailMember = new( + "Testing.SignUp", + "Email", + "Contact email", + ZodPath.ParseDotted("Email") + ); + + [Test] + public async Task AddNested_GivenOwner_RenamesTheDisplayNameAndReformatsTemplatedMessages( + CancellationToken cancellationToken + ) + { + // Arrange + var templated = ZodGeneratedValidation.Error( + "invalid_string", + "Field '{displayName}' is invalid.", + ZodPath.ParseDotted("Value"), + ValueMember + ); + var custom = ZodGeneratedValidation.ForMember( + ValidationError.Create("custom", "Use a work address", ZodPath.ParseDotted("Value")), + ValueMember + ); + var nested = ValidationResult.Failure([templated, custom]); + List? errors = null; + + // Act + ZodGeneratedValidation.AddNested(ref errors, nested, ZodPath.ParseDotted("Email"), EmailMember); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(errors).IsNotNull(); + await Assert.That(errors![0].Message).IsEqualTo("Field 'Contact email' is invalid."); + await Assert.That(errors[0].FullPath.ToDottedString()).IsEqualTo("Email.Value"); + await Assert.That(errors[0].Parameters![ZodIssueParameters.DisplayName]).IsEqualTo("Contact email"); + await Assert.That(errors[0].Parameters![ZodIssueParameters.Member]).IsEqualTo("Value"); + + // A message the template does not produce is the author's own text, so it is kept. + await Assert.That(errors[1].Message).IsEqualTo("Use a work address"); + await Assert.That(errors[1].Parameters![ZodIssueParameters.DisplayName]).IsEqualTo("Contact email"); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task ResolveToReferenceOwner_GivenResolvedContainer_ReplacesOnlyAValueTypeContainer( + bool containerIsStruct, + CancellationToken cancellationToken + ) + { + // Arrange + object owner = new(); + var container = containerIsStruct ? Box(new KeyValuePair("a", 1)) : Box(new object()); + var original = container; + var memberName = "Value"; + + // Act + var resolved = ZodGeneratedValidation.ResolveToReferenceOwner( + true, + owner, + "Email", + ref container, + ref memberName + ); + + // Assert + cancellationToken.ThrowIfCancellationRequested(); + await Assert.That(resolved).IsTrue(); + if (containerIsStruct) + { + await Assert.That(container).IsSameReferenceAs(owner); + await Assert.That(memberName).IsEqualTo("Email"); + } + else + { + await Assert.That(container).IsSameReferenceAs(original); + await Assert.That(memberName).IsEqualTo("Value"); + } + } + + static object? Box(T value) => value; +} diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsTests.cs new file mode 100644 index 00000000..08671f12 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsTests.cs @@ -0,0 +1,301 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZodSharp.Core; + +public partial class ZodJsonExceptionsTests +{ + const string MessageFormat = "Field '{displayName}' must be a valid email address."; + + [Test] + public async Task TryGetErrors_GivenConverterRejectionInNestedMember_LocatesErrorsUnderMemberNames() + { + // Arrange + var exception = Deserialize( /*lang=json,strict*/ + """{"customer":{"email_address":"x"}}""" + ); + + // Act + var found = ZodJsonExceptions.TryGetErrors(exception, RejectionJsonContext.Default.Envelope, out var errors); + + // Assert + await Assert.That(found).IsTrue(); + var error = errors.Single(); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("Customer.Email.Value"); + await Assert.That(error.Message).IsEqualTo("Field 'Email' must be a valid email address."); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.DisplayName)).IsEqualTo("Email"); + } + + [Test] + public async Task TryGetErrors_GivenConverterRejection_KeepsEveryOtherField() + { + // Arrange + var exception = Deserialize( /*lang=json,strict*/ + """{"customer":{"email_address":"x"}}""" + ); + + // Act + ZodJsonExceptions.TryGetErrors(exception, RejectionJsonContext.Default.Envelope, out var errors); + + // Assert + var error = errors.Single(); + var original = Rejection(); + await Assert.That(error.Code).IsEqualTo(original.Code); + await Assert.That(error.Category).IsEqualTo(original.Category); + await Assert.That(error.Origin).IsEqualTo(original.Origin); + await Assert.That(error.MessageFormat).IsEqualTo(original.MessageFormat); + await Assert.That(error.Minimum).IsEqualTo(original.Minimum); + await Assert.That(error.Maximum).IsEqualTo(original.Maximum); + await Assert.That(error.Inclusive).IsEqualTo(original.Inclusive); + await Assert.That(error.Parameters!.Get("pattern")).IsEqualTo("email"); + await Assert + .That( + error.Parameters!.Get>>(ZodIssueParameters.UnionErrors) + ) + .IsEquivalentTo( + original.Parameters!.Get>>( + ZodIssueParameters.UnionErrors + ) + ); + } + + [Test] + [Arguments( /*lang=json,strict*/ + """{"customers":[{},{"email_address":"x"}]}""", + "Customers[1].Email.Value" + )] + [Arguments( /*lang=json,strict*/ + """{"contact.email":"x"}""", + "Dotted.Value" + )] + [Arguments( /*lang=json,strict*/ + """{"map":{"en":"x"}}""", + "Map[en].Value" + )] + [Arguments( /*lang=json,strict*/ + """{"rejected":"x"}""", + "Rejected.Value" + )] + public async Task TryGetErrors_GivenSerializerPathShapes_MapsThemToMemberPaths(string json, string expectedPath) + { + // Arrange + var exception = Deserialize(json); + + // Act + ZodJsonExceptions.TryGetErrors(exception, RejectionJsonContext.Default.Envelope, out var errors); + + // Assert + await Assert.That(errors.Single().FullPath.ToDottedString()).IsEqualTo(expectedPath); + } + + [Test] + public async Task TryGetErrors_GivenDictionaryEntry_UsesAKeySegment() + { + // Arrange + var exception = Deserialize( /*lang=json,strict*/ + """{"map":{"en":"x"}}""" + ); + + // Act + ZodJsonExceptions.TryGetErrors(exception, RejectionJsonContext.Default.Envelope, out var errors); + + // Assert + await Assert.That(errors.Single().FullPath[1].Kind).IsEqualTo(ZodPathSegmentKind.Key); + } + + [Test] + public async Task TryGetErrors_GivenNoRootContract_KeepsTheJsonNames() + { + // Arrange + var exception = Deserialize( /*lang=json,strict*/ + """{"customer":{"email_address":"x"}}""" + ); + + // Act + var found = ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo: null, out var errors); + + // Assert + await Assert.That(found).IsTrue(); + var error = errors.Single(); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("customer.email_address.Value"); + await Assert.That(error.Message).IsEqualTo("Field 'email_address' must be a valid email address."); + } + + [Test] + public async Task TryGetErrors_GivenWrappedChain_UsesTheSerializersPath() + { + // Arrange: the shape minimal-API binding hands the exception handler. + InvalidOperationException wrapped = new( + "Failed to read parameter.", + Deserialize( /*lang=json,strict*/ + """{"rejected":"x"}""" + ) + ); + + // Act + var found = ZodJsonExceptions.TryGetErrors(wrapped, RejectionJsonContext.Default.Envelope, out var errors); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(errors.Single().FullPath.ToDottedString()).IsEqualTo("Rejected.Value"); + } + + [Test] + public async Task TryGetErrors_GivenErrorBelowTheValueObjectsOwnMember_KeepsItsDisplayName() + { + // Arrange + var nested = Rejection() with + { + FullPath = ZodPath.From(ZodPathSegment.Member("Inner"), ZodPathSegment.Member("Value")), + }; + JsonException exception = new("Rejected.", new ZodException([nested])); + + // Act + var errors = ZodJsonExceptions.Reroot( + [nested], + ZodPath.ParseJsonPath("$.customer.email_address"), + RejectionJsonContext.Default.Envelope + ); + + // Assert + await Assert.That(ZodJsonExceptions.TryGetErrors(exception, null, out _)).IsTrue(); + var error = errors.Single(); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("Customer.Email.Inner.Value"); + await Assert.That(error.Message).IsEqualTo(nested.Message); + } + + [Test] + public async Task TryGetErrors_GivenBareZodException_ReturnsFalse() + { + // Arrange + ZodException exception = new([Rejection()]); + + // Act + var found = ZodJsonExceptions.TryGetErrors(exception, RejectionJsonContext.Default.Envelope, out var errors); + + // Assert + await Assert.That(found).IsFalse(); + await Assert.That(errors.IsDefault).IsTrue(); + } + + [Test] + public async Task TryGetErrors_GivenJsonExceptionWithoutZodException_ReturnsFalse() + { + // Arrange + JsonException exception = new("Unexpected end.", "$.customer", 0, 12); + + // Act + var found = ZodJsonExceptions.TryGetErrors(exception, RejectionJsonContext.Default.Envelope, out _); + + // Assert + await Assert.That(found).IsFalse(); + } + + [Test] + public async Task TryReroot_GivenConverterRejection_ReturnsZodExceptionWithRerootedErrors() + { + // Arrange + var exception = Deserialize( /*lang=json,strict*/ + """{"rejected":"x"}""" + ); + + // Act + var found = ZodJsonExceptions.TryReroot(exception, RejectionJsonContext.Default.Envelope, out var rerooted); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(rerooted!.Errors.Single().FullPath.ToDottedString()).IsEqualTo("Rejected.Value"); + await Assert + .That(rerooted.Errors.Single().Message) + .IsEqualTo("Field 'Rejected' must be a valid email address."); + } + + static JsonException Deserialize(string json) + { + try + { + JsonSerializer.Deserialize(json, RejectionJsonContext.Default.Envelope); + } + catch (JsonException ex) + { + return ex; + } + + throw new InvalidOperationException("The document was expected to be rejected."); + } + + /// The error a scalar value object's schema reports for its own Value. + static ValidationError Rejection() + { + Dictionary parameters = new(StringComparer.Ordinal) + { + [ZodIssueParameters.DisplayName] = "Value", + ["pattern"] = "email", + [ZodIssueParameters.UnionErrors] = ImmutableArray.Create( + ImmutableArray.Create(ValidationError.Create("invalid_type", "Expected string", ZodPath.Root)) + ), + }; + + var error = ValidationError.Create( + "invalid_string", + "Field 'Value' must be a valid email address.", + ZodPath.Member("Value"), + parameters, + origin: "string", + minimum: 3, + maximum: 254, + inclusive: true, + category: ZodIssueCategories.InvalidFormat + ); + + return error with + { + MessageFormat = MessageFormat, + }; + } + + public sealed class Envelope + { + public Customer? Customer { get; set; } + + public List? Customers { get; init; } + + [JsonPropertyName("contact.email")] + public RejectedValue? Dotted { get; set; } + + public Dictionary? Map { get; init; } + + public RejectedValue? Rejected { get; set; } + } + + public sealed class Customer + { + [JsonPropertyName("email_address")] + public RejectedValue? Email { get; set; } + } + + /// A value object whose strict converter rejects every value, as Purview.ValueObjects will. + [JsonConverter(typeof(RejectingConverter))] + public readonly record struct RejectedValue(string Value); + + public sealed class RejectingConverter : JsonConverter + { + public override RejectedValue Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) => + throw new JsonException( + "Cannot deserialize RejectedValue: the value failed validation.", + new ZodException([Rejection()]) + ); + + public override void Write(Utf8JsonWriter writer, RejectedValue value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(Envelope))] + internal sealed partial class RejectionJsonContext : JsonSerializerContext; +} diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs new file mode 100644 index 00000000..e5d4ce52 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs @@ -0,0 +1,122 @@ +using System.Globalization; + +namespace ZodSharp.Core; + +public class ZodMessageFormatTests +{ + [Test] + [Arguments("Use {name}")] + [Arguments("Unbalanced { brace")] + [Arguments("Unbalanced } brace")] + [Arguments("{")] + [Arguments("}")] + [Arguments("{1}")] + [Arguments("{0,abc}")] + public async Task Format_GivenMalformedOrUnresolvableFormat_DoesNotThrow(string format) + { + // Act + var message = ZodMessageFormat.Format(format, ["value"], provider: CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsNotNull(); + } + + [Test] + public async Task Format_GivenUnresolvablePlaceholder_WritesItLiterally() + { + // Act + var message = ZodMessageFormat.Format("Use {name} here", parameters: null, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("Use {name} here"); + } + + [Test] + public async Task Format_GivenNamedAndPositionalValues_ResolvesBoth() + { + // Arrange + Dictionary parameters = new() { ["min"] = 3 }; + + // Act + var message = ZodMessageFormat.Format( + "{0} must be at least {min} long", + ["Name"], + parameters, + CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(message).IsEqualTo("Name must be at least 3 long"); + } + + [Test] + public async Task Format_GivenEscapedBraces_WritesSingleBraces() + { + // Act + var message = ZodMessageFormat.Format("{{literal}} {0}", ["x"], provider: CultureInfo.InvariantCulture); + + // Assert + await Assert.That(message).IsEqualTo("{literal} x"); + } + + [Test] + public async Task Format_GivenFormatSpecifier_UsesInvariantCultureByDefault() + { + // Arrange + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new("de-DE"); + try + { + // Act + var message = ZodMessageFormat.Format("{0:0.0}", [1.5], provider: null); + + // Assert + await Assert.That(message).IsEqualTo("1.5"); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Test] + public async Task Format_GivenError_ResolvesBuiltInFields() + { + // Arrange + var error = ValidationError.Create( + "too_small", + "ignored", + ZodPath.ParseDotted("Items[0].Quantity"), + minimum: 1, + inclusive: true + ); + + // Act + var message = ZodMessageFormat.Format( + "{member} at {path} must be >= {minimum} ({code})", + error, + CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(message).IsEqualTo("Quantity at Items[0].Quantity must be >= 1 (too_small)"); + } + + [Test] + public async Task TryFormatComplete_GivenMissingParameter_ReturnsFalse() + { + // Arrange + var error = ValidationError.Create("tenant", "Tenant is invalid", ZodPath.Root); + + // Act + var complete = ZodMessageFormat.TryFormatComplete( + "Tenant {tenantId} is invalid", + error, + out _, + CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(complete).IsFalse(); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodPathTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodPathTests.cs new file mode 100644 index 00000000..43fc87eb --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ZodPathTests.cs @@ -0,0 +1,212 @@ +namespace ZodSharp.Core; + +public class ZodPathTests +{ + [Test] + public async Task ToDottedString_GivenMembersAndIndexes_RendersBracketedIndexes() + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(0), ZodPathSegment.Member("Name")); + + // Act + var dotted = path.ToDottedString(); + + // Assert + await Assert.That(dotted).IsEqualTo("Items[0].Name"); + } + + [Test] + [Arguments("en", "Map[en]")] + [Arguments("0", "Map[\"0\"]")] + [Arguments("a.b", "Map[\"a.b\"]")] + [Arguments("say \"hi\"", "Map[\"say \\\"hi\\\"\"]")] + public async Task ToDottedString_GivenKey_QuotesOnlyWhenAmbiguous(string key, string expected) + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Member("Map"), ZodPathSegment.Key(key)); + + // Act + var dotted = path.ToDottedString(); + + // Assert + await Assert.That(dotted).IsEqualTo(expected); + } + + [Test] + public async Task ToDottedString_GivenRoot_ReturnsEmpty() + { + // Act + var dotted = ZodPath.Root.ToDottedString(); + + // Assert + await Assert.That(dotted).IsEqualTo(string.Empty); + } + + [Test] + public async Task ToDottedString_GivenNamer_RenamesMembersOnly() + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(2), ZodPathSegment.Key("Key")); + var namer = ZodPathNamer.FromFunc(static name => name.ToLowerInvariant()); + + // Act + var dotted = path.ToDottedString(namer); + + // Assert + await Assert.That(dotted).IsEqualTo("items[2][Key]"); + } + + [Test] + [Arguments("Items[0].Name")] + [Arguments("Map[en].Value")] + [Arguments("Map[\"0\"]")] + [Arguments("Map[\"a.b\"].Value")] + [Arguments("Customer.ShippingAddress.Street")] + public async Task ParseDotted_GivenDottedString_RoundTrips(string dotted) + { + // Act + var path = ZodPath.ParseDotted(dotted); + + // Assert + await Assert.That(path.ToDottedString()).IsEqualTo(dotted); + } + + [Test] + public async Task ParseDotted_GivenDigitsInBrackets_ProducesIndexSegment() + { + // Act + var path = ZodPath.ParseDotted("Items[3].Name"); + + // Assert + await Assert.That(path[1]).IsEqualTo(ZodPathSegment.At(3)); + } + + [Test] + [Arguments("$.items[0].name", "items[0].name")] + [Arguments("$['a.b'].c", "[\"a.b\"].c")] + [Arguments("items[0].name", "items[0].name")] + [Arguments("$", "")] + public async Task ParseJsonPath_GivenSystemTextJsonOrNewtonsoftPath_ParsesSegments( + string jsonPath, + string expectedDotted + ) + { + // Act + var path = ZodPath.ParseJsonPath(jsonPath); + + // Assert + await Assert.That(path.ToDottedString()).IsEqualTo(expectedDotted); + } + + [Test] + public async Task ToJsonPointer_GivenSpecialCharacters_EscapesPerRfc6901() + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Key("a/b~c"), ZodPathSegment.At(1)); + + // Act + var pointer = path.ToJsonPointer(); + + // Assert + await Assert.That(pointer).IsEqualTo("/a~1b~0c/1"); + } + + [Test] + public async Task ToJsonPath_GivenMembersAndIndex_RendersSystemTextJsonForm() + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Member("items"), ZodPathSegment.At(0), ZodPathSegment.Key("a.b")); + + // Act + var jsonPath = path.ToJsonPath(); + + // Assert + await Assert.That(jsonPath).IsEqualTo("$.items[0]['a.b']"); + } + + [Test] + public async Task ToConfigurationKey_GivenPath_JoinsWithColons() + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Member("Servers"), ZodPathSegment.At(0), ZodPathSegment.Member("Host")); + + // Act + var key = path.ToConfigurationKey(); + + // Assert + await Assert.That(key).IsEqualTo("Servers:0:Host"); + } + + [Test] + public async Task ToZodPath_GivenIndex_ReturnsIntegerSegment() + { + // Arrange + var path = ZodPath.From(ZodPathSegment.Member("items"), ZodPathSegment.At(4)); + + // Act + var zodPath = path.ToZodPath(); + + // Assert + await Assert.That(zodPath[0]).IsEqualTo("items"); + await Assert.That(zodPath[1]).IsEqualTo(4); + } + + [Test] + [Arguments("[2]")] + [Arguments("2")] + public async Task FromLegacy_GivenIndexSegment_ClassifiesAsIndex(string segment) + { + // Act + var path = ZodPath.FromLegacy(["Items", segment]); + + // Assert + await Assert.That(path[1]).IsEqualTo(ZodPathSegment.At(2)); + await Assert.That(path.LegacySegments[1]).IsEqualTo("2"); + } + + [Test] + public async Task Prepend_GivenSegment_UpdatesSegmentsAndLegacyProjection() + { + // Arrange + var path = ZodPath.Member("Name"); + + // Act + var prefixed = path.Prepend(ZodPathSegment.At(1)).Prepend(ZodPathSegment.Member("Items")); + + // Assert + await Assert.That(prefixed.ToDottedString()).IsEqualTo("Items[1].Name"); + await Assert.That(prefixed.LegacySegments.SequenceEqual(["Items", "1", "Name"])).IsTrue(); + } + + [Test] + public async Task Equals_GivenSameSegments_AreEqual() + { + // Arrange + var first = ZodPath.ParseDotted("Items[0].Name"); + var second = ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(0), ZodPathSegment.Member("Name")); + + // Assert + await Assert.That(first == second).IsTrue(); + await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode()); + } + + [Test] + public async Task Key_GivenFormattableKey_RendersInvariantCulture() + { + // Arrange + var previous = System.Globalization.CultureInfo.CurrentCulture; + System.Globalization.CultureInfo.CurrentCulture = new("de-DE"); + try + { + // Act + var segment = ZodPathSegment.Key(1.5); + + // Assert + await Assert.That(segment.Name).IsEqualTo("1.5"); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs new file mode 100644 index 00000000..125bc7ee --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace ZodSharp.JsonSchema; + +/// +/// The string-format rules that gained a JSON Schema mapping, the vendor extensions rules record for hosts that +/// carry them (OpenAPI), and the runtime entry point the OpenAPI integration uses. +/// +public class ToJsonSchemaVendorExtensionTests +{ + [Test] + [Arguments(IPAddressRuleType.IPv4, "ipv4")] + [Arguments(IPAddressRuleType.IPv6, "ipv6")] + public async Task ToJsonSchema_GivenIPAddressFamily_ExportsFormat(IPAddressRuleType ruleType, string format) + { + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().IP(ruleType)); + + // Assert + await Assert.That(jsonSchema.Format).IsEqualTo(format); + await Assert.That(jsonSchema.UnsupportedRules).IsNull(); + } + + [Test] + public async Task ToJsonSchema_GivenAnyIPAddress_RecordsVendorExtensionAndUnsupported() + { + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().IP()); + + // Assert + await Assert.That(jsonSchema.Format).IsNull(); + await Assert.That(jsonSchema.GetVendorExtensions()["x-zod-ipVersion"]).IsEqualTo("any"); + await Assert.That(jsonSchema.UnsupportedRules!).Contains(nameof(IPAddressRule)); + } + + [Test] + public async Task ToJsonSchema_GivenCidrAndJwt_RecordVendorExtensionsAndStayUnsupported() + { + // Act + var cidr = Z.ToJsonSchema(Z.String().Cidr(CidrRuleType.IPv6)); + var jwt = Z.ToJsonSchema(Z.String().JWT()); + + // Assert + await Assert.That(cidr.GetVendorExtensions()["x-zod-cidr"]).IsEqualTo("v6"); + await Assert.That(cidr.UnsupportedRules!).Contains(nameof(CidrRule)); + await Assert.That(jwt.GetVendorExtensions()["x-zod-jwt"] is true).IsTrue(); + await Assert.That(jwt.UnsupportedRules!).Contains(nameof(JWTRule)); + } + + [Test] + public async Task ToJsonSchema_GivenTemporalStringRules_ExportsFormatsAndPatterns() + { + // Act + var date = Z.ToJsonSchema(Z.String().Date()); + var dateTime = Z.ToJsonSchema(Z.String().Datetime()); + var time = Z.ToJsonSchema(Z.String().Time()); + var duration = Z.ToJsonSchema(Z.String().Duration()); + + // Assert + await Assert.That(date.Format).IsEqualTo("date"); + await Assert.That(dateTime.Format).IsEqualTo("date-time"); + await Assert.That(dateTime.Pattern).IsEqualTo(DatetimeStringRule.JsonSchemaPattern); + await Assert.That(time.Format).IsNull(); + await Assert.That(time.Pattern).IsEqualTo(TimeStringRule.JsonSchemaPattern); + await Assert.That(time.GetVendorExtensions()["x-zod-format"]).IsEqualTo("time"); + await Assert.That(duration.Format).IsEqualTo("duration"); + } + + [Test] + public async Task ToJsonSchema_GivenBase64String_ExportsContentEncoding() + { + // Act + var jsonSchema = Z.ToJsonSchema(Z.String().Base64String()); + + // Assert + await Assert.That(jsonSchema.ContentEncoding).IsEqualTo("base64"); + } + + [Test] + public async Task ToJsonSchema_GivenLiteralRules_RecordsTheLiteral() + { + // Act + var startsWith = Z.ToJsonSchema(Z.String().StartsWith("a.b")); + var uuid = Z.ToJsonSchema(Z.String().UUID(UuidVersion.V7)); + + // Assert + await Assert.That(startsWith.Pattern).IsEqualTo("^a\\.b"); + await Assert.That(startsWith.GetVendorExtensions()["x-zod-startsWith"]).IsEqualTo("a.b"); + await Assert.That(uuid.GetVendorExtensions()["x-zod-uuidVersion"]).IsEqualTo(7); + } + + [Test] + public async Task ToJsonSchema_GivenVendorExtensions_DoesNotSerializeThem() + { + // Arrange + var jsonSchema = Z.ToJsonSchema(Z.String().StartsWith("ab")); + + // Act + var json = JsonSerializer.Serialize(jsonSchema); + + // Assert - the exported JSON Schema is unchanged; only hosts that read the extensions see them. + await Assert.That(json).DoesNotContain("x-zod-startsWith"); + await Assert.That(json).DoesNotContain("VendorExtensions"); + } + + [Test] + public async Task SetVendorExtension_GivenNameWithoutPrefix_Throws() + { + // Arrange + JsonSchemaDefinition schema = new(); + + // Act + void Act() => schema.SetVendorExtension("zod-thing", true); + + // Assert + await Assert.That(Act).Throws(); + } + + [Test] + public async Task ToJsonSchema_GivenDiscriminatedUnion_ExportsOneOfInDeclarationOrder() + { + // Arrange + var schema = Z.DiscriminatedUnion("kind") + .Option("square", Z.Object().Field("kind", Z.Literal("square")).Build()) + .Option("circle", Z.Object().Field("kind", Z.Literal("circle")).Build()) + .Build(); + + // Act + var jsonSchema = Z.ToJsonSchema(schema); + + // Assert + await Assert.That(jsonSchema.AnyOf).IsNull(); + await Assert.That(jsonSchema.OneOf!.Count).IsEqualTo(2); + await Assert.That(jsonSchema.GetVendorExtensions()["x-zod-discriminator"]).IsEqualTo("kind"); + await Assert.That(jsonSchema.OneOf[0].GetVendorExtensions()["x-zod-discriminatorValue"]).IsEqualTo("square"); + await Assert.That(jsonSchema.OneOf[1].GetVendorExtensions()["x-zod-discriminatorValue"]).IsEqualTo("circle"); + } + + [Test] + public async Task ConvertUntyped_GivenRegisteredRuntimeValidator_UnwrapsTheAdapters() + { + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new ZodSchemaValidator(Z.String().Min(3))); + factory.TryGetUntypedValidator(typeof(string), out var validator); + + // Act + var jsonSchema = ToJsonSchemaConverter.ConvertUntyped(validator!); + + // Assert + await Assert.That(jsonSchema.Type).IsEqualTo("string"); + await Assert.That(jsonSchema.MinLength).IsEqualTo(3); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Options/ZodSchemaOptionsValidatorPathTests.cs b/src/tests/ZodSharp.UnitTests/Options/ZodSchemaOptionsValidatorPathTests.cs new file mode 100644 index 00000000..cf113d95 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Options/ZodSchemaOptionsValidatorPathTests.cs @@ -0,0 +1,360 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZodSharp.Core; + +namespace ZodSharp.Options; + +public class ZodSchemaOptionsValidatorPathTests +{ + public sealed class SmtpOptions + { + public List Servers { get; set; } = []; + } + + public sealed class SmtpServer + { + public string? Host { get; set; } + } + + static ValidationError HostRequired => + ValidationError.Create( + "required", + "Host is required", + ZodPath.From(ZodPathSegment.Member("Servers"), ZodPathSegment.At(0), ZodPathSegment.Member("Host")) + ); + + /// + /// Reports synchronously, and an extra error only from the service-aware + /// asynchronous path, recording the services it was given. + /// + sealed class SmtpValidator(bool asyncOnlyError = false) : IZodSchemaValidator + { + public IServiceProvider? AsyncServices { get; private set; } + + public int AsyncCalls { get; private set; } + + public bool RequiresAsyncValidation => asyncOnlyError; + + public ValidationResult Validate(SmtpOptions value) => + value.Servers.Count > 0 && value.Servers[0].Host is null + ? ValidationResult.Failure(HostRequired) + : ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + SmtpOptions value, + CancellationToken cancellationToken = default + ) => ValidateAsync(value, services: null, cancellationToken); + + public ValueTask> ValidateAsync( + SmtpOptions value, + IServiceProvider? services, + CancellationToken cancellationToken = default + ) + { + AsyncCalls++; + AsyncServices = services; + + var result = Validate(value); + if (!asyncOnlyError) + return ValueTask.FromResult(result); + + var asyncError = ValidationError.Create("in_use", "Port is already in use", ZodPath.Member("Port")); + return ValueTask.FromResult( + ValidationResult.Failure([.. result.IsSuccess ? [] : result.Errors, asyncError]) + ); + } + } + + static SmtpOptions InvalidOptions() => new() { Servers = [new SmtpServer()] }; + + static ServiceCollection CreateServices(IZodSchemaValidator validator) + { + ZodSchemaFactory factory = new(); + factory.Register(validator); + + ServiceCollection services = new(); + services.AddSingleton(factory); + return services; + } + + [Test] + public async Task Validate_GivenFailure_RendersPathMessageAndCode() + { + // Defect O1: failures carried only the message, losing the path and the code. + + // Arrange + ZodSchemaFactory factory = new(); + factory.Register(new SmtpValidator()); + ZodSchemaOptionsValidator validator = new(factory); + + // Act + var result = validator.Validate(Microsoft.Extensions.Options.Options.DefaultName, InvalidOptions()); + + // Assert + await Assert.That(result.Failures).IsEquivalentTo(["Servers[0].Host: Host is required (required)"]); + await Assert.That(result).IsTypeOf(); + await Assert.That(((ZodValidateOptionsResult)result).Errors[0].Code).IsEqualTo("required"); + } + + [Test] + public async Task OptionsAccess_GivenNamedOptions_PrefixesTheName() + { + // Arrange + var services = CreateServices(new SmtpValidator()); + services + .AddOptions("primary") + .Configure(static o => o.Servers.Add(new SmtpServer())) + .AddZodSchemaValidator(); + await using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + + // Act + var exception = Catch(() => monitor.Get("primary")); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert + .That(exception!.Failures) + .IsEquivalentTo(["primary: Servers[0].Host: Host is required (required)"]); + } + + [Test] + public async Task OptionsAccess_GivenNamedRegistration_DoesNotValidateOtherNames() + { + // Arrange + var services = CreateServices(new SmtpValidator()); + services.AddOptions("primary").AddZodSchemaValidator(); + services.AddOptions("secondary").Configure(static o => o.Servers.Add(new SmtpServer())); + await using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + + // Act + var exception = Catch(() => monitor.Get("secondary")); + + // Assert + await Assert.That(exception).IsNull(); + } + + [Test] + public async Task OptionsAccess_GivenConfigurationSection_UsesConfigurationKeys() + { + // Arrange + var services = CreateServices(new SmtpValidator()); + services + .AddOptions() + .Configure(static o => o.Servers.Add(new SmtpServer())) + .AddZodSchemaValidator( + MissingValidatorBehavior.Throw, + validateOnStart: false, + configurationSection: "Smtp" + ); + await using var provider = services.BuildServiceProvider(); + + // Act + var exception = Catch(() => provider.GetRequiredService>().Value); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).IsEquivalentTo(["Smtp:Servers:0:Host: Host is required (required)"]); + } + + [Test] + public async Task ValidateAsync_RunsTheServiceAwarePathWithAScopedProvider() + { + // Arrange + SmtpValidator schema = new(asyncOnlyError: true); + var services = CreateServices(schema); + await using var provider = services.BuildServiceProvider(); + ZodSchemaOptionsValidator validator = new( + provider.GetRequiredService(), + provider + ); + + // Act + var result = await validator.ValidateAsync(null, new SmtpOptions()); + + // Assert + await Assert.That(result.Failures).IsEquivalentTo(["Port: Port is already in use (in_use)"]); + await Assert.That(schema.AsyncServices).IsNotNull(); + await Assert.That(schema.AsyncServices).IsNotSameReferenceAs(provider); + } + + [Test] + public async Task Validate_GivenAsyncOnlyRules_ChecksTheSynchronousRulesOnly() + { + // Arrange + SmtpValidator schema = new(asyncOnlyError: true); + ZodSchemaFactory factory = new(); + factory.Register(schema); + ZodSchemaOptionsValidator validator = new(factory); + + // Act + var result = validator.Validate(null, new SmtpOptions()); + + // Assert + await Assert.That(result.Succeeded).IsTrue(); + await Assert.That(schema.AsyncCalls).IsEqualTo(0); + } + +#if NET11_0_OR_GREATER + [Test] + public async Task Validator_OnNet11_ImplementsAsyncValidateOptions() + { + // Arrange + ZodSchemaOptionsValidator validator = new(new ZodSchemaFactory()); + + // Act + var isAsync = validator is IAsyncValidateOptions; + + // Assert + await Assert.That(isAsync).IsTrue(); + } + + [Test] + public async Task ValidateOnStart_GivenAsyncRule_FailsStartupValidationWithTheAsyncError( + CancellationToken cancellationToken + ) + { + // Arrange + var services = CreateServices(new SmtpValidator(asyncOnlyError: true)); + services.AddOptions().AddZodSchemaValidator(MissingValidatorBehavior.Throw, validateOnStart: true); + await using var provider = services.BuildServiceProvider(); + var startup = provider.GetRequiredService(); + + // Act + OptionsValidationException? exception = null; + try + { + await startup.ValidateAsync(cancellationToken); + } + catch (OptionsValidationException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).Contains("Port: Port is already in use (in_use)"); + } + + [Test] + public async Task AddZodSchemaOptionsValidator_GivenAsyncRuleAndValidateOnStart_FailsStartupValidation( + CancellationToken cancellationToken + ) + { + // Arrange + SmtpValidator schema = new(asyncOnlyError: true); + var services = CreateServices(schema); + services.AddZodSchemaOptionsValidator(); + services.AddOptions().ValidateOnStart(); + await using var provider = services.BuildServiceProvider(); + var startup = provider.GetRequiredService(); + + // Act + OptionsValidationException? exception = null; + try + { + await startup.ValidateAsync(cancellationToken); + } + catch (OptionsValidationException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).Contains("Port: Port is already in use (in_use)"); + await Assert.That(schema.AsyncCalls).IsGreaterThan(0); + } + + [Test] + public async Task AddZodSchemaOptionsValidator_GivenNamedOptionsAndValidateOnStart_RunsTheAsyncRules( + CancellationToken cancellationToken + ) + { + // Arrange + SmtpValidator schema = new(asyncOnlyError: true); + var services = CreateServices(schema); + services.AddZodSchemaOptionsValidator(); + services.AddOptions("Backup").ValidateOnStart(); + await using var provider = services.BuildServiceProvider(); + var startup = provider.GetRequiredService(); + + // Act + OptionsValidationException? exception = null; + try + { + await startup.ValidateAsync(cancellationToken); + } + catch (OptionsValidationException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Failures).Contains("Backup: Port: Port is already in use (in_use)"); + } + + [Test] + public async Task AddZodSchemaOptionsValidator_GivenAsyncRule_SynchronousAccessRunsSynchronousRulesOnly() + { + // Arrange + SmtpValidator schema = new(asyncOnlyError: true); + var services = CreateServices(schema); + services.AddZodSchemaOptionsValidator(); + services.AddOptions(); + await using var provider = services.BuildServiceProvider(); + + // Act + var exception = Catch(() => provider.GetRequiredService>().Value); + + // Assert + await Assert.That(exception).IsNull(); + await Assert.That(schema.AsyncCalls).IsEqualTo(0); + } + + [Test] + public async Task OptionsAccess_GivenAsyncOnlyRule_ValidatesSynchronousRulesOnly() + { + // Arrange: the platform contract - synchronous access runs Validate, never ValidateAsync. + SmtpValidator schema = new(asyncOnlyError: true); + var services = CreateServices(schema); + services.AddOptions().AddZodSchemaValidator(MissingValidatorBehavior.Throw, validateOnStart: true); + await using var provider = services.BuildServiceProvider(); + + // Act + var exception = Catch(() => provider.GetRequiredService>().Value); + + // Assert + await Assert.That(exception).IsNull(); + await Assert.That(schema.AsyncCalls).IsEqualTo(0); + } +#else + [Test] + public async Task Validator_BeforeNet11_ImplementsOnlyTheSynchronousInterface() + { + // Arrange + var interfaces = typeof(ZodSchemaOptionsValidator).GetInterfaces(); + + // Act + var names = interfaces.Select(static type => type.Name).ToArray(); + + // Assert + await Assert.That(names).IsEquivalentTo(["IValidateOptions`1"]); + } +#endif + + static OptionsValidationException? Catch(Func access) + { + try + { + _ = access(); + return null; + } + catch (OptionsValidationException ex) + { + return ex; + } + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/CollectionCountRuleTests.cs b/src/tests/ZodSharp.UnitTests/Rules/CollectionCountRuleTests.cs new file mode 100644 index 00000000..ba3b6d64 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/CollectionCountRuleTests.cs @@ -0,0 +1,131 @@ +using System.Collections; +using ZodSharp.Core; + +namespace ZodSharp.Rules; + +public class CollectionCountRuleTests +{ + static IEnumerable Lazy(int count) + { + for (var i = 0; i < count; i++) + yield return i; + } + + [Test] + [Arguments(1, true)] + [Arguments(2, true)] + [Arguments(0, false)] + public async Task MinCount_IsValid_GivenCollection_ComparesTheCount( + int count, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + MinCountRule rule = new(1); + List values = [.. Enumerable.Range(0, count)]; + + // Act + var isValid = rule.IsValid(values); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task MaxCount_IsValid_GivenNonCollectionEnumerable_CountsByEnumerating( + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + MaxCountRule rule = new(2); + + // Act + var withinBound = rule.IsValid(Lazy(2)); + var aboveBound = rule.IsValid(Lazy(3)); + + // Assert + await Assert.That(withinBound).IsTrue(); + await Assert.That(aboveBound).IsFalse(); + } + + [Test] + [Arguments(0, false)] + [Arguments(1, true)] + [Arguments(3, true)] + [Arguments(4, false)] + public async Task CountRange_IsValid_GivenDictionary_CountsEntries( + int count, + bool expected, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + CountRangeRule rule = new(1, 3); + var entries = Enumerable + .Range(0, count) + .ToDictionary(static i => i.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // Act + var isValid = rule.IsValid(entries); + + // Assert + await Assert.That(isValid).IsEqualTo(expected); + } + + [Test] + public async Task MinCount_IsValid_GivenNull_ReturnsTrue(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange — null is [Required]'s concern, as for every size rule. + MinCountRule rule = new(1); + + // Act + var isValid = rule.IsValid(null!); + + // Assert + await Assert.That(isValid).IsTrue(); + } + + [Test] + public async Task MinCount_GetErrorMessage_GivenFailure_FormatsTheBoundAndCount(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + MinCountRule rule = new(2); + + // Act + var message = rule.GetErrorMessage(new List { 1 }); + + // Assert + await Assert.That(message).IsEqualTo("Collection must contain at least 2 element(s), but got 1"); + } + + [Test] + public async Task CountRules_Identity_MatchesTheCollectionSizeErrors(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Arrange + IZodRule min = new MinCountRule(1); + IZodRule max = new MaxCountRule(3); + IZodRule range = new CountRangeRule(1, 3); + + // Act / Assert + await Assert.That(min.Code).IsEqualTo(MinCountRule.ErrorCode); + await Assert.That(min.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(min.Origin).IsEqualTo(ZodTypeNames.Array); + await Assert.That(max.Code).IsEqualTo(MaxCountRule.ErrorCode); + await Assert.That(max.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(range.Lower).IsEqualTo(ErrorBound.Including(1)); + await Assert.That(range.Upper).IsEqualTo(ErrorBound.Including(3)); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs new file mode 100644 index 00000000..4961015e --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs @@ -0,0 +1,388 @@ +using ZodSharp.Core; + +namespace ZodSharp.Rules; + +/// +/// Regression tests for the rule defects (B1-B10) of the error-model overhaul. Each test is tagged with the defect +/// it would have caught. +/// +public class RuleErrorModelTests +{ + [Test] + public async Task StringRules_GivenFailure_ReportOriginStringAndCategory() + { + // Defect B1 + // Arrange + ValidationError[] errors = + [ + ZodRuleErrors.Create(new MinLengthRule(3), "a", ZodPath.Root), + ZodRuleErrors.Create(new MaxLengthRule(1), "abc", ZodPath.Root), + ZodRuleErrors.Create(new EmailRule(), "nope", ZodPath.Root), + ]; + + // Act + string[] origins = [.. errors.Select(static e => e.Origin ?? string.Empty)]; + string[] categories = [.. errors.Select(static e => e.Category ?? string.Empty)]; + + // Assert + await Assert.That(origins).IsEquivalentTo(["string", "string", "string"]); + await Assert + .That(categories) + .IsEquivalentTo([ZodIssueCategories.TooSmall, ZodIssueCategories.TooBig, ZodIssueCategories.InvalidFormat]); + await Assert.That(errors[2].Parameters!.Get(ZodIssueParameters.Format)).IsEqualTo("email"); + } + + [Test] + public async Task NumericBoundRules_GivenFailure_ReportStructuredBounds() + { + // Defect B1 + // Arrange + var schema = Z.Number().Min(2.5); + + // Act + var result = schema.Validate(1.0); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Origin).IsEqualTo("number"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(error.Lower).IsEqualTo(ErrorBound.Including(2.5)); + } + + [Test] + public async Task DateBoundRule_GivenFailure_ReportsOriginDateAndBound() + { + // Defect B1 + // Arrange + DateTime bound = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + MinValueRule rule = new(bound); + + // Act + var error = ZodRuleErrors.Create(rule, bound.AddDays(-1), ZodPath.Root); + + // Assert + await Assert.That(error.Origin).IsEqualTo("date"); + await Assert.That(error.Lower).IsEqualTo(ErrorBound.Including(bound)); + } + + [Test] + public async Task GreaterThanAndLessThan_GivenFailure_ReportExclusiveBounds() + { + // Defect B2 + // Arrange + GreaterThanRule greaterThan = new(5); + LessThanRule lessThan = new(5); + GreaterThanOrEqualRule greaterThanOrEqual = new(5); + + // Act + var greater = ZodRuleErrors.Create(greaterThan, 5.0, ZodPath.Root); + var less = ZodRuleErrors.Create(lessThan, 5.0, ZodPath.Root); + var greaterOrEqual = ZodRuleErrors.Create(greaterThanOrEqual, 4.0, ZodPath.Root); + + // Assert + await Assert.That(greater.Lower).IsEqualTo(ErrorBound.Excluding(5.0)); + await Assert.That(less.Upper).IsEqualTo(ErrorBound.Excluding(5.0)); + await Assert.That(greaterOrEqual.Lower).IsEqualTo(ErrorBound.Including(5.0)); + } + + [Test] + public async Task LengthRule_GivenTooShortOrTooLong_KeepsCodeAndReportsCategoryByDirection() + { + // Defect B3 + // Arrange + LengthRule rule = new(2, 4); + + // Act + var tooShort = ZodRuleErrors.Create(rule, "a", ZodPath.Root); + var tooLong = ZodRuleErrors.Create(rule, "abcde", ZodPath.Root); + + // Assert + await Assert.That(tooShort.Code).IsEqualTo(LengthRule.ErrorCode); + await Assert.That(tooShort.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(tooLong.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(tooShort.Minimum).IsEqualTo(2); + await Assert.That(tooShort.Maximum).IsEqualTo(4); + } + + [Test] + public async Task StringLengthRule_GivenSpanTooLong_ReportsCategoryAndCount() + { + // Defect B3 + // Arrange + StringLengthRule rule = new(3, 1); + + // Act + var error = ZodRuleErrors.Create(rule, "abcd".AsSpan(), ZodPath.Root); + + // Assert + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Count)).IsEqualTo(4); + } + + [Test] + public async Task RangeRule_GivenValueOnEachSide_ReportsCategoryByDirectionAndInclusivity() + { + // Defect B3 + // Arrange + RangeRule rule = new(1, 10, minimumIsExclusive: true); + + // Act + var below = ZodRuleErrors.Create(rule, 1.0, ZodPath.Root); + var above = ZodRuleErrors.Create(rule, 11.0, ZodPath.Root); + + // Assert + await Assert.That(below.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(above.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(below.Lower).IsEqualTo(ErrorBound.Excluding(1.0)); + await Assert.That(below.Upper).IsEqualTo(ErrorBound.Including(10.0)); + await Assert.That(below.Origin).IsEqualTo("number"); + } + + [Test] + public async Task SafeIntegerRule_GivenEachFailure_ReportsCategoryByKind() + { + // Defect B4 + // Arrange + SafeIntegerRule rule = new(); + + // Act + var fraction = ZodRuleErrors.Create(rule, 1.5, ZodPath.Root); + var tooSmall = ZodRuleErrors.Create(rule, int.MinValue - 1d, ZodPath.Root); + var tooBig = ZodRuleErrors.Create(rule, int.MaxValue + 1d, ZodPath.Root); + + // Assert + await Assert.That(fraction.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(fraction.Parameters!.Get(ZodIssueParameters.Expected)).IsEqualTo("int"); + await Assert.That(tooSmall.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(tooBig.Category).IsEqualTo(ZodIssueCategories.TooBig); + } + + [Test] + public async Task SafeIntegerRule_GivenEachFailure_ReportsTheCodeByDirection() + { + // Arrange + var schema = Z.Number().Safe(); + SafeIntegerRule rule = new(); + + // Act + var tooSmall = schema.Validate(int.MinValue - 1d).Errors.Single(); + var tooBig = schema.Validate(int.MaxValue + 1d).Errors.Single(); + var fraction = schema.Validate(1.5).Errors.Single(); + var generatedStyle = ZodRuleErrors.Create(rule, int.MinValue - 1d, ZodPath.Root, code: rule.Code); + + // Assert + await Assert.That(tooSmall.Code).IsEqualTo(ZodIssueCodes.TooSmall); + await Assert.That(tooSmall.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(tooBig.Code).IsEqualTo(ZodIssueCodes.TooBig); + await Assert.That(fraction.Code).IsEqualTo(ZodIssueCodes.InvalidType); + await Assert.That(generatedStyle.Code).IsEqualTo(ZodIssueCodes.TooSmall); + } + + [Test] + public async Task SafeIntegerRule_GivenCustomCode_KeepsItInEveryDirection() + { + // Arrange + SafeIntegerRule rule = new(code: "not_safe"); + + // Act + var tooSmall = ZodRuleErrors.Create(rule, int.MinValue - 1d, ZodPath.Root); + var overridden = ZodRuleErrors.Create(new SafeIntegerRule(), int.MinValue - 1d, ZodPath.Root, code: "custom"); + + // Assert + await Assert.That(tooSmall.Code).IsEqualTo("not_safe"); + await Assert.That(tooSmall.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(overridden.Code).IsEqualTo("custom"); + } + + [Test] + public async Task Schema_GivenCustomMessageWithStrayBraces_DoesNotThrowAtValidationTime() + { + // Defect B5 + // Arrange + var schema = Z.String().Min(3, "Use {braces} and {0 and }} freely {1}"); + + // Act + var result = schema.Validate("a"); + + // Assert + await Assert.That(result.Errors.Single().Message).IsEqualTo("Use {braces} and {0 and }} freely 1"); + } + + [Test] + public async Task UuidRule_GivenCustomMessage_ReceivesTheSameArgumentsAsTheDefault() + { + // Defect B6 + // Arrange + UUIDRule rule = new(UuidVersion.V4, "bad {0} v{1}"); + + // Act + var message = rule.GetErrorMessage("value"); + + // Assert + await Assert.That(message).IsEqualTo("bad value v4"); + } + + [Test] + public async Task UuidRule_GivenVersionedDefault_ReportsTheVersionAsAnInteger() + { + // Defect B6 + // Arrange + UUIDRule rule = new(UuidVersion.V7); + + // Act + var error = ZodRuleErrors.Create(rule, "value", ZodPath.Root); + + // Assert + await Assert.That(error.Message).IsEqualTo("Invalid UUID v7 format"); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Version)).IsEqualTo(7); + } + + [Test] + public async Task IPAddressAndCidrRules_GivenNoMessage_FallBackToTheDefaultFormat() + { + // Defect B7 + // Arrange + IPAddressRule defaultStruct = default; + IPAddressRule explicitIp = new(IPAddressRuleType.IPv4); + CidrRule defaultCidr = new(); + + // Act + var defaultStructMessage = defaultStruct.GetErrorMessage("x"); + var explicitMessage = explicitIp.GetErrorMessage("x"); + var cidrMessage = defaultCidr.GetErrorMessage("x"); + + // Assert + await Assert.That(defaultStructMessage).IsEqualTo("Invalid IPv4 address"); + await Assert.That(explicitMessage).IsEqualTo("Invalid IPv4 address"); + await Assert.That(cidrMessage).IsEqualTo("Invalid IPv4/IPv6 CIDR"); + } + + [Test] + public async Task NumericMessage_GivenNonInvariantCurrentCulture_FormatsInvariantly() + { + // Defect B8 + // Arrange + var schema = Z.Number().Min(2.5); + var culture = System.Globalization.CultureInfo.CurrentCulture; + + // Act + string message; + try + { + System.Globalization.CultureInfo.CurrentCulture = new("de-DE"); + message = schema.Validate(1.25).Errors[0].Message; + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = culture; + } + + // Assert + await Assert.That(message).IsEqualTo("Value must be at least 2.5"); + } + + [Test] + public async Task SensitiveRules_GivenFailure_NeverEchoTheInputInTheMessage() + { + // Defect B9 + // Arrange + const string secret = "4111-SECRET-1111"; + string[] messages = + [ + new EmailRule().GetErrorMessage(secret), + new CreditCardRule().GetErrorMessage(secret), + new JWTRule().GetErrorMessage(secret), + new PhoneRule().GetErrorMessage(secret), + new CompareRule("expected").GetErrorMessage(secret), + new StartsWithRule("pre").GetErrorMessage(secret), + new EndsWithRule("suf").GetErrorMessage(secret), + new IncludesRule("inc").GetErrorMessage(secret), + new RegexRule("^x$").GetErrorMessage(secret), + new UUIDRule().GetErrorMessage(secret), + new AllowedValuesRule(["a"]).GetErrorMessage(secret), + ]; + + // Act + var leaking = messages.Where(static m => m.Contains(secret, StringComparison.Ordinal)).ToArray(); + + // Assert + await Assert.That(leaking).IsEmpty(); + } + + [Test] + [NotInParallel(nameof(ZodErrorOptions))] + public async Task RuleError_GivenReceivedValuesDisabled_DoesNotCarryTheInput() + { + // Defect B9 + // Arrange + EmailRule rule = new(); + + // Act + var error = ZodRuleErrors.Create(rule, "secret@", ZodPath.Root); + + // Assert + await Assert.That(ZodErrorOptions.IncludeReceivedValues).IsFalse(); + await Assert.That(error.Parameters!.ContainsKey(ZodIssueParameters.Received)).IsFalse(); + } + + [Test] + [NotInParallel(nameof(ZodErrorOptions))] + public async Task RuleError_GivenReceivedValuesEnabled_CarriesTheInputAsReceived() + { + // Defect B9 + // Arrange + EmailRule rule = new(); + + // Act + ValidationError error; + try + { + ZodErrorOptions.IncludeReceivedValues = true; + error = ZodRuleErrors.Create(rule, "secret@", ZodPath.Root); + } + finally + { + ZodErrorOptions.IncludeReceivedValues = false; + } + + // Assert + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Received)).IsEqualTo("secret@"); + await Assert.That(error.Message).DoesNotContain("secret@"); + } + + [Test] + public async Task RequiredRule_GivenFailure_ReportsTheSharedRequiredIdentity() + { + // Defect B10 + // Arrange + RequiredRule rule = new(); + + // Act + var error = ZodRuleErrors.Create(rule, null, ZodPath.Root); + + // Assert + await Assert.That(error.Code).IsEqualTo(ZodIssueCodes.MissingField); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(error.Message).IsEqualTo("Value is required"); + await Assert.That(error.MessageFormat).IsEqualTo(ZodMessages.Required); + } + + [Test] + public async Task RequiredRule_GivenHostReformatsAtAMemberPath_NamesTheMember() + { + // Defect B10 + // Arrange + RequiredRule rule = new(); + var error = ZodRuleErrors.Create(rule, null, ZodPath.Member("Email")); + + // Act + var message = ZodMessageFormat.Format( + error.MessageFormat!, + error, + System.Globalization.CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(message).IsEqualTo("Email is required"); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/RuleMessageTemplateTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RuleMessageTemplateTests.cs new file mode 100644 index 00000000..498311f9 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/RuleMessageTemplateTests.cs @@ -0,0 +1,254 @@ +using System.Globalization; +using ZodSharp.Core; + +namespace ZodSharp.Rules; + +/// +/// The default messages and the named templates () of the built-in rules: a +/// default never contains the input, and every runtime-schema error carries a template a host can re-format into +/// exactly the same message. +/// +public class RuleMessageTemplateTests +{ + const double Input = 123456.789; + + const string InputText = "123456.789"; + + public static IEnumerable> NumericFailures() + { + yield return () => ("Range", Z.Number().Range(1, 10).Validate(Input).Errors.Single()); + yield return () => ("MultipleOf", Z.Number().MultipleOf(5).Validate(Input).Errors.Single()); + yield return () => ("Int", Z.Number().Int().Validate(Input).Errors.Single()); + yield return () => ("SafeInteger", Z.Number().Safe().Validate(Input).Errors.Single()); + yield return () => ("Min", Z.Number().Min(1e9).Validate(Input).Errors.Single()); + yield return () => ("Max", Z.Number().Max(1).Validate(Input).Errors.Single()); + yield return () => ("Gt", Z.Number().Gt(1e9).Validate(Input).Errors.Single()); + yield return () => ("Gte", Z.Number().Gte(1e9).Validate(Input).Errors.Single()); + yield return () => ("Lt", Z.Number().Lt(1).Validate(Input).Errors.Single()); + yield return () => ("Lte", Z.Number().Lte(1).Validate(Input).Errors.Single()); + yield return () => ("Even", ZodRuleErrors.Create(new EvenRule(), Input, ZodPath.Root)); + yield return () => ("Odd", ZodRuleErrors.Create(new OddRule(), 123456.0, ZodPath.Root)); + yield return () => ("Finite", ZodRuleErrors.Create(new FiniteRule(), double.NaN, ZodPath.Root)); + } + + [Test] + [MethodDataSource(nameof(NumericFailures))] + [NotInParallel(nameof(ZodErrorOptions))] + public async Task NumericRule_GivenFailure_DefaultMessageDoesNotContainTheInput(string name, ValidationError error) + { + // Arrange + await Assert.That(name).IsNotEmpty(); + + // Act + var message = error.Message; + + // Assert + await Assert.That(message).DoesNotContain(InputText).Because(name); + await Assert.That(message).DoesNotContain("NaN").Because(name); + await Assert.That(message).DoesNotContain("but got").Because(name); + await Assert.That(error.Parameters?.ContainsKey(ZodIssueParameters.Received) ?? false).IsFalse().Because(name); + } + + [Test] + public async Task NumericRule_GivenDefaults_UsesZodLikeWording() + { + // Arrange + var range = Z.Number().Range(1, 10).Validate(11); + var multipleOf = Z.Number().MultipleOf(5).Validate(7); + var integer = Z.Number().Int().Validate(1.5); + var minimum = Z.Number().Min(3).Validate(1); + + // Act + string[] messages = + [ + range.Errors.Single().Message, + multipleOf.Errors.Single().Message, + integer.Errors.Single().Message, + minimum.Errors.Single().Message, + ]; + + // Assert + await Assert + .That(messages) + .IsEquivalentTo([ + "Value must be between 1 and 10", + "Number must be a multiple of 5", + "Expected integer, received float", + "Value must be at least 3", + ]); + } + + [Test] + public async Task NumericRule_GivenCustomMessageWithValuePlaceholder_StillReceivesTheValue() + { + // Arrange + var schema = Z.Number().Range(1, 10, message: "{2} is outside {0}-{1}"); + MinValueRule minimum = new(3, "{1} is below {0}"); + + // Act + var rangeMessage = schema.Validate(11).Errors.Single().Message; + var minimumMessage = minimum.GetErrorMessage(1); + + // Assert + await Assert.That(rangeMessage).IsEqualTo("11 is outside 1-10"); + await Assert.That(minimumMessage).IsEqualTo("1 is below 3"); + } + + [Test] + [NotInParallel(nameof(ZodErrorOptions))] + public async Task NumericRule_GivenReceivedValuesEnabled_ReportsReceivedAndResolvesACustomTemplate() + { + // Arrange + var schema = Z.Number().Range(1, 10, message: "{2} is outside {0}-{1}"); + + // Act + ValidationError error; + try + { + ZodErrorOptions.IncludeReceivedValues = true; + error = schema.Validate(11).Errors.Single(); + } + finally + { + ZodErrorOptions.IncludeReceivedValues = false; + } + + var reformatted = DefaultZodMessageFormatter.Instance.Format(error, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Received)).IsEqualTo(11); + await Assert.That(error.MessageFormat).IsEqualTo("{received} is outside {minimum}-{maximum}"); + await Assert.That(reformatted).IsEqualTo("11 is outside 1-10"); + } + + public static IEnumerable> RuntimeFailures() + { + yield return () => ("String.Min", Z.String().Min(3).Validate("a").Errors.Single()); + yield return () => ("String.Max", Z.String().Max(1).Validate("abc").Errors.Single()); + yield return () => ("String.Length", Z.String().Length(2, 3).Validate("a").Errors.Single()); + yield return () => ("String.StringLength", Z.String().StringLength(3, 2).Validate("a").Errors.Single()); + yield return () => ("String.Email", Z.String().Email().Validate("nope").Errors.Single()); + yield return () => ("String.Regex", Z.String().Regex("^[a-z]+$").Validate("123").Errors.Single()); + yield return () => ("String.StartsWith", Z.String().StartsWith("pre").Validate("x").Errors.Single()); + yield return () => ("String.EndsWith", Z.String().EndsWith("suf").Validate("x").Errors.Single()); + yield return () => ("String.Includes", Z.String().Includes("inc").Validate("x").Errors.Single()); + yield return () => ("String.UUID", Z.String().UUID().Validate("x").Errors.Single()); + yield return () => ("String.IP", Z.String().IP().Validate("x").Errors.Single()); + yield return () => ("String.Cidr", Z.String().Cidr().Validate("x").Errors.Single()); + yield return () => ("String.Url", Z.String().Url().Validate("x").Errors.Single()); + yield return () => ("String.Uri", Z.String().Uri().Validate("").Errors.Single()); + yield return () => ("String.Guid", Z.String().Guid().Validate("x").Errors.Single()); + yield return () => ("String.Compare", Z.String().Compare("other").Validate("x").Errors.Single()); + yield return () => ("Number.Range", Z.Number().Range(1, 10).Validate(11).Errors.Single()); + yield return () => ("Number.MultipleOf", Z.Number().MultipleOf(5).Validate(7).Errors.Single()); + yield return () => ("Number.Int", Z.Number().Int().Validate(1.5).Errors.Single()); + yield return () => ("Number.Safe", Z.Number().Safe().Validate(1e12).Errors.Single()); + yield return () => ("Number.Min", Z.Number().Min(3).Validate(1).Errors.Single()); + yield return () => ("Number.Lt", Z.Number().Lt(1).Validate(1).Errors.Single()); + yield return () => ("Number.Even", Z.Number().Even().Validate(3).Errors.Single()); + yield return () => ("Number.Odd", Z.Number().Odd().Validate(2).Errors.Single()); + yield return () => ("Number.Finite", Z.Number().Finite().Validate(double.PositiveInfinity).Errors.Single()); + yield return () => ("MinCount", ZodRuleErrors.Create(new MinCountRule(2), (int[])[1], ZodPath.Root)); + yield return () => ("MaxCount", ZodRuleErrors.Create(new MaxCountRule(1), (int[])[1, 2], ZodPath.Root)); + yield return () => ("CountRange", ZodRuleErrors.Create(new CountRangeRule(2, 3), (int[])[1], ZodPath.Root)); + yield return () => ("Allowed", ZodRuleErrors.Create(new AllowedValuesRule(["a"]), "b", ZodPath.Root)); + yield return () => ("Denied", ZodRuleErrors.Create(new DeniedValuesRule(["a"]), "a", ZodPath.Root)); + yield return () => ("Enum", ZodRuleErrors.Create(new EnumRule(), (DayOfWeek)42, ZodPath.Root)); + yield return () => ("NonSentinel", ZodRuleErrors.Create(new NonSentinelRule(), Guid.Empty, ZodPath.Root)); + yield return () => + ("NullOrNonWhiteSpace", ZodRuleErrors.Create(new NullOrNonWhiteSpaceRule(), " ", ZodPath.Root)); + } + + [Test] + [MethodDataSource(nameof(RuntimeFailures))] + public async Task RuntimeRule_GivenFailure_ReportsATemplateThatReformatsToTheMessage( + string name, + ValidationError error + ) + { + // Arrange + await Assert.That(name).IsNotEmpty(); + + // Act + var complete = ZodMessageFormat.TryFormatComplete( + error.MessageFormat ?? string.Empty, + error, + out var reformatted, + CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(error.MessageFormat).IsNotNull().Because(name); + await Assert.That(complete).IsTrue().Because(name); + await Assert.That(reformatted).IsEqualTo(error.Message).Because(name); + } + + [Test] + public async Task StringRequired_GivenFailureAtTheRoot_ReportsAResolvableTemplate() + { + // Arrange + var error = Z.String().Required().Validate("").Errors.Single(); + + // Act + var complete = ZodMessageFormat.TryFormatComplete( + error.MessageFormat ?? string.Empty, + error, + out var reformatted, + CultureInfo.InvariantCulture + ); + + // Assert + await Assert.That(error.MessageFormat).IsEqualTo(ZodMessages.Required); + await Assert.That(complete).IsTrue(); + await Assert.That(reformatted).IsEqualTo("Value is required"); + await Assert.That(reformatted).IsEqualTo(error.Message); + } + + [Test] + public async Task StringRequired_GivenFailureUnderAMember_NamesTheMember() + { + // Arrange + var error = Z.String().Required().Validate("").Errors.Single().WithPathPrefix(ZodPathSegment.Member("Email")); + + // Act + var reformatted = DefaultZodMessageFormatter.Instance.Format(error, CultureInfo.InvariantCulture); + + // Assert + await Assert.That(reformatted).IsEqualTo("Email is required"); + } + + [Test] + public async Task RuntimeRule_GivenCustomMessage_ReportsItAsANamedTemplate() + { + // Arrange + var schema = Z.String().Min(3, "At least {0} characters"); + + // Act + var error = schema.Validate("a").Errors.Single(); + + // Assert + await Assert.That(error.Message).IsEqualTo("At least 3 characters"); + await Assert.That(error.MessageFormat).IsEqualTo("At least {minimum} characters"); + } + + [Test] + public async Task StringRules_GivenFailure_ReportTheTemplatesTheyDocument() + { + // Arrange + var minimum = Z.String().Min(3).Validate("a").Errors.Single(); + var ip = Z.String().IP(IPAddressRuleType.IPv6).Validate("x").Errors.Single(); + var regex = Z.String().Regex("^[a-z]+$").Validate("1").Errors.Single(); + + // Act + string[] templates = [minimum.MessageFormat ?? "", ip.MessageFormat ?? "", regex.MessageFormat ?? ""]; + + // Assert + await Assert + .That(templates) + .IsEquivalentTo([ + "String must be at least {minimum} characters long, but got {count}", + "Invalid IPv6 address", + "String does not match the required pattern: {pattern}", + ]); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs b/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs index 836dca61..58bd2ec2 100644 --- a/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs +++ b/src/tests/ZodSharp.UnitTests/Rules/UuidRuleGuidTests.cs @@ -66,6 +66,7 @@ public async Task Versioned_GivenGuid_UsesTheVersionedMessageFormat(Cancellation // Assert await Assert.That(message).Contains("v4"); - await Assert.That(message).Contains(V7); + // Defect B9: the default message never echoes the input. + await Assert.That(message).DoesNotContain(V7); } } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/SchemaErrorModelTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/SchemaErrorModelTests.cs new file mode 100644 index 00000000..cec4d735 --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Schemas/SchemaErrorModelTests.cs @@ -0,0 +1,684 @@ +using System.Collections.Immutable; +using ZodSharp.Core; + +namespace ZodSharp.Schemas; + +/// +/// Regression tests for the runtime-schema defects (A1-A21) of the error-model overhaul. Each test is named after +/// the behaviour it pins and tagged with the defect it would have caught. +/// +public class SchemaErrorModelTests +{ + static readonly string[] SingleTag = ["a"]; + + [Test] + public async Task ZodArray_GivenInvalidElement_KeepsEveryFieldOfTheElementError() + { + // Defect A1 + // Arrange + var schema = Z.Array(Z.String().Min(3)); + + // Act + var result = schema.Validate(["ok-value", "x"]); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.From(ZodPathSegment.At(1))); + await Assert.That(error.Origin).IsEqualTo("string"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(error.Minimum).IsEqualTo(3); + await Assert.That(error.Lower).IsEqualTo(ErrorBound.Including(3)); + } + + [Test] + public async Task ZodObject_GivenInvalidField_KeepsEveryFieldOfTheFieldError() + { + // Defect A2 + // Arrange + var schema = Z.Object().Field("name", Z.String().Max(2)).Build(); + + // Act + var result = schema.Validate(new Dictionary { ["name"] = "long" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("name"); + await Assert.That(error.Origin).IsEqualTo("string"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooBig); + await Assert.That(error.Maximum).IsEqualTo(2); + } + + [Test] + public async Task ZodObject_GivenInvalidCatchallValue_KeepsEveryFieldOfTheError() + { + // Defect A2 + // Arrange + var schema = Z.Object().Field("name", Z.String()).Build().Catchall(new MaxLengthObjectSchema(2)); + + // Act + var result = schema.Validate(new Dictionary { ["name"] = "a", ["extra"] = "long" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("extra"); + await Assert.That(error.Origin).IsEqualTo("string"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooBig); + } + + [Test] + public async Task ZodArray_GivenMinAndMaxMessages_ReportsMessageForFailedBound() + { + // Defect A3 + // Arrange + var schema = Z.Array(Z.String()).Min(2, "too few").Max(5, "too many"); + + // Act + var tooSmall = schema.Validate(["a"]); + var tooBig = schema.Validate(["a", "b", "c", "d", "e", "f"]); + + // Assert + await Assert.That(tooSmall.Errors[0].Code).IsEqualTo("too_small"); + await Assert.That(tooSmall.Errors[0].Message).IsEqualTo("too few"); + await Assert.That(tooBig.Errors[0].Code).IsEqualTo("too_big"); + await Assert.That(tooBig.Errors[0].Message).IsEqualTo("too many"); + } + + [Test] + public async Task ZodArray_GivenNoCustomMessage_ReportsTheCount() + { + // Defect A3 + // Arrange + var schema = Z.Array(Z.String()).Min(2).Max(5); + + // Act + var result = schema.Validate(["a"]); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Message).IsEqualTo("Array must have at least 2 elements, but got 1"); + await Assert.That(error.MessageFormat).IsEqualTo(ZodMessages.ArrayTooSmall); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Count)).IsEqualTo(1); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + [Test] + public async Task ZodArray_GivenExactLength_ReportsExactLengthMessageWithCount() + { + // Defect A3 + // Arrange + var schema = Z.Array(Z.String()).Length(2); + + // Act + var result = schema.Validate(["a", "b", "c"]); + + // Assert + await Assert.That(result.Errors[0].Message).IsEqualTo("Array must have exactly 2 elements, but got 3"); + await Assert.That(result.Errors[0].Code).IsEqualTo("too_big"); + } + + [Test] + public async Task ZodTuple_GivenInvalidElement_PrefixesTheElementIndex() + { + // Defect A4 + // Arrange + var schema = Z.Tuple(Z.String(), Z.String().Min(3)); + + // Act + var result = schema.Validate(["first", "x"]); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath).IsEqualTo(ZodPath.From(ZodPathSegment.At(1))); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + [Test] + public async Task ZodTuple_GivenWrongElementType_ReportsZodTypeNamesAtTheIndex() + { + // Defect A5 + // Arrange + var schema = Z.Tuple(Z.String(), Z.Number()); + + // Act + var result = schema.Validate(["first", "not-a-number"]); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Path).IsEquivalentTo(ImmutableArray.Create("1")); + await Assert.That(error.Message).IsEqualTo("Expected number, received string"); + await Assert.That(error.Message).DoesNotContain("Double"); + } + + [Test] + public async Task ZodTuple_GivenNullElementAndNullableSchema_LetsTheSchemaAcceptIt() + { + // Defect A5 + // Arrange + var schema = Z.Tuple(Z.Optional(Z.String()), Z.Number()); + + // Act + var result = schema.Validate([null, 1.0]); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task ZodTuple_GivenWrongLength_ReportsArityBoundsAndCategoryByDirection() + { + // Defect A6 + // Arrange + var schema = Z.Tuple(Z.String(), Z.String()); + + // Act + var tooShort = schema.Validate(["a"]); + var tooLong = schema.Validate(["a", "b", "c"]); + + // Assert + await Assert.That(tooShort.Errors[0].Code).IsEqualTo("invalid_tuple_length"); + await Assert.That(tooShort.Errors[0].Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(tooShort.Errors[0].Origin).IsEqualTo("array"); + await Assert.That(tooShort.Errors[0].Minimum).IsEqualTo(2); + await Assert.That(tooShort.Errors[0].Maximum).IsEqualTo(2); + await Assert.That(tooLong.Errors[0].Category).IsEqualTo(ZodIssueCategories.TooBig); + } + + [Test] + public async Task ZodUnion_GivenNoMatchingOption_GroupsIssuesPerOptionUnderUnionErrors() + { + // Defect A7 + // Arrange + var schema = Z.Union(new FailingObjectSchema("first_failed"), new FailingObjectSchema("second_failed")); + + // Act + var result = schema.Validate(new object()); + + // Assert + var error = result.Errors.Single(); + var unionErrors = error.GetUnionErrors(); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidUnion); + await Assert.That(error.Parameters!.ContainsKey("errors")).IsFalse(); + await Assert.That(unionErrors.Length).IsEqualTo(2); + await Assert.That(unionErrors[0][0].Code).IsEqualTo("first_failed"); + await Assert.That(unionErrors[1][0].Code).IsEqualTo("second_failed"); + } + + [Test] + public async Task ZodTypedUnion_GivenNoMatchingOption_KeepsEachOptionsIssues() + { + // Defect A8 + // Arrange + var schema = Z.Union(Z.String().Min(10), Z.Number().Min(100)); + + // Act + var result = schema.Validate("hi"); + + // Assert + var error = result.Errors.Single(); + var unionErrors = error.GetUnionErrors(); + await Assert.That(unionErrors.Length).IsEqualTo(2); + await Assert.That(unionErrors[0][0].Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(unionErrors[1][0].Code).IsEqualTo("invalid_type"); + await Assert.That(error.Message).IsEqualTo("Value does not match any of the union options (string, number)"); + } + +#if NET11_0_OR_GREATER + [Test] + public async Task ZodTypedNativeUnion_GivenNoMatchingOption_KeepsEachOptionsIssues() + { + // Defect A8 + // Arrange + var schema = Z.NativeUnion(Z.String().Min(10), Z.Array(Z.String())); + + // Act + var result = schema.Validate("hi"); + + // Assert + var unionErrors = result.Errors.Single().GetUnionErrors(); + await Assert.That(unionErrors.Length).IsEqualTo(2); + await Assert.That(unionErrors[0][0].Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(unionErrors[1][0].Message).IsEqualTo("Expected array, received string"); + } +#endif + + [Test] + public async Task ZodDiscriminatedUnion_GivenUnknownDiscriminator_ReportsAtTheDiscriminatorPathWithOptions() + { + // Defect A9 + // Arrange + var schema = Z.DiscriminatedUnion("type") + .Option("user", new PassThroughObjectSchema()) + .Option("admin", new PassThroughObjectSchema()) + .Build(); + + // Act + var result = schema.Validate(new Dictionary { ["type"] = "secret-input" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("invalid_discriminator"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidUnion); + await Assert.That(error.FullPath.ToDottedString()).IsEqualTo("type"); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Discriminator)).IsEqualTo("type"); + await Assert.That(error.Message).DoesNotContain("secret-input"); + } + + [Test] + public async Task ZodDiscriminatedUnion_GivenMissingDiscriminator_ReportsAtTheDiscriminatorPath() + { + // Defect A9 + // Arrange + var schema = Z.DiscriminatedUnion("type").Option("user", new PassThroughObjectSchema()).Build(); + + // Act + var result = schema.Validate(new Dictionary { ["name"] = "x" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("missing_discriminator"); + await Assert.That(error.Path).IsEquivalentTo(ImmutableArray.Create("type")); + } + + [Test] + public async Task ZodDiscriminatedUnion_GivenOptions_ListsThemInDeclarationOrder() + { + // Defect A10 + // Arrange + var schema = Z.DiscriminatedUnion("kind") + .Option("zebra", new PassThroughObjectSchema()) + .Option("apple", new PassThroughObjectSchema()) + .Option("mango", new PassThroughObjectSchema()) + .Build(); + + // Act + var result = schema.Validate(new Dictionary { ["kind"] = "other" }); + + // Assert + await Assert + .That(result.Errors[0].Message) + .IsEqualTo("Invalid discriminator value. Expected one of: zebra, apple, mango"); + } + + [Test] + public async Task ZodEnum_GivenValueNotAllowed_ReportsOptionsWithoutEchoingTheValue() + { + // Defect A11 + // Arrange + var schema = Z.Enum("b", "a"); + + // Act + var result = schema.Validate("secret"); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidValue); + await Assert.That(error.Message).IsEqualTo("Expected one of: a, b"); + await Assert + .That(error.Parameters!.Get>(ZodIssueParameters.Options)) + .IsEquivalentTo(ImmutableArray.Create("a", "b")); + } + + [Test] + public async Task ZodNativeEnum_GivenUndefinedValue_ReportsTheEnumRuleIdentity() + { + // Defect A11 + // Arrange + var schema = Z.Enum(); + + // Act + var result = schema.Validate((Colour)42); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(Rules.EnumRule.ErrorCode); + await Assert.That(error.Message).IsEqualTo("Value is not a defined member of Colour"); + await Assert.That(error.Origin).IsEqualTo("enum"); + await Assert + .That(error.Parameters!.Get>(ZodIssueParameters.Options)) + .IsEquivalentTo(ImmutableArray.Create("Red", "Green")); + } + + [Test] + public async Task ZodObject_GivenMissingField_ReportsTheSharedRequiredIdentity() + { + // Defect A12 + // Arrange + var schema = Z.Object().Field("email", Z.String()).Build(); + + // Act + var result = schema.Validate([]); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(ZodIssueCodes.MissingField); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidType); + await Assert.That(error.Message).IsEqualTo("email is required"); + await Assert.That(error.MessageFormat).IsEqualTo(ZodMessages.Required); + await Assert.That(error.Path).IsEquivalentTo(ImmutableArray.Create("email")); + } + + [Test] + public async Task ZodObject_GivenStrictAndSeveralUnknownKeys_ReportsOneAggregatedIssue() + { + // Defect A13 + // Arrange + var schema = Z.Object().Field("name", Z.String()).Build().Strict(); + + // Act + var result = schema.Validate( + new Dictionary + { + ["name"] = "a", + ["x"] = 1, + ["y"] = 2, + } + ); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo("unrecognized_keys"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.UnrecognizedKeys); + await Assert.That(error.FullPath.IsRoot).IsTrue(); + await Assert + .That(error.Parameters!.Get>(ZodIssueParameters.Keys)) + .IsEquivalentTo(ImmutableArray.Create("x", "y")); + await Assert.That(error.Message).IsEqualTo("Unrecognized keys: x, y"); + } + + [Test] + public async Task ZodLiteral_GivenDifferentValue_ReportsExpectedInvariantlyWithoutTheInput() + { + // Defect A14 + // Arrange + var schema = Z.Literal(1.5); + var culture = System.Globalization.CultureInfo.CurrentCulture; + + // Act + ValidationResult result; + try + { + System.Globalization.CultureInfo.CurrentCulture = new("de-DE"); + result = schema.Validate(2.25); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = culture; + } + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Message).IsEqualTo("Expected literal 1.5"); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidValue); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Expected)).IsEqualTo(1.5); + } + + [Test] + public async Task ZodObjectField_GivenValueOfWrongClrType_ReportsZodTypeNames() + { + // Defect A15 + // Arrange + var schema = Z.Object().Field("age", Z.Number()).Build(); + + // Act + var result = schema.Validate(new Dictionary { ["age"] = "forty" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Message).IsEqualTo("Expected number, received string"); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Expected)).IsEqualTo("number"); + await Assert.That(error.Parameters!.Get(ZodIssueParameters.Received)).IsEqualTo("string"); + } + + [Test] + public async Task ZodDiscriminatedUnionOption_GivenValueOfWrongClrType_ReportsZodTypeNames() + { + // Defect A15 + // Arrange + var schema = Z.DiscriminatedUnion("type").Option("n", Z.Number()).Build(); + + // Act + var result = schema.Validate(new Dictionary { ["type"] = "n" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Message).IsEqualTo("Expected number, received object"); + await Assert.That(error.Message).DoesNotContain("Dictionary"); + } + + [Test] + public async Task NullInput_GivenEachNullRejectingSchema_ReportsTheSharedNullMessage() + { + // Defect A16 + // Arrange / Act + string[] messages = + [ + Z.String().Validate(null!).Errors[0].Message, + Z.Array(Z.String()).Validate(null!).Errors[0].Message, + Z.Record(Z.String()).Validate(null!).Errors[0].Message, + Z.Object().Build().Validate(null!).Errors[0].Message, + Z.Enum("a").Validate(null!).Errors[0].Message, + Z.Tuple(Z.String(), Z.String()).Validate(null!).Errors[0].Message, + ]; + + // Assert + await Assert + .That(messages) + .IsEquivalentTo([ + "Expected string, received null", + "Expected array, received null", + "Expected record, received null", + "Expected object, received null", + "Expected string, received null", + "Expected tuple, received null", + ]); + } + + [Test] + public async Task Refine_GivenFailure_ReportsRefinementFailedWithCustomCategory() + { + // Defect A17 + // Arrange + var schema = Z.String().Refine(static value => value.Length > 3); + + // Act + var result = schema.Validate("ab"); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.Code).IsEqualTo(ZodIssueCodes.RefinementFailed); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.Custom); + } + + [Test] + public async Task SuperRefine_GivenStructuredIssue_KeepsEveryFieldAndPrefixesTheBasePath() + { + // Defect A18 + // Arrange + var issue = ValidationError.Create( + "too_short", + "Too short", + ZodPath.Member("Name"), + new Dictionary { ["min"] = 3 }, + origin: "string", + minimum: 3, + category: ZodIssueCategories.TooSmall + ); + RefineCtx ctx = new("x", ZodPath.From(ZodPathSegment.At(2))); + + // Act + ctx.AddIssue(issue); + + // Assert + var added = ctx.Issues.Single(); + await Assert.That(added.FullPath.ToDottedString()).IsEqualTo("[2].Name"); + await Assert.That(added.Origin).IsEqualTo("string"); + await Assert.That(added.Minimum).IsEqualTo(3); + await Assert.That(added.Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + [Test] + public async Task SuperRefine_GivenTypedPathIssue_UsesTheTypedSegments() + { + // Defect A18 + // Arrange + var schema = Z.Array(Z.String()) + .SuperRefine(static ctx => + ctx.AddIssue("dup", "Duplicate", ZodPath.From(ZodPathSegment.At(1)), category: "custom") + ); + + // Act + var result = schema.Validate(["a", "a"]); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath[0].Kind).IsEqualTo(ZodPathSegmentKind.Index); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.Custom); + } + + [Test] + public async Task SuperRefine_GivenNestedInObject_PrefixesTheFieldPath() + { + // Defect A19 + // Arrange + var schema = Z.Object() + .Field("tags", Z.Array(Z.String()).SuperRefine(static ctx => ctx.AddIssue("bad", ["0"]))) + .Build(); + + // Act + var result = schema.Validate(new Dictionary { ["tags"] = SingleTag }); + + // Assert + await Assert.That(result.Errors.Single().FullPath.ToDottedString()).IsEqualTo("tags[0]"); + } + + [Test] + public async Task ZodIntersection_GivenSameIssueFromBothSides_ReportsItOnce() + { + // Defect A20 + // Arrange + var schema = Z.Intersection(Z.String().Min(5), Z.String().Min(5).Max(10)); + + // Act + var result = schema.Validate("abc"); + + // Assert + await Assert.That(result.Errors.Length).IsEqualTo(1); + } + + [Test] + public async Task ZodStringSpan_GivenFailingRule_ReportsTheRulesStructuredIdentity() + { + // Defect A21 + // Arrange + var schema = Z.String().Min(5); + + // Act + var valid = schema.IsValidSpan("abc".AsSpan(), out var errors); + + // Assert + await Assert.That(valid).IsFalse(); + await Assert.That(errors[0].Origin).IsEqualTo("string"); + await Assert.That(errors[0].Category).IsEqualTo(ZodIssueCategories.TooSmall); + await Assert.That(errors[0].Lower).IsEqualTo(ErrorBound.Including(5)); + await Assert.That(errors[0].Parameters!.Get(ZodIssueParameters.Count)).IsEqualTo(3); + } + + [Test] + public async Task ZodRecord_GivenInvalidValue_PrefixesAKeySegment() + { + // Defect A1 (record wrapper consistency) + // Arrange + var schema = Z.Record(Z.String().Min(3)); + + // Act + var result = schema.Validate(new Dictionary { ["a.b"] = "x" }); + + // Assert + var error = result.Errors.Single(); + await Assert.That(error.FullPath[0].Kind).IsEqualTo(ZodPathSegmentKind.Key); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.TooSmall); + } + + [Test] + public async Task ZodTypeNames_GivenClrTypes_ReturnsZodNames() + { + // Defect A15 + // Arrange / Act + string[] names = + [ + ZodTypeNames.Of(typeof(int)), + ZodTypeNames.Of(typeof(double?)), + ZodTypeNames.Of(typeof(string)), + ZodTypeNames.Of(typeof(bool)), + ZodTypeNames.Of(typeof(DateTimeOffset)), + ZodTypeNames.Of(typeof(int[])), + ZodTypeNames.Of(typeof(List)), + ZodTypeNames.Of(typeof(Dictionary)), + ZodTypeNames.Of(typeof(System.Numerics.BigInteger)), + ZodTypeNames.OfValue(null), + ZodTypeNames.OfValue(double.NaN), + ]; + + // Assert + await Assert + .That(names) + .IsEquivalentTo([ + "number", + "number", + "string", + "boolean", + "date", + "array", + "array", + "object", + "bigint", + "null", + "nan", + ]); + } + + enum Colour + { + Red, + Green, + } + + sealed class FailingObjectSchema(string code) : IZodSchema + { + public ValidationResult Validate(object value) => + ValidationResult.Failure(new ValidationError(code, "failed")); + + public ValueTask> ValidateAsync( + object value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } + + sealed class PassThroughObjectSchema : IZodSchema + { + public ValidationResult Validate(object value) => ValidationResult.Success(value); + + public ValueTask> ValidateAsync( + object value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } + + sealed class MaxLengthObjectSchema(int max) : IZodSchema + { + readonly ZodString _inner = Z.String().Max(max); + + public ValidationResult Validate(object value) + { + var result = _inner.Validate((string)value); + return result.IsSuccess + ? ValidationResult.Success(result.Value) + : ValidationResult.Failure(result.Errors); + } + + public ValueTask> ValidateAsync( + object value, + CancellationToken cancellationToken = default + ) => new(Validate(value)); + } +} diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs index 09e90d4a..717b5b4b 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs @@ -1,3 +1,5 @@ +using ZodSharp.Core; + namespace ZodSharp.Schemas; public class ZodArrayTests @@ -40,7 +42,8 @@ public async Task ArrayElementValidation_GivenInvalidElement_ReturnsFailureWithI var result = Z.Array(Z.String().Min(2)).Validate(["ok", "x"]); await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Path).Contains("[1]"); + await Assert.That(result.Errors[0].Path).Contains("1"); + await Assert.That(result.Errors[0].FullPath[0]).IsEqualTo(ZodPathSegment.At(1)); } [Test] @@ -123,9 +126,10 @@ public async Task ArrayElementValidation_GivenScalarElement_PathIsExactlyTheInde var result = schema.Validate(["ok", "x"]); // Assert — the whole path, not just a containment check. A containment assertion cannot - // distinguish ["[1]"] from [null, "[1]"]. + // distinguish ["1"] from [null, "1"]. await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Path.SequenceEqual(["[1]"])).IsTrue(); + await Assert.That(result.Errors[0].Path.SequenceEqual(["1"])).IsTrue(); + await Assert.That(result.Errors[0].FullPath.ToDottedString()).IsEqualTo("[1]"); } [Test] @@ -144,7 +148,8 @@ public async Task ArrayElementValidation_GivenObjectElement_PrefixesIndexAndKeep // Assert await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Path.SequenceEqual(["[1]", "email"])).IsTrue(); + await Assert.That(result.Errors[0].Path.SequenceEqual(["1", "email"])).IsTrue(); + await Assert.That(result.Errors[0].FullPath.ToDottedString()).IsEqualTo("[1].email"); await Assert.That(result.Errors[0].Path.All(static segment => segment is not null)).IsTrue(); } diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodObjectShapeTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodObjectShapeTests.cs index d8df23bc..8858657d 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodObjectShapeTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodObjectShapeTests.cs @@ -117,7 +117,7 @@ public async Task Merge_GivenRightObjectIsStrict_ResultIsStrict() // Assert — the right (strict) policy wins over the left (strip). await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors.Any(static e => e.Code == "unrecognized_key")).IsTrue(); + await Assert.That(result.Errors.Any(static e => e.Code == "unrecognized_keys")).IsTrue(); } [Test] @@ -336,7 +336,7 @@ public async Task Strict_GivenUnknownKey_ReturnsError() // Assert await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors.Any(static e => e.Code == "unrecognized_key")).IsTrue(); + await Assert.That(result.Errors.Any(static e => e.Code == "unrecognized_keys")).IsTrue(); } [Test] diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs index 6ea381f5..db417c31 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs @@ -1,3 +1,5 @@ +using ZodSharp.Core; + namespace ZodSharp.Schemas; public class ZodTupleTests @@ -42,7 +44,7 @@ public async Task Tuple2_GivenWrongTypeAtPosition_ReturnsFailure() // Assert await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.Errors[0].Path).Contains("[0]"); + await Assert.That(result.Errors[0].FullPath[0]).IsEqualTo(ZodPathSegment.At(0)); } [Test] diff --git a/tests/e2e/blazor-sample.e2e.mjs b/tests/e2e/blazor-sample.e2e.mjs new file mode 100644 index 00000000..6c8beaf2 --- /dev/null +++ b/tests/e2e/blazor-sample.e2e.mjs @@ -0,0 +1,193 @@ +// End-to-end smoke test of the Blazor sample (src/src/Examples.Blazor) in a real browser: one form per render mode, +// against the published app, so the WebAssembly form runs the trimmed client. +// +// node tests/e2e/blazor-sample.e2e.mjs http://127.0.0.1:5090 +// +// Needs playwright-core (not a repository dependency: the nightly job installs it with --no-save) and a Chromium +// (`npx playwright-core install chromium`), or set E2E_BROWSER_CHANNEL=msedge / chrome to use an installed browser. +// Exits non-zero on the first failed check. +import { chromium } from "playwright-core"; + +const base = (process.argv[2] ?? "http://127.0.0.1:5090").replace(/\/$/, ""); +const timeout = 30_000; + +const browser = await chromium.launch({ channel: process.env.E2E_BROWSER_CHANNEL || undefined, headless: true }); +const page = await browser.newPage(); +const browserErrors = []; +page.on("pageerror", (error) => browserErrors.push(error.message)); +page.on("console", (message) => { + if (message.type() === "error" && !message.text().startsWith("Failed to load resource")) { + browserErrors.push(message.text()); + } +}); + +let failures = 0; + +async function check(name, body) { + try { + await body(); + console.log(`ok - ${name}`); + } catch (error) { + failures++; + console.log(`not ok - ${name}\n ${String(error?.message ?? error).split("\n").join("\n ")}`); + } +} + +/** Waits until fn() returns a truthy value, failing with the last value seen. */ +async function eventually(description, fn) { + const deadline = Date.now() + timeout; + let last; + while (Date.now() < deadline) { + last = await fn(); + if (last) return last; + await page.waitForTimeout(100); + } + + throw new Error(`Timed out waiting for ${description}; last value: ${JSON.stringify(last)}`); +} + +/** The codes and messages rendered next to the input with the given id (ZodValidationMessage markup). */ +async function messagesFor(id) { + return page.$$eval( + `#${id} ~ .validation-message`, + (elements) => elements.map((e) => ({ code: e.getAttribute("data-code"), text: e.textContent ?? "" })), + ); +} + +/** The outcome RegisterCustomerForm reports on its status line (RegistrationOutcome), or null before one. */ +async function outcome() { + return page.evaluate(() => document.querySelector("#status")?.getAttribute("data-outcome") ?? null); +} + +try { + await check("interactive WebAssembly: debounced validation, codes, async pending state", async () => { + await page.goto(`${base}/register`); + await page.waitForSelector("#name", { timeout }); + + await page.fill("#name", "A"); + await eventually("the invalid_length code on Name", async () => + (await messagesFor("name")).some((m) => m.code === "invalid_length"), + ); + + await page.fill("#email", "taken@example.com"); + // The uniqueness check is a service-aware async rule that asks the server; its pending state is visible. + await eventually("the pending indicator", async () => (await page.locator("#email-pending").count()) === 1); + await eventually("the email_taken code on Email", async () => + (await messagesFor("email")).some((m) => m.code === "email_taken"), + ); + await eventually("the pending indicator to clear", async () => (await page.locator("#email-pending").count()) === 0); + }); + + await check("interactive WebAssembly: server ProblemDetails mapped onto the fields", async () => { + await page.fill("#name", ""); + await page.fill("#email", "not-an-email"); + await page.fill("#age", "5"); + await page.click("#post-unvalidated"); + await page.waitForSelector("#status", { timeout }); + + await eventually("the server's invalid_range code on Age", async () => + (await messagesFor("age")).some((m) => m.code === "invalid_range"), + ); + await eventually("the server's invalid_string code on Email", async () => + (await messagesFor("email")).some((m) => m.code === "invalid_string"), + ); + }); + + await check("interactive WebAssembly: a valid registration is sent and its response validated (Purview.ZodSharp.Http)", async () => { + await page.goto(`${base}/register`); + await page.waitForSelector("#name", { timeout }); + await page.fill("#name", "Ada"); + await page.fill("#email", `e2e-wasm-${Date.now()}@example.com`); + await page.fill("#age", "36"); + await page.press("#age", "Tab"); + await page.click("button[type=submit]"); + await eventually("the Registered outcome", async () => (await outcome()) === "Registered"); + }); + + await check("interactive Server: the shared form posts service-to-service (Purview.ZodSharp.Http)", async () => { + await page.goto(`${base}/register-server`); + await page.waitForSelector("#name", { timeout }); + // Wait for the circuit: validation messages only appear once the page is interactive. + await eventually("the interactive circuit", async () => { + await page.fill("#name", "A"); + return (await messagesFor("name")).some((m) => m.code === "invalid_length"); + }); + + await page.fill("#name", "Grace"); + await page.fill("#email", `e2e-server-${Date.now()}@example.com`); + await page.fill("#age", "85"); + await page.press("#age", "Tab"); + await page.click("button[type=submit]"); + await eventually("the Registered outcome", async () => (await outcome()) === "Registered"); + + // Unvalidated: the server's ProblemDetails come back through TryReadZodValidationProblemAsync onto the field. + await page.fill("#email", "not-an-email"); + await page.click("#post-unvalidated"); + await eventually("the RejectedByServer outcome", async () => (await outcome()) === "RejectedByServer"); + await eventually("the server's invalid_string code on Email", async () => + (await messagesFor("email")).some((m) => m.code === "invalid_string"), + ); + }); + + await check("interactive Server: nested, collection and field-level messages", async () => { + await page.goto(`${base}/orders`); + // Wait for the circuit: the add-line button only works once the page is interactive. + await eventually("the interactive circuit", async () => { + await page.click("text=Add a line"); + return (await page.locator("#sku-1").count()) === 1; + }); + + // InputText binds onchange, so each value is committed with a blur. + await page.fill("#shipping-city", "Leeds"); + await page.press("#shipping-city", "Tab"); + await page.fill("#shipping-city", ""); + await page.press("#shipping-city", "Tab"); + await eventually("the City message next to the nested input", async () => + (await page.$$eval("#shipping-city + .validation-message", (e) => e.length)) === 1, + ); + await eventually("no message on an untouched field yet", async () => + (await page.$$eval("#shipping-street + .validation-message", (e) => e.length)) === 0, + ); + + await page.fill("#quantity-1", "500"); + await page.press("#quantity-1", "Tab"); + await eventually("the Quantity message on the second line", async () => + (await page.$$eval("#quantity-1 + .validation-message", (e) => e.length)) === 1, + ); + }); + + await check("static SSR: ZodSharp client rules block the submit in the browser", async () => { + await page.goto(`${base}/returns`); + await page.fill("#reference", "BAD-1"); + await page.fill("#order-id", "not-a-uuid"); + await page.click("button[type=submit]"); + + await eventually("the zod-startswith message", async () => + (await page.textContent("[data-valmsg-for='Model.Reference']"))?.includes("RMA-"), + ); + await eventually("a message on the UUID field", async () => + ((await page.textContent("[data-valmsg-for='Model.OrderId']")) ?? "").trim().length > 0, + ); + if ((await page.locator("#submitted").count()) !== 0) throw new Error("The invalid form was submitted."); + }); + + await check("French: the WebAssembly client loads the satellite resources", async () => { + await page.goto(`${base}/culture/set?culture=fr&redirectUri=%2Fregister`); + await page.waitForSelector("#email", { timeout }); + await page.fill("#email", "taken@example.com"); + await eventually("the French email_taken message", async () => + (await messagesFor("email")).some((m) => m.text.includes("déjà enregistrée")), + ); + }); + + await check("no unhandled browser errors", async () => { + if (browserErrors.length > 0) throw new Error(browserErrors.join("\n")); + }); +} finally { + await browser.close(); +} + +if (failures > 0) { + console.log(`${failures} check(s) failed`); + process.exit(1); +} diff --git a/tests/ts/client-validators.test.ts b/tests/ts/client-validators.test.ts new file mode 100644 index 00000000..c563ead6 --- /dev/null +++ b/tests/ts/client-validators.test.ts @@ -0,0 +1,108 @@ +/** + * Client-validator parity: C# (Purview.ZodSharp rules) -> zodsharp-validation.js. + * + * The C# producer (ZodSharp.Blazor.Ssr/ClientRuleParityFixtureTests) writes, for each ZodSharp rule, the client rule + * the server emits into a static-SSR form's `data-rules` payload and the C# rule's verdict on a set of inputs. This + * suite runs the browser validators from src/src/Blazor/wwwroot/zodsharp-validation.js against the same inputs and + * fails when JavaScript and C# disagree. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const fixtureDir = resolve("src", "tests", "cross-platform", "client-rules"); +const scriptPath = resolve("src", "src", "Blazor", "wwwroot", "zodsharp-validation.js"); + +interface FixtureRule { + name: string; + message: string; + params: Record; +} + +interface FixtureCase { + value: string; + valid: boolean; +} + +interface Fixture { + name: string; + rules: FixtureRule[]; + cases: FixtureCase[]; +} + +type ValidationResult = { success: boolean; message?: string }; +type Validator = (ctx: { value: string; element?: unknown; params: Record }) => ValidationResult; + +interface ZodSharpValidation { + validators: Record; + register: (blazor?: unknown) => boolean; +} + +let fixtures: Fixture[] = []; +try { + fixtures = readdirSync(fixtureDir) + .filter((file) => file.endsWith(".json")) + .map((file) => JSON.parse(readFileSync(resolve(fixtureDir, file), "utf-8")) as Fixture); +} catch { + // The directory does not exist until the C# tests run. Handled below. +} + +let api: ZodSharpValidation; + +beforeAll(async () => { + await import(pathToFileURL(scriptPath).href); + api = (globalThis as unknown as { ZodSharpValidation: ZodSharpValidation }).ZodSharpValidation; +}); + +function run(rule: FixtureRule, value: string): ValidationResult { + const validator = api.validators[rule.name]; + expect(validator, `no JavaScript validator named '${rule.name}'`).toBeTypeOf("function"); + return validator({ value, params: rule.params }); +} + +describe("zodsharp-validation.js matches the C# rules", () => { + if (fixtures.length === 0) { + // Do NOT pass silently: an empty directory means this suite verified nothing. + it("has C# client-rule fixtures to validate", () => { + expect.fail( + `No client-rule fixtures found in ${fixtureDir}. ` + + "Run the C# tests first (`just test`) so they write their output, then re-run `bun run test`.", + ); + }); + } + + for (const fixture of fixtures) { + for (const testCase of fixture.cases) { + it(`${fixture.name} / ${JSON.stringify(testCase.value)} — ${testCase.valid ? "valid" : "invalid"} like C#`, () => { + const results = fixture.rules.map((rule) => run(rule, testCase.value)); + expect( + results.every((r) => r.success), + `${fixture.name} (${JSON.stringify(testCase.value)}) ${JSON.stringify(fixture.rules)}`, + ).toBe(testCase.valid); + }); + } + + for (const rule of fixture.rules.filter((r) => r.params.template)) { + const failing = fixture.cases.find((c) => !c.valid); + if (!failing) continue; + it(`${fixture.name} — substitutes the value into the message`, () => { + const result = run(rule, failing.value); + expect(result.message).toBe(rule.params.template.split("{value}").join(failing.value)); + }); + } + } + + it("lets an empty value pass every validator, like the built-in validators", () => { + for (const [name, validator] of Object.entries(api.validators)) { + expect(validator({ value: "", params: { value: "1", divisor: "2", kind: "number" } }).success, name).toBe(true); + } + }); + + it("registers every validator with Blazor.formValidation", () => { + const registered: string[] = []; + const blazor = { formValidation: { addValidator: (name: string) => registered.push(name) } }; + expect(api.register(blazor)).toBe(true); + expect(registered.sort()).toEqual(Object.keys(api.validators).sort()); + }); +});