From 65a6bb09ca6f9fe7d5072e6da80f10bb3cdfd0a9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 12:26:57 +0000 Subject: [PATCH 001/107] build(skills): added purview skills --- .../purview-architecture-testing/SKILL.md | 183 +++++++++ .agents/skills/purview-architecture/SKILL.md | 146 +++++++ .../references/project-context-discovery.md | 115 ++++++ .../references/project-responsibilities.md | 107 ++++++ .../references/proportional-architecture.md | 92 +++++ .../skills/purview-buildsdk-adoption/SKILL.md | 114 ++++++ .../purview-buildsdk-configuration/SKILL.md | 110 ++++++ .../references/properties.md | 146 +++++++ .../SKILL.md | 134 +++++++ .../references/detection.md | 79 ++++ .../references/test-types.md | 35 ++ .../purview-buildsdk-project-layout/SKILL.md | 136 +++++++ .../SKILL.md | 111 ++++++ .../SKILL.md | 166 ++++++++ .../references/repository-root.md | 55 +++ .../purview-buildsdk-skill-tests/SKILL.md | 131 +++++++ .../references/authoring.md | 79 ++++ .../skills/purview-buildsdk-testing/SKILL.md | 121 ++++++ .../references/treenode-filter.md | 107 ++++++ .../purview-engineering-principles/SKILL.md | 171 +++++++++ .agents/skills/purview-persistence/SKILL.md | 106 ++++++ .../references/guid-generation.md | 93 +++++ .../references/postgresql.md | 98 +++++ .../references/sqlserver.md | 101 +++++ .agents/skills/purview-telemetry/SKILL.md | 123 ++++++ .../references/observability-levels.md | 93 +++++ .../purview-testing-conventions/SKILL.md | 357 ++++++++++++++++++ .../acceptance-and-container-tests.md | 117 ++++++ .../purview-architecture-testing/SKILL.md | 183 +++++++++ .claude/skills/purview-architecture/SKILL.md | 146 +++++++ .../references/project-context-discovery.md | 115 ++++++ .../references/project-responsibilities.md | 107 ++++++ .../references/proportional-architecture.md | 92 +++++ .../skills/purview-buildsdk-adoption/SKILL.md | 114 ++++++ .../purview-buildsdk-configuration/SKILL.md | 110 ++++++ .../references/properties.md | 146 +++++++ .../SKILL.md | 134 +++++++ .../references/detection.md | 79 ++++ .../references/test-types.md | 35 ++ .../purview-buildsdk-project-layout/SKILL.md | 136 +++++++ .../SKILL.md | 111 ++++++ .../SKILL.md | 166 ++++++++ .../references/repository-root.md | 55 +++ .../purview-buildsdk-skill-tests/SKILL.md | 131 +++++++ .../references/authoring.md | 79 ++++ .../skills/purview-buildsdk-testing/SKILL.md | 121 ++++++ .../references/treenode-filter.md | 107 ++++++ .../purview-engineering-principles/SKILL.md | 171 +++++++++ .claude/skills/purview-persistence/SKILL.md | 106 ++++++ .../references/guid-generation.md | 93 +++++ .../references/postgresql.md | 98 +++++ .../references/sqlserver.md | 101 +++++ .claude/skills/purview-telemetry/SKILL.md | 123 ++++++ .../references/observability-levels.md | 93 +++++ .../purview-testing-conventions/SKILL.md | 357 ++++++++++++++++++ .../acceptance-and-container-tests.md | 117 ++++++ skills-lock.json | 89 +++++ 57 files changed, 6941 insertions(+) create mode 100644 .agents/skills/purview-architecture-testing/SKILL.md create mode 100644 .agents/skills/purview-architecture/SKILL.md create mode 100644 .agents/skills/purview-architecture/references/project-context-discovery.md create mode 100644 .agents/skills/purview-architecture/references/project-responsibilities.md create mode 100644 .agents/skills/purview-architecture/references/proportional-architecture.md create mode 100644 .agents/skills/purview-buildsdk-adoption/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/references/properties.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/detection.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/test-types.md create mode 100644 .agents/skills/purview-buildsdk-project-layout/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-repository-review/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-installation/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-installation/references/repository-root.md create mode 100644 .agents/skills/purview-buildsdk-skill-tests/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-tests/references/authoring.md create mode 100644 .agents/skills/purview-buildsdk-testing/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-testing/references/treenode-filter.md create mode 100644 .agents/skills/purview-engineering-principles/SKILL.md create mode 100644 .agents/skills/purview-persistence/SKILL.md create mode 100644 .agents/skills/purview-persistence/references/guid-generation.md create mode 100644 .agents/skills/purview-persistence/references/postgresql.md create mode 100644 .agents/skills/purview-persistence/references/sqlserver.md create mode 100644 .agents/skills/purview-telemetry/SKILL.md create mode 100644 .agents/skills/purview-telemetry/references/observability-levels.md create mode 100644 .agents/skills/purview-testing-conventions/SKILL.md create mode 100644 .agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md create mode 100644 .claude/skills/purview-architecture-testing/SKILL.md create mode 100644 .claude/skills/purview-architecture/SKILL.md create mode 100644 .claude/skills/purview-architecture/references/project-context-discovery.md create mode 100644 .claude/skills/purview-architecture/references/project-responsibilities.md create mode 100644 .claude/skills/purview-architecture/references/proportional-architecture.md create mode 100644 .claude/skills/purview-buildsdk-adoption/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-configuration/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-configuration/references/properties.md create mode 100644 .claude/skills/purview-buildsdk-project-detection/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-project-detection/references/detection.md create mode 100644 .claude/skills/purview-buildsdk-project-detection/references/test-types.md create mode 100644 .claude/skills/purview-buildsdk-project-layout/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-repository-review/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-skill-installation/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-skill-installation/references/repository-root.md create mode 100644 .claude/skills/purview-buildsdk-skill-tests/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-skill-tests/references/authoring.md create mode 100644 .claude/skills/purview-buildsdk-testing/SKILL.md create mode 100644 .claude/skills/purview-buildsdk-testing/references/treenode-filter.md create mode 100644 .claude/skills/purview-engineering-principles/SKILL.md create mode 100644 .claude/skills/purview-persistence/SKILL.md create mode 100644 .claude/skills/purview-persistence/references/guid-generation.md create mode 100644 .claude/skills/purview-persistence/references/postgresql.md create mode 100644 .claude/skills/purview-persistence/references/sqlserver.md create mode 100644 .claude/skills/purview-telemetry/SKILL.md create mode 100644 .claude/skills/purview-telemetry/references/observability-levels.md create mode 100644 .claude/skills/purview-testing-conventions/SKILL.md create mode 100644 .claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md create mode 100644 skills-lock.json 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..8d4b5a80 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,146 @@ +--- +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 — 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. 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. + +## 6. 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? +- 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/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..1bf0a1a0 --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,107 @@ +# 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 | 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 | 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. + +## 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` | A client compiles against it without taking the server | The API host is the only consumer | +| `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..f0ee7156 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,110 @@ +--- +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, 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` +- 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` | + +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`. + +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..93a0d9b3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,146 @@ +# 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` | | + +## 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` | detected SDK version, else `1.0.0` | | +| `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` +- `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..2d0d9862 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,134 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, 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`. + +## 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` | + +## 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). + +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 | + +`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/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 00000000..bf75823d --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,79 @@ +# 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` | + +## 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..55a019cd --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,136 @@ +--- +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, 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`. + +## 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-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/.claude/skills/purview-architecture-testing/SKILL.md b/.claude/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 00000000..0619225f --- /dev/null +++ b/.claude/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/.claude/skills/purview-architecture/SKILL.md b/.claude/skills/purview-architecture/SKILL.md new file mode 100644 index 00000000..8d4b5a80 --- /dev/null +++ b/.claude/skills/purview-architecture/SKILL.md @@ -0,0 +1,146 @@ +--- +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 — 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. 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. + +## 6. 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? +- 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/.claude/skills/purview-architecture/references/project-context-discovery.md b/.claude/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 00000000..d40569a3 --- /dev/null +++ b/.claude/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/.claude/skills/purview-architecture/references/project-responsibilities.md b/.claude/skills/purview-architecture/references/project-responsibilities.md new file mode 100644 index 00000000..1bf0a1a0 --- /dev/null +++ b/.claude/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,107 @@ +# 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 | 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 | 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. + +## 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` | A client compiles against it without taking the server | The API host is the only consumer | +| `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/.claude/skills/purview-architecture/references/proportional-architecture.md b/.claude/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 00000000..3ed7f553 --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-adoption/SKILL.md b/.claude/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 00000000..b1f222c5 --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-configuration/SKILL.md b/.claude/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 00000000..f0ee7156 --- /dev/null +++ b/.claude/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,110 @@ +--- +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, 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` +- 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` | + +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`. + +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/.claude/skills/purview-buildsdk-configuration/references/properties.md b/.claude/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 00000000..93a0d9b3 --- /dev/null +++ b/.claude/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,146 @@ +# 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` | | + +## 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` | detected SDK version, else `1.0.0` | | +| `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` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.claude/skills/purview-buildsdk-project-detection/SKILL.md b/.claude/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 00000000..2d0d9862 --- /dev/null +++ b/.claude/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,134 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, 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`. + +## 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` | + +## 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). + +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 | + +`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/.claude/skills/purview-buildsdk-project-detection/references/detection.md b/.claude/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 00000000..bf75823d --- /dev/null +++ b/.claude/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,79 @@ +# 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` | + +## 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/.claude/skills/purview-buildsdk-project-detection/references/test-types.md b/.claude/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 00000000..dace44fc --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-project-layout/SKILL.md b/.claude/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 00000000..55a019cd --- /dev/null +++ b/.claude/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,136 @@ +--- +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, 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`. + +## 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/.claude/skills/purview-buildsdk-repository-review/SKILL.md b/.claude/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 00000000..ce1fd294 --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-skill-installation/SKILL.md b/.claude/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 00000000..66c20a33 --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.claude/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 00000000..e90b695c --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-skill-tests/SKILL.md b/.claude/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 00000000..4043ac4f --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-skill-tests/references/authoring.md b/.claude/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 00000000..69d4ee45 --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-testing/SKILL.md b/.claude/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 00000000..5a5a7d53 --- /dev/null +++ b/.claude/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/.claude/skills/purview-buildsdk-testing/references/treenode-filter.md b/.claude/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 00000000..35d032bc --- /dev/null +++ b/.claude/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/.claude/skills/purview-engineering-principles/SKILL.md b/.claude/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 00000000..ba4dd9bd --- /dev/null +++ b/.claude/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/.claude/skills/purview-persistence/SKILL.md b/.claude/skills/purview-persistence/SKILL.md new file mode 100644 index 00000000..4667fa29 --- /dev/null +++ b/.claude/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/.claude/skills/purview-persistence/references/guid-generation.md b/.claude/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 00000000..432053a2 --- /dev/null +++ b/.claude/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/.claude/skills/purview-persistence/references/postgresql.md b/.claude/skills/purview-persistence/references/postgresql.md new file mode 100644 index 00000000..cd557ecc --- /dev/null +++ b/.claude/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/.claude/skills/purview-persistence/references/sqlserver.md b/.claude/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 00000000..c7282955 --- /dev/null +++ b/.claude/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/.claude/skills/purview-telemetry/SKILL.md b/.claude/skills/purview-telemetry/SKILL.md new file mode 100644 index 00000000..6d831731 --- /dev/null +++ b/.claude/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/.claude/skills/purview-telemetry/references/observability-levels.md b/.claude/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 00000000..df97d785 --- /dev/null +++ b/.claude/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/.claude/skills/purview-testing-conventions/SKILL.md b/.claude/skills/purview-testing-conventions/SKILL.md new file mode 100644 index 00000000..1fe32320 --- /dev/null +++ b/.claude/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/.claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 00000000..e8b91109 --- /dev/null +++ b/.claude/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/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..732a07f6 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "04dff518cc8553f7ff05a7871737bf8edeea1f92f32fa1b5cff8af94a9818fd4" + }, + "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": "55f96148b07e03f292f06a786a9733ae6df6d971870af48caf32212a3b58e46d" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "1f251609d5e0c219a9eb627ce27fc38f845e1ab826a2b0bc0985af8ec1a069a2" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "d88899ee89edf9615af4b8406b50d53ffb9cec63ed2a9a4ace6d9a322aeef059" + }, + "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-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" + } + } +} From 2d340b8f103a55571728ae183a181a0e273eb98a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:17:33 +0000 Subject: [PATCH 002/107] feat(core): typed error paths, structured bounds and never-throw message formatting - ZodPath/ZodPathSegment: typed member/index/key segments with one renderer (Items[0].Name), JSON Pointer, JSONPath, configuration keys and parsing - ValidationError: FullPath, Lower/Upper ErrorBound, MessageFormat, WithPathPrefix, sequence-based equality; legacy Path keeps Zod's "0" form - ZodMessageFormat: lenient named/positional formatting that never throws; RuleMessage uses it and formats with the invariant culture - ZodException lists issues with dotted paths; Merge tolerates default results - ZodIssueCategories/ZodIssueParameters constants - ProblemDetails keys use the canonical renderer - CI SDK pinned to 11.0.100-rc.3 to match global.json --- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 2 +- global.json | 6 +- src/src/AspNetCore/ProblemDetailsMapper.cs | 21 +- src/src/ZodSharp/Core/ErrorBound.cs | 26 + src/src/ZodSharp/Core/ValidationError.cs | 181 +++++- src/src/ZodSharp/Core/ValidationResult.cs | 4 +- src/src/ZodSharp/Core/ZodException.cs | 47 +- src/src/ZodSharp/Core/ZodIssueCategories.cs | 81 +++ src/src/ZodSharp/Core/ZodMessageFormat.cs | 265 +++++++++ src/src/ZodSharp/Core/ZodPath.cs | 522 ++++++++++++++++++ src/src/ZodSharp/Core/ZodPathSegment.cs | 108 ++++ src/src/ZodSharp/Rules/RuleMessage.cs | 50 +- .../Core/ValidationErrorModelTests.cs | 155 ++++++ .../Core/ZodMessageFormatTests.cs | 122 ++++ .../ZodSharp.UnitTests/Core/ZodPathTests.cs | 212 +++++++ .../Schemas/ZodArrayTests.cs | 12 +- .../Schemas/ZodTupleTests.cs | 3 +- 18 files changed, 1754 insertions(+), 65 deletions(-) create mode 100644 src/src/ZodSharp/Core/ErrorBound.cs create mode 100644 src/src/ZodSharp/Core/ZodIssueCategories.cs create mode 100644 src/src/ZodSharp/Core/ZodMessageFormat.cs create mode 100644 src/src/ZodSharp/Core/ZodPath.cs create mode 100644 src/src/ZodSharp/Core/ZodPathSegment.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/ZodPathTests.cs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db498c65..01c9e4bc 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 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/global.json b/global.json index d704407b..59c4a9d3 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", - "rollForward": "latestMajor", + "version": "11.0.100-rc.3.26522.148", + "rollForward": "latestFeature", "allowPrerelease": true }, "msbuild-sdks": { @@ -10,4 +10,4 @@ "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/src/src/AspNetCore/ProblemDetailsMapper.cs b/src/src/AspNetCore/ProblemDetailsMapper.cs index 0d4c3311..32710cbc 100644 --- a/src/src/AspNetCore/ProblemDetailsMapper.cs +++ b/src/src/AspNetCore/ProblemDetailsMapper.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using ZodSharp.Core; @@ -39,7 +38,7 @@ bool formatMessages highest = errorType; } - var key = ToProblemDetailsKey(error.Path); + var key = error.FullPath.ToDottedString(); if (!groupedMessages.TryGetValue(key, out var messages)) { messages = []; @@ -97,24 +96,6 @@ bool formatMessages : 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 — 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/ValidationError.cs b/src/src/ZodSharp/Core/ValidationError.cs index 0472d96f..0f045f91 100644 --- a/src/src/ZodSharp/Core/ValidationError.cs +++ b/src/src/ZodSharp/Core/ValidationError.cs @@ -8,6 +8,10 @@ namespace ZodSharp.Core; /// public readonly record struct ValidationError { + readonly ZodPath _fullPath; + readonly ErrorBound? _lower; + readonly ErrorBound? _upper; + /// /// The error code (e.g., "invalid_type", "too_small", "too_big") /// @@ -15,24 +19,45 @@ 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 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 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 => _fullPath; + init => _fullPath = value; + } /// /// The origin category for structured issues (e.g. "string", "array", "collection"). /// - public string? Origin { get; } + public string? Origin { get; init; } /// /// The inclusive minimum bound for size-based validation issues. @@ -49,11 +74,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 +123,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 +147,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 +188,7 @@ public static ValidationError Create( Code = code; Category = category; Message = message; - Path = path; + _fullPath = path; Parameters = Wrap(parameters); Origin = origin; Minimum = minimum; @@ -137,6 +196,86 @@ 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/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/ZodIssueCategories.cs b/src/src/ZodSharp/Core/ZodIssueCategories.cs new file mode 100644 index 00000000..db69d1b0 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodIssueCategories.cs @@ -0,0 +1,81 @@ +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"; +} diff --git a/src/src/ZodSharp/Core/ZodMessageFormat.cs b/src/src/ZodSharp/Core/ZodMessageFormat.cs new file mode 100644 index 00000000..c6019899 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodMessageFormat.cs @@ -0,0 +1,265 @@ +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. {{ 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), + _ => (false, null), + }; + + 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 = value switch + { + null => string.Empty, + IFormattable formattable => formattable.ToString(itemFormat, provider), + _ => value.ToString() ?? string.Empty, + }; + + if (alignment != 0) + text = alignment > 0 ? text.PadLeft(alignment) : text.PadRight(-alignment); + + return true; + } +} diff --git a/src/src/ZodSharp/Core/ZodPath.cs b/src/src/ZodSharp/Core/ZodPath.cs new file mode 100644 index 00000000..b7f9617b --- /dev/null +++ b/src/src/ZodSharp/Core/ZodPath.cs @@ -0,0 +1,522 @@ +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; + readonly ImmutableArray _legacy; + + ZodPath(ImmutableArray segments, ImmutableArray legacy) + { + _segments = segments; + _legacy = 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 => _legacy.IsDefault ? [] : _legacy; + + /// + /// 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!, + _ => 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(); + if (span.Length > 2 && span[0] == '[' && span[^1] == ']' && TryParseIndex(span[1..^1], out var bracketed)) + return ZodPathSegment.At(bracketed); + + 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/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/Rules/RuleMessage.cs b/src/src/ZodSharp/Rules/RuleMessage.cs index 2e8a0765..57526a78 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,27 @@ 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); + 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/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs b/src/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs new file mode 100644 index 00000000..9dae40ac --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Core/ValidationErrorModelTests.cs @@ -0,0 +1,155 @@ +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/ZodMessageFormatTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs new file mode 100644 index 00000000..573209c6 --- /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 = System.Globalization.CultureInfo.CurrentCulture; + System.Globalization.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 + { + System.Globalization.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/Schemas/ZodArrayTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs index 09e90d4a..f77917ce 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs @@ -1,3 +1,4 @@ +using ZodSharp.Core; namespace ZodSharp.Schemas; public class ZodArrayTests @@ -40,7 +41,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 +125,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 +147,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/ZodTupleTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs index 6ea381f5..cd3bc287 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs @@ -1,3 +1,4 @@ +using ZodSharp.Core; namespace ZodSharp.Schemas; public class ZodTupleTests @@ -42,7 +43,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] From 7b65d90b6f5896e8201aee5e6a4ef35294261217 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:21:03 +0000 Subject: [PATCH 003/107] feat(core): member validation, member resolution, untyped validators and rule identity contracts --- src/src/ZodSharp/Core/IZodMemberValidator.cs | 63 ++++++++ src/src/ZodSharp/Core/IZodRule.cs | 34 ++++ src/src/ZodSharp/Core/IZodSchemaFactory.cs | 14 ++ src/src/ZodSharp/Core/IZodSchemaValidator.cs | 20 +++ src/src/ZodSharp/Core/IZodUntypedValidator.cs | 153 ++++++++++++++++++ src/src/ZodSharp/Core/ZodRuleErrors.cs | 111 +++++++++++++ src/src/ZodSharp/Core/ZodSchemaFactory.cs | 32 +++- src/src/ZodSharp/Core/ZodType.cs | 19 +-- 8 files changed, 428 insertions(+), 18 deletions(-) create mode 100644 src/src/ZodSharp/Core/IZodMemberValidator.cs create mode 100644 src/src/ZodSharp/Core/IZodUntypedValidator.cs create mode 100644 src/src/ZodSharp/Core/ZodRuleErrors.cs 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/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..176943f6 --- /dev/null +++ b/src/src/ZodSharp/Core/IZodUntypedValidator.cs @@ -0,0 +1,153 @@ +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 +{ + /// + /// Gets the wrapped validator. + /// + public IZodSchemaValidator Inner { get; } = validator ?? throw new ArgumentNullException(nameof(validator)); + + /// + 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/ZodRuleErrors.cs b/src/src/ZodSharp/Core/ZodRuleErrors.cs new file mode 100644 index 00000000..874debec --- /dev/null +++ b/src/src/ZodSharp/Core/ZodRuleErrors.cs @@ -0,0 +1,111 @@ +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. +/// +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; + return Build( + code ?? (zodRule?.Code is { } ruleCode ? ruleCode : rule.Code), + message ?? rule.GetErrorMessage(value), + path, + zodRule, + fallbackOrigin + ); + } + + /// + /// 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; + return Build( + code ?? (zodRule?.Code is { } ruleCode ? ruleCode : rule.Code), + message ?? rule.GetErrorMessage(value), + path, + zodRule, + fallbackOrigin + ); + } + + /// + /// 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 + ) + { + 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, + rule?.Parameters, + rule?.Origin ?? fallbackOrigin, + minimum, + maximum, + inclusive, + rule?.Category + ) with + { + Lower = lower, + Upper = upper, + MessageFormat = rule?.MessageFormat, + }; + } +} 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/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. /// From 878ece19d0f0537cc2801d72f0e3b95ee59d2da6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:34:59 +0000 Subject: [PATCH 004/107] fix(generator): [Required] rejects null, empty and whitespace strings by default (G1) The generated check was inverted: the default AllowEmptyStrings=false only rejected null while AllowEmptyStrings=true rejected empty strings. It now follows RequiredAttribute. --- CHANGELOG.md | 15 ++++ .../ZodSchemaGenerator.BuildSchema.cs | 15 ++-- .../Infra/GeneratedSchemaRuntime.cs | 67 +++++++++++++++ ...ZodSchemaGeneratorTests.DataAnnotations.cs | 2 +- .../ZodSchemaGeneratorTests.Defects.cs | 84 +++++++++++++++++++ ...dSchemaGeneratorTests.PositionalRecords.cs | 4 +- 6 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e04c37..65c24a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## [Unreleased] + +### Breaking behaviour changes + +- **`[Required]` on a string now matches `RequiredAttribute`.** 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 + (message `Field '{displayName}' is required and cannot be null, empty or whitespace`), 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`. + +### Fixed + +- `[Required]` string semantics (defect G1, see above). + ## 2.4.1 ### Fixed diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 985a31c9..6cd03cb1 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -711,18 +711,21 @@ CancellationToken cancellationToken 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" + + // RequiredAttribute semantics: 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 localErrorMessage = rejectsBlankStrings + ? "Field '{0}' is required and cannot be null, empty or whitespace" : "Required field '{0}' is null"; var errorMessage = string.Format( CultureInfo.InvariantCulture, required.ValidationAttribute.ErrorMessage ?? localErrorMessage, displayName ); - var comparison = isStringAndAllowEmptyStrings - ? $"string.IsNullOrEmpty(value.{propertyName})" + var comparison = rejectsBlankStrings + ? $"string.IsNullOrWhiteSpace(value.{propertyName})" : $"value.{propertyName} == null"; writer.Rule(propertyName, comparison, "missing_field", errorMessage); diff --git a/src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs b/src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs new file mode 100644 index 00000000..537797fd --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/Infra/GeneratedSchemaRuntime.cs @@ -0,0 +1,67 @@ +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 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/ZodSchemaGeneratorTests.DataAnnotations.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs index c7f1800a..4b3313b3 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs @@ -91,7 +91,7 @@ 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"); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs new file mode 100644 index 00000000..194937fc --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.Defects.cs @@ -0,0 +1,84 @@ +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].FullPath.ToDottedString()).IsEqualTo("Name"); + } + + // 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); + } +} diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs index c2b9445b..24906a86 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -206,7 +206,9 @@ 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("Field 'Account name' is required and cannot be null, empty or whitespace"); await Assert.That(generated).ContainsGeneratedCode("if (handleLength < 3)"); await Assert.That(generated).ContainsGeneratedCode("if (handleLength > 10)"); await Assert From e88684a60940bfcc275d84949a19b1740d0dea53 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:40:13 +0000 Subject: [PATCH 005/107] fix(json): keep JSON error locations, one invalid_json code and full converter errors - J1/J5: map JsonException/JsonReaderException/JsonSerializationException paths via ZodPath.ParseJsonPath, add line/position parameters, fixed generic message naming the expected Zod type - J2: single invalid_json code (category invalid_type) replaces json_error/deserialization_failed - J3: map STJ NotSupportedException/InvalidOperationException raised while deserializing - J4: ZodJsonValidationException (STJ) and ZodNewtonsoftJsonValidationException carry full errors under the converter's JSON path; DeserializeAndValidate unwraps them - J6: Newtonsoft DeserializeAndValidateAsync awaits ValidateAsync - J7-J10: JSON Schema import re-paths with typed index/key segments, mirrors ZodArray length failures, reports JSON kinds with Zod type names and no longer echoes literal input --- CHANGELOG.md | 36 +++ docs/wiki/NewtonsoftJson-Integration.md | 4 +- docs/wiki/SystemTextJson-Integration.md | 4 +- skills/purview-zodsharp-json/SKILL.md | 10 +- .../Json/JsonDeserializationErrors.cs | 168 ++++++++++++++ src/src/NewtonsoftJson/Json/JsonKinds.cs | 121 ++++++++++ .../NewtonsoftJson/Json/ZodJsonConverter.cs | 36 ++- .../ZodNewtonsoftJsonValidationException.cs | 95 ++++++++ .../NewtonsoftJson/FromJsonSchemaParser.cs | 194 ++++++++-------- .../NewtonsoftJsonExtensions.cs | 106 +++++---- .../Json/JsonDeserializationErrors.cs | 169 ++++++++++++++ src/src/SystemTextJson/Json/JsonKinds.cs | 110 +++++++++ .../SystemTextJson/Json/ZodJsonConverter.cs | 28 ++- .../Json/ZodJsonValidationException.cs | 88 ++++++++ .../SystemTextJson/FromJsonSchemaParser.cs | 191 +++++++++------- .../SystemTextJsonExtensions.cs | 107 +++++---- .../Json/NewtonsoftJsonErrorTests.cs | 199 +++++++++++++++++ .../Json/NewtonsoftJsonExtensionsTests.cs | 4 +- .../Schema/FromJsonSchemaParserErrorTests.cs | 129 +++++++++++ .../Schema/FromJsonSchemaParserErrorTests.cs | 135 +++++++++++ .../Json/SystemTextJsonErrorTests.cs | 210 ++++++++++++++++++ .../Json/SystemTextJsonExtensionsTests.cs | 8 +- 22 files changed, 1859 insertions(+), 293 deletions(-) create mode 100644 src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs create mode 100644 src/src/NewtonsoftJson/Json/JsonKinds.cs create mode 100644 src/src/NewtonsoftJson/Json/ZodNewtonsoftJsonValidationException.cs create mode 100644 src/src/SystemTextJson/Json/JsonDeserializationErrors.cs create mode 100644 src/src/SystemTextJson/Json/JsonKinds.cs create mode 100644 src/src/SystemTextJson/Json/ZodJsonValidationException.cs create mode 100644 src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs create mode 100644 src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs create mode 100644 src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs create mode 100644 src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e04c37..bb99a4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,42 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## [Unreleased] + +### Breaking behaviour changes + +- **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. + +### Fixed + +- **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. +- **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. ## 2.4.1 ### Fixed diff --git a/docs/wiki/NewtonsoftJson-Integration.md b/docs/wiki/NewtonsoftJson-Integration.md index 75417021..c3f3b50b 100644 --- a/docs/wiki/NewtonsoftJson-Integration.md +++ b/docs/wiki/NewtonsoftJson-Integration.md @@ -49,7 +49,7 @@ var converter = userSchema.CreateValidatingConverter(); var value = JsonConvert.DeserializeObject(json, converter); ``` -`CreateValidatingConverter()` returns a non-generic `Newtonsoft.Json.JsonConverter`. Invalid JSON throws `JsonSerializationException` with a `"Validation failed: ..."` message. The converter clones the `JsonSerializer` (without itself) to avoid recursion. +`CreateValidatingConverter()` returns a non-generic `Newtonsoft.Json.JsonConverter`. Invalid JSON throws `ZodNewtonsoftJsonValidationException` (a `JsonSerializationException`) with a `"Validation failed: ..."` message; its `Errors` property holds the full `ValidationError`s, located under the converted value's JSON path. The converter clones the `JsonSerializer` (without itself) to avoid recursion. ## API surface @@ -64,7 +64,7 @@ var value = JsonConvert.DeserializeObject(json, converter); ## Failure codes -Deserialize/validation failures produce `ValidationError` entries with codes `deserialization_failed` and `json_error` in addition to the schema's own codes. +A failure to read the JSON (malformed JSON, a value that cannot be converted, a `null` document or token) produces one `ValidationError` with code `invalid_json` (`NewtonsoftJsonExtensions.InvalidJsonCode`) and category `invalid_type`. Its `FullPath` is the location the serializer reported (`items[1].age`), its `line` and `position` parameters hold the serializer's one-based line number and line position, and its message is the fixed `"The JSON value could not be converted to {expected}."`, where `expected` is a Zod type name (`string`, `number`, `object`, `array`, …). The serializer's own message, which names CLR types and can quote the input, is never surfaced. A failure raised by a validating converter is returned as the schema's own errors, located under the converter's path. The stream overload validates with `ValidateAsync`, so asynchronous refinements run. ## JSON Schema import diff --git a/docs/wiki/SystemTextJson-Integration.md b/docs/wiki/SystemTextJson-Integration.md index 05594fde..55e65501 100644 --- a/docs/wiki/SystemTextJson-Integration.md +++ b/docs/wiki/SystemTextJson-Integration.md @@ -47,7 +47,7 @@ var options = new JsonSerializerOptions { Converters = { converter } }; var value = JsonSerializer.Deserialize(json, options); ``` -`CreateValidatingConverter()` returns a `System.Text.Json.Serialization.JsonConverter`. When the JSON is invalid, deserialization throws `JsonException` with a `"Validation failed: ..."` message. The converter strips itself from the options it uses internally to avoid recursion. +`CreateValidatingConverter()` returns a `System.Text.Json.Serialization.JsonConverter`. When the JSON is invalid, deserialization throws `ZodJsonValidationException` (a `JsonException`) with a `"Validation failed: ..."` message; its `Errors` property holds the full `ValidationError`s, located under the converted value's JSON path. The converter strips itself from the options it uses internally to avoid recursion. ## API surface @@ -61,7 +61,7 @@ var value = JsonSerializer.Deserialize(json, options); ## Failure codes -Deserialize/validation failures produce `ValidationError` entries with codes `deserialization_failed` and `json_error` in addition to the schema's own codes. +A failure to read the JSON (malformed JSON, a value that cannot be converted, a `null` document, or a type the serializer cannot handle — `JsonException`, `NotSupportedException`, `InvalidOperationException`) produces one `ValidationError` with code `invalid_json` (`SystemTextJsonExtensions.InvalidJsonCode`) and category `invalid_type`. Its `FullPath` is the location the serializer reported (`$.items[1].age` becomes `items[1].age`), its `line` and `position` parameters hold the serializer's zero-based line number and byte position in the line, and its message is the fixed `"The JSON value could not be converted to {expected}."`, where `expected` is a Zod type name (`string`, `number`, `object`, `array`, …). The serializer's own message, which names CLR types and can quote the input, is never surfaced. A failure raised by a validating converter is returned as the schema's own errors, located under the converter's path. ## JSON Schema import diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index adaedda0..c8b4592e 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -52,7 +52,9 @@ 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 is one error with code `invalid_json` (category `invalid_type`), located at the +serializer's reported path, with `line`/`position` parameters and a fixed message that never quotes the input or +names CLR types. Schema failures keep the schema's own codes. ## Validating converters @@ -65,8 +67,10 @@ 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 JSON **throws** — `ZodJsonValidationException : JsonException` (STJ) or +`ZodNewtonsoftJsonValidationException : JsonSerializationException` (Newtonsoft), with a `"Validation failed: …"` +message and the full errors in `Errors`, located under the converted value's path. `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 diff --git a/src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs b/src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs new file mode 100644 index 00000000..8aa7fa8a --- /dev/null +++ b/src/src/NewtonsoftJson/Json/JsonDeserializationErrors.cs @@ -0,0 +1,168 @@ +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. +/// +/// +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..968b7645 100644 --- a/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs +++ b/src/src/NewtonsoftJson/Json/ZodJsonConverter.cs @@ -17,20 +17,32 @@ 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) { - var errorMessages = string.Join(", ", result.Errors.Select(e => e.Message)); - throw new JsonSerializationException($"Validation failed: {errorMessages}"); + // 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); } - return result.Value; + var result = schema.Validate(deserialized); + + 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 +55,15 @@ 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); } + 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..807d32af 100644 --- a/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs +++ b/src/src/NewtonsoftJson/JsonSchema/NewtonsoftJson/FromJsonSchemaParser.cs @@ -429,6 +429,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 +536,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 +571,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 +603,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 +624,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 +657,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 +694,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 +706,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 +736,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 +764,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 +851,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/NewtonsoftJsonExtensions.cs b/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs index 196fd8ca..93b38e50 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,7 +15,14 @@ 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. @@ -28,21 +36,9 @@ public static ValidationResult DeserializeAndValidate( if (schema == null) throw new ArgumentNullException(nameof(schema)); - 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); } /// @@ -55,24 +51,23 @@ 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); } /// @@ -89,21 +84,52 @@ 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); + } + + 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; } /// diff --git a/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs new file mode 100644 index 00000000..2b17bff4 --- /dev/null +++ b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs @@ -0,0 +1,169 @@ +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. +/// +/// +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; + + 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 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 NET9_0_OR_GREATER + if ( + current.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary + && current.ElementType is { } element + ) + return current.Options.GetTypeInfo(element); +#endif + + // A scalar has no members, and before .NET 9 a collection contract does not expose its element type. + return null; + } +} diff --git a/src/src/SystemTextJson/Json/JsonKinds.cs b/src/src/SystemTextJson/Json/JsonKinds.cs new file mode 100644 index 00000000..65f7a341 --- /dev/null +++ b/src/src/SystemTextJson/Json/JsonKinds.cs @@ -0,0 +1,110 @@ +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", + }; +} diff --git a/src/src/SystemTextJson/Json/ZodJsonConverter.cs b/src/src/SystemTextJson/Json/ZodJsonConverter.cs index 485d50ae..610aa05e 100644 --- a/src/src/SystemTextJson/Json/ZodJsonConverter.cs +++ b/src/src/SystemTextJson/Json/ZodJsonConverter.cs @@ -34,17 +34,24 @@ 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 + { + deserialized = + JsonSerializer.Deserialize(document.RootElement, TypeInfoFor(options)) + ?? throw new JsonException("Failed to deserialize JSON"); + } + catch (ZodJsonValidationException nested) { - var errorMessages = string.Join(", ", result.Errors.Select(e => e.Message)); - throw new JsonException($"Validation failed: {errorMessages}"); + // 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); } + 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 +64,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/ZodJsonValidationException.cs b/src/src/SystemTextJson/Json/ZodJsonValidationException.cs new file mode 100644 index 00000000..069ccc9b --- /dev/null +++ b/src/src/SystemTextJson/Json/ZodJsonValidationException.cs @@ -0,0 +1,88 @@ +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."; + + readonly ImmutableArray _errors; + + /// + /// 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 _errors; + + var builder = ImmutableArray.CreateBuilder(_errors.Length); + foreach (var error in _errors) + 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/SystemTextJsonExtensions.cs b/src/src/SystemTextJson/SystemTextJsonExtensions.cs index 61b509cb..667f1249 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,21 @@ public static async ValueTask> DeserializeAndValidateAsync.Failure( - new ValidationError("deserialization_failed", "Failed to deserialize JSON", EmptyPath) - ) - : await schema.ValidateAsync(deserialized, 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 schema.ValidateAsync(deserialized, cancellationToken).ConfigureAwait(false); } /// @@ -202,21 +203,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 +238,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); } /// @@ -323,4 +319,29 @@ public static async ValueTask> ValidateAndSerializeAsyn /// 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/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs new file mode 100644 index 00000000..6e6dd4f0 --- /dev/null +++ b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs @@ -0,0 +1,199 @@ +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 = """{"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 = """{"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] + 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("""{"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 = """{"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 = """{"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..daebb9aa --- /dev/null +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs @@ -0,0 +1,129 @@ +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("""{"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("""{"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("""{"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("""{"type":"string"}""", "42", "Expected string, but got number")] + [Arguments("""{"type":"number"}""", "\"x\"", "Expected number, but got string")] + [Arguments("""{"type":"boolean"}""", "{}", "Expected boolean, but got object")] + [Arguments("""{"type":"object"}""", "[]", "Expected object, but got array")] + [Arguments("""{"type":"array"}""", "true", "Expected array, but got boolean")] + [Arguments("""{"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("""{"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( + """{"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/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs new file mode 100644 index 00000000..50556c7c --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs @@ -0,0 +1,135 @@ +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("""{"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("""{"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("""{"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("""{"type":"string"}""", "42", "Expected string, but got number")] + [Arguments("""{"type":"number"}""", "\"x\"", "Expected number, but got string")] + [Arguments("""{"type":"boolean"}""", "{}", "Expected boolean, but got object")] + [Arguments("""{"type":"object"}""", "[]", "Expected object, but got array")] + [Arguments("""{"type":"array"}""", "true", "Expected array, but got boolean")] + [Arguments("""{"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("""{"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( + """{"type":"object","additionalProperties":{"type":"array","minItems":2}}""" + ); + + // Act + var result = schema.Validate(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); + } + + static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} diff --git a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs new file mode 100644 index 00000000..fcefe3bb --- /dev/null +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs @@ -0,0 +1,210 @@ +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 = """{"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 = """{"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] + 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("""{"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 = """{"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 = """{"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("""{"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; } + } + + 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] From 3a6b6b4cb6a515f1dbe9206a69c14354b665d53a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:40:48 +0000 Subject: [PATCH 006/107] perf(generator): add generated-validation benchmarks Benchmarks did not exercise [ZodSchema] validation; the project now loads the merged generator explicitly because it does not import Purview.BuildSdk. --- src/src/Benchmarks/Benchmarks.csproj | 25 ++++++ .../GeneratedValidationPerformanceTests.cs | 78 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/src/Benchmarks/GeneratedValidationPerformanceTests.cs 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..5cdcd7fd --- /dev/null +++ b/src/src/Benchmarks/GeneratedValidationPerformanceTests.cs @@ -0,0 +1,78 @@ +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); +} + +/// 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; +} From 7436f821ef28c3687c5dade2adc5be38d8e8db73 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:41:54 +0000 Subject: [PATCH 007/107] style: format array and tuple tests --- src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs | 1 + src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs index f77917ce..717b5b4b 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs @@ -1,4 +1,5 @@ using ZodSharp.Core; + namespace ZodSharp.Schemas; public class ZodArrayTests diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs index cd3bc287..db417c31 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs @@ -1,4 +1,5 @@ using ZodSharp.Core; + namespace ZodSharp.Schemas; public class ZodTupleTests From 9072c41ab1f18885070b2e16d3ec2460fe0ad650 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:48:07 +0000 Subject: [PATCH 008/107] fix(schemas): structured, consistent errors from runtime schemas and rules - Re-path every element/field/catchall/record/tuple error with WithPathPrefix (A1, A2, A4) - Separate array min/max/length messages reporting the count (A3); tuple arity bounds, index-prefixed element errors and null pass-through (A5, A6) - Union issues grouped per option under unionErrors, kept by typed/native unions (A7, A8); discriminator errors at the discriminator path in declaration order (A9, A10) - Shared identities: ZodIssueCodes, ZodMessages, ZodIssues, ZodTypeNames (A11-A17); one aggregated unrecognized_keys issue (A13); typed RefineCtx issues (A18, A19); intersection de-dup (A20); span rule errors via ZodRuleErrors (A21) - Every built-in rule reports origin, Zod category, bounds with inclusivity and parameters; IZodFailureDescriber for direction-dependent categories (B1-B4) - Default messages never echo the input; ZodErrorOptions.IncludeReceivedValues (B9); consistent UUID arguments, null custom-message default, invariant formatting (B5-B8); RequiredRule shares the "{displayName} is required" identity (B10) --- CHANGELOG.md | 65 ++ src/src/ZodSharp/Core/IZodFailureDescriber.cs | 41 ++ src/src/ZodSharp/Core/ZodErrorOptions.cs | 23 + src/src/ZodSharp/Core/ZodIssueCategories.cs | 24 + src/src/ZodSharp/Core/ZodIssueCodes.cs | 52 ++ src/src/ZodSharp/Core/ZodIssues.cs | 228 ++++++ src/src/ZodSharp/Core/ZodMessageFormat.cs | 31 +- src/src/ZodSharp/Core/ZodMessages.cs | 69 ++ src/src/ZodSharp/Core/ZodRuleErrors.cs | 91 ++- src/src/ZodSharp/Core/ZodTypeNames.cs | 187 +++++ src/src/ZodSharp/Rules/AllowedValuesRule.cs | 15 +- src/src/ZodSharp/Rules/Base64StringRule.cs | 14 +- src/src/ZodSharp/Rules/Base64UrlRule.cs | 14 +- src/src/ZodSharp/Rules/CidrRule.cs | 46 +- src/src/ZodSharp/Rules/CompareRule.cs | 12 +- src/src/ZodSharp/Rules/CreditCardRule.cs | 14 +- src/src/ZodSharp/Rules/Cuid2Rule.cs | 14 +- src/src/ZodSharp/Rules/DateStringRule.cs | 14 +- src/src/ZodSharp/Rules/DatetimeStringRule.cs | 14 +- src/src/ZodSharp/Rules/DeniedValuesRule.cs | 12 +- src/src/ZodSharp/Rules/DurationRule.cs | 14 +- src/src/ZodSharp/Rules/E164Rule.cs | 14 +- src/src/ZodSharp/Rules/EmailRule.cs | 14 +- src/src/ZodSharp/Rules/EmojiRule.cs | 14 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 13 +- src/src/ZodSharp/Rules/EnumRule.cs | 30 +- src/src/ZodSharp/Rules/EvenRule.cs | 8 +- src/src/ZodSharp/Rules/FiniteRule.cs | 15 +- .../ZodSharp/Rules/GreaterThanOrEqualRule.cs | 10 +- src/src/ZodSharp/Rules/GreaterThanRule.cs | 11 +- src/src/ZodSharp/Rules/GuidRule.cs | 14 +- src/src/ZodSharp/Rules/HexRule.cs | 14 +- src/src/ZodSharp/Rules/IPAddressRule.cs | 42 +- src/src/ZodSharp/Rules/IncludesRule.cs | 13 +- src/src/ZodSharp/Rules/IntRule.cs | 15 +- src/src/ZodSharp/Rules/JWTRule.cs | 14 +- src/src/ZodSharp/Rules/KsuidRule.cs | 14 +- src/src/ZodSharp/Rules/LengthRule.cs | 30 +- src/src/ZodSharp/Rules/LessThanOrEqualRule.cs | 10 +- src/src/ZodSharp/Rules/LessThanRule.cs | 11 +- src/src/ZodSharp/Rules/MaxLengthRule.cs | 18 +- src/src/ZodSharp/Rules/MaxValueRule.cs | 10 +- src/src/ZodSharp/Rules/MinLengthRule.cs | 18 +- src/src/ZodSharp/Rules/MinValueRule.cs | 10 +- src/src/ZodSharp/Rules/MultipleOfRule.cs | 11 +- src/src/ZodSharp/Rules/NanoidRule.cs | 14 +- src/src/ZodSharp/Rules/NonSentinelRule.cs | 8 +- .../ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs | 12 +- src/src/ZodSharp/Rules/OddRule.cs | 8 +- src/src/ZodSharp/Rules/PhoneRule.cs | 14 +- src/src/ZodSharp/Rules/RangeRule.cs | 20 +- src/src/ZodSharp/Rules/RegexRule.cs | 21 +- src/src/ZodSharp/Rules/RequiredRule.cs | 22 +- src/src/ZodSharp/Rules/RuleParameters.cs | 34 + src/src/ZodSharp/Rules/SafeIntegerRule.cs | 28 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 13 +- src/src/ZodSharp/Rules/StringLengthRule.cs | 30 +- src/src/ZodSharp/Rules/TimeStringRule.cs | 14 +- src/src/ZodSharp/Rules/ULIDRule.cs | 14 +- src/src/ZodSharp/Rules/UriRule.cs | 14 +- src/src/ZodSharp/Rules/UrlRule.cs | 14 +- src/src/ZodSharp/Rules/UuidRule.cs | 33 +- src/src/ZodSharp/Rules/XidRule.cs | 14 +- src/src/ZodSharp/Schemas/RefineCtx.cs | 113 ++- .../ZodSharp/Schemas/SchemaValueCoercion.cs | 15 +- src/src/ZodSharp/Schemas/ZodArray.cs | 78 +- .../ZodSharp/Schemas/ZodDiscriminatedUnion.cs | 50 +- .../Schemas/ZodDiscriminatedUnionBuilder.cs | 25 +- src/src/ZodSharp/Schemas/ZodEnum.cs | 19 +- src/src/ZodSharp/Schemas/ZodIntersection.cs | 14 +- src/src/ZodSharp/Schemas/ZodLiteral.cs | 24 +- src/src/ZodSharp/Schemas/ZodNativeEnum.cs | 11 +- src/src/ZodSharp/Schemas/ZodNull.cs | 4 +- src/src/ZodSharp/Schemas/ZodNumber.cs | 4 +- src/src/ZodSharp/Schemas/ZodObject.cs | 52 +- src/src/ZodSharp/Schemas/ZodRecord.cs | 27 +- src/src/ZodSharp/Schemas/ZodRefinement.cs | 4 +- src/src/ZodSharp/Schemas/ZodString.cs | 16 +- .../ZodSharp/Schemas/ZodSuperRefinement.cs | 8 +- src/src/ZodSharp/Schemas/ZodTransform.cs | 9 +- src/src/ZodSharp/Schemas/ZodTuple.cs | 182 ++--- .../ZodSharp/Schemas/ZodTypedNativeUnion.cs | 28 +- src/src/ZodSharp/Schemas/ZodTypedUnion.cs | 28 +- src/src/ZodSharp/Schemas/ZodUnion.cs | 16 +- .../Rules/RuleErrorModelTests.cs | 351 +++++++++ .../Rules/UuidRuleGuidTests.cs | 3 +- .../Schemas/SchemaErrorModelTests.cs | 684 ++++++++++++++++++ .../Schemas/ZodArrayTests.cs | 1 + .../Schemas/ZodObjectShapeTests.cs | 4 +- .../Schemas/ZodTupleTests.cs | 1 + 90 files changed, 2866 insertions(+), 599 deletions(-) create mode 100644 src/src/ZodSharp/Core/IZodFailureDescriber.cs create mode 100644 src/src/ZodSharp/Core/ZodErrorOptions.cs create mode 100644 src/src/ZodSharp/Core/ZodIssueCodes.cs create mode 100644 src/src/ZodSharp/Core/ZodIssues.cs create mode 100644 src/src/ZodSharp/Core/ZodMessages.cs create mode 100644 src/src/ZodSharp/Core/ZodTypeNames.cs create mode 100644 src/src/ZodSharp/Rules/RuleParameters.cs create mode 100644 src/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Schemas/SchemaErrorModelTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e04c37..a66fbf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,71 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## [Unreleased] + +### Added + +- **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 `, `. + +### Breaking behaviour changes + +- **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. +- 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`. + +### Fixed + +- 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`). +- `ZodString` span validation reports the rule's full structured identity. +- `default(IPAddressRule)` no longer formats a `null` message. + ## 2.4.1 ### Fixed diff --git a/src/src/ZodSharp/Core/IZodFailureDescriber.cs b/src/src/ZodSharp/Core/IZodFailureDescriber.cs new file mode 100644 index 00000000..14ae1ce6 --- /dev/null +++ b/src/src/ZodSharp/Core/IZodFailureDescriber.cs @@ -0,0 +1,41 @@ +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 +); + +/// +/// 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/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/ZodIssueCategories.cs b/src/src/ZodSharp/Core/ZodIssueCategories.cs index db69d1b0..92735e62 100644 --- a/src/src/ZodSharp/Core/ZodIssueCategories.cs +++ b/src/src/ZodSharp/Core/ZodIssueCategories.cs @@ -78,4 +78,28 @@ public static class ZodIssueParameters /// 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/ZodMessageFormat.cs b/src/src/ZodSharp/Core/ZodMessageFormat.cs index c6019899..c920e244 100644 --- a/src/src/ZodSharp/Core/ZodMessageFormat.cs +++ b/src/src/ZodSharp/Core/ZodMessageFormat.cs @@ -250,16 +250,39 @@ out alignment } } - text = value switch + 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, }; - if (alignment != 0) - text = alignment > 0 ? text.PadLeft(alignment) : text.PadRight(-alignment); + 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(", "); - return true; + 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/ZodRuleErrors.cs b/src/src/ZodSharp/Core/ZodRuleErrors.cs index 874debec..04e3d4bb 100644 --- a/src/src/ZodSharp/Core/ZodRuleErrors.cs +++ b/src/src/ZodSharp/Core/ZodRuleErrors.cs @@ -5,6 +5,12 @@ namespace ZodSharp.Core; /// () 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 { /// @@ -29,12 +35,17 @@ public static ValidationError Create( { ArgumentNullException.ThrowIfNull(rule); var zodRule = rule as IZodRule; - return Build( + var failure = rule is IZodFailureDescriber describer ? describer.DescribeFailure(value) : default; + var includeReceived = ZodErrorOptions.IncludeReceivedValues; + return BuildCore( code ?? (zodRule?.Code is { } ruleCode ? ruleCode : rule.Code), message ?? rule.GetErrorMessage(value), path, zodRule, - fallbackOrigin + fallbackOrigin, + failure, + includeReceived, + includeReceived ? value : null ); } @@ -59,12 +70,17 @@ public static ValidationError Create( { ArgumentNullException.ThrowIfNull(rule); var zodRule = rule as IZodRule; - return Build( + var failure = rule is IZodStringFailureDescriber describer ? describer.DescribeFailure(value) : default; + var includeReceived = ZodErrorOptions.IncludeReceivedValues; + return BuildCore( code ?? (zodRule?.Code is { } ruleCode ? ruleCode : rule.Code), message ?? rule.GetErrorMessage(value), path, zodRule, - fallbackOrigin + fallbackOrigin, + failure, + includeReceived, + includeReceived ? value.ToString() : null ); } @@ -83,6 +99,37 @@ public static ValidationError Build( 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(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; @@ -95,12 +142,12 @@ public static ValidationError Build( code, message, path, - rule?.Parameters, + MergeParameters(rule?.Parameters, failure.Parameters, includeReceived, received), rule?.Origin ?? fallbackOrigin, minimum, maximum, inclusive, - rule?.Category + failure.Category ?? rule?.Category ) with { Lower = lower, @@ -108,4 +155,36 @@ public static ValidationError Build( 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/ZodTypeNames.cs b/src/src/ZodSharp/Core/ZodTypeNames.cs new file mode 100644 index 00000000..9b064f45 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodTypeNames.cs @@ -0,0 +1,187 @@ +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; + + if (type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(DateOnly)) + return Date; + + 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; + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + return Array; + + 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/Rules/AllowedValuesRule.cs b/src/src/ZodSharp/Rules/AllowedValuesRule.cs index 6d14d2a8..045b231f 100644 --- a/src/src/ZodSharp/Rules/AllowedValuesRule.cs +++ b/src/src/ZodSharp/Rules/AllowedValuesRule.cs @@ -17,11 +17,11 @@ 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"; readonly T[]? _values; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct that accepts a single value. @@ -41,7 +41,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 +73,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..a4b71635 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -11,10 +11,10 @@ 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 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; + readonly string? _message; /// /// Initializes a new instance of the Base64StringRule struct. @@ -23,7 +23,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 +60,9 @@ public bool IsValid(in string value) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("base64"); } diff --git a/src/src/ZodSharp/Rules/Base64UrlRule.cs b/src/src/ZodSharp/Rules/Base64UrlRule.cs index 29cf9085..faad8e54 100644 --- a/src/src/ZodSharp/Rules/Base64UrlRule.cs +++ b/src/src/ZodSharp/Rules/Base64UrlRule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the Base64UrlRule struct. @@ -22,7 +22,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 +74,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..0f57ad87 100644 --- a/src/src/ZodSharp/Rules/CidrRule.cs +++ b/src/src/ZodSharp/Rules/CidrRule.cs @@ -35,11 +35,11 @@ public enum CidrRuleType /// 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"; readonly CidrRuleType _ruleType; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct with the default rule type (Any). @@ -51,7 +51,6 @@ public enum CidrRuleType public CidrRule() { _ruleType = CidrRuleType.Any; - _message = MessageFormat; } /// @@ -71,7 +70,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 +115,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 +123,34 @@ 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.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format(FormatName); } diff --git a/src/src/ZodSharp/Rules/CompareRule.cs b/src/src/ZodSharp/Rules/CompareRule.cs index 97dee78a..71db644c 100644 --- a/src/src/ZodSharp/Rules/CompareRule.cs +++ b/src/src/ZodSharp/Rules/CompareRule.cs @@ -15,12 +15,12 @@ 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"; readonly string _other; readonly StringComparison _comparison; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -38,7 +38,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 +76,7 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; } diff --git a/src/src/ZodSharp/Rules/CreditCardRule.cs b/src/src/ZodSharp/Rules/CreditCardRule.cs index de5ca86a..41bc2f26 100644 --- a/src/src/ZodSharp/Rules/CreditCardRule.cs +++ b/src/src/ZodSharp/Rules/CreditCardRule.cs @@ -11,10 +11,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the CreditCardRule struct. @@ -23,7 +23,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 +98,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..4d5e4c04 100644 --- a/src/src/ZodSharp/Rules/Cuid2Rule.cs +++ b/src/src/ZodSharp/Rules/Cuid2Rule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the Cuid2Rule struct. @@ -22,7 +22,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 +72,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..89b2a9ac 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -12,10 +12,10 @@ 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 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; + readonly string? _message; /// /// Initializes a new instance of the DateStringRule struct. @@ -24,7 +24,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 +69,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("date"); } diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index fdc1d2a0..4c0ed892 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -16,10 +16,10 @@ public readonly record struct DatetimeStringRule /// 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; + readonly string? _message; /// /// Initializes a new instance of the DatetimeStringRule struct. @@ -28,7 +28,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 +126,9 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("datetime"); } diff --git a/src/src/ZodSharp/Rules/DeniedValuesRule.cs b/src/src/ZodSharp/Rules/DeniedValuesRule.cs index 961ce1bd..5f45e4de 100644 --- a/src/src/ZodSharp/Rules/DeniedValuesRule.cs +++ b/src/src/ZodSharp/Rules/DeniedValuesRule.cs @@ -17,11 +17,11 @@ 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"; readonly T[]? _values; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct that rejects a single value. @@ -41,7 +41,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 +73,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..e0737f1a 100644 --- a/src/src/ZodSharp/Rules/DurationRule.cs +++ b/src/src/ZodSharp/Rules/DurationRule.cs @@ -12,8 +12,8 @@ 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 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"; /// /// Regular expression for ISO 8601-1 durations. Does not accept the 8601-2 extensions (negative @@ -25,7 +25,7 @@ namespace ZodSharp.Rules; RegexRule.DefaultMatchTimeout ); - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -34,7 +34,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 +97,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("duration"); } diff --git a/src/src/ZodSharp/Rules/E164Rule.cs b/src/src/ZodSharp/Rules/E164Rule.cs index 13bc568c..3a30bda2 100644 --- a/src/src/ZodSharp/Rules/E164Rule.cs +++ b/src/src/ZodSharp/Rules/E164Rule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the E164Rule struct. @@ -22,7 +22,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 +76,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..13761b64 100644 --- a/src/src/ZodSharp/Rules/EmailRule.cs +++ b/src/src/ZodSharp/Rules/EmailRule.cs @@ -16,8 +16,8 @@ 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"; /// /// Regular expression for validating email format. @@ -28,11 +28,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 +78,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..47ab1302 100644 --- a/src/src/ZodSharp/Rules/EmojiRule.cs +++ b/src/src/ZodSharp/Rules/EmojiRule.cs @@ -16,10 +16,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -28,7 +28,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 +90,11 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..e594315a 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -17,11 +17,11 @@ 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}'"; readonly string _suffix; readonly StringComparison _comparison; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the EndsWithRule struct. @@ -39,7 +39,7 @@ public EndsWithRule( { _suffix = suffix.OrNull() ?? throw new ArgumentNullException(nameof(suffix)); _comparison = comparison; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -85,5 +85,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..65ab4809 100644 --- a/src/src/ZodSharp/Rules/EnumRule.cs +++ b/src/src/ZodSharp/Rules/EnumRule.cs @@ -33,8 +33,10 @@ namespace ZodSharp.Rules; // 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 +57,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 +103,27 @@ .. Enum.GetValues() string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..3630445d 100644 --- a/src/src/ZodSharp/Rules/EvenRule.cs +++ b/src/src/ZodSharp/Rules/EvenRule.cs @@ -17,7 +17,7 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be even, but got {0}"; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the EvenRule struct. @@ -26,7 +26,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 +57,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..2716502e 100644 --- a/src/src/ZodSharp/Rules/FiniteRule.cs +++ b/src/src/ZodSharp/Rules/FiniteRule.cs @@ -17,7 +17,12 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be finite, but got {0}"; - readonly string _message; + 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 +31,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 +64,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..3e6e435e 100644 --- a/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanOrEqualRule.cs @@ -20,7 +20,7 @@ public readonly record struct GreaterThanOrEqualRule // 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 +31,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 +65,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..662a83b0 100644 --- a/src/src/ZodSharp/Rules/GreaterThanRule.cs +++ b/src/src/ZodSharp/Rules/GreaterThanRule.cs @@ -17,7 +17,7 @@ namespace ZodSharp.Rules; readonly T _exclusiveMinimum; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the GreaterThanRule struct. @@ -28,7 +28,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 +63,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..b0b86397 100644 --- a/src/src/ZodSharp/Rules/GuidRule.cs +++ b/src/src/ZodSharp/Rules/GuidRule.cs @@ -15,8 +15,8 @@ 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"; /// /// The JSON Schema pattern for a canonical 8-4-4-4-12 hexadecimal identifier (any version or @@ -26,7 +26,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 +35,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 +103,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..ddfe360a 100644 --- a/src/src/ZodSharp/Rules/HexRule.cs +++ b/src/src/ZodSharp/Rules/HexRule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the HexRule struct. @@ -22,7 +22,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 +69,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..8ee16239 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -34,11 +34,11 @@ public enum IPAddressRuleType /// 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"; readonly IPAddressRuleType _ruleType; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct with the default rule type (Any). @@ -50,7 +50,6 @@ public enum IPAddressRuleType public IPAddressRule() { _ruleType = IPAddressRuleType.Any; - _message = MessageFormat; } /// @@ -74,7 +73,7 @@ public IPAddressRule( ) { _ruleType = ruleType; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -114,8 +113,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 +121,34 @@ 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.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format(FormatName); } diff --git a/src/src/ZodSharp/Rules/IncludesRule.cs b/src/src/ZodSharp/Rules/IncludesRule.cs index a523ab94..7db6c5c9 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -17,10 +17,10 @@ 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}'"; readonly string _substring; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the IncludesRule struct. @@ -31,7 +31,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); } @@ -77,5 +77,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..2b90778a 100644 --- a/src/src/ZodSharp/Rules/IntRule.cs +++ b/src/src/ZodSharp/Rules/IntRule.cs @@ -17,11 +17,16 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Expected integer, but got {0}"; - readonly string _message; + 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 +61,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..9002df85 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -10,10 +10,10 @@ 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 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; + readonly string? _message; /// /// Initializes a new instance of the JWTRule struct. @@ -22,7 +22,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 +92,9 @@ static bool IsBase64Url(ReadOnlySpan segment) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("jwt"); } diff --git a/src/src/ZodSharp/Rules/KsuidRule.cs b/src/src/ZodSharp/Rules/KsuidRule.cs index 1bf682ee..8ce455b4 100644 --- a/src/src/ZodSharp/Rules/KsuidRule.cs +++ b/src/src/ZodSharp/Rules/KsuidRule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -22,7 +22,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 +74,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..81e94388 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"; @@ -19,7 +24,7 @@ namespace ZodSharp.Rules; readonly int _minimum; readonly int _maximum; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -32,7 +37,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 +80,22 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..a25724f5 100644 --- a/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs +++ b/src/src/ZodSharp/Rules/LessThanOrEqualRule.cs @@ -20,7 +20,7 @@ public readonly record struct LessThanOrEqualRule // 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 +31,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 +65,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..69293440 100644 --- a/src/src/ZodSharp/Rules/LessThanRule.cs +++ b/src/src/ZodSharp/Rules/LessThanRule.cs @@ -17,7 +17,7 @@ namespace ZodSharp.Rules; readonly T _exclusiveMaximum; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the LessThanRule struct. @@ -28,7 +28,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 +63,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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/MaxLengthRule.cs b/src/src/ZodSharp/Rules/MaxLengthRule.cs index 49ab650f..8712370a 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. @@ -19,7 +21,7 @@ public readonly record struct MaxLengthRule // 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 +32,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 +71,17 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..e377c1d9 100644 --- a/src/src/ZodSharp/Rules/MaxValueRule.cs +++ b/src/src/ZodSharp/Rules/MaxValueRule.cs @@ -17,7 +17,7 @@ namespace ZodSharp.Rules; // 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 +28,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 +62,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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/MinLengthRule.cs b/src/src/ZodSharp/Rules/MinLengthRule.cs index e770d38f..e8e2f0dc 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. @@ -19,7 +21,7 @@ public readonly record struct MinLengthRule // 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 +32,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 +71,17 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..8934fa17 100644 --- a/src/src/ZodSharp/Rules/MinValueRule.cs +++ b/src/src/ZodSharp/Rules/MinValueRule.cs @@ -18,7 +18,7 @@ namespace ZodSharp.Rules; // 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 +29,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 +63,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..88087383 100644 --- a/src/src/ZodSharp/Rules/MultipleOfRule.cs +++ b/src/src/ZodSharp/Rules/MultipleOfRule.cs @@ -24,7 +24,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 +38,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 +87,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..929a1960 100644 --- a/src/src/ZodSharp/Rules/NanoidRule.cs +++ b/src/src/ZodSharp/Rules/NanoidRule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the NanoidRule struct. @@ -22,7 +22,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 +72,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..8e249450 100644 --- a/src/src/ZodSharp/Rules/NonSentinelRule.cs +++ b/src/src/ZodSharp/Rules/NonSentinelRule.cs @@ -146,7 +146,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 +155,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 +269,9 @@ 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)); + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidValue; } /// diff --git a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs index dfc656e4..fb4eefad 100644 --- a/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs +++ b/src/src/ZodSharp/Rules/NullOrNonWhiteSpaceRule.cs @@ -27,10 +27,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -39,7 +39,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 +75,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..c6ef6faf 100644 --- a/src/src/ZodSharp/Rules/OddRule.cs +++ b/src/src/ZodSharp/Rules/OddRule.cs @@ -17,7 +17,7 @@ namespace ZodSharp.Rules; /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be odd, but got {0}"; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the OddRule struct. @@ -26,7 +26,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 +57,7 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..e69156eb 100644 --- a/src/src/ZodSharp/Rules/PhoneRule.cs +++ b/src/src/ZodSharp/Rules/PhoneRule.cs @@ -12,10 +12,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the PhoneRule struct. @@ -24,7 +24,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 +85,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..3bb95a1f 100644 --- a/src/src/ZodSharp/Rules/RangeRule.cs +++ b/src/src/ZodSharp/Rules/RangeRule.cs @@ -13,7 +13,7 @@ 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. @@ -26,7 +26,7 @@ namespace ZodSharp.Rules; readonly T _maximum; readonly bool _minimumIsExclusive; readonly bool _maximumIsExclusive; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -50,7 +50,7 @@ public RangeRule( _maximum = maximum; _minimumIsExclusive = minimumIsExclusive; _maximumIsExclusive = maximumIsExclusive; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -83,5 +83,17 @@ 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); + + // 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..db341c02 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -16,7 +16,7 @@ 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}"; /// @@ -41,7 +41,7 @@ public readonly record struct RegexRule /// public static readonly TimeSpan DefaultMatchTimeout = TimeSpan.FromSeconds(2); - 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 +55,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 +110,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 +125,17 @@ public bool IsValid(ReadOnlySpan value) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..6414a00f 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,10 @@ 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 as-is. + string? Core.IZodRule.MessageFormat => _message is null ? Core.ZodMessages.Required : 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..8df02aec 100644 --- a/src/src/ZodSharp/Rules/SafeIntegerRule.cs +++ b/src/src/ZodSharp/Rules/SafeIntegerRule.cs @@ -5,15 +5,24 @@ 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. public const string ErrorCode = "too_big"; + static readonly IReadOnlyDictionary IntParameters = RuleParameters.One( + Core.ZodIssueParameters.Expected, + "int" + ); + /// Gets the message format; {0} is the offending value. public const string MessageFormat = "Number must be a safe integer, but got {0}"; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the SafeIntegerRule struct. @@ -22,7 +31,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 +65,16 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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. + Core.ZodRuleFailure Core.IZodFailureDescriber.DescribeFailure(in double value) => + value != Math.Truncate(value) ? new(Core.ZodIssueCategories.InvalidType, IntParameters) + : value < int.MinValue ? new(Core.ZodIssueCategories.TooSmall) + : new(Core.ZodIssueCategories.TooBig); } diff --git a/src/src/ZodSharp/Rules/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 3824ef79..69d1d446 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -17,11 +17,11 @@ 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}'"; readonly string _prefix; readonly StringComparison _comparison; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the StartsWithRule struct. @@ -39,7 +39,7 @@ public StartsWithRule( { _prefix = prefix.OrNull() ?? throw new ArgumentNullException(nameof(prefix)); _comparison = comparison; - _message = message.Or(MessageFormat); + _message = message.OrNull(); Code = code.Or(ErrorCode); } @@ -85,5 +85,10 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..4209dfcc 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"; @@ -19,7 +24,7 @@ namespace ZodSharp.Rules; readonly int _maximumLength; readonly int _minimumLength; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -32,7 +37,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 +80,22 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..bfb9666d 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -11,10 +11,10 @@ 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 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; + readonly string? _message; /// /// Initializes a new instance of the TimeStringRule struct. @@ -23,7 +23,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 +108,9 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + string? Core.IZodRule.Origin => Core.ZodTypeNames.String; + + string? Core.IZodRule.Category => Core.ZodIssueCategories.InvalidFormat; + + IReadOnlyDictionary? Core.IZodRule.Parameters => RuleParameters.Format("time"); } diff --git a/src/src/ZodSharp/Rules/ULIDRule.cs b/src/src/ZodSharp/Rules/ULIDRule.cs index 8116a69f..27419b6a 100644 --- a/src/src/ZodSharp/Rules/ULIDRule.cs +++ b/src/src/ZodSharp/Rules/ULIDRule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the ULIDRule struct. @@ -22,7 +22,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 +93,9 @@ static bool IsCrockfordBase32(char c) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..79402256 100644 --- a/src/src/ZodSharp/Rules/UriRule.cs +++ b/src/src/ZodSharp/Rules/UriRule.cs @@ -10,11 +10,11 @@ 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}"; readonly UriKind _uriKind; - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the UriRule struct. @@ -25,7 +25,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 +57,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..c47fd1ae 100644 --- a/src/src/ZodSharp/Rules/UrlRule.cs +++ b/src/src/ZodSharp/Rules/UrlRule.cs @@ -17,8 +17,8 @@ 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 Regex UrlRegex = new( @"^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$", @@ -26,7 +26,7 @@ namespace ZodSharp.Rules; RegexRule.DefaultMatchTimeout ); - readonly string _message; + readonly string? _message; /// /// Initializes a new instance of the UrlRule struct. @@ -35,7 +35,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 +74,9 @@ public void ApplyTo(JsonSchema.JsonSchemaDefinition schema) string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..4b4fcfc5 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -15,11 +15,17 @@ 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 message format; {0} is the version and {1} the value. - public const string VersionedMessageFormat = "Invalid UUID v{0} format: {1}"; + /// + /// 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"; const string NilUuid = "00000000-0000-0000-0000-000000000000"; const string MaxUuid = "ffffffff-ffff-ffff-ffff-ffffffffffff"; @@ -146,13 +152,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) @@ -225,5 +227,12 @@ public static string JsonSchemaVersionPattern(UuidVersion version) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..eb5aaede 100644 --- a/src/src/ZodSharp/Rules/XidRule.cs +++ b/src/src/ZodSharp/Rules/XidRule.cs @@ -10,10 +10,10 @@ 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; + readonly string? _message; /// /// Initializes a new instance of the struct. @@ -22,7 +22,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 +74,9 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; - string? Core.IZodRule.Origin => null; + 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..9b58dfbd 100644 --- a/src/src/ZodSharp/Schemas/RefineCtx.cs +++ b/src/src/ZodSharp/Schemas/RefineCtx.cs @@ -10,26 +10,51 @@ 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. /// -/// The validated value. -/// The base path prepended to any added issues. -public sealed class RefineCtx(T value, ImmutableArray path) +public sealed class RefineCtx { // 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)) { } + + /// + /// 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 RefineCtx(T value, ZodPath path) + { + Value = value; + FullPath = path; + } + /// /// The value being validated. /// - public T Value { get; } = value; + public T Value { get; } + + /// + /// The base path under which issues are reported, in the legacy string form. Prefer . + /// + public ImmutableArray Path => FullPath.LegacySegments; /// - /// The base path under which issues are reported. + /// The typed base path under which issues are reported. /// - public ImmutableArray Path { get; } = path; + public ZodPath FullPath { get; } /// /// The issues added by the refinement so far. @@ -47,16 +72,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 +130,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..0b02c317 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs @@ -33,6 +33,28 @@ static readonly ConditionalWeakTable< 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 _optionKeys = [.. options.Keys.Order(StringComparer.Ordinal)]; + + /// + /// 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. + internal ZodDiscriminatedUnion( + string discriminator, + ImmutableDictionary> options, + ImmutableArray optionOrder + ) + : this(discriminator, options) + { + _optionKeys = optionOrder; + } + /// /// Parses and validates the value using the discriminated union. /// @@ -42,9 +64,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 +72,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 +87,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) diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs index 5fe26e07..3cf5f665 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnionBuilder.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using ZodSharp.Core; namespace ZodSharp.Schemas; @@ -13,6 +13,7 @@ namespace ZodSharp.Schemas; public class ZodDiscriminatedUnionBuilder(string discriminator) { readonly Dictionary> _options = []; + readonly List _order = []; /// /// Adds an object-typed option to the discriminated union. @@ -25,7 +26,7 @@ public ZodDiscriminatedUnionBuilder Option(string value, IZodSchema(string value, IZodSchema sch if (schema is null) throw new ArgumentNullException(nameof(schema)); - _options[value] = new SchemaWrapper(schema); + AddOption(value, new SchemaWrapper(schema)); return this; } + 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]); sealed class SchemaWrapper(IZodSchema inner) : IZodSchema, IOptionalSchema { @@ -63,13 +72,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/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/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs b/src/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs new file mode 100644 index 00000000..108926cc --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Rules/RuleErrorModelTests.cs @@ -0,0 +1,351 @@ +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 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, but got 1.25"); + } + + [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/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..a11f522a --- /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("'42' 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 f77917ce..717b5b4b 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodArrayTests.cs @@ -1,4 +1,5 @@ using ZodSharp.Core; + namespace ZodSharp.Schemas; public class ZodArrayTests 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 cd3bc287..db417c31 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodTupleTests.cs @@ -1,4 +1,5 @@ using ZodSharp.Core; + namespace ZodSharp.Schemas; public class ZodTupleTests From b671979f335fc948b33bb3e36bbdc77565508a7d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:51:57 +0000 Subject: [PATCH 009/107] feat(core): generated-validator helpers (depth guard, member info, registration, JSON paths) Adds ZodValidationDepth (MaxDepth 32, M.E.V semantics), ZodGeneratedValidation/ZodGeneratedMember (runtime message formatting, typed nested re-pathing, reflection-free member walking), IZodJsonPathMapper, ZodSchemaRegistration + AddZodSchemaRegistration (applied by AddZodSharpFactory), ZodAsyncMemberAttribute and RegexRule.DefaultMatchTimeoutMilliseconds. --- src/src/ZodSharp/Core/IZodJsonPathMapper.cs | 22 + .../ZodSharp/Core/ZodGeneratedValidation.cs | 615 ++++++++++++++++++ .../ZodSharp/Core/ZodSchemaRegistration.cs | 20 + src/src/ZodSharp/Core/ZodValidationDepth.cs | 57 ++ .../ServiceCollectionExtensions.cs | 23 +- src/src/ZodSharp/Rules/RegexRule.cs | 8 +- src/src/ZodSharp/ZodAsyncMemberAttribute.cs | 26 + 7 files changed, 769 insertions(+), 2 deletions(-) create mode 100644 src/src/ZodSharp/Core/IZodJsonPathMapper.cs create mode 100644 src/src/ZodSharp/Core/ZodGeneratedValidation.cs create mode 100644 src/src/ZodSharp/Core/ZodSchemaRegistration.cs create mode 100644 src/src/ZodSharp/Core/ZodValidationDepth.cs create mode 100644 src/src/ZodSharp/ZodAsyncMemberAttribute.cs 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/ZodGeneratedValidation.cs b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs new file mode 100644 index 00000000..d1a9b4c7 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs @@ -0,0 +1,615 @@ +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 string _displayName; + 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 _displayName; + + var resolved = _displayNameResolver(); + return string.IsNullOrEmpty(resolved) ? _displayName : 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) }; + } + + /// + /// Creates the error for a failed rule, copying the rule's structured identity + /// () and adding the member's parameters. + /// + /// The error code. + /// The rule's own message, used when no is supplied. + /// The error path. + /// The member that failed. + /// The rule instance; its identity is copied when implemented. + /// The origin used when the rule reports none. + /// The category used when the rule reports none. + /// A message template that replaces the rule's message (an attribute ErrorMessage). + /// The names positional placeholders in bind to. + /// The error. + public static ValidationError RuleError( + string code, + string? ruleMessage, + ZodPath path, + ZodGeneratedMember member, + object? rule, + string? fallbackOrigin = null, + string? fallbackCategory = null, + string? messageFormat = null, + string[]? ordinalNames = null + ) + { + ArgumentNullException.ThrowIfNull(member); + + var zodRule = rule as IZodRule; + var error = ZodRuleErrors.Build(code, ruleMessage ?? string.Empty, path, zodRule, fallbackOrigin); + error = error.WithParameters(CreateParameters(member, error.Parameters, null)) with + { + Category = error.Category ?? fallbackCategory, + }; + + if (messageFormat is null) + return error; + + var named = ToNamedFormat(messageFormat, ordinalNames); + error = error with { MessageFormat = named }; + return error with { Message = ZodMessageFormat.Format(named, in error, 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 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; + } + + /// + /// 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); + + /// + /// 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/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/ZodValidationDepth.cs b/src/src/ZodSharp/Core/ZodValidationDepth.cs new file mode 100644 index 00000000..2f0a8f18 --- /dev/null +++ b/src/src/ZodSharp/Core/ZodValidationDepth.cs @@ -0,0 +1,57 @@ +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; + + static int CurrentMaxDepth = DefaultMaxDepth; + + /// + /// 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 CurrentMaxDepth); + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + Volatile.Write(ref CurrentMaxDepth, value); + } + } + + /// + /// 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/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index b1cf6181..1d87f617 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 diff --git a/src/src/ZodSharp/Rules/RegexRule.cs b/src/src/ZodSharp/Rules/RegexRule.cs index a8048a16..06d7b2cc 100644 --- a/src/src/ZodSharp/Rules/RegexRule.cs +++ b/src/src/ZodSharp/Rules/RegexRule.cs @@ -39,7 +39,13 @@ 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; diff --git a/src/src/ZodSharp/ZodAsyncMemberAttribute.cs b/src/src/ZodSharp/ZodAsyncMemberAttribute.cs new file mode 100644 index 00000000..3245a8cf --- /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))] +/// 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 ?? []; +} From ebf1134b58c7c866d18e20c2c1fd1a8436d605fb Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 13:52:19 +0000 Subject: [PATCH 010/107] feat(openapi): add Purview.ZodSharp.AspNetCore.OpenApi schema, operation and document transformers Describes what ZodSharp enforces in ASP.NET Core 11 OpenAPI documents. Core JSON Schema export gains method-based vendor extensions, ConvertUntyped, discriminated-union oneOf export, inner-schema unwrapping for validator adapters, and JSON Schema mappings for the IP, CIDR, JWT, duration, base64, date, date-time and time string rules. --- Directory.Packages.props | 7 + src/src/AspNetCore.OpenApi/.editorconfig | 6 + .../AspNetCore.OpenApi.csproj | 30 ++ .../ZodSharpOpenApiOptionsExtensions.cs | 39 ++ src/src/AspNetCore.OpenApi/MapperContext.cs | 42 ++ .../AspNetCore.OpenApi/ZodJsonSchemaSource.cs | 104 +++++ .../ZodOpenApiDocumentTransformer.cs | 71 ++++ .../ZodOpenApiOperationTransformer.cs | 136 ++++++ .../AspNetCore.OpenApi/ZodOpenApiOptions.cs | 45 ++ .../ZodOpenApiSchemaMapper.cs | 394 ++++++++++++++++++ .../ZodOpenApiSchemaTransformer.cs | 231 ++++++++++ .../ZodValidationProblemSchemas.cs | 127 ++++++ src/src/ZodSharp/Core/IZodUntypedValidator.cs | 7 +- src/src/ZodSharp/Core/ZodSchemaValidator.cs | 7 +- .../JsonSchema/JsonSchemaDefinition.cs | 35 ++ .../JsonSchema/ToJsonSchemaConverter.cs | 43 +- src/src/ZodSharp/Rules/Base64StringRule.cs | 10 +- src/src/ZodSharp/Rules/CidrRule.cs | 23 +- src/src/ZodSharp/Rules/DateStringRule.cs | 10 +- src/src/ZodSharp/Rules/DatetimeStringRule.cs | 18 +- src/src/ZodSharp/Rules/DurationRule.cs | 10 +- src/src/ZodSharp/Rules/EndsWithRule.cs | 1 + src/src/ZodSharp/Rules/IPAddressRule.cs | 26 +- src/src/ZodSharp/Rules/IncludesRule.cs | 1 + src/src/ZodSharp/Rules/JWTRule.cs | 14 +- src/src/ZodSharp/Rules/StartsWithRule.cs | 1 + src/src/ZodSharp/Rules/TimeStringRule.cs | 18 +- src/src/ZodSharp/Rules/UuidRule.cs | 3 + .../ZodSharp/Schemas/ZodDiscriminatedUnion.cs | 4 + .../.editorconfig | 6 + .../AspNetCore.OpenApi.UnitTests.csproj | 37 ++ .../Fixtures/OpenApiFixtures.cs | 206 +++++++++ .../OpenApiTestHost.cs | 62 +++ 33 files changed, 1763 insertions(+), 11 deletions(-) create mode 100644 src/src/AspNetCore.OpenApi/.editorconfig create mode 100644 src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj create mode 100644 src/src/AspNetCore.OpenApi/Extensions/Microsoft/AspNetCore/OpenApi/ZodSharpOpenApiOptionsExtensions.cs create mode 100644 src/src/AspNetCore.OpenApi/MapperContext.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodOpenApiOptions.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs create mode 100644 src/src/AspNetCore.OpenApi/ZodValidationProblemSchemas.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/.editorconfig create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/AspNetCore.OpenApi.UnitTests.csproj create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a52669e9..7e6c005b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,13 @@ `just test` covers both sides of the parity guarantee rather than only the C# side. --> + + + + + + + + net11.0 + + true + true + true + + true + 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..a3b0c8c7 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/MapperContext.cs @@ -0,0 +1,42 @@ +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; + + /// + /// 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/ZodJsonSchemaSource.cs b/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs new file mode 100644 index 00000000..e77b7c3a --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs @@ -0,0 +1,104 @@ +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..f1cb1478 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs @@ -0,0 +1,71 @@ +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); + + return Task.CompletedTask; + } + + 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..37b29b87 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using Microsoft.AspNetCore.Http; +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)) + return true; + } + + return false; + } +} 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..a91cec5c --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs @@ -0,0 +1,394 @@ +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"; + + /// + /// 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) + 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 }); + } + } + + static void ApplyValues(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + { + if (source.Enum is { Count: > 0 } values) + { + var nodes = ToNodes(values); + if (!SameNodes(target.Enum, nodes)) + { + context.Conflict("enum", Render(target.Enum), Render(nodes)); + target.Enum = nodes; + } + } + + 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..a2be6bbd --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs @@ -0,0 +1,231 @@ +using DisplayAttribute = System.ComponentModel.DataAnnotations.DisplayAttribute; +using System.Text.Json; +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; + +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(); + + internal ZodJsonSchemaSource Source => _source; + + /// + 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)); + } + + return Task.CompletedTask; + } + + 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; + + 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; + } + } + + 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 (_options.DiscriminatedUnionsAsOneOf && schema.AnyOf is { Count: > 0 } && schema.Discriminator is not null) + { + schema.OneOf = schema.AnyOf; + schema.AnyOf = null; + } + } + + void ApplyDiscriminatedUnion(OpenApiSchema schema, JsonSchemaDefinition definition) + { + if (!_options.DiscriminatedUnionsAsOneOf) + return; + + schema.OneOf = schema.AnyOf; + schema.AnyOf = null; + + if ( + schema.Discriminator is null + && definition.GetVendorExtensions().TryGetValue(ZodOpenApiSchemaMapper.DiscriminatorExtensionName, out var name) + && name is string propertyName + ) + { + schema.Discriminator = new OpenApiDiscriminator { PropertyName = propertyName }; + } + } + + /// + /// Adds the members ZodSharp rejects when absent. 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 (definition.Required is not { Count: > 0 } required || schema.Properties is null) + return; + + foreach (var property in typeInfo.Properties) + { + if (!schema.Properties.ContainsKey(property.Name) || !ContainsMember(required, property.Name)) + continue; + + var type = property.PropertyType; + if (type.IsValueType && Nullable.GetUnderlyingType(type) is null) + 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!) + ); + } + + static bool ContainsMember(List names, string name) => + names.Contains(name, StringComparer.Ordinal) + || names.Contains(JsonNamingPolicy.CamelCase.ConvertName(name), StringComparer.Ordinal); + /// + /// 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..e599e4b7 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/ZodValidationProblemSchemas.cs @@ -0,0 +1,127 @@ +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 Zod code, its category and 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 Zod-compatible issue code, for example 'too_small'.", + }, + ["category"] = Nullable(JsonSchemaType.String), + ["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/ZodSharp/Core/IZodUntypedValidator.cs b/src/src/ZodSharp/Core/IZodUntypedValidator.cs index 176943f6..db5af8df 100644 --- a/src/src/ZodSharp/Core/IZodUntypedValidator.cs +++ b/src/src/ZodSharp/Core/IZodUntypedValidator.cs @@ -80,13 +80,18 @@ CancellationToken cancellationToken /// /// The validated type. /// The typed validator. -public sealed class ZodUntypedValidator(IZodSchemaValidator validator) : IZodUntypedValidator +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); 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/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index 78846830..233c58cd 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 ??= new(StringComparer.Ordinal); + _vendorExtensions[name] = value; + } + + static readonly Dictionary EmptyVendorExtensions = new(StringComparer.Ordinal); } diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 0f682acf..e1857629 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,25 @@ 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, option) in schema.Options.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + 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/Rules/Base64StringRule.cs b/src/src/ZodSharp/Rules/Base64StringRule.cs index 0dbec1c8..7ceef9cb 100644 --- a/src/src/ZodSharp/Rules/Base64StringRule.cs +++ b/src/src/ZodSharp/Rules/Base64StringRule.cs @@ -6,7 +6,7 @@ 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"; @@ -61,4 +61,12 @@ public bool IsValid(in string value) string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// 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/CidrRule.cs b/src/src/ZodSharp/Rules/CidrRule.cs index ae314a93..b5050654 100644 --- a/src/src/ZodSharp/Rules/CidrRule.cs +++ b/src/src/ZodSharp/Rules/CidrRule.cs @@ -30,7 +30,7 @@ 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"; @@ -141,4 +141,25 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// + /// 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/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index 2bd9024f..614be021 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -7,7 +7,7 @@ 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"; @@ -70,4 +70,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// 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..b54192c8 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -11,7 +11,8 @@ 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"; @@ -127,4 +128,19 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// 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/DurationRule.cs b/src/src/ZodSharp/Rules/DurationRule.cs index ebd51d07..b0e7561a 100644 --- a/src/src/ZodSharp/Rules/DurationRule.cs +++ b/src/src/ZodSharp/Rules/DurationRule.cs @@ -7,7 +7,7 @@ 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"; @@ -98,4 +98,12 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// 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/EndsWithRule.cs b/src/src/ZodSharp/Rules/EndsWithRule.cs index bf6ac599..a17a1257 100644 --- a/src/src/ZodSharp/Rules/EndsWithRule.cs +++ b/src/src/ZodSharp/Rules/EndsWithRule.cs @@ -78,6 +78,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. diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 64ebeea5..dd1316c0 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -29,7 +29,7 @@ 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"; @@ -135,4 +135,28 @@ public string GetErrorMessage(ReadOnlySpan value) => string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// + /// 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..36bb98aa 100644 --- a/src/src/ZodSharp/Rules/IncludesRule.cs +++ b/src/src/ZodSharp/Rules/IncludesRule.cs @@ -70,6 +70,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. diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index 96066b69..038d2ebe 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -5,7 +5,7 @@ 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"; @@ -93,4 +93,16 @@ static bool IsBase64Url(ReadOnlySpan segment) string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// + /// 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/StartsWithRule.cs b/src/src/ZodSharp/Rules/StartsWithRule.cs index 3824ef79..497bab1f 100644 --- a/src/src/ZodSharp/Rules/StartsWithRule.cs +++ b/src/src/ZodSharp/Rules/StartsWithRule.cs @@ -78,6 +78,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. diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 6d1e8e60..b54c3891 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -6,7 +6,7 @@ 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"; @@ -109,4 +109,20 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi string? Core.IZodRule.Code => Code; string? Core.IZodRule.Origin => null; + + /// 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/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index be72d443..c9f17f16 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -206,7 +206,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); + } } /// diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs index 5889f26f..6773f51e 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs @@ -33,6 +33,10 @@ static readonly ConditionalWeakTable< static readonly Func MissingDiscriminatorAccessor = static _ => null; + // Exposed to the JSON Schema exporter, which exports the union as oneOf with the discriminator recorded. + internal string Discriminator => discriminator; + + internal ImmutableDictionary> Options => options; /// /// Parses and validates the value using the discriminated union. /// 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..223e0814 --- /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/Fixtures/OpenApiFixtures.cs b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs new file mode 100644 index 00000000..ec5db8ea --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs @@ -0,0 +1,206 @@ +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; } + + 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; } +} + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(CircleShape), "circle")] +[JsonDerivedType(typeof(SquareShape), "square")] +public abstract 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; } +} diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs b/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs new file mode 100644 index 00000000..bf4c7ede --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs @@ -0,0 +1,62 @@ +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(); + + builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(OpenApiTestHost).Assembly)); + services?.Invoke(builder.Services); + 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]; +} From a7e9d970c4835841781f60cbca3d191a9af01e9e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:06:26 +0000 Subject: [PATCH 011/107] feat(options): async options validation and path-bearing failures - ZodSchemaOptionsValidator implements IAsyncValidateOptions on .NET 11; ValidateOnStart runs async and service-aware rules in a scope, synchronous access checks the synchronous rules only (the platform contract). - OptionsBuilder.AddZodSchemaValidator registers through Validate() on .NET 11 and validates only the builder's options name; new overload with validateOnStart and configurationSection. - Failures read "{name}: {location}: {message} ({code})", using configuration keys (Smtp:Servers:0:Host) when a section is recorded (defect O1); ZodValidateOptionsResult keeps the full errors. - Core IZodMessageFormatter / DefaultZodMessageFormatter localization hook and the shared ZodPathNaming enum. - Microsoft.Extensions.Options 11 on net11.0; TestHost versions per TFM. --- Directory.Packages.props | 13 + src/src/ZodSharp/Core/ErrorTypeParameters.cs | 5 +- src/src/ZodSharp/Core/IZodMessageFormatter.cs | 54 ++++ src/src/ZodSharp/Core/ZodPathNaming.cs | 19 ++ .../OptionsServiceCollectionExtensions.cs | 72 ++++- .../ServiceCollectionExtensions.cs | 1 + .../ZodOptionsValidationRegistration.cs | 56 ++++ .../Options/ZodSchemaOptionsValidator.cs | 217 +++++++++++++- .../Core/DefaultZodMessageFormatterTests.cs | 52 ++++ .../ZodSchemaOptionsValidatorPathTests.cs | 283 ++++++++++++++++++ 10 files changed, 755 insertions(+), 17 deletions(-) create mode 100644 src/src/ZodSharp/Core/IZodMessageFormatter.cs create mode 100644 src/src/ZodSharp/Core/ZodPathNaming.cs create mode 100644 src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/DefaultZodMessageFormatterTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/Options/ZodSchemaOptionsValidatorPathTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a52669e9..dbfe8ece 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,8 +25,19 @@ $(DotnetRuntimeVersion) 8.0.2 9.0.20 + + 11.0.0-rc.3.26522.148 $(MicrosoftExtensionsVersion) 8.0.1 + $(DotnetRuntimeVersion) + 8.0.33 + 9.0.22 + 11.0.0-rc.3.26522.148 @@ -40,6 +51,8 @@ Version="$(MicrosoftExtensionsVersion)" /> + + 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/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/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/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs index f7baeb7b..3136ca69 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,87 @@ 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 + ) => Register(builder, missingValidatorBehavior, configurationSection: null); + } + + extension<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>( + OptionsBuilder builder + ) + where TOptions : class + { + /// + /// 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 for chaining. + public OptionsBuilder AddZodSchemaValidator( + MissingValidatorBehavior missingValidatorBehavior, + bool validateOnStart, + string? configurationSection = null ) { - builder.Services.AddZodSchemaOptionsValidator(missingValidatorBehavior); + Register(builder, missingValidatorBehavior, configurationSection); + + if (validateOnStart) + builder.ValidateOnStart(); return builder; } } + + 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; + } } diff --git a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index b1cf6181..803bd963 100644 --- a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -58,6 +58,7 @@ public IServiceCollection AddZodSchemaOptionsValidator( { services.AddSingleton>(sp => new ZodSchemaOptionsValidator( sp.GetRequiredService(), + sp, missingValidatorBehavior )); diff --git a/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs b/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs new file mode 100644 index 00000000..39c6e94f --- /dev/null +++ b/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs @@ -0,0 +1,56 @@ +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(": "); + + string location; + if (!string.IsNullOrEmpty(configurationSection)) + { + location = error.FullPath.IsRoot + ? configurationSection + : configurationSection + ":" + error.FullPath.ToConfigurationKey(); + } + else + { + location = error.FullPath.ToDottedString(); + } + + 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/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/Options/ZodSchemaOptionsValidatorPathTests.cs b/src/tests/ZodSharp.UnitTests/Options/ZodSchemaOptionsValidatorPathTests.cs new file mode 100644 index 00000000..1797fcdf --- /dev/null +++ b/src/tests/ZodSharp.UnitTests/Options/ZodSchemaOptionsValidatorPathTests.cs @@ -0,0 +1,283 @@ +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 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; + } + } +} From 22eaaea2b7aed3857578ec38df9a7903f99083d5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:06:54 +0000 Subject: [PATCH 012/107] feat(aspnetcore): one ProblemDetails writer, endpoint filter factory and typed results - ZodProblemDetailsWriter is the single implementation behind the endpoint filter, ZodExceptionHandler, ZodTypedResults and every To*ProblemDetails / ToValidationProblem overload; a contract test proves byte-identical bodies. - Keys from FullPath.ToDottedString with ZodPathNaming; messages through IZodMessageFormatter in the request culture; ErrorType templates only when complete; FormatMessages honoured; StatusCodeSelector/StatusCodeOverride win; reserved members protected; per-issue camel-cased safe parameters; full MVC copies; traceId everywhere; safe ValidationIssue DTO with segments, typed bounds and unionErrors (defects D1-D10). - WithZodSharpValidation() on any IEndpointConventionBuilder (route groups included) via an endpoint filter factory: build-time parameter selection and validator resolution, every argument validated, build-time missing-validator failures, .NET 11 binding-failure pass-through or conversion, and 400 problem+json response metadata (defects D6, D11, D12). - ZodTypedResults.ValidationProblem/Problem, ToValidationProblem(HttpContext), options overloads, Apply() for merging foreign errors (for the Microsoft.Extensions.Validation bridge), source-generated JSON metadata for Native AOT, and BindAndValidateWithZod(section). - Skill and package README updated; CHANGELOG records the behaviour changes. --- CHANGELOG.md | 64 ++ skills/purview-zodsharp-aspnetcore/SKILL.md | 308 ++++---- .../Builder/RouteHandlerBuilderExtensions.cs | 22 +- ...harpEndpointConventionBuilderExtensions.cs | 67 ++ .../ServiceCollectionExtensions.cs | 32 +- .../ZodSharpOptionsBuilderExtensions.cs | 84 +++ .../ZodSharp/ProblemDetailsExtensions.cs | 119 ++- .../ZodSharp/ZodExceptionExtensions.cs | 79 +- src/src/AspNetCore/ProblemDetailsMapper.cs | 122 --- src/src/AspNetCore/Sdk/README.md | 40 +- src/src/AspNetCore/ValidationIssue.cs | 105 ++- .../ZodEndpointValidationFilterFactory.cs | 376 ++++++++++ .../ZodEndpointValidationOptions.cs | 46 ++ src/src/AspNetCore/ZodExceptionHandler.cs | 58 +- .../ZodProblemDetailsJsonContext.cs | 36 + .../AspNetCore/ZodProblemDetailsOptions.cs | 76 +- src/src/AspNetCore/ZodProblemDetailsResult.cs | 45 ++ src/src/AspNetCore/ZodProblemDetailsWriter.cs | 694 ++++++++++++++++++ src/src/AspNetCore/ZodTypedResults.cs | 110 +++ src/src/AspNetCore/ZodValidationFilter.cs | 43 +- .../AspNetCore.UnitTests.csproj | 8 +- .../ErrorTypeMappingTests.cs | 9 +- .../Fixtures/DelegateValidator.cs | 21 + .../Fixtures/OrderRequest.cs | 93 +++ .../AspNetCore.UnitTests/Fixtures/TestApp.cs | 61 ++ .../Options/BindAndValidateWithZodTests.cs | 151 ++++ .../ProblemDetailsContractTests.cs | 116 +++ .../ZodEndpointValidationTests.cs | 479 ++++++++++++ .../ZodExceptionHandlerTests.cs | 8 +- .../ZodProblemDetailsWriterTests.cs | 434 +++++++++++ 30 files changed, 3466 insertions(+), 440 deletions(-) create mode 100644 src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs create mode 100644 src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpOptionsBuilderExtensions.cs delete mode 100644 src/src/AspNetCore/ProblemDetailsMapper.cs create mode 100644 src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs create mode 100644 src/src/AspNetCore/ZodEndpointValidationOptions.cs create mode 100644 src/src/AspNetCore/ZodProblemDetailsJsonContext.cs create mode 100644 src/src/AspNetCore/ZodProblemDetailsResult.cs create mode 100644 src/src/AspNetCore/ZodProblemDetailsWriter.cs create mode 100644 src/src/AspNetCore/ZodTypedResults.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/DelegateValidator.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/OrderRequest.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs create mode 100644 src/tests/AspNetCore.UnitTests/Options/BindAndValidateWithZodTests.cs create mode 100644 src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs create mode 100644 src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs create mode 100644 src/tests/AspNetCore.UnitTests/ZodProblemDetailsWriterTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c0aa87..9a3004ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,41 @@ Released versions correspond to `v` GitHub releases. - `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, errors, ...)` + (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 `true`), + `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. ### Breaking behaviour changes - **Index paths.** `ZodArray` and `ZodTuple` element errors are located with typed index segments; the legacy @@ -80,6 +115,29 @@ Released versions correspond to `v` GitHub releases. `"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). +- **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). ### Fixed - Re-pathing an element, field, catchall, record value or tuple element error no longer drops its origin, @@ -107,6 +165,12 @@ Released versions correspond to `v` GitHub releases. 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. ## 2.4.1 ### Fixed diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index a11c2587..061fc62d 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -2,9 +2,10 @@ 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, ZodTypedResults, the shared ZodProblemDetailsWriter, registering + IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects (including .NET 11 + async startup validation), and mapping error codes to HTTP statuses with ErrorType. license: MIT metadata: product: Purview.ZodSharp.AspNetCore @@ -13,230 +14,201 @@ 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. - -## Thrown `ZodException` - -A `ZodException` from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under -strict deserialization maps the same way: +`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. + +## Manual results and exceptions ```csharp -catch (ZodException ex) +app.MapPost("/users", Results, ValidationProblem> (UserDto dto, HttpContext ctx) => { - return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); -} + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value!) : ZodTypedResults.ValidationProblem(result, ctx); +}); ``` -Or handle it globally: +| 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, …)` | 2.x overloads, no DI | +| `ToValidationProblemDetails(…)` | MVC `ValidationProblemDetails`, every extension copied | -```csharp -builder.Services.AddZodSharpProblemDetails(); +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. -var app = builder.Build(); -app.UseExceptionHandler(); +## 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 +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` (`true`) - also copy parameters to the top level. Reserved names + (`type`, `title`, `status`, `detail`, `instance`, `errors`, `issues`, `traceId`) become `param_{name}`. +- `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); -``` - -`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); +builder.Services.AddZodSharpProblemDetails(o => o.MapErrorType(ConcurrentErrorType.SaveFailed)); +var error = ConcurrentErrorType.CreateSaveFailed("agg-123"); // or ThrowSaveFailed(...) ``` -`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: +| 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 | -| 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))`. - -> **`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 + (required)` 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. +- `MissingValidatorBehavior`: `Throw` (default) or `Ignore`. ## Related skills -`purview-zodsharp-core` for the runtime API, `purview-zodsharp-source-generator` for the compile-time +`purview-zodsharp-core` for the runtime API and error model, `purview-zodsharp-source-generator` for the validators this resolves, and `purview-zodsharp-diagnostics` for `ZODSASP*`. diff --git a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs index 0f3dd812..0eaed63f 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,30 @@ 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(); + builder.AddEndpointFilterFactory( + ZodEndpointValidationFilterFactory.Create(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..839a727b --- /dev/null +++ b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs @@ -0,0 +1,67 @@ +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. + /// + /// + /// 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); + + builder.AddEndpointFilterFactory( + ZodEndpointValidationFilterFactory.Create(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..29bfbd96 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,9 @@ using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZodSharp.AspNetCore; using ZodSharp.Core; namespace Microsoft.Extensions.DependencyInjection; @@ -32,6 +35,7 @@ public IServiceCollection AddZodSharp(Action? configure } ); services.TryAddSingleton(CreateFactory); + AddProblemDetailsWriter(services); return services; } @@ -118,13 +122,18 @@ 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. + /// /// public IServiceCollection AddZodSharpProblemDetails( Action? configure = null, @@ -136,12 +145,31 @@ public IServiceCollection AddZodSharpProblemDetails( if (configure is not null) services.Configure(configure); + AddProblemDetailsWriter(services); services.AddExceptionHandler(); return services; } } + 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) + ); + } + } + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( "Trimming", "IL2026:RequiresUnreferencedCode", 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..1e31f062 --- /dev/null +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpOptionsBuilderExtensions.cs @@ -0,0 +1,84 @@ +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 +{ + /// + /// 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 (required)), so it can be found in appsettings.json. + /// + /// + /// Binding uses the reflection-based configuration binder. A Native AOT application should call + /// BindConfiguration(section) itself (the configuration binder source generator intercepts that call) + /// followed by AddZodSchemaValidator(MissingValidatorBehavior.Throw, validateOnStart, 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( + "Binds through the reflection-based configuration binder. Call BindConfiguration and AddZodSchemaValidator in the application instead." + )] + [RequiresDynamicCode( + "Binds through the reflection-based configuration binder. Call BindConfiguration and AddZodSchemaValidator in the application instead." + )] + 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( + "Binds through the reflection-based configuration binder. Call Bind and AddZodSchemaValidator in the application instead." + )] + [RequiresDynamicCode( + "Binds through the reflection-based configuration binder. Call Bind and AddZodSchemaValidator in the application instead." + )] + 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/ProblemDetailsExtensions.cs b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs index 6ba41b10..66abeefd 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs @@ -1,31 +1,28 @@ 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. /// 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); } /// @@ -37,8 +34,9 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( 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); } /// @@ -50,8 +48,37 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( 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); } /// @@ -90,23 +117,41 @@ 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. 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 +160,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..0964bf39 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs @@ -1,32 +1,29 @@ 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. /// 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 @@ -38,13 +35,9 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( ) { 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); } /// @@ -57,13 +50,24 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( ) { 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); } /// @@ -100,5 +104,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 32710cbc..00000000 --- a/src/src/AspNetCore/ProblemDetailsMapper.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Immutable; -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 = error.FullPath.ToDottedString(); - 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; - - /// - /// 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..4f286672 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -14,32 +14,32 @@ dotnet add package Purview.ZodSharp.AspNetCore ## Usage ```csharp -using ZodSharp.AspNetCore; - -var result = BasketSchema.Validate(basket); +using ZodSharp; // the ProblemDetails extension methods -if (!result.IsSuccess) -{ - var problem = result.ToHttpValidationProblemDetails(); - return Results.ValidationProblem( - problem.Errors, - extensions: new Dictionary - { - ["issues"] = problem.Extensions["issues"], - }); -} -``` +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpProblemDetails(); -`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. +var app = builder.Build(); +app.UseExceptionHandler(); -A `ValidationProblemDetails` overload is also available: +// 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)); -```csharp -var problem = result.ToValidationProblemDetails(); +// Or validate manually and return the same contract. +app.MapPost("/baskets", (Basket basket, HttpContext context) => +{ + var result = BasketSchema.Validate(basket); + return result.IsSuccess ? Results.Ok(result.Value) : result.ToValidationProblem(context); +}); ``` -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). - +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). ## Exception handling Thrown `ZodException`s (e.g. from a value object's strict deserialization) are converted automatically by 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..0dfc7968 --- /dev/null +++ b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs @@ -0,0 +1,376 @@ +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.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"; + + public static Func Create( + ZodEndpointValidationOptions options, + Type? requiredType, + Func? requiredTypeResolver + ) => (context, next) => Build(context, next, options, requiredType, requiredTypeResolver); + + static EndpointFilterDelegate Build( + EndpointFilterFactoryContext context, + EndpointFilterDelegate next, + ZodEndpointValidationOptions options, + Type? requiredType, + Func? requiredTypeResolver + ) + { + var services = context.ApplicationServices; + var factory = services.GetService(); + var isService = services.GetService(); + var method = context.MethodInfo; + var parameters = method.GetParameters(); + + 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; + + 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 (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)); + } + + if (errors is not null) + return new ZodProblemDetailsResult(writer, writer.Create(CollectionsMarshal.AsSpan(errors), httpContext)); + + return 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; + + if (factory.TryGetUntypedValidator(validatedType, out validator) && validator is not null) + return validator; + + 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). Needs dynamic code, so it is skipped under Native AOT, where + /// validators must implement or be registered through + /// . + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2060:MakeGenericMethod", + Justification = "Adapt has no DynamicallyAccessedMembers requirements on T." + )] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Guarded by RuntimeFeature.IsDynamicCodeSupported; under Native AOT the adapter is not created." + )] + 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..aa4a2904 100644 --- a/src/src/AspNetCore/ZodExceptionHandler.cs +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -7,9 +6,9 @@ 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. /// /// /// @@ -19,17 +18,28 @@ namespace ZodSharp.AspNetCore; /// 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; } /// @@ -44,35 +54,9 @@ CancellationToken cancellationToken if (exception is not ZodException zodException) 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(zodException.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); } diff --git a/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs b/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs new file mode 100644 index 00000000..fd967a65 --- /dev/null +++ b/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs @@ -0,0 +1,36 @@ +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(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..dc4f8929 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,66 @@ 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 true for compatibility; the parameters are always present per + /// issue in issues[].parameters, which is the reliable place to read them. 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; } = true; + + /// + /// 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; } + /// /// Registers an with the configured . /// @@ -34,4 +85,19 @@ 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, + }; } 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..6ec462c3 --- /dev/null +++ b/src/src/AspNetCore/ZodProblemDetailsWriter.cs @@ -0,0 +1,694 @@ +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 = new(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 ZodProblemDetailsOptions _options; + 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 => _options; + + /// + /// 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); + } + + /// + /// 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); + } + + void Populate( + HttpValidationProblemDetails target, + ReadOnlySpan errors, + HttpContext? httpContext, + bool mergeExistingErrors + ) + { + Dictionary> grouped = new(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); + + 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; + + if (_options.ErrorTypeResolver is { } resolver) + return resolver(code); + + 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 (IsUnionParameter(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; + } + } + } + + static Dictionary? ProjectParameters(ErrorTypeParameters? parameters) + { + if (parameters is null || parameters.Count == 0) + return null; + + Dictionary? result = null; + foreach (var pair in parameters) + { + if (IsUnionParameter(pair.Key) || !TryNormalize(pair.Value, allowCollections: true, out var safe)) + continue; + + result ??= new Dictionary(StringComparer.Ordinal); + result[JsonNamingPolicy.CamelCase.ConvertName(pair.Key)] = safe; + } + + return result; + } + + 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, + _ => 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; + if ( + options is not null + && options.TryGetTypeInfo(typeof(HttpValidationProblemDetails), out var typeInfo) + && typeInfo is JsonTypeInfo typed + ) + { + return typed; + } + + 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/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/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..f156b11a 100644 --- a/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs +++ b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs @@ -117,8 +117,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 +151,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/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/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/TestApp.cs b/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs new file mode 100644 index 00000000..caef6a40 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs @@ -0,0 +1,61 @@ +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 + ) + { + var builder = WebApplication.CreateSlimBuilder(); + 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/Options/BindAndValidateWithZodTests.cs b/src/tests/AspNetCore.UnitTests/Options/BindAndValidateWithZodTests.cs new file mode 100644 index 00000000..4d6f62b6 --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/Options/BindAndValidateWithZodTests.cs @@ -0,0 +1,151 @@ +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); + } + +#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..e7e2f79a --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs @@ -0,0 +1,116 @@ +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 +/// , and the manual results. +/// +public class ProblemDetailsContractTests +{ + const string ExpectedContract = """ + { + "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 }] }; + + [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()); + }, + app => + { + app.UseExceptionHandler(); + + app.MapPost("/filter", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(); + app.MapPost( + "/exception", + IResult (OrderRequest order) => throw new ZodException(OrderRequestValidator.Check(order)) + ); + app.MapPost( + "/manual", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? result.ToValidationProblem(context) + : TypedResults.Ok() + ); + app.MapPost( + "/typed", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? ZodTypedResults.ValidationProblem(result, context) + : TypedResults.Ok() + ); + app.MapPost( + "/problem", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? ZodTypedResults.Problem(result, context) + : TypedResults.Ok() + ); + }, + cancellationToken + ); + + // Act + List<(string Path, int Status, string? ContentType, string Body)> responses = []; + foreach (var path in new[] { "/filter", "/exception", "/manual", "/typed", "/problem" }) + { + 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/ZodEndpointValidationTests.cs b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs new file mode 100644 index 00000000..3fdcb78d --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs @@ -0,0 +1,479 @@ +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..c852f496 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); } @@ -79,7 +82,8 @@ await Assert .IsEqualTo("Aggregate 'agg-123' failed to save"); await Assert.That(root.GetProperty("aggregateId").GetString()).IsEqualTo("agg-123"); 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/ZodProblemDetailsWriterTests.cs b/src/tests/AspNetCore.UnitTests/ZodProblemDetailsWriterTests.cs new file mode 100644 index 00000000..1c209f1f --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodProblemDetailsWriterTests.cs @@ -0,0 +1,434 @@ +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_GivenNoRegistry_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(); + + // 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().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(); + + // 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 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; + } + } +} From 95369e91ec7cee29a8bab3246c1174257470d720 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:07:56 +0000 Subject: [PATCH 013/107] feat(validation): add Purview.ZodSharp.Extensions.Validation for Microsoft.Extensions.Validation New net11.0 package that registers ZodSharp as the first .NET 11 validation resolver, so minimal APIs, Blazor Server and Blazor WebAssembly validate ZodSharp types with ZodSharp. - AddZodSharpValidation(): registers the factory and ZodValidatableInfoResolver, and places the resolver through IPostConfigureOptions, so it is first whether AddValidation() is called before, after or not at all (proved against the real M.E.V generator interceptor). - Type, property and parameter adapters: Container/Name resolution (IZodMemberResolver, reflection fallback, dictionary-backed schemas), CurrentValidationPath prefixing, MaxDepth parity, sync Validate throwing on async-only rules, M.E.V parameter path conventions, union case following. - Rich-error side channel: ValidateContext.ZodReport and IZodValidationReportSink. - StringLocalizerZodMessageFormatter, auto-enabled with an IStringLocalizerFactory. - ValidationOptions.ValidateAsync for non-HTTP hosts. - IZodMessageFormatter in ZodSharp.Core. --- .config/purview-build.json | 6 + .csharpierignore | 1 + CHANGELOG.md | 14 + Directory.Packages.props | 2 + src/Directory.Build.targets | 3 + src/ZodSharp.slnx | 2 + .../Extensions.Validation.csproj | 55 +++ ...rpValidationServiceCollectionExtensions.cs | 54 +++ .../ZodValidateContextExtensions.cs | 31 ++ .../ZodValidationOptionsExtensions.cs | 71 ++++ .../IZodValidatableInfoSource.cs | 18 + .../IZodValidationReportSink.cs | 25 ++ src/src/Extensions.Validation/Sdk/README.md | 84 ++++ .../StringLocalizerZodMessageFormatter.cs | 192 +++++++++ .../ZodCompositeValidatableTypeInfo.cs | 119 ++++++ .../Extensions.Validation/ZodErrorEmitter.cs | 139 +++++++ .../Extensions.Validation/ZodMemberLocator.cs | 211 ++++++++++ .../ZodResolverPlacement.cs | 20 + .../ZodUnionValidatableTypeInfo.cs | 116 ++++++ .../ZodValidatableInfoResolver.cs | 245 ++++++++++++ .../ZodValidatableParameterInfo.cs | 144 +++++++ .../ZodValidatablePropertyInfo.cs | 92 +++++ .../ZodValidatableTypeInfo.cs | 170 ++++++++ .../ZodValidationOptions.cs | 45 +++ .../ZodValidationOptionsSetup.cs | 48 +++ .../ZodValidationReport.cs | 57 +++ src/src/ZodSharp/Core/IZodMessageFormatter.cs | 24 ++ .../Extensions.Validation.UnitTests.csproj | 33 ++ ...idationServiceCollectionExtensionsTests.cs | 164 ++++++++ .../ZodValidationOptionsExtensionsTests.cs | 73 ++++ .../Fixtures/GeneratedModels.cs | 21 + .../Fixtures/Models.cs | 68 ++++ .../Fixtures/TestHelpers.cs | 112 ++++++ .../Fixtures/TestValidators.cs | 95 +++++ .../Fixtures/Unions.cs | 6 + ...StringLocalizerZodMessageFormatterTests.cs | 243 +++++++++++ .../ZodValidatableInfoResolverTests.cs | 247 ++++++++++++ .../ZodValidatableParameterInfoTests.cs | 151 +++++++ .../ZodValidatablePropertyInfoTests.cs | 116 ++++++ ...ValidatableTypeInfoGeneratedSchemaTests.cs | 73 ++++ ...alidatableTypeInfoMemberResolutionTests.cs | 326 +++++++++++++++ .../ZodValidatableTypeInfoTests.cs | 376 ++++++++++++++++++ 42 files changed, 4092 insertions(+) create mode 100644 src/src/Extensions.Validation/Extensions.Validation.csproj create mode 100644 src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs create mode 100644 src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs create mode 100644 src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensions.cs create mode 100644 src/src/Extensions.Validation/IZodValidatableInfoSource.cs create mode 100644 src/src/Extensions.Validation/IZodValidationReportSink.cs create mode 100644 src/src/Extensions.Validation/Sdk/README.md create mode 100644 src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs create mode 100644 src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs create mode 100644 src/src/Extensions.Validation/ZodErrorEmitter.cs create mode 100644 src/src/Extensions.Validation/ZodMemberLocator.cs create mode 100644 src/src/Extensions.Validation/ZodResolverPlacement.cs create mode 100644 src/src/Extensions.Validation/ZodUnionValidatableTypeInfo.cs create mode 100644 src/src/Extensions.Validation/ZodValidatableInfoResolver.cs create mode 100644 src/src/Extensions.Validation/ZodValidatableParameterInfo.cs create mode 100644 src/src/Extensions.Validation/ZodValidatablePropertyInfo.cs create mode 100644 src/src/Extensions.Validation/ZodValidatableTypeInfo.cs create mode 100644 src/src/Extensions.Validation/ZodValidationOptions.cs create mode 100644 src/src/Extensions.Validation/ZodValidationOptionsSetup.cs create mode 100644 src/src/Extensions.Validation/ZodValidationReport.cs create mode 100644 src/src/ZodSharp/Core/IZodMessageFormatter.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Extensions.Validation.UnitTests.csproj create mode 100644 src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/Validation/ZodValidationOptionsExtensionsTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Fixtures/Models.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Fixtures/TestValidators.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/StringLocalizerZodMessageFormatterTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/ZodValidatableInfoResolverTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/ZodValidatablePropertyInfoTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoMemberResolutionTests.cs create mode 100644 src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoTests.cs diff --git a/.config/purview-build.json b/.config/purview-build.json index 332e7fa1..5d186730 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -30,6 +30,12 @@ "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.newtonsoftjson": [ "lib/$(TFM)/Purview.ZodSharp.NewtonsoftJson.dll", "lib/$(TFM)/Purview.ZodSharp.NewtonsoftJson.xml", diff --git a/.csharpierignore b/.csharpierignore index f9b18582..16275ab7 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1,3 +1,4 @@ # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c0aa87..ed425b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ Released versions correspond to `v` GitHub releases. ### Added +- **`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. References the + `Microsoft.Extensions.Validation` package only - never the ASP.NET Core shared framework - so it works in + WebAssembly clients. +- **`IZodMessageFormatter`** in `ZodSharp.Core`: formats an error's user-facing message for a culture at a host + boundary. - **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 diff --git a/Directory.Packages.props b/Directory.Packages.props index a52669e9..dfe6b527 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,8 @@ Version="$(MicrosoftExtensionsVersion)" /> + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ea8040de..5fcd6639 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -15,6 +15,9 @@ --> true + 2.4.0 + diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 39f6e3cd..5bf20b7e 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -14,6 +14,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/src/Extensions.Validation/Extensions.Validation.csproj b/src/src/Extensions.Validation/Extensions.Validation.csproj new file mode 100644 index 00000000..e2c28241 --- /dev/null +++ b/src/src/Extensions.Validation/Extensions.Validation.csproj @@ -0,0 +1,55 @@ + + + + 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..08b7fbb4 --- /dev/null +++ b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +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. + /// + /// Calling this more than once registers the resolver once; each applies. + /// + public IServiceCollection AddZodSharpValidation(Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + 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 + )); + 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..d3fc1b5c --- /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 = new(); + + 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..a50a4a61 --- /dev/null +++ b/src/src/Extensions.Validation/Sdk/README.md @@ -0,0 +1,84 @@ +# 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. + +### 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 | +| `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. Outside HTTP and Blazor, +`ValidationOptions.ValidateAsync(value, services)` validates and returns the report directly. + +### 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` | + +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..f2d880c9 --- /dev/null +++ b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs @@ -0,0 +1,192 @@ +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 error, CultureInfo culture) => Format(error, 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 = new(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..6f8b160b --- /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 = new(); + 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..64c61abd --- /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) + { + if (options.MessageFormatter is not null) + return options.MessageFormatter; + + return 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..4ee051f8 --- /dev/null +++ b/src/src/Extensions.Validation/ZodMemberLocator.cs @@ -0,0 +1,211 @@ +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 +{ + 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)) + { + container = resolvedContainer; + memberName = resolvedMember ?? string.Empty; + return; + } + } + catch (Exception) + { + resolvedContainer = null; + resolvedMember = string.Empty; + } + + if (Walk(value, path, out container, out memberName)) + return; + + // The validator followed part of the path itself: its nearest ancestor is better informed than ours. + if (!string.IsNullOrEmpty(resolvedMember)) + { + container = resolvedContainer; + memberName = resolvedMember; + } + } + + /// + /// 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. + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2075:DynamicallyAccessedMembers", + Justification = "Failure-path fallback for runtime schemas over CLR types. When the trimmer removed a " + + "property, the walk stops at the nearest object reached, which only degrades field placement; it " + + "never throws. Generated validators resolve members without reflection and never reach this." + )] + 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; + + container = current; + memberName = segment.Name ?? string.Empty; + + var property = Properties.GetOrAdd( + (current.GetType(), memberName), + 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; + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:DynamicallyAccessedMembers", + Justification = "See Walk: a trimmed property only stops the walk early." + )] + 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/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..d2026175 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs @@ -0,0 +1,245 @@ +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 ZodValidationOptions _options; + readonly ConcurrentDictionary _types = new(); + readonly ConditionalWeakTable _parameters = new(); + ValidationOptions? _attachedTo; + + /// + /// Creates the resolver. + /// + /// The schema factory; to use only . + /// The bridge options; defaults when . + public ZodValidatableInfoResolver(IZodSchemaFactory? factory, ZodValidationOptions? options = null) + { + _factory = factory; + _options = options ?? new(); + } + + /// + /// Gets the bridge options. + /// + public ZodValidationOptions Options => _options; + + /// + /// 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. + /// + public bool TryGetValidatableParameterInfo( + ParameterInfo parameterInfo, + [NotNullWhen(true)] out IValidatableParameterInfo? validatableParameterInfo + ) + { + ArgumentNullException.ThrowIfNull(parameterInfo); + + 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)); + + if (isCollection && GetElementType(type) is { } elementType && TryGetValidator(elementType, out _)) + return new ZodValidatableParameterInfo(parameter.Name, declared, allowsNull, true, null); + + 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..c990d3d3 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs @@ -0,0 +1,144 @@ +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; + + internal ZodValidatableParameterInfo( + string name, + Type parameterType, + bool allowsNull, + bool isCollection, + ZodValidatableTypeInfo? typeInfo + ) + { + 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 (_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 (_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..7db4e27b --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs @@ -0,0 +1,170 @@ +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 ZodValidationOptions _options; + 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 => _options; + + /// + /// + /// 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..aaa3a8c6 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidationOptions.cs @@ -0,0 +1,45 @@ +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 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..ec5f9718 --- /dev/null +++ b/src/src/Extensions.Validation/ZodValidationOptionsSetup.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; + +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); + } + + 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..46198de5 --- /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 = []; + ImmutableHashSet _keys = ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); + + /// + /// 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 => _keys; + + internal void Add(ImmutableArray errors, IEnumerable keys) + { + lock (_lock) + { + _errors = _errors.AddRange(errors); + _keys = _keys.Union(keys); + } + } +} diff --git a/src/src/ZodSharp/Core/IZodMessageFormatter.cs b/src/src/ZodSharp/Core/IZodMessageFormatter.cs new file mode 100644 index 00000000..ebc38dd1 --- /dev/null +++ b/src/src/ZodSharp/Core/IZodMessageFormatter.cs @@ -0,0 +1,24 @@ +using System.Globalization; + +namespace ZodSharp.Core; + +/// +/// Produces the user-facing message for a at a host boundary (ProblemDetails, +/// Microsoft.Extensions.Validation, Blazor), so messages can be localized without validation itself depending +/// on a culture or a localization framework. +/// +public interface IZodMessageFormatter +{ + /// + /// Formats for . + /// + /// The error. + /// The culture to format for, usually . + /// The message. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "'error' names a ValidationError throughout the public API; VB's Error keyword is not a concern." + )] + string Format(in ValidationError error, CultureInfo culture); +} 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..2583eb4a --- /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..bcf7b3fe --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs @@ -0,0 +1,164 @@ +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(); + } +} 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..e63f8c93 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs @@ -0,0 +1,21 @@ +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; } +} 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..bb2a9330 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs @@ -0,0 +1,112 @@ +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) + { + if (!provider.GetValidationOptions().TryGetValidatableTypeInfo(typeof(T), out var info)) + throw new InvalidOperationException($"No type info for {typeof(T).Name}."); + + 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; + if (resources.TryGetValue($"{culture}:{name}", out var specific)) + return new(name, specific, resourceNotFound: false); + + 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..f3778e20 --- /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 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..20fd1dfa --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/StringLocalizerZodMessageFormatterTests.cs @@ -0,0 +1,243 @@ +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"); + } + + 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..91e951f1 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs @@ -0,0 +1,151 @@ +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, + ZodSharp.Core.ValidationError error + ) + { + var provider = CreateProvider(factory => factory.Register(new ScriptedValidator(error))); + if (!provider.GetValidationOptions().TryGetValidatableParameterInfo(Parameter(parameter), out var info)) + throw new InvalidOperationException($"Parameter '{parameter}' was not claimed."); + + 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..7dc35e41 --- /dev/null +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs @@ -0,0 +1,73 @@ +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(); + } + + 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."); + } +} From 6acb3da65ec0cb48757939536efdf145ddeb6742 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:12:07 +0000 Subject: [PATCH 014/107] fix(validation): align formatter parameter name with IZodMessageFormatter --- .../StringLocalizerZodMessageFormatter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs index f2d880c9..bd092467 100644 --- a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs +++ b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs @@ -59,7 +59,8 @@ public StringLocalizerZodMessageFormatter( } /// - public string Format(in ZodValidationError error, CultureInfo culture) => Format(error, null, culture); + public string Format(in ZodValidationError validationError, CultureInfo culture) => + Format(validationError, null, culture); /// /// Formats for , looking keys up in the localizer of From 1ce9a9705f02ed590911b8b595ca63374444b5a4 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:13:43 +0000 Subject: [PATCH 015/107] feat(openapi): describe unions, required members and error contract; add OpenAPI test suite Adds AspNetCore.OpenApi.UnitTests (net11.0): every keyword mapping row for OpenAPI 3.1 and 3.2, conflict policy and Development logging, unknown types untouched, polymorphic oneOf with resolvable case references, C# union cases constrained without restructuring, operation responses (body, [AsParameters], union, WithZodSharpValidation, ProducesValidationProblem merge, 422 from the registry) and a JsonSchema.Net cross-check of fixture payloads against the emitted document. Registers the package in the solution, pack validation and the changelog. --- .config/purview-build.json | 6 + .csharpierignore | 1 + CHANGELOG.md | 21 ++ src/ZodSharp.slnx | 2 + .../AspNetCore.OpenApi.csproj | 3 + src/src/AspNetCore.OpenApi/MapperContext.cs | 6 + src/src/AspNetCore.OpenApi/Sdk/README.md | 93 ++++++ .../AspNetCore.OpenApi/ZodJsonSchemaSource.cs | 9 +- .../ZodOpenApiDocumentTransformer.cs | 31 +- .../ZodOpenApiOperationTransformer.cs | 29 +- .../ZodOpenApiSchemaMapper.cs | 37 ++- .../ZodOpenApiSchemaTransformer.cs | 136 +++++++-- .../JsonSchema/ToJsonSchemaConverter.cs | 1 + src/src/ZodSharp/Rules/CidrRule.cs | 6 +- src/src/ZodSharp/Rules/DateStringRule.cs | 6 +- src/src/ZodSharp/Rules/DatetimeStringRule.cs | 3 +- src/src/ZodSharp/Rules/DurationRule.cs | 6 +- src/src/ZodSharp/Rules/IPAddressRule.cs | 6 +- src/src/ZodSharp/Rules/JWTRule.cs | 6 +- src/src/ZodSharp/Rules/TimeStringRule.cs | 6 +- .../DocumentCrossCheckTests.cs | 275 ++++++++++++++++++ .../FixtureApi.cs | 58 ++++ .../Fixtures/OpenApiFixtures.cs | 65 ++++- .../Fixtures/PetUnion.cs | 4 + .../OpenApiTestHost.cs | 9 +- .../ZodOpenApiOperationTransformerTests.cs | 206 +++++++++++++ ...odOpenApiSchemaTransformerTests.Mapping.cs | 144 +++++++++ .../ZodOpenApiSchemaTransformerTests.cs | 268 +++++++++++++++++ .../ToJsonSchemaVendorExtensionTests.cs | 156 ++++++++++ 29 files changed, 1543 insertions(+), 56 deletions(-) create mode 100644 src/src/AspNetCore.OpenApi/Sdk/README.md create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiOperationTransformerTests.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs create mode 100644 src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.cs create mode 100644 src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs diff --git a/.config/purview-build.json b/.config/purview-build.json index 332e7fa1..291cf59d 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -30,6 +30,12 @@ "purview-logo-light.png", "README.md" ], + "purview.zodsharp.aspnetcore.openapi": [ + "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", diff --git a/.csharpierignore b/.csharpierignore index f9b18582..e1ab4854 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1,3 +1,4 @@ # 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/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c0aa87..0e824778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ Released versions correspond to `v` GitHub releases. ### Added +- **`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`. - **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 diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 39f6e3cd..b58afeb6 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -13,6 +13,7 @@ + @@ -23,6 +24,7 @@ + diff --git a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj index ce1d9a53..7ef2c1b2 100644 --- a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj +++ b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj @@ -12,6 +12,9 @@ true true + + $(NoWarn);NU5104 ZodSharp.AspNetCore.OpenApi Purview.ZodSharp.AspNetCore.OpenApi Purview.ZodSharp.AspNetCore.OpenApi diff --git a/src/src/AspNetCore.OpenApi/MapperContext.cs b/src/src/AspNetCore.OpenApi/MapperContext.cs index a3b0c8c7..88b5c67f 100644 --- a/src/src/AspNetCore.OpenApi/MapperContext.cs +++ b/src/src/AspNetCore.OpenApi/MapperContext.cs @@ -12,6 +12,12 @@ sealed class MapperContext(ZodOpenApiOptions options, ILogger? logger, string lo { 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. diff --git a/src/src/AspNetCore.OpenApi/Sdk/README.md b/src/src/AspNetCore.OpenApi/Sdk/README.md new file mode 100644 index 00000000..521ee194 --- /dev/null +++ b/src/src/AspNetCore.OpenApi/Sdk/README.md @@ -0,0 +1,93 @@ +# 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 +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`. + +## 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/ZodJsonSchemaSource.cs b/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs index e77b7c3a..34910782 100644 --- a/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs +++ b/src/src/AspNetCore.OpenApi/ZodJsonSchemaSource.cs @@ -58,7 +58,10 @@ public bool TryGet(Type type, IServiceProvider services, out JsonSchemaDefinitio JsonSchemaDefinition definition; try { - definition = ToJsonSchemaConverter.ConvertUntyped(source, new ToJsonSchemaOptions { IncludeSchema = false }); + definition = ToJsonSchemaConverter.ConvertUntyped( + source, + new ToJsonSchemaOptions { IncludeSchema = false } + ); } catch (NotSupportedException) { @@ -75,7 +78,9 @@ public bool TryGet(Type type, IServiceProvider services, out JsonSchemaDefinitio object? CreateGeneratedValidator(Type type) { var generated = _generated.GetOrAdd(type.Assembly, ReadGeneratedValidators); - return generated.TryGetValue(type, out var attribute) ? Activator.CreateInstance(attribute.ValidatorType) : null; + 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 diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs index f1cb1478..a0d05935 100644 --- a/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiDocumentTransformer.cs @@ -29,9 +29,34 @@ CancellationToken cancellationToken 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) @@ -56,7 +81,11 @@ static bool ReferencesValidationProblem(OpenApiDocument document) { if ( mediaType.Schema is OpenApiSchemaReference { Reference.Id: { } id } - && string.Equals(id, ZodOpenApiOptions.ValidationProblemDetailsSchemaId, StringComparison.Ordinal) + && string.Equals( + id, + ZodOpenApiOptions.ValidationProblemDetailsSchemaId, + StringComparison.Ordinal + ) ) { return true; diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs index 37b29b87..c155ef9b 100644 --- a/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiOperationTransformer.cs @@ -1,5 +1,7 @@ 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; @@ -102,7 +104,8 @@ static IEnumerable GetStatusCodes(IServiceProvider services) // 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; + var registry = + services.GetService>()?.Value.Registry ?? ErrorTypeRegistry.Default; if (registry.All.Any(static errorType => errorType.HttpStatus == StatusCodes.Status422UnprocessableEntity)) yield return StatusCodes.Status422UnprocessableEntity; } @@ -127,10 +130,32 @@ bool IsValidated(ApiDescription description, IServiceProvider services) continue; type = Nullable.GetUnderlyingType(type) ?? type; - if (_source.IsKnown(type, services)) + 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/ZodOpenApiSchemaMapper.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs index a91cec5c..abe1d207 100644 --- a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaMapper.cs @@ -26,6 +26,7 @@ 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. @@ -102,7 +103,7 @@ static void ApplyType(OpenApiSchema target, JsonSchemaDefinition source, MapperC static void ApplyFormat(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) { - if (source.Format is { } format) + if (source.Format is { } format && !string.Equals(format, context.IgnoredFormat, StringComparison.Ordinal)) target.Format = Set("format", target.Format, format, context); } @@ -135,7 +136,8 @@ static void ApplyBounds(OpenApiSchema target, JsonSchemaDefinition source, Mappe 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 = SetNumber( + "exclusiveMinimum", target.ExclusiveMinimum, source.ExclusiveMinimum, context @@ -145,7 +147,8 @@ static void ApplyBounds(OpenApiSchema target, JsonSchemaDefinition source, Mappe 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 = SetNumber( + "exclusiveMaximum", target.ExclusiveMaximum, source.ExclusiveMaximum, context @@ -197,7 +200,8 @@ static void ApplyPatterns(OpenApiSchema target, JsonSchemaDefinition source, Map { if ( target.AllOf.Any(schema => - schema is OpenApiSchema { } current && string.Equals(current.Pattern, branch, StringComparison.Ordinal) + schema is OpenApiSchema { } current + && string.Equals(current.Pattern, branch, StringComparison.Ordinal) ) ) { @@ -208,17 +212,26 @@ static void ApplyPatterns(OpenApiSchema target, JsonSchemaDefinition source, Map } } - static void ApplyValues(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) + /// 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. + public static void ApplyEnum(OpenApiSchema target, JsonSchemaDefinition source, MapperContext context) { - if (source.Enum is { Count: > 0 } values) + if (UnwrapNullable(source, out _).Enum is not { Count: > 0 } values) + return; + + var nodes = ToNodes(values); + if (!SameNodes(target.Enum, nodes)) { - var nodes = ToNodes(values); - if (!SameNodes(target.Enum, nodes)) - { - context.Conflict("enum", Render(target.Enum), Render(nodes)); - 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 }) { diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs index a2be6bbd..8db426cc 100644 --- a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs @@ -1,4 +1,3 @@ -using DisplayAttribute = System.ComponentModel.DataAnnotations.DisplayAttribute; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.OpenApi; @@ -7,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.OpenApi; using ZodSharp.JsonSchema; +using DisplayAttribute = System.ComponentModel.DataAnnotations.DisplayAttribute; namespace ZodSharp.AspNetCore.OpenApi; @@ -32,8 +32,6 @@ public sealed class ZodOpenApiSchemaTransformer(ZodOpenApiOptions options) : IOp readonly ZodOpenApiOptions _options = options ?? throw new ArgumentNullException(nameof(options)); readonly ZodJsonSchemaSource _source = new(); - internal ZodJsonSchemaSource Source => _source; - /// public Task TransformAsync( OpenApiSchema schema, @@ -59,6 +57,10 @@ CancellationToken cancellationToken { 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; } @@ -79,6 +81,23 @@ MapperContext context 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)) + ZodOpenApiSchemaMapper.ApplyEnum(schema, definition, context); + + 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. @@ -92,7 +111,53 @@ schema.Description is null } } - void TransformType(OpenApiSchema schema, JsonTypeInfo typeInfo, JsonSchemaDefinition definition, MapperContext context) + /// + /// 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 _); @@ -112,24 +177,20 @@ void TransformType(OpenApiSchema schema, JsonTypeInfo typeInfo, JsonSchemaDefini // System.Text.Json polymorphism ([JsonPolymorphic]/[JsonDerivedType], or a closed hierarchy) already // dispatches on a discriminator, so exactly one case matches. - if (_options.DiscriminatedUnionsAsOneOf && schema.AnyOf is { Count: > 0 } && schema.Discriminator is not null) - { - schema.OneOf = schema.AnyOf; - schema.AnyOf = null; - } + if (schema.AnyOf is { Count: > 0 } && schema.Discriminator is not null) + MarkOneOf(schema); } void ApplyDiscriminatedUnion(OpenApiSchema schema, JsonSchemaDefinition definition) { - if (!_options.DiscriminatedUnionsAsOneOf) - return; - - schema.OneOf = schema.AnyOf; - schema.AnyOf = null; + MarkOneOf(schema); if ( - schema.Discriminator is null - && definition.GetVendorExtensions().TryGetValue(ZodOpenApiSchemaMapper.DiscriminatorExtensionName, out var name) + _options.DiscriminatedUnionsAsOneOf + && schema.Discriminator is null + && definition + .GetVendorExtensions() + .TryGetValue(ZodOpenApiSchemaMapper.DiscriminatorExtensionName, out var name) && name is string propertyName ) { @@ -138,25 +199,50 @@ schema.Discriminator is null } /// - /// Adds the members ZodSharp rejects when absent. 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. + /// 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 (definition.Required is not { Count: > 0 } required || schema.Properties is null) + if (schema.Properties is null) return; foreach (var property in typeInfo.Properties) { - if (!schema.Properties.ContainsKey(property.Name) || !ContainsMember(required, property.Name)) + 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); } @@ -177,9 +263,6 @@ static bool TryGetMember(JsonSchemaDefinition owner, string name, out JsonSchema ); } - static bool ContainsMember(List names, string name) => - names.Contains(name, StringComparer.Ordinal) - || names.Contains(JsonNamingPolicy.CamelCase.ConvertName(name), StringComparer.Ordinal); /// /// 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. @@ -217,8 +300,7 @@ out string location 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, Type type) => CreateContext(services, $"type '{type.Name}'"); MapperContext CreateContext(IServiceProvider services, string location) { diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index 94626e53..f5a8f272 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -330,6 +330,7 @@ static JsonSchemaDefinition ConvertDiscriminatedUnion(ZodDiscriminatedUnion sche return result; } + static JsonSchemaDefinition ConvertGeneric(object schema, ConversionContext ctx) { var schemaType = schema.GetType(); diff --git a/src/src/ZodSharp/Rules/CidrRule.cs b/src/src/ZodSharp/Rules/CidrRule.cs index 8763f48a..cd81a80b 100644 --- a/src/src/ZodSharp/Rules/CidrRule.cs +++ b/src/src/ZodSharp/Rules/CidrRule.cs @@ -30,7 +30,11 @@ public enum CidrRuleType /// z.string().cidr(). Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct CidrRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +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"; diff --git a/src/src/ZodSharp/Rules/DateStringRule.cs b/src/src/ZodSharp/Rules/DateStringRule.cs index c39a2159..1f83026a 100644 --- a/src/src/ZodSharp/Rules/DateStringRule.cs +++ b/src/src/ZodSharp/Rules/DateStringRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct DateStringRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +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"; diff --git a/src/src/ZodSharp/Rules/DatetimeStringRule.cs b/src/src/ZodSharp/Rules/DatetimeStringRule.cs index ec736686..3dfeb1a4 100644 --- a/src/src/ZodSharp/Rules/DatetimeStringRule.cs +++ b/src/src/ZodSharp/Rules/DatetimeStringRule.cs @@ -134,7 +134,8 @@ static bool IsTwoDigitNumber(ReadOnlySpan value, int index, int maxExclusi 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]$"; + 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 diff --git a/src/src/ZodSharp/Rules/DurationRule.cs b/src/src/ZodSharp/Rules/DurationRule.cs index 2372264f..d23d26ba 100644 --- a/src/src/ZodSharp/Rules/DurationRule.cs +++ b/src/src/ZodSharp/Rules/DurationRule.cs @@ -7,7 +7,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct DurationRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +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"; diff --git a/src/src/ZodSharp/Rules/IPAddressRule.cs b/src/src/ZodSharp/Rules/IPAddressRule.cs index 55dd515e..6a5d7e83 100644 --- a/src/src/ZodSharp/Rules/IPAddressRule.cs +++ b/src/src/ZodSharp/Rules/IPAddressRule.cs @@ -29,7 +29,11 @@ public enum IPAddressRuleType /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct IPAddressRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +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"; diff --git a/src/src/ZodSharp/Rules/JWTRule.cs b/src/src/ZodSharp/Rules/JWTRule.cs index d1e76ee6..8a7b35ec 100644 --- a/src/src/ZodSharp/Rules/JWTRule.cs +++ b/src/src/ZodSharp/Rules/JWTRule.cs @@ -5,7 +5,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct JWTRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +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"; diff --git a/src/src/ZodSharp/Rules/TimeStringRule.cs b/src/src/ZodSharp/Rules/TimeStringRule.cs index 4d8b0720..1e858aca 100644 --- a/src/src/ZodSharp/Rules/TimeStringRule.cs +++ b/src/src/ZodSharp/Rules/TimeStringRule.cs @@ -6,7 +6,11 @@ namespace ZodSharp.Rules; /// Uses struct to avoid allocations. /// [Core.ZodRule] -public readonly record struct TimeStringRule : Core.IValidationRule, Core.IStringValidationRule, Core.IZodRule, JsonSchema.IJsonSchemaRule +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"; diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs b/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs new file mode 100644 index 00000000..35863421 --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs @@ -0,0 +1,275 @@ +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", + """ + { + "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", """{ "name": "ab" }""", Accepted: true, "optional members absent"), + new("StringRulesRequest", """{ "name": "a" }""", Accepted: false, "minLength"), + new("StringRulesRequest", """{ "name": "abcdefghijk" }""", Accepted: false, "maxLength"), + new("StringRulesRequest", """{ }""", Accepted: false, "required member absent"), + new("StringRulesRequest", """{ "name": "ab", "email": "not-an-email" }""", Accepted: false, "format: email"), + new("StringRulesRequest", """{ "name": "ab", "legacyEmail": "nope" }""", Accepted: false, "[EmailAddress]"), + new("StringRulesRequest", """{ "name": "ab", "slug": "ABC" }""", Accepted: false, "pattern"), + new("StringRulesRequest", """{ "name": "ab", "prefixed": "xab" }""", Accepted: false, "startsWith"), + new("StringRulesRequest", """{ "name": "ab", "suffixed": "yzx" }""", Accepted: false, "endsWith"), + new("StringRulesRequest", """{ "name": "ab", "containing": "nope" }""", Accepted: false, "includes"), + new("StringRulesRequest", """{ "name": "ab", "bracketed": "abx" }""", Accepted: false, "allOf patterns"), + new( + "StringRulesRequest", + """{ "name": "ab", "identifier": "6f1c2d3e-4a5b-1c6d-8e7f-0123456789ab" }""", + Accepted: false, + "uuid version" + ), + new("StringRulesRequest", """{ "name": "ab", "addressV4": "::1" }""", Accepted: false, "format: ipv4"), + new("StringRulesRequest", """{ "name": "ab", "addressV6": "10.0.0.1" }""", Accepted: false, "format: ipv6"), + new( + "StringRulesRequest", + """{ "name": "ab", "timestamp": "2024-01-01T10:00:00+01:00" }""", + Accepted: false, + "UTC date-time" + ), + new("StringRulesRequest", """{ "name": "ab", "day": "2024-13-01" }""", Accepted: false, "format: date"), + new("StringRulesRequest", """{ "name": "ab", "timeOfDay": "25:00" }""", Accepted: false, "time pattern"), + new("StringRulesRequest", """{ "name": "ab", "colour": "blue" }""", Accepted: false, "enum"), + new("StringRulesRequest", """{ "name": "ab", "username": "admin" }""", Accepted: false, "not enum"), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: true, + "every constraint satisfied" + ), + new( + "NumberRulesRequest", + """{ "count": 0, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "minimum" + ), + new( + "NumberRulesRequest", + """{ "count": 11, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "maximum" + ), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 0, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "exclusiveMinimum" + ), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 100, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "exclusiveMaximum" + ), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 50.5, "bounded": 7, "step": 10, "whole": 3, "safe": 7 }""", + Accepted: false, + "maximum (LessThanOrEqual)" + ), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 7, "whole": 3, "safe": 7 }""", + Accepted: false, + "multipleOf" + ), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3.5, "safe": 7 }""", + Accepted: false, + "integer" + ), + new( + "NumberRulesRequest", + """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 30000000000 }""", + Accepted: false, + "safe integer" + ), + new( + "ShapesRequest", + """ + { + "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", + """{ "tags": [], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "minItems" + ), + new( + "ShapesRequest", + """{ "tags": ["a","b","c","d"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "maxItems" + ), + new( + "ShapesRequest", + """{ "tags": ["a"], "scores": { "a": 1, "b": 2, "c": 3 }, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "maxProperties" + ), + new( + "ShapesRequest", + """{ "tags": ["a"], "scores": {}, "reference": null, "title": "t", "home": { "street": "Main" }, "others": [] }""", + Accepted: false, + "null where ZodSharp requires a value" + ), + new( + "ShapesRequest", + """{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "" }, "others": [] }""", + Accepted: false, + "nested minLength" + ), + new( + "ShapesRequest", + """{ "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", """{ "name": "ab", "token": "x" }""", Accepted: false, "jwt"); + Payload cidr = new( + "StringRulesRequest", + """{ "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..a7ac249d --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs @@ -0,0 +1,58 @@ +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 index ec5db8ea..300fd8ee 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs @@ -125,6 +125,10 @@ public partial class ShapesRequest 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; } @@ -187,10 +191,11 @@ public class PlainRequest public string? Comment { get; set; } } +[ZodSchema] [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] [JsonDerivedType(typeof(CircleShape), "circle")] [JsonDerivedType(typeof(SquareShape), "square")] -public abstract class ShapeBase +public abstract partial class ShapeBase { public string? Label { get; set; } } @@ -204,3 +209,61 @@ 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 : ZodSharp.Core.ZodType, ZodSharp.JsonSchema.IJsonSchemaProvider +{ + public ZodSharp.JsonSchema.JsonSchemaDefinition BuildJsonSchema() => + new() + { + Type = "number", + Minimum = 0, + Maximum = 100, + }; + + protected override ZodSharp.Core.ValidationResult ParseInternal(Percentage value) => + value.Value is >= 0 and <= 100 + ? ZodSharp.Core.ValidationResult.Success(value) + : ZodSharp.Core.ValidationResult.Failure( + new ZodSharp.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; } +} 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..1cadacee --- /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 union PetUnion(CatCase, DogCase); \ No newline at end of file diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs b/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs index bf4c7ede..c7e3327b 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/OpenApiTestHost.cs @@ -26,8 +26,9 @@ public static async Task GetDocumentAsync( var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = environment }); builder.WebHost.UseTestServer(); - builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(OpenApiTestHost).Assembly)); + // 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; @@ -55,8 +56,8 @@ public static async Task GetDocumentAsync( 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 JsonObject Property(this JsonObject schema, string name) => schema["properties"]![name]!.AsObject(); - public static IEnumerable Versions() => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_2]; + 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..b09b0e2f --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiOperationTransformerTests.cs @@ -0,0 +1,206 @@ +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 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.Mapping.cs b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs new file mode 100644 index 00000000..c7f8e3df --- /dev/null +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs @@ -0,0 +1,144 @@ +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, + """[{"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, """{"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/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs new file mode 100644 index 00000000..f6dfd8b4 --- /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 + var 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); + } +} From f404723490cac0209bdaf472b1f415d616bd90d0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:16:41 +0000 Subject: [PATCH 016/107] build: remove duplicate TestHost package version after merge --- Directory.Packages.props | 1 - 1 file changed, 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3f6b0054..68ef9e92 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -51,7 +51,6 @@ - Date: Tue, 27 Oct 2026 14:20:21 +0000 Subject: [PATCH 017/107] feat(generator): per-member validation, typed paths, runtime messages, async graph, member lookup Change 1: Validate calls one ValidateMember_X method per member. Change 2: ValidateMember/ValidateMemberAsync/IsValidatableMember (no nested recursion). Change 3: TryResolveMember (reflection-free). Change 4: Paths constants, typed index/key segments, WithPathPrefix, JsonNames + ToJsonNamedPath, MessageFormat + parameters formatted at runtime with ZodMessageFormat. Change 5: per-assembly Register/AddZodSharp{Assembly}Schemas, adapters implement IZodUntypedValidator, depth guard. G11 async graph, G23 IValidatableObject/IAsyncValidatableObject/AsyncValidationAttribute, Change 9 service-aware custom method + [ZodAsyncMember], Change 7 union/closed dispatch, G21 options failures. --- .../CodeWriterExtensions.cs | 38 - .../Helpers/CodeGenHelpers.cs | 9 +- .../Helpers/CustomRuleResolver.cs | 2 +- .../Helpers/DiagnosticLibrary.cs | 2 +- .../Helpers/ExternalSchemaResolver.cs | 40 +- ...SourceGenLibrary.CustomValidationMethod.cs | 66 +- .../Helpers/SourceGenLibrary.cs | 103 +- .../Helpers/ValidationInputsResolver.cs | 447 ++++++ .../Models/CustomValidationMethodData.cs | 13 +- .../Models/SchemaGenerationModel.cs | 75 +- .../ZodSchemaGenerator.BuildSchema.cs | 1102 +++++---------- ...ZodSchemaGenerator.CollectionValidators.cs | 324 ++++- .../ZodSchemaGenerator.CustomRules.cs | 147 +- .../ZodSchemaGenerator.EnumValidators.cs | 64 +- ...SchemaGenerator.LengthValidationHelpers.cs | 184 +-- .../ZodSchemaGenerator.NumericValidators.cs | 81 +- .../ZodSchemaGenerator.OutputContext.cs | 89 +- .../ZodSchemaGenerator.Registration.cs | 142 ++ .../ZodSchemaGenerator.Resolution.cs | 375 +++++ .../ZodSchemaGenerator.StringValidators.cs | 504 +++---- .../ZodSchemaGenerator.Validation.cs | 1238 +++++++++++++++++ .../ZodSchemaGenerator.ValueValidators.cs | 74 +- .../SourceGenerators/ZodSchemaGenerator.cs | 27 +- .../ZodSharp/Core/ZodGeneratedValidation.cs | 59 +- ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 18 +- ...odSchemaGeneratorTests.ComparableRanges.cs | 10 +- .../ZodSchemaGeneratorTests.CustomRules.cs | 2 +- ...odSchemaGeneratorTests.CustomValidation.cs | 4 +- ...ZodSchemaGeneratorTests.DataAnnotations.cs | 9 +- .../ZodSchemaGeneratorTests.Dictionaries.cs | 4 +- .../ZodSchemaGeneratorTests.EnumValidation.cs | 2 +- ...ZodSchemaGeneratorTests.ExternalSchemas.cs | 14 +- .../ZodSchemaGeneratorTests.GenericRules.cs | 4 +- ...odSchemaGeneratorTests.OptionsValidator.cs | 6 +- ...dSchemaGeneratorTests.PositionalRecords.cs | 4 +- ...odSchemaGeneratorTests.ScalarAdaptation.cs | 4 +- .../ZodSchemaGeneratorTests.SyncValidation.cs | 2 +- .../ZodSchemaGeneratorTests.TypeRules.cs | 4 +- ...dSchemaGeneratorTests.ValidationMethods.cs | 2 +- ...odSchemaGeneratorTests.ValidatorAdapter.cs | 6 +- 40 files changed, 3861 insertions(+), 1439 deletions(-) delete mode 100644 src/src/SourceGenerators/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs create mode 100644 src/src/SourceGenerators/Helpers/ValidationInputsResolver.cs create mode 100644 src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs create mode 100644 src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs create mode 100644 src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs 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/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..742d59cd 100644 --- a/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs +++ b/src/src/SourceGenerators/Helpers/CustomRuleResolver.cs @@ -1487,7 +1487,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..78e9fa16 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 diff --git a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs index d1669df7..d11f125b 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) + && !ZodSharp.SourceGenerators.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/SourceGenLibrary.CustomValidationMethod.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs index b68daf7f..a0b2b199 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 = new(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.CustomValidationInvalidParameterCount, + 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..75a1b704 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); @@ -610,6 +662,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 +716,50 @@ rangeAttribute with enumSerializedAsString, enumJsonNames, dateTimeOffsetSerialization - ), + ) + { + DisplayNameResourceExpression = ValidationInputsResolver.GetDisplayNameResourceExpression( + validationAttributes + ), + NestedSchemaIsModern = nestedIsModern, + 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); + + if (targetType is not INamedTypeSymbol namedType || externalSchemas.IsSchemaOwnedByThisCompilation(namedType)) + return (true, false); + + return 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 @@ -1389,10 +1485,7 @@ attribute.AttributeClass is 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/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index fb7ce7fa..9d8f532c 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 @@ -130,7 +160,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 +264,35 @@ readonly record struct ZodPropertyDescriptor( bool EnumSerializedAsString, EquatableArray EnumJsonNames, DateTimeOffsetSerialization? DateTimeOffsetSerialization = null -); +) +{ + /// + /// 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 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/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 6cd03cb1..b7b77176 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,14 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { + const string TypeMemberFieldName = "TypeMember"; + + const string ValidationContextType = "global::System.ComponentModel.DataAnnotations.ValidationContext"; + + const string ServiceProviderType = "global::System.IServiceProvider"; + + const string CancellationTokenType = "global::System.Threading.CancellationToken"; + static void BuildSchema( SchemaGenerationOutputContext outputContext, SourceProductionContext context, @@ -16,39 +23,43 @@ bool isPrimary context.CancellationToken.ThrowIfCancellationRequested(); var writer = outputContext.Context.CreateCodeWriter(); - GenerateSchemaClass(outputContext, writer, isPrimary, context.CancellationToken); + GenerateSchemaClass(outputContext, writer, isPrimary, context); context.AddSource($"{outputContext.ZodSchema.SchemaType.Name}.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); } + /// + /// 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; + + 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 @@ -108,7 +119,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 +156,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 +173,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 +197,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 +251,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 resultTaskType = $"global::System.Threading.Tasks.ValueTask<{resultType}>"; + var resultTaskReference = TypeLibraryGenerator + .ValueTask.MakeGeneric(TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)) + .AsTypeReference(); + var cancellationTokenReference = TypeLibraryGenerator.CancellationToken.AsTypeReference(); + 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 +303,192 @@ 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})" + ) ); - GenerateValidateAsyncMethod(body, schema, cancellationToken); + body.XmlSummary("Gets whether some rules only run asynchronously.") + .Line($"public bool RequiresAsyncValidation => {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.") + .Line( + $"public bool IsValidatableMember(string memberName) => {schemaName}.IsValidatableMember(memberName);" + ) + .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.") + .Line( + $"public {resultType} ValidateMember({targetType} value, string memberName) => {schemaName}.ValidateMember(value, memberName);" + ) + .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.") + .Line( + $"public {resultTaskType} ValidateMemberAsync({targetType} value, string memberName, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default) => {schemaName}.ValidateMemberAsync(value, memberName, services, cancellationToken);" + ) + .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.") + .Line( + $"public bool TryResolveMember({targetType} value, {ZodPathType} path, out object? container, out string memberName) => {schemaName}.TryResolveMember(value, path, out container, out memberName);" + ) + .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.") + .Line( + $"public {ZodPathType} ToJsonNamedPath({ZodPathType} path, global::System.Func? namingPolicy = null) => {schemaName}.ToJsonNamedPath(path, namingPolicy);" + ) + .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(); + 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);" + ); + body.NewLine(); + + if (usesAdapterInstance) + { + var custom = schema.CustomValidationMethod.Value; + var servicesParameter = custom.RequiresServices + ? $", {ServiceProviderType} services" + : string.Empty; + var servicesArgument = custom.RequiresServices ? ", services" : string.Empty; + body.XmlSummary("Invokes the custom validation method for the generated schema."); + body.Line( + $"internal {resultTaskType} {CustomValidationBridgeName}({targetType} value{servicesParameter}, {CancellationTokenType} cancellationToken) => {custom.MethodName}(value{servicesArgument}, cancellationToken);" + ); + body.NewLine(); + } + + 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.Line( + $"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 +500,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 +527,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 +543,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.") @@ -355,195 +582,43 @@ CancellationToken cancellationToken ); 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)" - ); - - method.NewLine(); - method.Return( - $"{TypeLibrary.ZodSharp.Core.ValidationResult.MakeGeneric(schema.TargetType)}.Merge(syncResult, customResult)" + "schemaResult", + $"await {schema.SchemaType.Name}.ValidateAsync(options, null, cancellationToken).ConfigureAwait(false)" ); - } - ); - } - - 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))" - ) + body.IfBlock("schemaResult.IsSuccess", ifBody => ifBody.Return($"{validateOptionsResult}.Success")); + body.NewLine(); + body.Return( + $"{validateOptionsResult}.Fail(schemaResult.Errors.Select(static error => {GeneratedValidation}.FormatOptionsFailure(error)))" ); } - - 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 +659,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 @@ -686,429 +763,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; - - // RequiredAttribute semantics: 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 localErrorMessage = rejectsBlankStrings - ? "Field '{0}' is required and cannot be null, empty or whitespace" - : "Required field '{0}' is null"; - var errorMessage = string.Format( - CultureInfo.InvariantCulture, - required.ValidationAttribute.ErrorMessage ?? localErrorMessage, - displayName - ); - var comparison = rejectsBlankStrings - ? $"string.IsNullOrWhiteSpace(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, @@ -1127,18 +781,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})", } ); } @@ -1168,133 +827,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..8559d1d0 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,36 +5,38 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { - static void GenerateCollectionValidations(CodeWriter writer, ZodPropertyDescriptor property) + 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")) @@ -48,30 +49,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 +87,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 +108,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 +135,212 @@ 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"); + + 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 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..843e57c3 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -40,9 +40,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,8 +88,9 @@ CustomRuleDescriptor requiredRule ruleVariable, identityVariable, "default!", - CodeGenHelpers.GetPathFieldName(propertyName), - property.DisplayName, + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property), invalidCondition: $"value.{propertyName} == null || {scalarExpression}.{scalarPropertyName} == null" ); return; @@ -104,8 +106,9 @@ CustomRuleDescriptor requiredRule ruleVariable, identityVariable, "default!", - CodeGenHelpers.GetPathFieldName(propertyName), - property.DisplayName, + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property), invalidCondition: $"value.{propertyName} == null" ); return; @@ -117,8 +120,9 @@ CustomRuleDescriptor requiredRule ruleVariable, identityVariable, $"value.{propertyName}", - CodeGenHelpers.GetPathFieldName(propertyName), - property.DisplayName + PathOf(property), + MemberOf(property), + GetFallbackOrigin(property) ); } @@ -137,7 +141,7 @@ static void GenerateTypeRuleValidations(CodeWriter writer, ZodSchemaDescriptor s schema.TargetType.Name, "value", "EmptyPath", - schema.TargetType.Name, + TypeMemberFieldName, declareValueLocal: false ); } @@ -148,7 +152,7 @@ static void GenerateRuleValidations( string localPrefix, string valueExpression, string pathExpression, - string displayName, + string memberExpression, bool declareValueLocal ) { @@ -161,8 +165,9 @@ bool declareValueLocal i, valueExpression, pathExpression, - displayName, - declareValueLocal + memberExpression, + declareValueLocal, + fallbackOrigin: null ); } } @@ -174,8 +179,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 +194,8 @@ bool declareValueLocal valueExpression, scalarPropertyName, pathExpression, - displayName + memberExpression, + fallbackOrigin ); return; } @@ -210,7 +217,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 +243,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 +253,8 @@ static void EmitGuardedScalarRule( string scalarExpression, string scalarPropertyName, string pathExpression, - string displayName + string memberExpression, + string? fallbackOrigin ) { var ruleVariable = CodeGenHelpers.GetLocalIdentifier(localPrefix, $"CustomRule{index}"); @@ -255,7 +273,8 @@ string displayName requiredIdentity, "default!", pathExpression, - displayName, + memberExpression, + fallbackOrigin, invalidCondition: $"{underlyingExpression} == null" ); writer.NewLine(); @@ -267,7 +286,16 @@ string displayName body => { var identity = EmitRuleInstance(body, rule, identityVariable, ruleVariable, arguments); - WriteRuleFailure(body, rule, ruleVariable, identity, "scalarValue", pathExpression, displayName); + WriteRuleFailure( + body, + rule, + ruleVariable, + identity, + "scalarValue", + pathExpression, + memberExpression, + fallbackOrigin + ); } ); @@ -321,7 +349,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 +362,8 @@ static void WriteRuleFailure( string identityVariable, string valueVariable, string pathExpression, - string displayName, + string memberExpression, + string? fallbackOrigin, string? invalidCondition = null ) { @@ -345,35 +375,56 @@ 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 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}"; + 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}, (object){identityVariable} as {zodRuleInterface}{originArgument})"; - var message = !string.IsNullOrEmpty(rule.Message.ErrorMessage) - ? BuildErrorMessageExpression(rule.Message, "Field '{0}' is invalid.", displayName.StringLiteral()) - : $"{ruleVariable}.GetErrorMessage({valueVariable})"; + 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 => - { - 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})" - ); - } + 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.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..c52a2e88 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,79 @@ 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"; + + // The bounds are reported as typed values (Lower/Upper); the message keeps the attribute's literal + // rendering, so a converted range ("1.5", a date string) reads exactly as declared. var minimumDisplay = Convert.ToString(range.Minimum, CultureInfo.InvariantCulture) ?? string.Empty; var maximumDisplay = Convert.ToString(range.Maximum, CultureInfo.InvariantCulture) ?? string.Empty; - var messageExpression = BuildMessageExpression( + 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 = ValuesExpression( + ("minimum", minimumDisplay.StringLiteral()), + ("maximum", maximumDisplay.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..042fbfe5 --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs @@ -0,0 +1,142 @@ +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.Line("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.Line( + $"public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddZodSharp{identifier}Schemas(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) => services.AddZodSchemaRegistration(static factory => global::{RegistrationNamespace}.{className}.Register(factory));" + ); + } + } + } + + context.AddSource("ZodSharpSchemaRegistration.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..0b35225d --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs @@ -0,0 +1,375 @@ +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.Line("public static string? Get(string memberName) => null;"); + } + 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(); + 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.Line( + $"public static bool TryResolveMember({targetType} value, {ZodPathType} path, out object? container, out string memberName) => TryResolveMember(value, path, 0, out container, out memberName);" + ); + 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( + dispatchCase.SchemaType.StaticMember( + $"TryResolveMember({caseName}, path, offset + 1, out container, out memberName)" + ) + ); + } + } + + 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"; + writer.IfBlock( + $"!{GeneratedValidation}.{lookup}(value.{property.Name}, 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")); + } + + writer.Return( + elementSchema.StaticMember( + $"TryResolveMember({elementExpression}, path, offset + 2, out container, out memberName)" + ) + ); + 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")); + } + + writer.Return( + schemaType.StaticMember($"TryResolveMember({valueName}, path, offset + 1, out container, out memberName)") + ); + } +} diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.StringValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.StringValidators.cs index fb2582eb..26780264 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,84 @@ 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..d33a92d3 --- /dev/null +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -0,0 +1,1238 @@ +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 } + ) + ) + { + 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)}."); + writer.Line( + $"public static {(IsInheritedStaticMemberName(name) ? "new " : string.Empty)}readonly {ZodPathType} {GetPathMemberName(name)} = {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; + var memberType = "global::ZodSharp.Core.ZodGeneratedMember"; + var declaringType = GetDeclaringTypeName(schema); + + writer.Line( + $"static readonly {memberType} {TypeMemberFieldName} = 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.Line( + $"static readonly {memberType} {MemberOf(property)} = new({declaringType.StringLiteral()}, {property.Name.StringLiteral()}, {property.DisplayName.StringLiteral()}, {PathOf(property)}{resolver});" + ); + + for (var i = 0; i < property.AsyncValidationAttributes.Count; i++) + { + writer.Line( + $"static readonly global::System.ComponentModel.DataAnnotations.AsyncValidationAttribute {CodeGenHelpers.GetAsyncAttributeFieldName(property.Name, i)} = {property.AsyncValidationAttributes[i]};" + ); + } + } + + if ( + schema.CustomValidationMethod.Value.InvocationKind + == CustomValidationInvocationKind.DefinedOnSchemaValidator + ) + writer.Line($"static {schema.SchemaType.Name}Validator? {CustomValidatorFieldName};"); + + 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; + + 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; + } + + 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), + 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 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.Line($"public const bool RequiresAsyncValidation = {(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."); + writer.Line($"public static {resultType} Validate({targetType} value) => ValidateCore(value, 0);"); + 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.Line( + $"public static {resultTaskType} ValidateAsync({targetType} value, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default) => ValidateCoreAsync(value, services, 0, cancellationToken);" + ); + 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.Line( + $"public static {resultTaskType} ValidateCoreAsync({targetType} value, {ServiceProviderType}? services, int depth, {CancellationTokenType} cancellationToken) => new(ValidateCore(value, depth));" + ); + writer.NewLine(); + } + else + { + if (adapterParameter.Length > 0) + { + writer.Line( + $"public static {resultTaskType} ValidateCoreAsync({targetType} value, {ServiceProviderType}? services, int depth, {CancellationTokenType} cancellationToken) => ValidateCoreAsync(value, services, depth, cancellationToken, null);" + ); + 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", + $"new {ValidationContextType}(value, {schema.TargetType.Name.StringLiteral()}, services, null)" + ); + using ( + writer.OpenBlockScope( + $"await foreach (var validationResult in ((global::System.ComponentModel.DataAnnotations.IAsyncValidatableObject)value).ValidateAsync(validationContext, cancellationToken).ConfigureAwait(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", + $"new {ValidationContextType}(value, {schema.TargetType.Name.StringLiteral()}, services, null)" + ); + 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 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.Line( + $"public static {resultTaskType} ValidateMemberAsync({targetType} value, string memberName, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default) => new(ValidateMember(value, memberName));" + ); + 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.Line($"{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 && 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})" + : $"value.{propertyName} == null"; + + // 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) + { + // 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($"value.{propertyName} == null")) + { + 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($"value.{propertyName} != 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) + 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")) + 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", + $"new {ValidationContextType}(value, {MemberOf(property)}.DisplayName, services, null) {{ MemberName = {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)); + body.Assignment("errors", $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)})"); + } + + 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; + + writer.Assignment("var", collectionName, $"value.{property.Name}"); + using (writer.IfBlockScope($"{collectionName} is not null")) + { + 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..8ce73f8f 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.ValueValidators.cs @@ -58,23 +58,15 @@ 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) - ) + ifBody => WriteAddError(ifBody, ValueSetError(property, message, displayValues)) ); writer.NewLine(); @@ -101,28 +93,35 @@ 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) - ) + 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 +172,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..704afc43 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; @@ -37,6 +38,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!model.Context.Capabilities.HasRequiredAttribute) return; + List processed = []; + foreach (var schema in model.ZodSchemas) + { + if (schema.ShouldProcess) + processed.Add(schema.Value); + } + + // 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) @@ -44,7 +56,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) try { - SchemaGenerationOutputContext outputContext = new(model.Context, schema.Value); + 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 @@ -65,6 +77,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.ReportDiagnostic(diagnostic.ToDiagnostic()); } } + + try + { + GenerateAssemblyRegistration(spc, model, processed); + } + catch (Exception ex) + { + spc.ReportDiagnostic( + ReportableDiagnostic + .Create(DiagnosticLibrary.UnhandledException, true, "assembly registration", ex.Message) + .ToDiagnostic() + ); + } } ); diff --git a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs index d1a9b4c7..768f0d7c 100644 --- a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs +++ b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs @@ -152,30 +152,26 @@ public static ValidationError Error( MessageFormat = named, }; - return error with { Message = ZodMessageFormat.Format(named, in error, System.Globalization.CultureInfo.InvariantCulture) }; + return error with + { + Message = ZodMessageFormat.Format(named, in error, System.Globalization.CultureInfo.InvariantCulture), + }; } /// - /// Creates the error for a failed rule, copying the rule's structured identity - /// () and adding the member's parameters. + /// 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 code. - /// The rule's own message, used when no is supplied. - /// The error path. + /// The error. /// The member that failed. - /// The rule instance; its identity is copied when implemented. - /// The origin used when the rule reports none. - /// The category used when the rule reports none. - /// A message template that replaces the rule's message (an attribute ErrorMessage). + /// 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 RuleError( - string code, - string? ruleMessage, - ZodPath path, + public static ValidationError ForMember( + in ValidationError error, ZodGeneratedMember member, - object? rule, - string? fallbackOrigin = null, string? fallbackCategory = null, string? messageFormat = null, string[]? ordinalNames = null @@ -183,19 +179,20 @@ public static ValidationError RuleError( { ArgumentNullException.ThrowIfNull(member); - var zodRule = rule as IZodRule; - var error = ZodRuleErrors.Build(code, ruleMessage ?? string.Empty, path, zodRule, fallbackOrigin); - error = error.WithParameters(CreateParameters(member, error.Parameters, null)) with + var result = error.WithParameters(CreateParameters(member, error.Parameters, null)) with { Category = error.Category ?? fallbackCategory, }; if (messageFormat is null) - return error; + return result; var named = ToNamedFormat(messageFormat, ordinalNames); - error = error with { MessageFormat = named }; - return error with { Message = ZodMessageFormat.Format(named, in error, System.Globalization.CultureInfo.InvariantCulture) }; + result = result with { MessageFormat = named }; + return result with + { + Message = ZodMessageFormat.Format(named, in result, System.Globalization.CultureInfo.InvariantCulture), + }; } /// @@ -428,6 +425,24 @@ string member 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). diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 5c53c575..6e8f438e 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 == 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,9 @@ await Assert .ContainsGeneratedCode( "((global::ZodSharp.Core.IZodRule)externalIdCustomRuleInner0).Code ?? \"validation_failed\"" ); - await Assert.That(generated).ContainsGeneratedCode("EmptyPath, origin:"); + await Assert + .That(generated) + .ContainsGeneratedCode("EmptyPath, (object)externalIdCustomRuleInner0 as global::ZodSharp.Core.IZodRule)"); } [Test] @@ -644,8 +646,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 == null"); + await Assert.That(generated).ContainsGeneratedCode("ZodPath.Member(\"Value\")"); await Assert.That(generated).ContainsGeneratedCode("AnyOf = new()"); await Assert.That(generated).ContainsGeneratedCode("Type = \"null\""); } 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 4b3313b3..9c4ef60a 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.DataAnnotations.cs @@ -98,9 +98,7 @@ CancellationToken cancellationToken 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.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.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..455eec80 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,7 +286,9 @@ 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(); 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.OptionsValidator.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OptionsValidator.cs index c6267665..00fd09fc 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] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs index 24906a86..04bc4085 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.PositionalRecords.cs @@ -206,9 +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("Field 'Account name' is required and cannot be null, empty or whitespace"); + 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..57f774a2 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -198,7 +198,9 @@ 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, (object)tenantIdCustomRuleInner0 as global::ZodSharp.Core.IZodRule)"); // The wrapped rule owns the identity, not the adapter. await Assert .That(generated) 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])!; From e1e12c113b33352b5766e997f201e4b986df94fe Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:30:26 +0000 Subject: [PATCH 018/107] feat(aspnetcore): full-fidelity ProblemDetails from the .NET 11 validation pipeline On net11.0 AddZodSharpProblemDetails() registers a scoped ZodRequestValidationReport as the bridge's IZodValidationReportSink and post-configures ProblemDetailsOptions.CustomizeProblemDetails so the ZodSharp callback runs before any application callback. Pipeline responses are rewritten with ZodProblemDetailsWriter.Apply (DataAnnotations entries merged, ErrorType/selector/override status mirrored to the response), and DataAnnotations-only failures gain only the issues projection. WithZodSharpValidation() skips parameters the bridge already validates on endpoints the pipeline covers, logged once at Debug. The AspNetCore package references Extensions.Validation for net11.0 only. The contract test now covers Mode A alongside the filter, exception and manual paths. --- .csharpierignore | 1 + CHANGELOG.md | 12 + docs/wiki/AspNetCore-Integration.md | 61 ++ skills/purview-zodsharp-aspnetcore/SKILL.md | 45 +- src/src/AspNetCore/AspNetCore.csproj | 9 + .../Builder/RouteHandlerBuilderExtensions.cs | 4 +- ...harpEndpointConventionBuilderExtensions.cs | 10 +- .../ServiceCollectionExtensions.cs | 32 ++ src/src/AspNetCore/Sdk/README.md | 26 + .../ZodEndpointValidationFilterFactory.cs | 41 +- src/src/AspNetCore/ZodProblemDetailsWriter.cs | 38 ++ .../AspNetCore/ZodRequestValidationReport.cs | 40 ++ .../AspNetCore/ZodValidationPipelineGuard.cs | 93 ++++ .../ZodValidationProblemDetailsCustomizer.cs | 60 ++ .../Fixtures/OrderOrReference.cs | 9 + .../Fixtures/PipelineFixtures.cs | 54 ++ .../Fixtures/ValidationMessages.fr.resx | 21 + .../Fixtures/ValidationMessages.resx | 18 + .../ProblemDetailsContractTests.cs | 90 ++- .../ZodValidationPipelineTests.cs | 527 ++++++++++++++++++ 20 files changed, 1157 insertions(+), 34 deletions(-) create mode 100644 src/src/AspNetCore/ZodRequestValidationReport.cs create mode 100644 src/src/AspNetCore/ZodValidationPipelineGuard.cs create mode 100644 src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/OrderOrReference.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/PipelineFixtures.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.fr.resx create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/ValidationMessages.resx create mode 100644 src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs diff --git a/.csharpierignore b/.csharpierignore index c88c205f..c688e96a 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -3,3 +3,4 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cfae82..ab197b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ Released versions correspond to `v` GitHub releases. `ValidationOptions.ValidateAsync` validates outside HTTP and Blazor. References the `Microsoft.Extensions.Validation` package only - never the ASP.NET Core shared framework - so it works in WebAssembly clients. +- **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 diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 563d4cd4..b97a4597 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -78,6 +78,67 @@ the same `HttpValidationProblemDetails` payload produced by `ToHttpValidationPro passes through to the handler. `ToValidationProblem()` is the handler-side equivalent, returning a status-400 `Results.ValidationProblem` `IResult`. +## .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(); +``` + +How it works: + +- `AddZodSharpProblemDetails()` registers a scoped `ZodRequestValidationReport`, which is the bridge's + `IZodValidationReportSink`. The bridge appends every full-fidelity ZodSharp error of the request to it. +- 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()` 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. +- **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. + +| | `WithZodSharpValidation()` filter | Built-in pipeline | +|---|---|---| +| Frameworks | net8.0 to 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 A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a value object's generated diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index 061fc62d..379c1872 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -3,7 +3,8 @@ 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 and route-group validation with - WithZodSharpValidation, ZodTypedResults, the shared ZodProblemDetailsWriter, registering + 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), and mapping error codes to HTTP statuses with ErrorType. license: MIT @@ -104,6 +105,48 @@ endpoint filter **factory** picks the parameters once, when the endpoint is buil 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) + +```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(); + +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 a scoped `ZodRequestValidationReport` (the +bridge's `IZodValidationReportSink`) 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. + +| | Filter (`WithZodSharpValidation`) | Pipeline (`AddZodSharpValidation`) | +| --- | --- | --- | +| Frameworks | net8.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 | + ## Manual results and exceptions ```csharp diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 640de6cf..6d662128 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -22,4 +22,13 @@ + + + + + diff --git a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs index 0eaed63f..02c856ed 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/RouteHandlerBuilderExtensions.cs @@ -27,9 +27,7 @@ public static RouteHandlerBuilder WithZodSharpValidation(this RouteHandlerBui ArgumentNullException.ThrowIfNull(builder); ZodEndpointValidationOptions options = new(); - builder.AddEndpointFilterFactory( - ZodEndpointValidationFilterFactory.Create(options, typeof(T), ResolveTyped) - ); + ZodEndpointValidationFilterFactory.AddTo(builder, options, typeof(T), ResolveTyped); ZodSharpEndpointConventionBuilderExtensions.AddValidationMetadata(builder, options); return builder; } diff --git a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs index 839a727b..c1ea4fa4 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/AspNetCore/Builder/ZodSharpEndpointConventionBuilderExtensions.cs @@ -32,6 +32,12 @@ public static class ZodSharpEndpointConventionBuilderExtensions /// 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. @@ -48,9 +54,7 @@ public static TBuilder WithZodSharpValidation( ZodEndpointValidationOptions options = new(); configure?.Invoke(options); - builder.AddEndpointFilterFactory( - ZodEndpointValidationFilterFactory.Create(options, requiredType: null, requiredTypeResolver: null) - ); + ZodEndpointValidationFilterFactory.AddTo(builder, options, requiredType: null, requiredTypeResolver: null); AddValidationMetadata(builder, options); return builder; } diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index 29bfbd96..30a43588 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -134,6 +134,14 @@ public IServiceCollection AddZodSharpLoadedAssemblies() /// 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 scoped ZodRequestValidationReport as the bridge's report sink 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, @@ -147,11 +155,35 @@ public IServiceCollection AddZodSharpProblemDetails( AddProblemDetailsWriter(services); services.AddExceptionHandler(); +#if NET11_0_OR_GREATER + AddValidationPipelineEnrichment(services); +#endif return services; } } +#if NET11_0_OR_GREATER + /// + /// Enriches the built-in Microsoft.Extensions.Validation pipeline's ProblemDetails with the ZodSharp contract: a + /// scoped collects the bridge's full errors for the request, and a + /// composed callback rewrites the response from it. + /// + static void AddValidationPipelineEnrichment(IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(static sp => + sp.GetRequiredService() + ); + services.TryAddEnumerable( + ServiceDescriptor.Singleton< + IPostConfigureOptions, + ZodValidationProblemDetailsCustomizer + >() + ); + } +#endif + static void AddProblemDetailsWriter(IServiceCollection services) { services.AddOptions(); diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 4f286672..7b2d6592 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -40,6 +40,32 @@ they produce the same body: `errors` keyed by dotted paths (`Items[0].Quantity`) 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). + +### .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(); +``` + +`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. + ## Exception handling Thrown `ZodException`s (e.g. from a value object's strict deserialization) are converted automatically by diff --git a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs index 0dfc7968..bb0723d7 100644 --- a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs +++ b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs @@ -5,6 +5,7 @@ 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; @@ -21,18 +22,30 @@ static class ZodEndpointValidationFilterFactory { internal const string InvalidJsonCode = "invalid_json"; - public static Func Create( + /// + /// 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 - ) => (context, next) => Build(context, next, options, requiredType, 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 + Func? requiredTypeResolver, + IList endpointMetadata ) { var services = context.ApplicationServices; @@ -40,6 +53,12 @@ static EndpointFilterDelegate Build( 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)) { @@ -56,6 +75,17 @@ static EndpointFilterDelegate Build( 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) { @@ -94,6 +124,11 @@ static EndpointFilterDelegate Build( ); } +#if NET11_0_OR_GREATER + if (coveredByPipeline is not null) + pipeline!.LogCovered(DescribeHandler(method), coveredByPipeline); +#endif + if (targets.Count == 0) return next; diff --git a/src/src/AspNetCore/ZodProblemDetailsWriter.cs b/src/src/AspNetCore/ZodProblemDetailsWriter.cs index 6ec462c3..e22bfee6 100644 --- a/src/src/AspNetCore/ZodProblemDetailsWriter.cs +++ b/src/src/AspNetCore/ZodProblemDetailsWriter.cs @@ -176,6 +176,39 @@ public void Apply( Populate(problemDetails, errors, httpContext, mergeExistingErrors: true); } + /// + /// 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. /// @@ -256,6 +289,11 @@ bool mergeExistingErrors 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(); diff --git a/src/src/AspNetCore/ZodRequestValidationReport.cs b/src/src/AspNetCore/ZodRequestValidationReport.cs new file mode 100644 index 00000000..8c35f05c --- /dev/null +++ b/src/src/AspNetCore/ZodRequestValidationReport.cs @@ -0,0 +1,40 @@ +#if NET11_0_OR_GREATER +using System.Collections.Immutable; +using ZodSharp.Core; +using ZodSharp.Extensions.Validation; + +namespace ZodSharp.AspNetCore; + +/// +/// The full-fidelity ZodSharp errors reported during one request by the Microsoft.Extensions.Validation bridge. +/// Registered scoped by AddZodSharpProblemDetails(), so the bridge reaches it through +/// ValidateContext.ServiceProvider (the request services), and the ProblemDetails customization uses it +/// to rebuild the built-in pipeline's response with the ZodSharp contract. +/// +public sealed class ZodRequestValidationReport : IZodValidationReportSink +{ + readonly Lock _lock = new(); + ImmutableArray _errors = []; + + /// + /// 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 => _errors; + + /// + /// Gets whether any error was reported. + /// + public bool HasErrors => !_errors.IsEmpty; + + /// + public void Add(Type validatedType, string pathPrefix, ImmutableArray errors) + { + if (errors.IsDefaultOrEmpty) + return; + + lock (_lock) + _errors = _errors.AddRange(errors); + } +} +#endif diff --git a/src/src/AspNetCore/ZodValidationPipelineGuard.cs b/src/src/AspNetCore/ZodValidationPipelineGuard.cs new file mode 100644 index 00000000..818943d7 --- /dev/null +++ b/src/src/AspNetCore/ZodValidationPipelineGuard.cs @@ -0,0 +1,93 @@ +#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; + if (options is null || !options.Resolvers.Contains(resolver)) + return null; + + 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. + if (_isService?.IsService(parameter.ParameterType) == true) + return false; + + return _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..b521b1c4 --- /dev/null +++ b/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs @@ -0,0 +1,60 @@ +#if NET11_0_OR_GREATER +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +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); + if (services.GetService() 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/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/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/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/ProblemDetailsContractTests.cs b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs index e7e2f79a..c6251215 100644 --- a/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs +++ b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs @@ -12,7 +12,7 @@ namespace ZodSharp.AspNetCore; /// /// One ProblemDetails contract on every path: the endpoint filter, a thrown handled by -/// , and the manual results. +/// , the manual results and, on .NET 11, the built-in validation pipeline. /// public class ProblemDetailsContractTests { @@ -38,6 +38,31 @@ public class ProblemDetailsContractTests 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) { @@ -49,44 +74,61 @@ public async Task ProblemDetails_GivenTheSameErrors_IsIdenticalOnEveryPath(Cance 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(); - app.MapPost("/filter", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation(); - app.MapPost( - "/exception", - IResult (OrderRequest order) => throw new ZodException(OrderRequestValidator.Check(order)) + ModeBOnly(app.MapPost("/filter", (OrderRequest order) => TypedResults.Ok()).WithZodSharpValidation()); + ModeBOnly( + app.MapPost( + "/exception", + IResult (OrderRequest order) => throw new ZodException(OrderRequestValidator.Check(order)) + ) ); - 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( + "/manual", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? result.ToValidationProblem(context) + : TypedResults.Ok() + ) ); - 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( + "/typed", + IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => + factory.Validate(order) is { IsSuccess: false } result + ? ZodTypedResults.ValidationProblem(result, context) + : TypedResults.Ok() + ) ); - app.MapPost( - "/problem", - IResult (OrderRequest order, IZodSchemaFactory factory, HttpContext context) => - factory.Validate(order) is { IsSuccess: false } result - ? ZodTypedResults.Problem(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 new[] { "/filter", "/exception", "/manual", "/typed", "/problem" }) + foreach (var path in Paths) { using var response = await app.Client.PostAsJsonAsync(path, InvalidOrder, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs new file mode 100644 index 00000000..a7adee2d --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs @@ -0,0 +1,527 @@ +#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] + [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 From bd96878dc6fbf2d85e904ce2496d6ad327127662 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:35:25 +0000 Subject: [PATCH 019/107] fix(options): keep the 2.4.0 AddZodSchemaValidator overload binary compatible; bump version to 2.5.0 --- package.json | 2 +- .../OptionsServiceCollectionExtensions.cs | 62 ++++++++++--------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 703a2c15..9a639da3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.4.2", + "version": "2.5.0", "private": true, "license": "MIT", "author": { diff --git a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs index 3136ca69..cc6c654d 100644 --- a/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs +++ b/src/src/ZodSharp/Extensions/Microsoft/Extensions/DependencyInjection/OptionsServiceCollectionExtensions.cs @@ -34,40 +34,44 @@ public OptionsBuilder AddZodSchemaValidator( ) => Register(builder, missingValidatorBehavior, configurationSection: null); } - extension<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>( - OptionsBuilder builder + // 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 { - /// - /// 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 for chaining. - public OptionsBuilder AddZodSchemaValidator( - MissingValidatorBehavior missingValidatorBehavior, - bool validateOnStart, - string? configurationSection = null - ) - { - Register(builder, missingValidatorBehavior, configurationSection); + Register(builder, missingValidatorBehavior, configurationSection); - if (validateOnStart) - builder.ValidateOnStart(); + if (validateOnStart) + builder.ValidateOnStart(); - return builder; - } + return builder; } static OptionsBuilder Register( From 4f4e98a9feac2705b3ba6953453689b1d4f8c52e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:38:29 +0000 Subject: [PATCH 020/107] fix(validation): validate C# union parameters without an application AddValidation() call --- .../ZodValidatableInfoResolver.cs | 5 +++++ .../ZodValidatableParameterInfo.cs | 17 +++++++++++++++- .../ZodValidationPipelineTests.cs | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs index d2026175..e3c695c0 100644 --- a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs +++ b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs @@ -164,6 +164,11 @@ public bool TryGetValidatableParameterInfo( ) 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); + if (isCollection && GetElementType(type) is { } elementType && TryGetValidator(elementType, out _)) return new ZodValidatableParameterInfo(parameter.Name, declared, allowsNull, true, null); diff --git a/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs b/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs index c990d3d3..5af60056 100644 --- a/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs +++ b/src/src/Extensions.Validation/ZodValidatableParameterInfo.cs @@ -20,15 +20,18 @@ namespace ZodSharp.Extensions.Validation; public sealed class ZodValidatableParameterInfo : IValidatableParameterInfo { readonly ZodValidatableTypeInfo? _typeInfo; + readonly IValidatableTypeInfo? _delegateInfo; internal ZodValidatableParameterInfo( string name, Type parameterType, bool allowsNull, bool isCollection, - ZodValidatableTypeInfo? typeInfo + ZodValidatableTypeInfo? typeInfo, + IValidatableTypeInfo? delegateInfo = null ) { + _delegateInfo = delegateInfo; Name = name; ParameterType = parameterType; AllowsNull = allowsNull; @@ -65,6 +68,12 @@ public void Validate(object? value, ValidateContext 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); @@ -103,6 +112,12 @@ public async Task ValidateAsync(object? value, ValidateContext context, Cancella 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) diff --git a/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs index a7adee2d..03428af4 100644 --- a/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodValidationPipelineTests.cs @@ -469,6 +469,26 @@ public async Task Pipeline_GivenUnionBody_ValidatesActiveCaseWithZodSharp(Cancel 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")] From a98165f12504e9250e5cc31e39217e6ff887d3ad Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 14:40:15 +0000 Subject: [PATCH 021/107] feat(blazor): add Purview.ZodSharp.Blazor (ZodValidator, message components, problem details) --- .config/purview-build.json | 7 + Directory.Packages.props | 8 + src/ZodSharp.slnx | 1 + src/src/Blazor/Blazor.csproj | 69 +++ .../Forms/ZodEditContextExtensions.cs | 140 ++++++ ...dSharpBlazorServiceCollectionExtensions.cs | 53 ++ .../Http/ZodHttpResponseMessageExtensions.cs | 69 +++ src/src/Blazor/Sdk/README.md | 2 + src/src/Blazor/ZodBlazorOptions.cs | 24 + src/src/Blazor/ZodFieldLocator.cs | 238 +++++++++ src/src/Blazor/ZodFieldNameFormatter.cs | 77 +++ src/src/Blazor/ZodFormErrors.cs | 127 +++++ src/src/Blazor/ZodMessageRenderer.cs | 57 +++ src/src/Blazor/ZodModelSchema.cs | 141 ++++++ src/src/Blazor/ZodValidationMessage.cs | 191 ++++++++ src/src/Blazor/ZodValidationProblemDetails.cs | 250 ++++++++++ src/src/Blazor/ZodValidationSummary.cs | 234 +++++++++ src/src/Blazor/ZodValidationTrigger.cs | 27 ++ src/src/Blazor/ZodValidator.cs | 451 ++++++++++++++++++ .../Purview.ZodSharp.Blazor.lib.module.js | 12 + src/src/Blazor/wwwroot/zodsharp-validation.js | 352 ++++++++++++++ 21 files changed, 2530 insertions(+) create mode 100644 src/src/Blazor/Blazor.csproj create mode 100644 src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs create mode 100644 src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs create mode 100644 src/src/Blazor/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs create mode 100644 src/src/Blazor/Sdk/README.md create mode 100644 src/src/Blazor/ZodBlazorOptions.cs create mode 100644 src/src/Blazor/ZodFieldLocator.cs create mode 100644 src/src/Blazor/ZodFieldNameFormatter.cs create mode 100644 src/src/Blazor/ZodFormErrors.cs create mode 100644 src/src/Blazor/ZodMessageRenderer.cs create mode 100644 src/src/Blazor/ZodModelSchema.cs create mode 100644 src/src/Blazor/ZodValidationMessage.cs create mode 100644 src/src/Blazor/ZodValidationProblemDetails.cs create mode 100644 src/src/Blazor/ZodValidationSummary.cs create mode 100644 src/src/Blazor/ZodValidationTrigger.cs create mode 100644 src/src/Blazor/ZodValidator.cs create mode 100644 src/src/Blazor/wwwroot/Purview.ZodSharp.Blazor.lib.module.js create mode 100644 src/src/Blazor/wwwroot/zodsharp-validation.js diff --git a/.config/purview-build.json b/.config/purview-build.json index 6de227b2..ec532720 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -30,6 +30,13 @@ "purview-logo-light.png", "README.md" ], + "purview.zodsharp.blazor": [ + "lib/$(TFM)/Purview.ZodSharp.Blazor.dll", + "lib/$(TFM)/Purview.ZodSharp.Blazor.xml", + "staticwebassets/zodsharp-validation.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", diff --git a/Directory.Packages.props b/Directory.Packages.props index 68ef9e92..c378ba4b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,6 +44,8 @@ + + @@ -51,6 +53,12 @@ + + + + diff --git a/src/src/Blazor/Blazor.csproj b/src/src/Blazor/Blazor.csproj new file mode 100644 index 00000000..4f1abf68 --- /dev/null +++ b/src/src/Blazor/Blazor.csproj @@ -0,0 +1,69 @@ + + + + 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 + $(MicrosoftExtensionsVersion) + + + + + + + + + + + + + + + + + + + + + + + + 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..3f19529a --- /dev/null +++ b/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs @@ -0,0 +1,140 @@ +using System.Linq.Expressions; +using ZodSharp.Blazor; +using ZodSharp.Core; + +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(editContext); + ArgumentNullException.ThrowIfNull(problem); + + var errors = ZodFormErrors.GetOrCreate(editContext); + ClearServerErrors(errors); + + var target = store ?? (errors.ServerStore ??= new ValidationMessageStore(editContext)); + var validator = errors.Validator?.RootValidator; + foreach (var error in problem.ToValidationErrors()) + { + 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(); + } + + /// + /// Clears every ZodSharp error on the form: the messages and errors placed and the + /// server errors mapped by AddZodProblemDetails. + /// + 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 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..a0616597 --- /dev/null +++ b/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +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. + /// + /// + /// Calls AddZodSharpValidation, 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. + /// + /// + /// 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); + + services.AddZodSharpValidation(configureValidation); + services.AddOptions(); + if (configure is not null) + services.Configure(configure); + + services.TryAddSingleton(TimeProvider.System); + return services; + } + } +} diff --git a/src/src/Blazor/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs b/src/src/Blazor/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs new file mode 100644 index 00000000..8dc85242 --- /dev/null +++ b/src/src/Blazor/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs @@ -0,0 +1,69 @@ +using System.Net.Http.Json; +using System.Text.Json; +using ZodSharp.Blazor; + +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( + ZodBlazorJsonContext.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/Blazor/Sdk/README.md b/src/src/Blazor/Sdk/README.md new file mode 100644 index 00000000..94c6c86b --- /dev/null +++ b/src/src/Blazor/Sdk/README.md @@ -0,0 +1,2 @@ +# Purview.ZodSharp.Blazor + 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..4de874c7 --- /dev/null +++ b/src/src/Blazor/ZodFieldLocator.cs @@ -0,0 +1,238 @@ +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 +{ + 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) + && resolvedContainer is not null + ) + return new(resolvedContainer, resolvedMember ?? string.Empty); + } + catch (Exception) + { + resolvedContainer = null; + resolvedMember = string.Empty; + } + } + + if (Walk(root, path, ignoreCase, out var container, out var member)) + return new(container ?? root, member); + + // The validator followed part of the path itself: its nearest ancestor is better informed than ours. + if (resolvedContainer is not null && !string.IsNullOrEmpty(resolvedMember)) + return new(resolvedContainer, resolvedMember); + + return new(container ?? root, member); + } + + 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. + /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2075:DynamicallyAccessedMembers", + Justification = "Failure-path fallback for validators that do not resolve members themselves. When the " + + "trimmer removed a property, the walk stops at the nearest object reached, which only degrades field " + + "placement; it never throws. Form models are application types, preserved because they are rendered." + )] + 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); + container = current; + memberName = property?.Name ?? segment.Name ?? string.Empty; + if (property is null || !TryGetValue(property, current, out current)) + return false; + + break; + } + } + + return true; + } + + 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)); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:DynamicallyAccessedMembers", + Justification = "See Walk: a trimmed property only stops the walk early." + )] + 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..deeb29e5 --- /dev/null +++ b/src/src/Blazor/ZodModelSchema.cs @@ -0,0 +1,141 @@ +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 +{ + /// + /// 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. + /// + public static IZodUntypedValidator For< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TModel + >(ZodObject schema) + where TModel : class + { + ArgumentNullException.ThrowIfNull(schema); + return new ObjectModelValidator(typeof(TModel), schema); + } + + 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(ImmutableArray.Empty); + + 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); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:DynamicallyAccessedMembers", + Justification = "The root model type is annotated on For; nested model types are reached through " + + "the root's properties and are application types. A trimmed property is simply not validated." + )] + 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/ZodValidationMessage.cs b/src/src/Blazor/ZodValidationMessage.cs new file mode 100644 index 00000000..e06b6f39 --- /dev/null +++ b/src/src/Blazor/ZodValidationMessage.cs @@ -0,0 +1,191 @@ +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() + { + if (_previousEditContext is not null) + _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/ZodValidationProblemDetails.cs b/src/src/Blazor/ZodValidationProblemDetails.cs new file mode 100644 index 00000000..c75be7f4 --- /dev/null +++ b/src/src/Blazor/ZodValidationProblemDetails.cs @@ -0,0 +1,250 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using ZodSharp.Core; + +namespace ZodSharp.Blazor; + +/// +/// 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() and 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(); + } +} + +/// +/// 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 = new(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 ??= new(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 ZodBlazorJsonContext : JsonSerializerContext; diff --git a/src/src/Blazor/ZodValidationSummary.cs b/src/src/Blazor/ZodValidationSummary.cs new file mode 100644 index 00000000..6ad3916d --- /dev/null +++ b/src/src/Blazor/ZodValidationSummary.cs @@ -0,0 +1,234 @@ +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() + { + if (_previousEditContext is not null) + _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + } +} diff --git a/src/src/Blazor/ZodValidationTrigger.cs b/src/src/Blazor/ZodValidationTrigger.cs new file mode 100644 index 00000000..097a7808 --- /dev/null +++ b/src/src/Blazor/ZodValidationTrigger.cs @@ -0,0 +1,27 @@ +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 (an input bound with @bind-Value:event="oninput") validates once. Each change supersedes and + /// cancels the pending check. + /// + Input = 4, +} diff --git a/src/src/Blazor/ZodValidator.cs b/src/src/Blazor/ZodValidator.cs new file mode 100644 index 00000000..9f1ff35c --- /dev/null +++ b/src/src/Blazor/ZodValidator.cs @@ -0,0 +1,451 @@ +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; + + if (_resolver is not null && _resolver.TryGetValidator(type, out var registered)) + return registered; + + 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..4e73b6a5 --- /dev/null +++ b/src/src/Examples.Blazor/Components/Layout/MainLayout.razor @@ -0,0 +1,32 @@ +@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..3ad4ea22 --- /dev/null +++ b/src/src/Examples.Blazor/Components/Pages/Home.razor @@ -0,0 +1,24 @@ +@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. +
  • +
  • + 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/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/SampleApp.cs b/src/src/Examples.Blazor/SampleApp.cs new file mode 100644 index 00000000..3d8a4444 --- /dev/null +++ b/src/src/Examples.Blazor/SampleApp.cs @@ -0,0 +1,119 @@ +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 (which also makes ZodSharp the first + // Microsoft.Extensions.Validation resolver, so the minimal API below is validated by the built-in pipeline). + services.AddZodSharpZodSharpExamplesSharedSchemas(); + services.AddZodSharpBlazor(); + + // 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(); + + 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! : "/"); + }); + } + + 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.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; } +} From f2f8a71ce97380552d257b5a85294d7426eab667 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 21:44:16 +0000 Subject: [PATCH 073/107] test: cover the Blazor sample in memory and in a browser Examples.Blazor.IntegrationTests hosts the sample with WebApplicationFactory and asserts on the rendered HTML with AngleSharp: - the static-SSR form's data-rules carry zod-startswith and zod-uuid beside the built-in rules; - an invalid nested and collection POST re-renders each message next to its input, and a valid one submits; - POST /api/customers returns the ZodValidationProblemDetails contract, the async uniqueness rule rejects a registered address (localized for Accept-Language: fr), and the culture switch only redirects locally. tests/e2e/blazor-sample.e2e.mjs drives the published sample in Chromium through playwright-core, one form per render mode, including the trimmed WebAssembly client's pending state, server error mapping and French satellite resources. It is not a vitest suite and is run by the nightly job. --- Directory.Packages.props | 3 + src/ZodSharp.slnx | 1 + .../CustomerEndpointTests.cs | 157 ++++++++++++++++++ .../Examples.Blazor.IntegrationTests.csproj | 25 +++ .../SampleAppFactory.cs | 125 ++++++++++++++ .../StaticSsrFormTests.cs | 130 +++++++++++++++ tests/e2e/blazor-sample.e2e.mjs | 152 +++++++++++++++++ 7 files changed, 593 insertions(+) create mode 100644 src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs create mode 100644 src/tests/Examples.Blazor.IntegrationTests/Examples.Blazor.IntegrationTests.csproj create mode 100644 src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs create mode 100644 src/tests/Examples.Blazor.IntegrationTests/StaticSsrFormTests.cs create mode 100644 tests/e2e/blazor-sample.e2e.mjs diff --git a/Directory.Packages.props b/Directory.Packages.props index 43590bfb..fb419992 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,6 +50,9 @@ + + + + diff --git a/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs b/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs new file mode 100644 index 00000000..08542265 --- /dev/null +++ b/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs @@ -0,0 +1,157 @@ +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, + """{ "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, + $$"""{ "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 = """{ "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..b7fe834a --- /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 = new(StringComparer.Ordinal); + foreach (var field in json.RootElement.GetProperty("fields").EnumerateArray()) + { + List rules = []; + foreach (var rule in field.GetProperty("rules").EnumerateArray()) + { + Dictionary parameters = new(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/tests/e2e/blazor-sample.e2e.mjs b/tests/e2e/blazor-sample.e2e.mjs new file mode 100644 index 00000000..7f4a8a0b --- /dev/null +++ b/tests/e2e/blazor-sample.e2e.mjs @@ -0,0 +1,152 @@ +// 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 ?? "" })), + ); +} + +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 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); +} From 53eb3a8379c64ad9bc5e2f1dddb8ae98370d147a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 21:44:16 +0000 Subject: [PATCH 074/107] ci: verify the Blazor WebAssembly client trimmed, AOT-compiled and in a browser - pr.yml gains a wasm-trimmed job: it publishes Examples.Blazor.Client trimmed with -warnaserror, fails on any IL2xxx/IL3xxx warning, then checks the Brotli size of every Purview.ZodSharp* assembly in _framework against .config/wasm-size-baseline.json (more than 10% growth fails). - scripts/check-wasm-size.ps1 is that check; -Update rewrites the baseline after an intended change. `just wasm-size` runs the same publish and check locally. - wasm-nightly.yml (schedule and workflow_dispatch) publishes the client with RunAOTCompilation=true after installing wasm-tools, and runs the browser test against the published sample. --- .config/wasm-size-baseline.json | 9 +++ .github/workflows/pr.yml | 35 +++++++++ .github/workflows/wasm-nightly.yml | 93 ++++++++++++++++++++++++ Justfile | 10 +++ scripts/check-wasm-size.ps1 | 111 +++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 .config/wasm-size-baseline.json create mode 100644 .github/workflows/wasm-nightly.yml create mode 100644 scripts/check-wasm-size.ps1 diff --git a/.config/wasm-size-baseline.json b/.config/wasm-size-baseline.json new file mode 100644 index 00000000..72506641 --- /dev/null +++ b/.config/wasm-size-baseline.json @@ -0,0 +1,9 @@ +{ + "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": 28837, + "Purview.ZodSharp.Extensions.Validation.wasm": 6741, + "Purview.ZodSharp.wasm": 24062 + } +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dfebc1dc..346bc95b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -153,6 +153,41 @@ jobs: 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 + # 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 diff --git a/.github/workflows/wasm-nightly.yml b/.github/workflows/wasm-nightly.yml new file mode 100644 index 00000000..d25a75f2 --- /dev/null +++ b/.github/workflows/wasm-nightly.yml @@ -0,0 +1,93 @@ +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 + + # 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 + + # 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/Justfile b/Justfile index cd944bb8..18633873 100644 --- a/Justfile +++ b/Justfile @@ -5,6 +5,7 @@ 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" @@ -133,6 +134,15 @@ verify-trim rid=default_rid *args: 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 +# 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 + # Displays the current version of the project. # Requires Bun. [group('Build and Test')] diff --git a/scripts/check-wasm-size.ps1 b/scripts/check-wasm-size.ps1 new file mode 100644 index 00000000..83b9b24e --- /dev/null +++ b/scripts/check-wasm-size.ps1 @@ -0,0 +1,111 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fails when the Purview.ZodSharp assemblies in a published Blazor WebAssembly app grow past the size budget. + +.DESCRIPTION + Reads the Brotli-compressed size of every Purview.ZodSharp* assembly in the publish output's _framework + folder (what a browser downloads) and compares it with .config/wasm-size-baseline.json. The check fails + when an assembly grows more than the baseline's tolerancePercent (10%) over its recorded size, when a + baseline assembly is missing from the output, or when a Purview.ZodSharp assembly appears that the + baseline does not list. + + Published file names carry a content fingerprint (Purview.ZodSharp.Blazor.h3u4mo6gz6.wasm.br); sizes are + keyed by the name without it (Purview.ZodSharp.Blazor.wasm). + + The CI wasm-trimmed job runs it after publishing Examples.Blazor.Client; locally, `just wasm-size` does + both. After an intended change in size, rerun with -Update and commit the new baseline. + +.PARAMETER PublishDirectory + The publish output of Examples.Blazor.Client (the folder that contains wwwroot/_framework). + +.PARAMETER BaselinePath + The committed baseline. Defaults to .config/wasm-size-baseline.json in the repository. + +.PARAMETER Update + Rewrite the baseline from the publish output instead of checking it. +#> +[CmdletBinding()] +param ( + [string] $PublishDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/wasm-trimmed'), + [string] $BaselinePath = (Join-Path (Split-Path -Parent $PSScriptRoot) '.config/wasm-size-baseline.json'), + [switch] $Update +) + +$ErrorActionPreference = 'Stop' + +$framework = Join-Path $PublishDirectory 'wwwroot/_framework' +if (-not (Test-Path $framework)) { + throw "No published _framework folder at '$framework'. Publish Examples.Blazor.Client first." +} + +# Purview.ZodSharp.Blazor.h3u4mo6gz6.wasm.br -> Purview.ZodSharp.Blazor.wasm +$measured = [ordered]@{} +Get-ChildItem $framework -File -Filter 'Purview.ZodSharp*.br' | Sort-Object Name | ForEach-Object { + if ($_.Name -notmatch '^(?Purview\.ZodSharp(\.[A-Za-z]+)*)\.(?[a-z0-9]{6,})\.(?wasm|dll)\.br$') { + throw "Unexpected Purview.ZodSharp file name '$($_.Name)'." + } + + $key = "$($Matches.name).$($Matches.ext)" + if ($measured.Contains($key)) { + throw "More than one '$key' in '$framework': the folder holds a previous publish. Delete it and publish again." + } + + $measured[$key] = $_.Length +} + +if ($measured.Count -eq 0) { + throw "No Brotli-compressed Purview.ZodSharp assemblies in '$framework'." +} + +if ($Update) { + $sorted = [ordered]@{} + foreach ($key in ($measured.Keys | Sort-Object)) { $sorted[$key] = $measured[$key] } + + $baseline = [ordered]@{ + 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 = $sorted + } + $json = ($baseline | ConvertTo-Json -Depth 3) -replace "`r`n", "`n" + [System.IO.File]::WriteAllText($BaselinePath, "$json`n") + Write-Host "Wrote $BaselinePath" + $sorted.GetEnumerator() | ForEach-Object { Write-Host (" {0,-45} {1,8:N0} B" -f $_.Key, $_.Value) } + return +} + +$baseline = Get-Content $BaselinePath -Raw | ConvertFrom-Json +$tolerance = [double] $baseline.tolerancePercent +$failures = [System.Collections.Generic.List[string]]::new() + +Write-Host ("{0,-45} {1,10} {2,10} {3,8}" -f 'Assembly', 'Baseline', 'Measured', 'Change') +foreach ($entry in $baseline.assemblies.PSObject.Properties) { + $expected = [long] $entry.Value + if (-not $measured.Contains($entry.Name)) { + $failures.Add("$($entry.Name) is in the baseline but not in the publish output.") + continue + } + + $actual = [long] $measured[$entry.Name] + $change = ($actual - $expected) * 100.0 / $expected + Write-Host ("{0,-45} {1,10:N0} {2,10:N0} {3,7:N1}%" -f $entry.Name, $expected, $actual, $change) + if ($change -gt $tolerance) { + $failures.Add(("{0} grew {1:N1}% ({2:N0} B -> {3:N0} B), over the {4}% budget." -f $entry.Name, $change, $expected, $actual, $tolerance)) + } +} + +foreach ($name in $measured.Keys) { + if (-not $baseline.assemblies.PSObject.Properties[$name]) { + $failures.Add("$name ($($measured[$name]) B) is not in the baseline; add it with -Update.") + } +} + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$failure" } else { Write-Host "ERROR: $failure" } + } + + exit 1 +} + +Write-Host "Every Purview.ZodSharp assembly is within the $tolerance% size budget." From e2099a409ff14195d9284f3feb219c2d4bcc5161 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 21:44:17 +0000 Subject: [PATCH 075/107] docs(blazor): document the Blazor sample and per-keystroke input for Input validation - Blazor-Integration.md gains a sample section (pages, registration, localization, verification). - The Route 2 example and ZodValidationTrigger.Input's XML docs suggested @bind-Value:event="oninput" on InputText, which throws at runtime: a component binding has no event to choose. They now show an InputText subclass that binds its with @bind:event="oninput", as the sample's LiveInputText does. - CHANGELOG: the sample and the WebAssembly CI under 3.0.0-prerelease.1, Added. --- CHANGELOG.md | 14 +++++++ docs/wiki/Blazor-Integration.md | 58 +++++++++++++++++++++++++- src/src/Blazor/ZodValidationTrigger.cs | 5 ++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0914f154..432b7ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -196,6 +196,20 @@ Released versions correspond to `v` GitHub releases. 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. ### Breaking behaviour changes - **Dropped net8.0 and net9.0; supported target frameworks are net10.0 and net11.0.** Every package ships diff --git a/docs/wiki/Blazor-Integration.md b/docs/wiki/Blazor-Integration.md index 4402b8c9..77a931c4 100644 --- a/docs/wiki/Blazor-Integration.md +++ b/docs/wiki/Blazor-Integration.md @@ -74,12 +74,22 @@ The platform's message store holds strings only, so Route 1 cannot carry codes o ```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. | @@ -221,3 +231,49 @@ against the C# verdicts on the same inputs. localized messages need `AddLocalization()`, satellite assemblies and a startup culture. - WebAssembly is single-threaded: the obsolete synchronous `EditContext.Validate()` throws for models with async-only rules instead of blocking. Use `ValidateAsync`. + +## Sample + +`src/src/Examples.Blazor` (server) and `src/src/Examples.Blazor.Client` (WebAssembly) are a Blazor Web App with every +render mode enabled. Both validate the `[ZodSchema]` models of `src/src/Examples.Shared` - the same types +`Examples.MinimalApi` binds - and both `Program.cs` files register them the same way: + +```csharp +builder.Services.AddZodSharpZodSharpExamplesSharedSchemas(); // generated, trim-safe +builder.Services.AddZodSharpBlazor(); +``` + +| Page | Render mode | Shows | +| --- | --- | --- | +| `/orders` | Interactive Server | Route 1: `` over `CreateOrderRequest` - a nested `ShippingAddress`, a list of `Lines` and a dictionary of `AdditionalAddresses` - with field-level validation and each message next to its input. | +| `/register` | Interactive WebAssembly | Route 2: ``, `ZodValidationMessage ShowCode`, the service-aware async email-uniqueness rule (its registry calls the server from the browser) with a pending indicator from `IsValidationPending`, and a button that posts without validating so the server's ProblemDetails are mapped back with `AddZodProblemDetails`. | +| `/returns` | Static SSR | `FormName` + `[SupplyParameterFromForm]`, server validation of the posted form, and browser-side checks of `[StartsWith]` and `[UUID]` from `AddZodSharpClientValidation()`. | + +The server also exposes the minimal API the WebAssembly form posts to (`POST /api/customers`, validated by the .NET 11 +pipeline through the bridge `AddZodSharpBlazor` registers) and a culture switch (`/culture/set?culture=fr`). One +`StringLocalizerZodMessageFormatter` over `SampleMessages.fr.resx` localizes the ProblemDetails, both form routes and +the WebAssembly client, which adopts the server-rendered `` at startup so `RunAsync` loads the French +satellite assembly. + +```bash +dotnet run --project src/src/Examples.Blazor +just wasm-size # trimmed WebAssembly publish (fails on IL2xxx/IL3xxx) + the size budget +``` + +### Verification + +- `Examples.Blazor.IntegrationTests` hosts the sample in memory (`WebApplicationFactory`): the static-SSR page's + `data-rules` carry `zod-startswith` and `zod-uuid`, an invalid nested and collection POST re-renders each message + next to its input (AngleSharp), and `POST /api/customers` returns the ZodValidationProblemDetails contract, + localized for `Accept-Language: fr`. +- The `wasm-trimmed` PR job publishes the client trimmed with `-warnaserror` and checks the Brotli size of each + `Purview.ZodSharp*` assembly against `.config/wasm-size-baseline.json` (more than 10% growth fails; + `scripts/check-wasm-size.ps1 -Update` rewrites the baseline after an intended change). +- The nightly `wasm-nightly.yml` workflow publishes the client with `RunAOTCompilation=true` (the `wasm-tools` + workload) and runs `tests/e2e/blazor-sample.e2e.mjs` against the published sample in Chromium. +- The client does not reference Microsoft.Extensions.Validation directly and has no `[ValidatableType]`; nested, + collection and dictionary validation still works in the browser because the ZodSharp resolver supplies the type + info. +- In a trimmed or AOT publish the `ZodSharp.Reflection.IsSupported` feature switch is off. Generated `[ZodSchema]` + models place every error exactly; a runtime schema's errors attach to the nearest object the trimmed app can still + resolve. diff --git a/src/src/Blazor/ZodValidationTrigger.cs b/src/src/Blazor/ZodValidationTrigger.cs index 097a7808..e640a194 100644 --- a/src/src/Blazor/ZodValidationTrigger.cs +++ b/src/src/Blazor/ZodValidationTrigger.cs @@ -20,8 +20,9 @@ public enum ZodValidationTrigger /// /// Also validate a field as it changes, delayed by so a burst of - /// changes (an input bound with @bind-Value:event="oninput") validates once. Each change supersedes and - /// cancels the pending check. + /// 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, } From d9945249aac1a792e1cf5e0b6662923361d6d9b5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 22:58:21 +0000 Subject: [PATCH 076/107] build(deps): bump Purview.SourceGeneratorFramework to 1.2.2 and drop the publish workaround SourceGeneratorFramework 1.2.2 no longer declares its assembly as copy-to-output content, which flowed through ZodSharp's analyzer ProjectReference to the generator into every app that references ZodSharp by project. Purview.SourceGeneratorFramework.dll is now absent from app build and publish output without consumer configuration, so the RemoveSourceGeneratorFrameworkFromPublish target in Examples.Blazor.Client is removed. Verified locally: the full test suite, just wasm-size, the WebAssembly AOT publish (RunAOTCompilation=true -warnaserror), just verify-trim win-x64 and just publish-aot-sample all pass, and none of their publish folders contain the framework DLL. 1.2.2 exists only on the local NuGet feed until it is published to nuget.org; CI will not restore this commit until then. --- Directory.Packages.props | 2 +- .../Examples.Blazor.Client.csproj | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index fb419992..7e5e1271 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ 5.9.0 5.9.0 1.75.0 - 1.2.1 + 1.2.2 10.0.13 - - - - - From 2a69f7208dbd6beefb2fa31a6d8f6629d5cfbd1c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 27 Oct 2026 23:27:35 +0000 Subject: [PATCH 077/107] build(lint): point the pipeline at the vendored CSharpier The Justfile lint recipes run .assets/csharpier, but the pipeline linted with the tool-manifest 1.3.0, which lays out lambda arguments differently, so `just lint-fix` output failed the pipeline lint gate. Build:CSharpierPath makes the pipeline, the Justfile and CI use one formatter. --- .config/purview-build.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.config/purview-build.json b/.config/purview-build.json index 824030b0..c983cc16 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 From 757b61e052434a838ca89708f58ebb3ef6b174d0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:10:23 +0000 Subject: [PATCH 078/107] build(pack): forbid compiler-host content outside analyzers in every package ForbiddenContent now rejects Purview.SourceGeneratorFramework* and Microsoft.CodeAnalysis* at the package root and under lib/, ref/, runtimes/, build*/, tools/, content*/ and staticwebassets/, a loose copy of either under analyzers/, and the generator or code fixes under lib/, ref/ or runtimes/, for every package id. scripts/test-pack-validation-rules.ps1 proves each rule bites with the real purview-build validation - a control run on untouched packages, then each package with every rule's file injected, then each rule's file alone. RequireExplicitContent is off for those runs, so only ForbiddenContent can fail them. --- .config/purview-build.json | 44 ++++++- scripts/test-pack-validation-rules.ps1 | 175 +++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 scripts/test-pack-validation-rules.ps1 diff --git a/.config/purview-build.json b/.config/purview-build.json index c983cc16..81221013 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -74,9 +74,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/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 new file mode 100644 index 00000000..cf80667a --- /dev/null +++ b/scripts/test-pack-validation-rules.ps1 @@ -0,0 +1,175 @@ +#!/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. + +.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 just pack -c Release)." +} + +# 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 From 441d5d18c6e9a3a8c2124e3bebc3453fd56b2e4b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:10:23 +0000 Subject: [PATCH 079/107] fix(openapi): flow Microsoft.AspNetCore.OpenApi build assets to consumers A consumer referencing only Purview.ZodSharp.AspNetCore.OpenApi got the OpenAPI source generator but not the build/ targets that enable its interceptors, so AddOpenApi(...) failed with CS9137. The dependency now uses PrivateAssets="none". Found by building a minimal API against the packed packages. --- CHANGELOG.md | 6 ++++++ src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432b7ea4..90020d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,12 @@ Released versions correspond to `v` GitHub releases. every name). ### Fixed +- **`Purview.ZodSharp.AspNetCore.OpenApi` alone is enough to call `AddOpenApi`.** The package referenced + Microsoft.AspNetCore.OpenApi with the default private assets, so a consumer that installed only + `Purview.ZodSharp.AspNetCore.OpenApi` (as its README shows) got Microsoft.AspNetCore.OpenApi's source generator but + not its `build/` targets, which enable the generator's interceptors, and `AddOpenApi(...)` failed to compile with + CS9137. The dependency now flows every asset. 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 diff --git a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj index 7ef2c1b2..f6cac916 100644 --- a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj +++ b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj @@ -24,7 +24,13 @@ - + + From 6900b6673495b17454d67ff3a4eee153d522df02 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:10:36 +0000 Subject: [PATCH 080/107] ci: gate releases on packed consumers and a compiler-host leak scan scripts/test-packed-consumers.ps1 (just smoke-packed-consumers) packs every shipping package to an isolated feed and package cache, then publishes and runs consumers that reference packages only: a Native AOT console, a Blazor WebAssembly app (trimmed, and RunAOTCompilation when wasm-tools is installed) and a Native AOT minimal API. scripts/assert-no-compiler-host-assemblies.ps1 scans build and publish output by file name and by deps.json, runtimeconfig.json and boot manifest content. Every publish job and recipe now runs it, and the new packed-consumers PR job runs the gate and the forbidden-content rule proof. It fails first, with what to do, if Purview.SourceGeneratorFramework cannot be restored. Release-Flow.md lists every gate and a pre-release checklist; AGENTS.md matches. --- .github/workflows/pr.yml | 80 +++ .github/workflows/wasm-nightly.yml | 19 + AGENTS.md | 5 +- Justfile | 28 + docs/wiki/Release-Flow.md | 59 +- .../assert-no-compiler-host-assemblies.ps1 | 160 +++++ .../AotConsole/AotConsole.csproj | 40 ++ scripts/packed-consumers/AotConsole/Models.cs | 69 +++ .../packed-consumers/AotConsole/Program.cs | 260 ++++++++ scripts/packed-consumers/BlazorWasm/App.razor | 8 + .../BlazorWasm/BlazorWasm.csproj | 38 ++ scripts/packed-consumers/BlazorWasm/Models.cs | 32 + .../BlazorWasm/Pages/Register.razor | 35 ++ .../packed-consumers/BlazorWasm/Program.cs | 13 + .../BlazorWasm/_Imports.razor | 5 + .../BlazorWasm/wwwroot/index.html | 13 + .../MinimalApi/MinimalApi.csproj | 38 ++ .../packed-consumers/MinimalApi/Program.cs | 63 ++ scripts/test-packed-consumers.ps1 | 573 ++++++++++++++++++ 19 files changed, 1535 insertions(+), 3 deletions(-) create mode 100644 scripts/assert-no-compiler-host-assemblies.ps1 create mode 100644 scripts/packed-consumers/AotConsole/AotConsole.csproj create mode 100644 scripts/packed-consumers/AotConsole/Models.cs create mode 100644 scripts/packed-consumers/AotConsole/Program.cs create mode 100644 scripts/packed-consumers/BlazorWasm/App.razor create mode 100644 scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj create mode 100644 scripts/packed-consumers/BlazorWasm/Models.cs create mode 100644 scripts/packed-consumers/BlazorWasm/Pages/Register.razor create mode 100644 scripts/packed-consumers/BlazorWasm/Program.cs create mode 100644 scripts/packed-consumers/BlazorWasm/_Imports.razor create mode 100644 scripts/packed-consumers/BlazorWasm/wwwroot/index.html create mode 100644 scripts/packed-consumers/MinimalApi/MinimalApi.csproj create mode 100644 scripts/packed-consumers/MinimalApi/Program.cs create mode 100644 scripts/test-packed-consumers.ps1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 346bc95b..f5e1608d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -126,6 +126,15 @@ jobs: 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 @@ -188,6 +197,15 @@ jobs: 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 @@ -234,6 +252,18 @@ jobs: 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. @@ -252,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/wasm-nightly.yml b/.github/workflows/wasm-nightly.yml index d25a75f2..3358f89c 100644 --- a/.github/workflows/wasm-nightly.yml +++ b/.github/workflows/wasm-nightly.yml @@ -49,6 +49,15 @@ jobs: 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. @@ -67,6 +76,16 @@ jobs: 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: | diff --git a/AGENTS.md b/AGENTS.md index 3fc54803..902f70c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,10 @@ 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 diff --git a/Justfile b/Justfile index 18633873..a7b8e3fa 100644 --- a/Justfile +++ b/Justfile @@ -62,6 +62,31 @@ 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) and a Native AOT minimal API, 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. Pack first +# (`just pipeline-pack-validate`, or `just pack ./artifacts -c Release`); pass -PackagesFolder to use other packages. +[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. @@ -121,6 +146,7 @@ pack publish_folder=artifacts_folder *args: 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 }} # 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. @@ -133,6 +159,7 @@ verify-trim rid=default_rid *args: 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 # 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 @@ -142,6 +169,7 @@ wasm-size *args: 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 # Displays the current version of the project. # Requires Bun. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 4e28a1af..dc530e2a 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -16,6 +16,59 @@ A push to `main` publishes, so everything below has to be true *before* the merg 2. **Move new diagnostics into a shipped block.** Anything in `AnalyzerReleases.Unshipped.md` moves to a `## Release ` block in `AnalyzerReleases.Shipped.md`. A rule stays recorded under the release that *introduced* it, so earlier blocks are never rewritten. 3. **Bump the package-validation baseline.** `src/Directory.Build.targets` sets `PackageValidationBaselineVersion` to the previous stable release; it must name the version currently on NuGet, not the one being released. The build then fails `dotnet pack` on an unintended public-API change. An intended one needs a `CompatibilitySuppressions.xml` beside the owning project (`dotnet pack /p:GenerateCompatibilitySuppressionFile=true`) *and* a matching upgrade note — the two are each other's evidence. 4. **Run `just pipeline-pr`**, which covers restore, build, lint, tests, pack and pack validation in one pass. +5. **Run the [pre-release checklist](#pre-release-checklist)**, which adds the gates that consume the packed packages + and the Native AOT and WebAssembly publishes. + +## Release gates + +Each gate proves one thing the others cannot. Most build against ProjectReferences, which is fast and debuggable but +is not what ships; the packed gates consume the `.nupkg` files themselves. A gate that has nothing to check fails +rather than passing. + +| Gate | What it proves | Local | CI | +|---|---|---|---| +| Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes (including the cross-platform vitest suites, through `Repository.UnitTests`). | `just build`, `just lint-check`, `just test` | `pr.yml` `build` | +| Pack and pack validation | Every package packs; package validation holds the public API to the 2.4.2 baseline; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` (`run-pack`, `validate-pack`) | +| Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of each package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` | +| Packed generator | The IL-merged generator in the `.nupkg` loads and generates in a net10.0 consumer (it has regressed twice while the in-repo tests, which use the unmerged generator, stayed green). | `just smoke-packed-generator` | `pr.yml` `packed-generator` | +| Packed consumers | Every shipping package, packed to an isolated feed and cache, works for consumers that reference packages only: a Native AOT console (generated validation, member resolution, count attributes, JSON Schema export, the validation bridge, ProblemDetails, OpenAPI options), a Blazor WebAssembly app with `` (trimmed, and `RunAOTCompilation` when `wasm-tools` is installed; `_framework` holds the ZodSharp assemblies, `_content` the static web assets) and a Native AOT minimal API (an invalid POST returns 400 `application/problem+json` with `issues`; the OpenAPI document carries `ZodValidationProblemDetails`). Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | +| Compiler-host leak scan | No generator, code-fix, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest. Purview.SourceGeneratorFramework.dll once leaked this way and broke Blazor WebAssembly AOT while every gate passed. | `scripts/assert-no-compiler-host-assemblies.ps1 -Path ` (`just scan-compiler-host-leaks `); every publish recipe below runs it | a step in every job that publishes; `ReleaseGateTests` fails a job that publishes without it | +| Trim and Native AOT harness | The whole trim-safe surface is warning-free under ILC with every ZodSharp assembly rooted (IL3054 alone tolerated, see [Trimming and Native AOT](Trimming-and-AOT.md#the-rooted-publish-and-il3054-generic-recursion)), and again as a plain consumer; each native binary checks its behaviour. | `just verify-trim ` | `pr.yml` `verify-trim` | +| Native AOT sample | Examples.MinimalApi publishes with `PublishAot=true` and no IL warning, and the native binary returns the ProblemDetails contract. | `just publish-aot-sample` | `pr.yml` `aot-sample` | +| WebAssembly size budget | Examples.Blazor.Client publishes trimmed with no IL warning, and each ZodSharp assembly stays within 10% of `.config/wasm-size-baseline.json`. | `just wasm-size` | `pr.yml` `wasm-trimmed` | +| WebAssembly AOT and browser | Examples.Blazor.Client compiles with `RunAOTCompilation=true`; the published Blazor sample passes its browser checks. | `just wasm-size -p:RunAOTCompilation=true` | `wasm-nightly.yml` `wasm-aot`, `blazor-e2e` | + +`ReleaseGateTests` (in `src/tests/Repository.UnitTests`, so `just test` runs it) guards the gates themselves: the leak +scan must catch each kind of leak it claims to, every workflow job that publishes must scan its output, the +`packed-consumers` job must run the gate and the rule proof, and `ForbiddenContent` must keep every rule. + +### Purview.SourceGeneratorFramework + +The generator is built against the Purview.SourceGeneratorFramework version `Directory.Packages.props` pins. Until that +version is on nuget.org, CI cannot restore it: the `packed-consumers` job fails at its first step with what to do, +rather than skipping. Publish it, or set the `ZODSHARP_SGF_FEED` repository variable to a feed that has it. Locally, +`just smoke-packed-consumers` also reads `ZODSHARP_SGF_FEED` or `LOCAL_NUGET_FEED_PATH`, and uses that feed for +Purview.SourceGeneratorFramework only. + +### Pre-release checklist + +Run these on a clean tree before bumping and merging a release. Each must pass. + +```powershell +$env:LOCAL_NUGET_FEED_PATH = 'P:\_sync-projects\.local-nuget' # wherever Purview.SourceGeneratorFramework is, until it is on nuget.org + +just scrub # delete bin/obj, clean, force-restore, stop build servers +just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ + # restore, build, lint, tests, pack, pack validation, local publish +just smoke-packed-generator # the IL-merged generator loads and generates from the .nupkg +just smoke-packed-consumers # Native AOT, Blazor WebAssembly and minimal API consumers of the packages +just wasm-size # trimmed WebAssembly publish, size budget and leak scan +just verify-trim # rooted and consumer Native AOT harness, e.g. win-x64 or linux-x64 +just publish-aot-sample # Native AOT minimal API sample and leak scan +``` + +`just verify-pack-rules` re-proves the forbidden-content rules against the packages in `artifacts/`; run it after +changing `PackValidation`. ## Workflows @@ -27,7 +80,7 @@ A push to `main` publishes, so everything below has to be true *before* the merg Both consume `purview-build.json`: - `Build` — solution (`src/ZodSharp.slnx`), test root (`src/tests`), patterns (`*Tests.csproj`), filter (`/*/*/*/*`). -- `PackValidation` — requires symbol packages/files and validates `RequiredContent` per package (per-TFM DLL + XML, analyzer assemblies and `buildTransitive/Purview.ZodSharp.props` for the core package, `README.md` and `purview-logo-light.png` for every package). +- `PackValidation` — requires symbol packages/files and validates `RequiredContent` per package (per-TFM DLL + XML, analyzer assemblies and `buildTransitive/Purview.ZodSharp.props` for the core package, `README.md` and `purview-logo-light.png` for every package). `ForbiddenContent` rejects, in every package, a compiler-host assembly (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`) anywhere NuGet hands files to an application (the root, `lib/`, `ref/`, `runtimes/`, `build*/`, `tools/`, `content*/`, `staticwebassets/`), a loose copy of either under `analyzers/`, and the generator or code fixes under `lib/`, `ref/` or `runtimes/`. - `Release.Mode` — `None` for PR/local runs. ## Pipeline commands @@ -52,4 +105,6 @@ Commits must follow [Conventional Commits](https://www.conventionalcommits.org/) - Relevant tests pass (`just test`). - `just lint-check` (CSharpier) reports no formatting changes. - Packed packages match `purview-build.json` `PackValidation`. -- Generated code is deterministic and reviewable. \ No newline at end of file +- Generated code is deterministic and reviewable. +- Before a release, every gate in [Release gates](#release-gates) passes, through the + [pre-release checklist](#pre-release-checklist). \ No newline at end of file diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 new file mode 100644 index 00000000..4947483b --- /dev/null +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -0,0 +1,160 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fails when a build or publish output carries a compiler-host-only assembly. + +.DESCRIPTION + The [ZodSchema] generator, the code fixes and the framework they are built on run inside the compiler. None of + them may reach an application: Purview.SourceGeneratorFramework.dll once leaked into app build and publish + output through analyzer-only references, and Blazor WebAssembly AOT compilation failed on it. Every publish gate + compiled cleanly while it leaked, because nothing looked at what was actually written to disk. + + This script looks. Every file under each -Path is checked by name, and every dependency manifest + (*.deps.json, *.runtimeconfig.json, blazor.boot.json, dotnet.boot.js, and dotnet.js, which embeds the boot manifest since + .NET 10 - including fingerprinted and Brotli/gzip compressed copies) is checked by content. A file, or a manifest entry, naming any of these fails the scan: + + Purview.SourceGeneratorFramework* + Microsoft.CodeAnalysis* + Purview.ZodSharp.SourceGenerators* + Purview.ZodSharp.CodeFixes* + + A path that does not exist, or that holds no files, fails too: a scan of nothing proves nothing. + + Used by scripts/test-packed-consumers.ps1 on every consumer bin and publish folder, by the aot-sample, verify-trim, + wasm-trimmed, wasm-aot and blazor-e2e CI jobs on their build and publish output, and by the matching Justfile recipes. + +.PARAMETER Path + One or more build or publish output directories to scan, recursively. Space-separated when run with -File. + +.PARAMETER Allow + Wildcard patterns (PowerShell -like, matched against each file's path relative to the scanned directory, with + forward slashes) for third-party tools that legitimately carry Roslyn and are never loaded by the application - + for example '*BlazorDebugProxy/*', the WebAssembly debugging proxy Microsoft.AspNetCore.Components.WebAssembly.Server + publishes beside a Blazor server. Allowed files are counted and reported, never silently ignored. Keep each pattern + as narrow as the tool's own folder. + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/wasm-trimmed + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/blazor-sample -Allow '*BlazorDebugProxy/*' + +.EXAMPLE + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/aot-sample bin/Release +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory, Position = 0)] + [string[]] $Path, + + [string[]] $Allow = @(), + + # Collects the rest of `pwsh -File ... -Path a b` (space-separated): -File passes arguments as strings and does + # not parse PowerShell's comma array syntax. + [Parameter(ValueFromRemainingArguments, DontShow)] + [string[]] $AdditionalPath = @() +) + +$ErrorActionPreference = 'Stop' +$Path = @($Path) + @($AdditionalPath) + +# File-name prefixes. Published WebAssembly files carry a fingerprint (Name.abc123.wasm), which a prefix still matches. +$forbiddenNames = @( + 'Purview.SourceGeneratorFramework' + 'Microsoft.CodeAnalysis' + 'Purview.ZodSharp.SourceGenerators' + 'Purview.ZodSharp.CodeFixes' +) + +$forbiddenContent = [regex]::new( + (($forbiddenNames | ForEach-Object { [regex]::Escape($_) }) -join '|'), + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +# Dependency manifests, optionally fingerprinted (dotnet.boot.abc123.js) and optionally compressed (.br, .gz). Since .NET 10 +# the WebAssembly boot manifest is embedded in _framework/dotnet.js, so that file is a manifest too; dotnet.native.*.js and +# dotnet.runtime.*.js are the runtime itself and never list assemblies. +$manifestPattern = [regex]::new( + '(\.deps\.json|\.runtimeconfig\.json|^blazor\.boot(\.[a-z0-9]+)?\.json|^dotnet(\.boot)?(\.[a-z0-9]+)?\.js)(\.br|\.gz)?$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +function Read-ManifestText([System.IO.FileInfo] $file) { + $stream = [System.IO.File]::OpenRead($file.FullName) + try { + $source = switch -Regex ($file.Name) { + '\.br$' { [System.IO.Compression.BrotliStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + '\.gz$' { [System.IO.Compression.GZipStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + default { $stream } + } + + $reader = [System.IO.StreamReader]::new($source) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +$violations = [System.Collections.Generic.List[string]]::new() + +foreach ($directory in $Path) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + $violations.Add("'$directory' does not exist or is not a directory, so it cannot be scanned.") + continue + } + + $root = (Resolve-Path -LiteralPath $directory).Path + $files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force) + if ($files.Count -eq 0) { + $violations.Add("'$root' holds no files, so the scan would prove nothing.") + continue + } + + $manifests = 0 + $allowed = 0 + foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath($root, $file.FullName) + $forwardSlashed = $relative.Replace('\', '/') + if ($Allow | Where-Object { $forwardSlashed -like $_ }) { + $allowed++ + continue + } + + foreach ($name in $forbiddenNames) { + if ($file.Name.StartsWith($name, [System.StringComparison]::OrdinalIgnoreCase)) { + $violations.Add("compiler-host file '$relative' in '$root'.") + break + } + } + + if ($manifestPattern.IsMatch($file.Name)) { + $manifests++ + $matched = $forbiddenContent.Matches((Read-ManifestText $file)) | + ForEach-Object Value | + Sort-Object -Unique + if ($matched) { + $violations.Add("manifest '$relative' in '$root' names $($matched -join ', ').") + } + } + } + + $allowedNote = $allowed -gt 0 ? (", {0} allowed by -Allow {1}" -f $allowed, ($Allow -join ', ')) : '' + Write-Host ("Scanned {0}: {1} file(s), {2} manifest(s){3}." -f $root, $files.Count, $manifests, $allowedNote) +} + +if ($violations.Count -gt 0) { + foreach ($violation in $violations) { Write-Failure $violation } + Write-Failure ( + "$($violations.Count) compiler-host leak(s) found. Source generators, analyzers and their dependencies run " + + 'inside the compiler and must never reach build or publish output; check analyzer-only references ' + + '(OutputItemType="Analyzer", ReferenceOutputAssembly="false", PrivateAssets="all") and the package layout.' + ) + exit 1 +} + +Write-Host 'No compiler-host assemblies or manifest references found.' -ForegroundColor Green diff --git a/scripts/packed-consumers/AotConsole/AotConsole.csproj b/scripts/packed-consumers/AotConsole/AotConsole.csproj new file mode 100644 index 00000000..a8a2f278 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -0,0 +1,40 @@ + + + + Exe + net11.0 + PackedAot + PackedAot + enable + enable + true + true + false + true + true + true + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotConsole/Models.cs b/scripts/packed-consumers/AotConsole/Models.cs new file mode 100644 index 00000000..461d942c --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Models.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using ZodSharp; +using ZodSharp.Rules; + +namespace PackedAot; + +/// +/// An order with a nested object, a counted collection and a counted dictionary: the members the packed generator has +/// to emit nested, collection, dictionary and count-attribute rules for. +/// +[ZodSchema] +public sealed class Order +{ + [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; } = []; +} + +[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; } = 1; +} + +[ZodSchema] +public sealed class Address +{ + [Required] + [StringLength(100, MinimumLength = 1)] + public string? Street { get; set; } + + [Required] + public string? City { get; set; } +} + +/// A JSON request model, deserialized through a source-generated . +[ZodSchema] +public 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; } = []; +} + +[JsonSerializable(typeof(Customer))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public sealed partial class ConsumerJsonContext : JsonSerializerContext { } diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs new file mode 100644 index 00000000..9ddcc8ad --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -0,0 +1,260 @@ +// Packed-consumer gate, consumer (a): a Native AOT console built from the packages alone. Every check records a +// failure rather than throwing, so one run reports every mismatch; any failure makes the process exit non-zero. +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; +using PackedAot; +using ZodSharp; +using ZodSharp.AspNetCore; +using ZodSharp.Core; +using ZodSharp.JsonSchema; +using ZodSharp.JsonSchema.SystemTextJson; + +var failures = new List(); +var passed = 0; + +void Check(bool condition, string description) +{ + if (condition) + { + passed++; + Console.WriteLine($" pass {description}"); + } + else + { + failures.Add(description); + Console.WriteLine($" FAIL {description}"); + } +} + +void CheckPaths(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(); + Check( + missing.Length == 0 && unexpected.Length == 0, + missing.Length == 0 && unexpected.Length == 0 + ? description + : $"{description} (missing: [{string.Join(", ", missing)}], unexpected: [{string.Join(", ", unexpected)}])" + ); +} + +async Task Section(string name, Func body) +{ + Console.WriteLine(name); + try + { + await body(); + } + catch (Exception ex) + { + Check(false, $"{name} threw {ex.GetType().Name}: {ex.Message}"); + } +} + +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" }, + }, + }; + +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 }, + }, + }; + +await Section("native image", () => +{ + // A native image compiles nothing at run time; `dotnet run` of the same project would JIT. + Check(System.Runtime.JitInfo.GetCompiledMethodCount() == 0, "the process is a Native AOT image (no JIT)"); + return Task.CompletedTask; +}); + +await Section("generated validation", () => +{ + Check(OrderSchema.Validate(ValidOrder()).IsSuccess, "a valid order passes"); + CheckPaths( + OrderSchema.Validate(InvalidOrder()).Errors, + [ + "CustomerEmail", + "ShippingAddress.Street", + "Lines[0].Sku", + "Lines[0].Quantity", + "AdditionalAddresses[billing].City", + ], + "an invalid order reports nested, collection and dictionary errors" + ); + return Task.CompletedTask; +}); + +await Section("count attributes", () => +{ + var empty = ValidOrder(); + empty.Lines = []; + var tooFew = OrderSchema.Validate(empty).Errors.Single(); + Check( + 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(); + Check(tooMany.Code == "too_big" && tooMany.Maximum == 3, "[MaxCount] reports too_big with the maximum"); + + var crowded = ValidOrder(); + for (var i = 0; i < 2; i++) + crowded.AdditionalAddresses[$"extra{i}"] = new() { Street = "s", City = "c" }; + var range = OrderSchema.Validate(crowded).Errors.Single(); + Check( + range.Code == "too_big" && range.Maximum == 2 && range.FullPath.ToDottedString() == "AdditionalAddresses", + $"[CountRange] counts dictionary entries (got {range.Code} at {range.FullPath.ToDottedString()})" + ); + return Task.CompletedTask; +}); + +await Section("member validation and resolution", () => +{ + var order = InvalidOrder(); + CheckPaths( + OrderSchema.ValidateMember(order, nameof(Order.CustomerEmail)).Errors, + ["CustomerEmail"], + "ValidateMember reports only the member" + ); + + IZodUntypedValidator validator = new OrderSchemaValidator(); + var city = validator + .Validate(order) + .First(static e => e.FullPath.ToDottedString() == "AdditionalAddresses[billing].City"); + var resolved = validator.TryResolveMember(order, city.FullPath, out var container, out var memberName); + Check( + resolved && ReferenceEquals(container, order.AdditionalAddresses["billing"]) && memberName == "City", + "TryResolveMember resolves the owning dictionary entry without reflection" + ); + return Task.CompletedTask; +}); + +await Section("JSON Schema export", () => +{ + var typeInfo = JsonSchemaSerializerOptions.Default.GetTypeInfo(); + var json = JsonSerializer.Serialize(OrderSchema.BuildJsonSchema(), typeInfo); + Check(json.Contains("\"minItems\": 1", StringComparison.Ordinal), "the generated schema exports minItems"); + Check(json.Contains("\"maxItems\": 3", StringComparison.Ordinal), "the generated schema exports maxItems"); + Check( + json.Contains("\"maxProperties\": 2", StringComparison.Ordinal), + "the generated schema exports maxProperties" + ); + + using var document = JsonDocument.Parse(json); + var members = document.RootElement.TryGetProperty("properties", out var properties) + ? properties.EnumerateObject().Select(static p => p.Name).Order(StringComparer.Ordinal).ToArray() + : []; + Check( + // Exported in JSON (camelCase) names. + members.SequenceEqual(["additionalAddresses", "customerEmail", "lines", "shippingAddress"]), + $"the generated schema describes every member (got [{string.Join(", ", members)}])" + ); + + var runtime = JsonSerializer.Serialize( + Z.ToJsonSchema(Z.Object().Field("name", Z.String().Min(2)).Build()), + typeInfo + ); + Check(runtime.Contains("\"minLength\": 2", StringComparison.Ordinal), "a runtime schema exports its rules"); + return Task.CompletedTask; +}); + +await Section("System.Text.Json with a source-generated context", () => +{ + CustomerSchemaValidator schema = new(); + var typeInfo = ConsumerJsonContext.Default.Customer; + + var valid = schema.DeserializeAndValidate("""{"name":"Ada","age":36,"tags":["a"]}""", typeInfo); + Check(valid.IsSuccess && valid.Value.Name == "Ada", "a valid document deserializes and validates"); + + var invalid = schema.DeserializeAndValidate("""{"name":"A","age":200,"tags":["a","b","c"]}""", typeInfo); + CheckPaths(invalid.Errors, ["Name", "Age", "Tags"], "an invalid document reports each member"); + return Task.CompletedTask; +}); + +await Section("Microsoft.Extensions.Validation bridge", async () => +{ + ServiceCollection services = new(); + services.AddZodSharpValidation(); + services.AddZodSharpPackedAotSchemas(); + await using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + var report = await options.ValidateAsync(InvalidOrder(), provider); + Check( + report.Errors.Any(static e => e.FullPath.ToDottedString() == "Lines[0].Sku"), + "ValidationOptions validates through the ZodSharp resolver with full-fidelity errors" + ); + Check( + (await options.ValidateAsync(ValidOrder(), provider)).Errors.Count == 0, + "a valid order produces no errors through the bridge" + ); +}); + +await Section("ZodProblemDetailsWriter without a host", async () => +{ + ZodProblemDetailsWriter writer = new(new ZodProblemDetailsOptions()); + var errors = OrderSchema.Validate(InvalidOrder()).Errors; + + var problem = writer.Create(errors.AsSpan()); + Check(problem.Status == StatusCodes.Status400BadRequest, "the problem is a 400"); + Check(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()); + + using var document = JsonDocument.Parse(Encoding.UTF8.GetString(body.ToArray())); + Check( + document.RootElement.TryGetProperty("issues", out var issues) && issues.GetArrayLength() > 0, + "the body carries the issues array" + ); + Check( + context.Response.ContentType?.StartsWith("application/problem+json", StringComparison.Ordinal) == true, + "the response is application/problem+json" + ); +}); + +await Section("OpenAPI options", () => +{ + OpenApiOptions options = new(); + Check(ReferenceEquals(options.AddZodSharp(), options), "AddZodSharp registers the ZodSharp transformers"); + return Task.CompletedTask; +}); + +Console.WriteLine(); +if (failures.Count == 0) +{ + Console.WriteLine($"Packed Native AOT consumer passed: {passed} checks."); + return 0; +} + +Console.WriteLine($"Packed Native AOT consumer FAILED: {failures.Count} failure(s), {passed} passed."); +foreach (var failure in failures) + Console.WriteLine($" {failure}"); + +return 1; diff --git a/scripts/packed-consumers/BlazorWasm/App.razor b/scripts/packed-consumers/BlazorWasm/App.razor new file mode 100644 index 00000000..abdf5c3b --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/App.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj new file mode 100644 index 00000000..5d459cf6 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,38 @@ + + + + net11.0 + PackedWasm + PackedWasm + enable + enable + true + false + true + true + true + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorWasm/Models.cs b/scripts/packed-consumers/BlazorWasm/Models.cs new file mode 100644 index 00000000..08a31c2a --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Models.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Rules; + +namespace PackedWasm; + +/// The form model: generated rules on a nested object and a counted collection. +[ZodSchema] +public sealed class Registration +{ + [Required] + [StringLength(50, MinimumLength = 2)] + public string? Name { get; set; } + + [Required] + [EmailAddress] + public string? Email { get; set; } + + [Required] + public Address Address { get; set; } = new(); + + [MaxCount(3)] + public List Tags { get; set; } = []; +} + +[ZodSchema] +public sealed class Address +{ + [Required] + [StringLength(100, MinimumLength = 1)] + public string? City { get; set; } +} diff --git a/scripts/packed-consumers/BlazorWasm/Pages/Register.razor b/scripts/packed-consumers/BlazorWasm/Pages/Register.razor new file mode 100644 index 00000000..056e0238 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Pages/Register.razor @@ -0,0 +1,35 @@ +@page "/" + +

Register

+ + + + + + + + + + + + + + + + + + + + + +@if (_submitted) +{ +

Registered @_model.Name.

+} + +@code { + readonly Registration _model = new(); + bool _submitted; + + void Submit() => _submitted = true; +} diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs new file mode 100644 index 00000000..868b7de6 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using PackedWasm; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// The generated, trim-safe registration of this assembly's [ZodSchema] models, then ZodSharp for Blazor forms. +builder.Services.AddZodSharpPackedWasmSchemas(); +builder.Services.AddZodSharpBlazor(); + +await builder.Build().RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/_Imports.razor b/scripts/packed-consumers/BlazorWasm/_Imports.razor new file mode 100644 index 00000000..10c3eb5e --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using ZodSharp.Blazor +@using PackedWasm diff --git a/scripts/packed-consumers/BlazorWasm/wwwroot/index.html b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html new file mode 100644 index 00000000..a736bb33 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + + Packed ZodSharp Blazor consumer + + + +
Loading...
+ + + 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/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 new file mode 100644 index 00000000..beac6aa0 --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,573 @@ +#!/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 . 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. + 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) { + $env:ASPNETCORE_URLS = "http://127.0.0.1:$port" + try { + return Start-Process -FilePath (Join-Path $directory "PackedApi$exe") -WorkingDirectory $directory -PassThru ` + -RedirectStandardOutput (Join-Path $logs 'minimal-api.stdout.log') ` + -RedirectStandardError (Join-Path $logs 'minimal-api.stderr.log') + } + finally { + Remove-Item Env:\ASPNETCORE_URLS -ErrorAction SilentlyContinue + } +} + +# 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) + } +} +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 From e10d71c03a95d59af50c2df8a781d1b587c9abb2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:10:36 +0000 Subject: [PATCH 081/107] test: guard the release gates with ReleaseGateTests The leak scan must catch each kind of leak it claims to (file names, fingerprinted and compressed WebAssembly files, deps.json, runtimeconfig.json and boot manifests), must fail when it has nothing to scan, and -Allow must not hide a leak elsewhere. Every workflow job that publishes must run the scan, the packed-consumers job must run the gate and the rule proof, and ForbiddenContent must keep every rule. --- .../Repository.UnitTests/ReleaseGateTests.cs | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 src/tests/Repository.UnitTests/ReleaseGateTests.cs diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs new file mode 100644 index 00000000..b6d428e0 --- /dev/null +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -0,0 +1,459 @@ +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", + """{"libraries":{"Purview.SourceGeneratorFramework/1.2.2":{"type":"package"}}}""" + ), + () => + new( + "a runtimeconfig.json reference", + "PackedApp.runtimeconfig.json", + """{"runtimeOptions":{"configProperties":{"Microsoft.CodeAnalysis.Leak":true}}}""" + ), + () => + new( + "a blazor.boot.json asset", + "wwwroot/_framework/blazor.boot.json", + """{"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 : System.IO.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( + System.IO.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 = $"{System.IO.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( + System.IO.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( + System.IO.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", + """{"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", """{"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 = System.IO.Path.Combine(root, file.RelativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); + Directory.CreateDirectory(System.IO.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", + System.IO.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(System.IO.Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(System.IO.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) { } + } + } +} From 2afab9b9b618b44c65d7546b41cfa03a31a747c4 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:10:37 +0000 Subject: [PATCH 082/107] test(trim): record why the rooted publish still tolerates IL3054 ZodType.Or returns ZodTypedUnion, which derives from ZodType, object>, and Union is a struct, so rooting expands Union> without bound. Every change that breaks the cycle breaks the 2.4.2 API: sealing is CP0009 and, measured, still leaves 32 IL3054 warnings. The tolerance stays, scoped to the rooted mode. --- docs/wiki/Trimming-and-AOT.md | 42 +++++++++++++++++++ .../TrimSafety.Verification.csproj | 4 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/wiki/Trimming-and-AOT.md b/docs/wiki/Trimming-and-AOT.md index f48fd06f..c955784c 100644 --- a/docs/wiki/Trimming-and-AOT.md +++ b/docs/wiki/Trimming-and-AOT.md @@ -100,6 +100,48 @@ is not trim- or AOT-compatible. Every API that reaches it (`DeserializeAndValida `[RequiresDynamicCode]`. Use `Purview.ZodSharp.SystemTextJson` with a source-generated context in a trimmed or AOT application. +## The rooted publish and IL3054 (generic recursion) + +The rooted publish of `src/tests/TrimSafety.Verification` tolerates exactly one warning, IL3054, and the consumer +publish tolerates none. IL3054 is ILC reporting that it stopped expanding a generic cycle: + +```text +ZodTypedUnion<__Canon,__Canon>: Generic expansion to +'ZodType<__Canon,Union,__Canon>,__Canon>,__Canon>,__Canon>>.Or<__Canon>(...)' +was aborted due to generic recursion. +``` + +The cycle is in the public type shape: + +- `ZodType.Or(other)` returns `ZodTypedUnion`. +- `ZodTypedUnion` derives from `ZodType, object>`, so it inherits `Or` (and `And`, `Catch`, + `Default`, `Prefault`, `Refine`, `SuperRefine`, `Transform` and `Pipe`, which close their own generic types over + the output type in the same way). +- `Union` is a `readonly struct`, so `Union, T3>` is a new, unshareable instantiation at every + step. A class would canonicalise to `__Canon` and end the expansion; a struct does not. + +Nothing in the library calls `Or` (or any of the others) on its own result, so no reachable code needs a deeper +instantiation. ILC only builds the chain because `TrimmerRootAssembly` roots every method of every type, including +the inherited generic methods of each `ZodTypedUnion` it constructs, and the warning's own message ("an exception will +be thrown at runtime if this codepath is ever reached") describes code no application reaches unless it nests `Or` +that deep itself. The consumer publish - nothing rooted, `Or()` and `NativeUnion()` called - compiles with no IL3054, +and so does the packed-consumer gate (`just smoke-packed-consumers`). + +It is not fixed in 3.0, because every change that breaks the cycle breaks the 2.4.2 API that package validation holds +the packages to: + +| Change | Why it is not taken | +|---|---| +| Seal `ZodTypedUnion` | Package validation rejects it (`CP0009`: the type gains the `sealed` modifier). It would not help either: measured, the rooted publish still reports 32 IL3054 warnings with the class sealed, because the recursion runs through the methods `ZodTypedUnion` inherits, not through overrides. | +| Make `Or` non-virtual | It already is. So are `And`, `Catch`, `Default`, `Prefault`, `Refine`, `SuperRefine`, `Transform` and `Pipe`. | +| Derive `ZodTypedUnion` from a base without `Or` | Changes the base type of a public class, and every caller that holds a union as `ZodType, object>`. | +| Make `Union` a class | Changes a public value type to a reference type: binary-breaking, and an allocation per union result. | +| Move `Or` off `ZodType` (an extension method constrained away from unions) | Removes a public instance member (`CP0002`). | + +The tolerance is scoped as narrowly as ILC allows: `IL3054` is in `NoWarn` only when `TrimVerificationMode` is +`Rooted`, and every other trim or AOT warning in the rooted publish, including one in a ZodSharp assembly, still fails +it. Revisit it in the next major version, where the type shape can change. + ## Related - [Source Generator](Source-Generator.md) — generated registration and validators. diff --git a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj index 30b4ba0b..d94f896c 100644 --- a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj +++ b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj @@ -78,7 +78,9 @@ their code, not only what this program calls. Rooting also makes ILC expand ZodType.Or() over its own output (Union>) until it gives up, which it reports as IL3054 generic recursion. That expansion only exists because the generic method is rooted; consumer mode below proves an application that calls Or() and - NativeUnion() compiles without it, so IL3054 alone is tolerated here. + NativeUnion() compiles without it, so IL3054 alone is tolerated here. Breaking the cycle needs a binary-breaking + change to the public type shape; docs/wiki/Trimming-and-AOT.md ("The rooted publish and IL3054") records each + option and why it is not taken. - Consumer (-p:TrimVerificationMode=Consumer): nothing is rooted and nothing is tolerated, exactly as an application that references the packages is compiled; the published binary must still pass every check. --> From 9b0de1f3991fdd4a44c5fd5252e6257f3e011638 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:29:37 +0000 Subject: [PATCH 083/107] fix(openapi): enable the OpenAPI generator interceptors through buildTransitive PrivateAssets="none" on Microsoft.AspNetCore.OpenApi flowed its build/ targets into in-repo ProjectReference consumers as well, where its XML-comment target fed duplicate doc IDs to the generator and every AspNetCore.OpenApi unit test failed with a 500. Revert that and ship buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets instead: it adds only the Microsoft.AspNetCore.OpenApi.Generated interceptor namespace, which is what a package-only consumer lacked (CS9137). A direct Microsoft.AspNetCore.OpenApi reference still builds; the namespace is then listed twice, which is harmless. --- .config/purview-build.json | 1 + CHANGELOG.md | 13 +++++++------ .../AspNetCore.OpenApi/AspNetCore.OpenApi.csproj | 8 +------- .../Purview.ZodSharp.AspNetCore.OpenApi.targets | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 src/src/AspNetCore.OpenApi/Sdk/buildTransitive/Purview.ZodSharp.AspNetCore.OpenApi.targets diff --git a/.config/purview-build.json b/.config/purview-build.json index 81221013..3616a1c5 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -56,6 +56,7 @@ "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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 90020d74..15354956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,12 +355,13 @@ Released versions correspond to `v` GitHub releases. every name). ### Fixed -- **`Purview.ZodSharp.AspNetCore.OpenApi` alone is enough to call `AddOpenApi`.** The package referenced - Microsoft.AspNetCore.OpenApi with the default private assets, so a consumer that installed only - `Purview.ZodSharp.AspNetCore.OpenApi` (as its README shows) got Microsoft.AspNetCore.OpenApi's source generator but - not its `build/` targets, which enable the generator's interceptors, and `AddOpenApi(...)` failed to compile with - CS9137. The dependency now flows every asset. Found by the new packed-consumer release gate - (`just smoke-packed-consumers`), which builds a Native AOT minimal API against the packed packages only. +- **`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 diff --git a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj index f6cac916..7ef2c1b2 100644 --- a/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj +++ b/src/src/AspNetCore.OpenApi/AspNetCore.OpenApi.csproj @@ -24,13 +24,7 @@ - - + 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 + + From bf8ef22a0ec190cb3b5405e3ea177c6c875cf0c8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 00:53:39 +0000 Subject: [PATCH 084/107] ci: scan project bins in the local publish recipes and clarify the rule proof input publish-aot-sample, verify-trim and wasm-size now leak-scan the project's bin/Release as well as the publish folder, as the CI jobs do. verify-pack-rules reads artifacts/, which pipeline-local-release empties into the local feed, so say to pass -PackagesFolder there. --- Justfile | 11 ++++++----- docs/wiki/Release-Flow.md | 4 ++-- scripts/test-pack-validation-rules.ps1 | 8 ++++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Justfile b/Justfile index a7b8e3fa..801831af 100644 --- a/Justfile +++ b/Justfile @@ -75,8 +75,9 @@ smoke-packed-consumers *args: 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. Pack first -# (`just pipeline-pack-validate`, or `just pack ./artifacts -c Release`); pass -PackagesFolder to use other packages. +# 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..." @@ -146,7 +147,7 @@ pack publish_folder=artifacts_folder *args: 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 }} + 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. @@ -159,7 +160,7 @@ verify-trim rid=default_rid *args: 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 + 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 @@ -169,7 +170,7 @@ wasm-size *args: 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 + 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. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index dc530e2a..8aefb719 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -67,8 +67,8 @@ just verify-trim # rooted and consumer Native AOT h just publish-aot-sample # Native AOT minimal API sample and leak scan ``` -`just verify-pack-rules` re-proves the forbidden-content rules against the packages in `artifacts/`; run it after -changing `PackValidation`. +`just verify-pack-rules` re-proves the forbidden-content rules against the packages in `artifacts/` (after `just pipeline-local-release`, which moves them to the local feed, pass `-PackagesFolder `); run it after changing +`PackValidation`. ## Workflows diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 index cf80667a..f1b94bfa 100644 --- a/scripts/test-pack-validation-rules.ps1 +++ b/scripts/test-pack-validation-rules.ps1 @@ -22,7 +22,8 @@ .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-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. @@ -47,7 +48,10 @@ if ($rules.Count -eq 0) { throw "No ForbiddenContent rules for '*' in $configPat $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 just pack -c Release)." + 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. From ef504a0b12f64a2bd8a5800b104e4d78564d3431 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 01:21:26 +0000 Subject: [PATCH 085/107] fix: define [ZodSchema] and [ErrorType] in Purview.ZodSharp instead of generating them The generators emitted ZodSchemaAttribute and ErrorTypeAttribute, public and documented, into every compilation as post-initialization output. Every project in a solution then documented the same type IDs, and the ASP.NET Core OpenAPI XML-comment generator - which reads an app's own public and internal types plus each referenced project's XML file - added the same key twice and threw: "An item with the same key has already been added. Key: T:ZodSharp.Core.ErrorTypeAttribute". [Embedded] already prevented CS0436, and internal copies would still have collided, so both attributes are now ordinary public types in Purview.ZodSharp, with the members the package already exposed as generated [Embedded] copies, and the generators emit no marker types at all. 2.x is affected too (reproduced with 2.4.2 on net10.0). --- AGENTS.md | 2 +- CHANGELOG.md | 25 ++ docs/wiki/Source-Generator.md | 2 +- .../SKILL.md | 5 +- .../SourceGenerators/ErrorTypeGenerator.cs | 30 +- .../Helpers/AttributeGenHelper.cs | 205 +-------- .../SourceGenerators/ZodSchemaGenerator.cs | 11 +- src/src/ZodSharp/Core/ErrorTypeAttribute.cs | 13 + src/src/ZodSharp/ZodSchemaAttribute.cs | 102 +++++ .../CrossAssemblyMarkerAttributeTests.cs | 399 ++++++++++++++++++ .../ErrorTypeGeneratorTests.cs | 26 ++ .../Infra/ErrorTypeAnalyzerTestOptions.cs | 11 - .../Infra/ErrorTypeGeneratorTestOptions.cs | 1 - .../Infra/ZodAnalyzerTestOptions.cs | 21 - .../Infra/ZodCodeFixTestOptions.cs | 18 - .../Infra/ZodSharpSourceGeneratorTestBase.cs | 7 +- .../Infra/ZodSourceGeneratorTestOptions.cs | 1 - .../MSBuildSwitchGlobalConfigTests.cs | 3 +- .../ZodSchemaGeneratorTests.AttributeGen.cs | 97 +++-- 19 files changed, 650 insertions(+), 329 deletions(-) create mode 100644 src/src/ZodSharp/Core/ErrorTypeAttribute.cs create mode 100644 src/src/ZodSharp/ZodSchemaAttribute.cs create mode 100644 src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs diff --git a/AGENTS.md b/AGENTS.md index 902f70c6..8fd20830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Formatting is enforced with CSharpier; `.editorconfig` at the repo root defines ## 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`: diff --git a/CHANGELOG.md b/CHANGELOG.md index 15354956..3397ced8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,26 @@ Released versions correspond to `v` GitHub releases. every name). ### Fixed +- **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 @@ -491,6 +511,11 @@ Each note names the old form, the new form, and the edit to make. 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. +- **`[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. ## 2.4.1 diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 740f3dc8..34f59ad1 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -1,6 +1,6 @@ # Source Generator -Mark a class, struct, or record with `[ZodSchema]` and the generator emits a static, zero-allocation validator at compile time. The `[ZodSchema]` attribute is generated into the `ZodSharp` namespace by the generator itself (assembly `Purview.ZodSharp.SourceGenerators`), so no extra package is needed beyond `Purview.ZodSharp`. +Mark a class, struct, or record with `[ZodSchema]` and the generator emits a static, zero-allocation validator at compile time. The `[ZodSchema]` attribute is `ZodSharp.ZodSchemaAttribute`, defined in the `Purview.ZodSharp` assembly (as is `[ErrorType]`), so no extra package is needed beyond `Purview.ZodSharp`. The generator emits no marker types into your project, so every project in a solution binds to the same attribute. ```csharp using System.ComponentModel.DataAnnotations; diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index 5df69ccd..79030ecf 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -15,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; diff --git a/src/src/SourceGenerators/ErrorTypeGenerator.cs b/src/src/SourceGenerators/ErrorTypeGenerator.cs index 4ff85d70..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 @@ -90,26 +84,4 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } }); } - - 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/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/ZodSchemaGenerator.cs b/src/src/SourceGenerators/ZodSchemaGenerator.cs index c7aeb775..d5726372 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.cs @@ -18,14 +18,9 @@ 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) => 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/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/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs b/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs new file mode 100644 index 00000000..bfd450ec --- /dev/null +++ b/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs @@ -0,0 +1,399 @@ +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 + ); + + if (generatorDiagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)) + throw new InvalidOperationException(string.Join(Environment.NewLine, generatorDiagnostics)); + + return (output, driver.GetRunResult().GeneratedTrees); + } + + static byte[] Emit(Compilation compilation, CancellationToken cancellationToken) + { + using MemoryStream stream = new(); + var result = compilation.Emit(stream, cancellationToken: cancellationToken); + if (!result.Success) + { + throw new InvalidOperationException( + $"{compilation.AssemblyName} did not compile:{Environment.NewLine}{string.Join(Environment.NewLine, Errors(result.Diagnostics))}" + ); + } + + 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/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/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 45a45cf9..db1e80dc 100644 --- a/src/tests/SourceGenerators.UnitTests/Infra/ZodSourceGeneratorTestOptions.cs +++ b/src/tests/SourceGenerators.UnitTests/Infra/ZodSourceGeneratorTestOptions.cs @@ -20,7 +20,6 @@ public ZodSourceGeneratorTestOptions() typeof(Microsoft.Extensions.Options.ValidateOptionsResult), ]; AdditionalNamespaces = [TypeLibraryGenerator.ZodSharpNamespace]; - ExcludeGeneratedSourceHintNames = ["EmbeddedAttribute", "ZodSchemaAttribute"]; DisableSourceGeneratorPropertyName = PropertyLibrary.DisableZodSharpSourceGeneratorProperty; } diff --git a/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs b/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs index a548a053..749eed48 100644 --- a/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs +++ b/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs @@ -154,9 +154,8 @@ [new ZodSchemaGenerator().AsSourceGenerator()], Environment.NewLine, result .GeneratedTrees - // The generated attribute sources document the switches, so only the schema outputs are inspected. + // Only the schema outputs are inspected; a generated rule attribute documents its own options. .Where(tree => !tree.FilePath.EndsWith("Attribute.g.cs", StringComparison.Ordinal)) - .Where(tree => !tree.FilePath.EndsWith("EmbeddedAttribute.cs", StringComparison.Ordinal)) .Select(tree => tree.GetText(cancellationToken).ToString()) ); } 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(); } } From e81a692e5cc39f26cd94b675748d4ff3ffa3e012 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 01:21:27 +0000 Subject: [PATCH 086/107] test(packed): add a multi-project OpenAPI consumer to the packed-consumer gate Consumer (d) is a [ZodSchema]/[ErrorType] class library referenced by a minimal API that references Microsoft.AspNetCore.OpenApi directly, with XML documentation on in both projects. The gate publishes and starts it, checks the OpenAPI comment cache has no duplicate documentation ID and no ZodSharp marker attribute, and asserts /openapi/v1.json carries both projects' schemas, the ZodSharp constraints and the XML-comment descriptions, plus the library's issue paths and ErrorType at runtime. Against the previous packages it fails with the duplicate-key crash. --- Justfile | 3 +- docs/wiki/Release-Flow.md | 4 +- .../MultiProject/Api/Api.csproj | 43 ++++++ .../MultiProject/Api/Program.cs | 53 ++++++++ .../MultiProject/Contracts/Contracts.csproj | 31 +++++ .../MultiProject/Contracts/Models.cs | 49 +++++++ scripts/test-packed-consumers.ps1 | 125 +++++++++++++++++- 7 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 scripts/packed-consumers/MultiProject/Api/Api.csproj create mode 100644 scripts/packed-consumers/MultiProject/Api/Program.cs create mode 100644 scripts/packed-consumers/MultiProject/Contracts/Contracts.csproj create mode 100644 scripts/packed-consumers/MultiProject/Contracts/Models.cs diff --git a/Justfile b/Justfile index 801831af..32217dee 100644 --- a/Justfile +++ b/Justfile @@ -64,7 +64,8 @@ smoke-packed-generator *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) and a Native AOT minimal API, each leak-scanned for compiler-host assemblies. +# 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 diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 8aefb719..c892c400 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -31,7 +31,7 @@ rather than passing. | Pack and pack validation | Every package packs; package validation holds the public API to the 2.4.2 baseline; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` (`run-pack`, `validate-pack`) | | Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of each package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` | | Packed generator | The IL-merged generator in the `.nupkg` loads and generates in a net10.0 consumer (it has regressed twice while the in-repo tests, which use the unmerged generator, stayed green). | `just smoke-packed-generator` | `pr.yml` `packed-generator` | -| Packed consumers | Every shipping package, packed to an isolated feed and cache, works for consumers that reference packages only: a Native AOT console (generated validation, member resolution, count attributes, JSON Schema export, the validation bridge, ProblemDetails, OpenAPI options), a Blazor WebAssembly app with `` (trimmed, and `RunAOTCompilation` when `wasm-tools` is installed; `_framework` holds the ZodSharp assemblies, `_content` the static web assets) and a Native AOT minimal API (an invalid POST returns 400 `application/problem+json` with `issues`; the OpenAPI document carries `ZodValidationProblemDetails`). Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | +| Packed consumers | Every shipping package, packed to an isolated feed and cache, works for consumers that reference packages only: a Native AOT console (generated validation, member resolution, count attributes, JSON Schema export, the validation bridge, ProblemDetails, OpenAPI options), a Blazor WebAssembly app with `` (trimmed, and `RunAOTCompilation` when `wasm-tools` is installed; `_framework` holds the ZodSharp assemblies, `_content` the static web assets) and a Native AOT minimal API (an invalid POST returns 400 `application/problem+json` with `issues`; the OpenAPI document carries `ZodValidationProblemDetails`), and a multi-project API - a `[ZodSchema]`/`[ErrorType]` class library referenced by a minimal API that references `Microsoft.AspNetCore.OpenApi` with XML documentation on (the OpenAPI document is served with both projects' schemas and XML-comment descriptions, and no documentation ID is duplicated). Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | | Compiler-host leak scan | No generator, code-fix, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest. Purview.SourceGeneratorFramework.dll once leaked this way and broke Blazor WebAssembly AOT while every gate passed. | `scripts/assert-no-compiler-host-assemblies.ps1 -Path ` (`just scan-compiler-host-leaks `); every publish recipe below runs it | a step in every job that publishes; `ReleaseGateTests` fails a job that publishes without it | | Trim and Native AOT harness | The whole trim-safe surface is warning-free under ILC with every ZodSharp assembly rooted (IL3054 alone tolerated, see [Trimming and Native AOT](Trimming-and-AOT.md#the-rooted-publish-and-il3054-generic-recursion)), and again as a plain consumer; each native binary checks its behaviour. | `just verify-trim ` | `pr.yml` `verify-trim` | | Native AOT sample | Examples.MinimalApi publishes with `PublishAot=true` and no IL warning, and the native binary returns the ProblemDetails contract. | `just publish-aot-sample` | `pr.yml` `aot-sample` | @@ -61,7 +61,7 @@ just scrub # delete bin/obj, clean, force-res just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ # restore, build, lint, tests, pack, pack validation, local publish just smoke-packed-generator # the IL-merged generator loads and generates from the .nupkg -just smoke-packed-consumers # Native AOT, Blazor WebAssembly and minimal API consumers of the packages +just smoke-packed-consumers # Native AOT, Blazor WebAssembly, minimal API and multi-project API consumers just wasm-size # trimmed WebAssembly publish, size budget and leak scan just verify-trim # rooted and consumer Native AOT harness, e.g. win-x64 or linux-x64 just publish-aot-sample # Native AOT minimal API sample and leak scan 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-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index beac6aa0..b5982808 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -32,6 +32,11 @@ 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 @@ -219,18 +224,30 @@ $mappingXml "@ | Set-Content $path -Encoding utf8 } -function Start-Api([string] $directory, [int] $port) { +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 "PackedApi$exe") -WorkingDirectory $directory -PassThru ` - -RedirectStandardOutput (Join-Path $logs 'minimal-api.stdout.log') ` - -RedirectStandardError (Join-Path $logs 'minimal-api.stderr.log') + 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 @@ -534,6 +551,106 @@ try { 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 From 7a2a91211acb3dda2c5adc53c0c390fec50be01d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:01:04 +0000 Subject: [PATCH 087/107] build(deps): bump Purview.BuildSdk and Purview.Skills.Testing to 1.2.0 BuildSdk 1.2.0 rewrites its managed .editorconfig and adds PRBLZ0001: every project a Blazor WebAssembly client references must be browser-safe. Purview.ZodSharp, Extensions.Validation, Blazor and Examples.Shared run in the browser, so they set PurviewBrowserSafe=true, which turns the browser-unsupported API, trim and AOT diagnostics into errors; all four build clean under them. --- .editorconfig | 2 +- Directory.Packages.props | 2 +- global.json | 2 +- src/src/Blazor/Blazor.csproj | 3 +++ src/src/Examples.Shared/Examples.Shared.csproj | 3 +++ src/src/Extensions.Validation/Extensions.Validation.csproj | 3 +++ src/src/ZodSharp/ZodSharp.csproj | 5 ++++- 7 files changed, 16 insertions(+), 4 deletions(-) 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/Directory.Packages.props b/Directory.Packages.props index 7e5e1271..00baca88 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,7 +69,7 @@ - + diff --git a/global.json b/global.json index 59c4a9d3..ec9d6f9d 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/src/src/Blazor/Blazor.csproj b/src/src/Blazor/Blazor.csproj index e5175c11..1fb1dbc2 100644 --- a/src/src/Blazor/Blazor.csproj +++ b/src/src/Blazor/Blazor.csproj @@ -1,5 +1,8 @@ + + true + true + true + true true Purview.ZodSharp Purview.ZodSharp From 5f6ee3b1d00d7f6186265c6946a88409485e0513 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:33:45 +0000 Subject: [PATCH 088/107] fix(generator): test for null with patterns so member operators never bind The generator emitted value.Member == null and != null. A [Required] Nullable member whose type defines ==(string?, T) plus an implicit conversion to string (every string-backed value object) failed with CS9342, and a reference type with a ==(T?, string?) overload failed the same way. Every emitted null test is now is null / is not null. 2.4.2 emitted the same comparison. --- CHANGELOG.md | 8 + .../ZodSchemaGenerator.CustomRules.cs | 6 +- .../ZodSchemaGenerator.Validation.cs | 6 +- ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 8 +- .../ZodSchemaGeneratorTests.OperatorSafety.cs | 171 ++++++++++++++++++ 5 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.OperatorSafety.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3397ced8..b0428e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,14 @@ Released versions correspond to `v` GitHub releases. every name). ### 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). - **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 diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index d18b0560..bea8f02d 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -91,7 +91,7 @@ CustomRuleDescriptor requiredRule PathOf(property), MemberOf(property), GetFallbackOrigin(property), - invalidCondition: $"value.{propertyName} == null || {scalarExpression}.{scalarPropertyName} == null" + invalidCondition: $"value.{propertyName} is null || {scalarExpression}.{scalarPropertyName} is null" ); return; } @@ -109,7 +109,7 @@ CustomRuleDescriptor requiredRule PathOf(property), MemberOf(property), GetFallbackOrigin(property), - invalidCondition: $"value.{propertyName} == null" + invalidCondition: $"value.{propertyName} is null" ); return; } @@ -275,7 +275,7 @@ static void EmitGuardedScalarRule( pathExpression, memberExpression, fallbackOrigin, - invalidCondition: $"{underlyingExpression} == null" + invalidCondition: $"{underlyingExpression} is null" ); writer.NewLine(); return; diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs index acf8a4d3..a9a13f57 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -773,7 +773,7 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor property.PropertyType.SpecialType == SpecialType.System_String && !required.AllowEmptyStrings; var comparison = rejectsBlankStrings ? $"string.IsNullOrWhiteSpace(value.{propertyName})" - : $"value.{propertyName} == null"; + : $"value.{propertyName} is null"; // The shared missing-field identity (code missing_field, category invalid_type, the runtime // "{displayName} is required" template); an attribute message replaces only the text. @@ -804,7 +804,7 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor // 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($"value.{propertyName} == null")) + using (writer.IfBlockScope($"value.{propertyName} is null")) { WriteAddError( writer, @@ -822,7 +822,7 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor { GenerateValueSetValidations(writer, property, emitEnumRule); if (property.IsDeclaredNullable) - block = writer.IfBlockScope($"value.{propertyName} != null"); + block = writer.IfBlockScope($"value.{propertyName} is not null"); } GenerateTypeSpecificValidations(writer, property, emitEnumRule, skipRequiredRule: hasRequiredRule); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 6e8f438e..1d1d73ab 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -247,7 +247,7 @@ 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).DoesNotContain("value.Value == null"); + await Assert.That(generated).DoesNotContain("value.Value is null"); await Assert.That(generated).ContainsGeneratedCode("ZodPath.Member(\"Value\")"); } @@ -646,7 +646,7 @@ 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).DoesNotContain("value.Value == null"); + 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\""); @@ -852,7 +852,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\""); } @@ -994,6 +994,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.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"]); + } +} From 527f01267e4445e3a74fa26b5ecfafbef58a8de2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:38:35 +0000 Subject: [PATCH 089/107] fix(generator): pass a rule identity by direct cast so wasm aot compiles An adapted or required rule's identity was emitted as (object)rule as IZodRule, a runtime type test on a boxed struct that crashed the Mono AOT compiler (0xC0000005) for a scalar with a type-level [ZodRule]. The generator knows whether the rule implements IZodRule, so it emits (IZodRule)rule, or null when it does not. The packed Blazor WebAssembly consumer now carries such a scalar. --- CHANGELOG.md | 8 ++++ docs/wiki/Release-Flow.md | 2 +- docs/wiki/Value-Objects-Integration.md | 5 +++ scripts/packed-consumers/BlazorWasm/Models.cs | 18 ++++++++- .../BlazorWasm/ValueObjects.cs | 34 ++++++++++++++++ scripts/test-packed-consumers.ps1 | 5 ++- skills/purview-zodsharp-custom-rules/SKILL.md | 5 +++ .../ZodSchemaGenerator.CustomRules.cs | 8 +++- ...ZodSchemaGeneratorTests.AutomaticScalar.cs | 4 +- ...odSchemaGeneratorTests.ScalarAdaptation.cs | 40 ++++++++++++++++++- 10 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 scripts/packed-consumers/BlazorWasm/ValueObjects.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b0428e26..f2aa82f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -363,6 +363,14 @@ Released versions correspond to `v` GitHub releases. 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. - **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 diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index c892c400..5c5a4c2f 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -31,7 +31,7 @@ rather than passing. | Pack and pack validation | Every package packs; package validation holds the public API to the 2.4.2 baseline; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` (`run-pack`, `validate-pack`) | | Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of each package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` | | Packed generator | The IL-merged generator in the `.nupkg` loads and generates in a net10.0 consumer (it has regressed twice while the in-repo tests, which use the unmerged generator, stayed green). | `just smoke-packed-generator` | `pr.yml` `packed-generator` | -| Packed consumers | Every shipping package, packed to an isolated feed and cache, works for consumers that reference packages only: a Native AOT console (generated validation, member resolution, count attributes, JSON Schema export, the validation bridge, ProblemDetails, OpenAPI options), a Blazor WebAssembly app with `` (trimmed, and `RunAOTCompilation` when `wasm-tools` is installed; `_framework` holds the ZodSharp assemblies, `_content` the static web assets) and a Native AOT minimal API (an invalid POST returns 400 `application/problem+json` with `issues`; the OpenAPI document carries `ZodValidationProblemDetails`), and a multi-project API - a `[ZodSchema]`/`[ErrorType]` class library referenced by a minimal API that references `Microsoft.AspNetCore.OpenApi` with XML documentation on (the OpenAPI document is served with both projects' schemas and XML-comment descriptions, and no documentation ID is duplicated). Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | +| Packed consumers | Every shipping package, packed to an isolated feed and cache, works for consumers that reference packages only: a Native AOT console (generated validation, member resolution, count attributes, JSON Schema export, the validation bridge, ProblemDetails, OpenAPI options), a Blazor WebAssembly app with `` and a scalar value object carrying a type-level rule (trimmed, and `RunAOTCompilation` when `wasm-tools` is installed; `_framework` holds the ZodSharp assemblies, `_content` the static web assets) and a Native AOT minimal API (an invalid POST returns 400 `application/problem+json` with `issues`; the OpenAPI document carries `ZodValidationProblemDetails`), and a multi-project API - a `[ZodSchema]`/`[ErrorType]` class library referenced by a minimal API that references `Microsoft.AspNetCore.OpenApi` with XML documentation on (the OpenAPI document is served with both projects' schemas and XML-comment descriptions, and no documentation ID is duplicated). Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | | Compiler-host leak scan | No generator, code-fix, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest. Purview.SourceGeneratorFramework.dll once leaked this way and broke Blazor WebAssembly AOT while every gate passed. | `scripts/assert-no-compiler-host-assemblies.ps1 -Path ` (`just scan-compiler-host-leaks `); every publish recipe below runs it | a step in every job that publishes; `ReleaseGateTests` fails a job that publishes without it | | Trim and Native AOT harness | The whole trim-safe surface is warning-free under ILC with every ZodSharp assembly rooted (IL3054 alone tolerated, see [Trimming and Native AOT](Trimming-and-AOT.md#the-rooted-publish-and-il3054-generic-recursion)), and again as a plain consumer; each native binary checks its behaviour. | `just verify-trim ` | `pr.yml` `verify-trim` | | Native AOT sample | Examples.MinimalApi publishes with `PublishAot=true` and no IL warning, and the native binary returns the ProblemDetails contract. | `just publish-aot-sample` | `pr.yml` `aot-sample` | diff --git a/docs/wiki/Value-Objects-Integration.md b/docs/wiki/Value-Objects-Integration.md index 8dfd7081..fa8ffd82 100644 --- a/docs/wiki/Value-Objects-Integration.md +++ b/docs/wiki/Value-Objects-Integration.md @@ -291,6 +291,11 @@ if (!assetIdCustomRule0.IsValid(value)) The reported error keeps the **empty path**, and the error identity (`Code`/`Origin`) is read from the **wrapped** rule, not the adapter. +Whether the wrapped rule has an identity is decided when the schema is generated: a rule that implements `IZodRule` is +passed with a direct cast, and one that does not is passed as `null`. Generated code never tests a boxed rule at runtime +(`(object)rule as IZodRule`), which crashed the Mono AOT compiler, so a scalar with a type-level rule publishes for +Blazor WebAssembly with `RunAOTCompilation=true`. + > [!NOTE] > If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](Custom-Rules.md#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](Custom-Rules.md#generic-rules). diff --git a/scripts/packed-consumers/BlazorWasm/Models.cs b/scripts/packed-consumers/BlazorWasm/Models.cs index 08a31c2a..08faf88b 100644 --- a/scripts/packed-consumers/BlazorWasm/Models.cs +++ b/scripts/packed-consumers/BlazorWasm/Models.cs @@ -4,7 +4,7 @@ namespace PackedWasm; -/// The form model: generated rules on a nested object and a counted collection. +/// The form model: generated rules on a nested object, a counted collection and a scalar value object. [ZodSchema] public sealed class Registration { @@ -21,6 +21,8 @@ public sealed class Registration [MaxCount(3)] public List Tags { get; set; } = []; + + public PlanId Plan { get; set; } = new() { Value = Guid.NewGuid() }; } [ZodSchema] @@ -30,3 +32,17 @@ public sealed class Address [StringLength(100, MinimumLength = 1)] public string? City { get; set; } } + +/// +/// A scalar value object with a type-level rule, shaped like a Purview.ValueObjects scalar: the generator adapts the +/// Guid rule through ScalarRuleAdapter and copies the wrapped rule's identity. It once emitted that copy as +/// (object)rule as IZodRule, a runtime type test on a boxed struct that crashed the Mono AOT compiler (exit code +/// 0xC0000005) under RunAOTCompilation=true, so this type is what the WebAssembly AOT publish proves. +/// +[Purview.ValueObjects.Serialization.Scalar] +[NonSentinel(Message = "Choose a plan.")] +[ZodSchema] +public readonly partial record struct PlanId : Purview.ValueObjects.IScalarValueObject +{ + public Guid Value { get; init; } +} diff --git a/scripts/packed-consumers/BlazorWasm/ValueObjects.cs b/scripts/packed-consumers/BlazorWasm/ValueObjects.cs new file mode 100644 index 00000000..9886fafb --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/ValueObjects.cs @@ -0,0 +1,34 @@ +// Stand-ins for the Purview.ValueObjects contracts the ZodSharp generator recognizes, so this consumer depends on +// nothing but the packed ZodSharp packages. In a real consumer the value-object generator emits the adapter. +namespace Purview.ValueObjects.Serialization +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class ScalarAttribute(string propertyName = "Value") : Attribute + { + public string PropertyName { get; } = propertyName; + } +} + +namespace Purview.ValueObjects +{ + public interface IScalarValueObject + where TSelf : IScalarValueObject + { + TValue Value { get; } + } + + public readonly record struct ScalarRuleAdapter(TRule Rule) + : ZodSharp.Core.IValidationRule + where TSelf : IScalarValueObject + where TRule : ZodSharp.Core.IValidationRule + { + // The validation-rule conventions (ZODSGEN042) apply to every rule declared in a compilation. + public const string ErrorCode = "validation_failed"; + + public const string MessageFormat = "{displayName} is invalid."; + + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); + } +} diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index b5982808..033b9f24 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -27,8 +27,9 @@ 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 . Published trimmed with -warnaserror, and with - RunAOTCompilation=true when the wasm-tools workload is installed. _framework must hold the ZodSharp + (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. diff --git a/skills/purview-zodsharp-custom-rules/SKILL.md b/skills/purview-zodsharp-custom-rules/SKILL.md index 974833ee..4bf4c492 100644 --- a/skills/purview-zodsharp-custom-rules/SKILL.md +++ b/skills/purview-zodsharp-custom-rules/SKILL.md @@ -234,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); drop no rules to work around it, upgrade instead. + ## Diagnostics you will meet `ZODSGEN030` (mapped rule does not implement `IValidationRule` for the property type), diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index bea8f02d..7adaa45d 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs @@ -381,10 +381,16 @@ static void WriteRuleFailure( // 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}, (object){identityVariable} as {zodRuleInterface}{originArgument})"; + : $"global::ZodSharp.Core.ZodRuleErrors.Build({codeExpression}, {ruleVariable}.GetErrorMessage({valueVariable}), {pathExpression}, {identityArgument}{originArgument})"; var errorExpression = !string.IsNullOrEmpty(rule.Message.ErrorMessage) diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs index 1d1d73ab..ab9055c3 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AutomaticScalar.cs @@ -392,7 +392,9 @@ await Assert ); await Assert .That(generated) - .ContainsGeneratedCode("EmptyPath, (object)externalIdCustomRuleInner0 as global::ZodSharp.Core.IZodRule)"); + .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] diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs index 57f774a2..4a3ac942 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.ScalarAdaptation.cs @@ -200,7 +200,9 @@ await Assert await Assert.That(generated).ContainsGeneratedCode(".IsValid(value)"); await Assert .That(generated) - .ContainsGeneratedCode("EmptyPath, (object)tenantIdCustomRuleInner0 as global::ZodSharp.Core.IZodRule)"); + .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) @@ -400,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."); + } } From 7d8545b453bda7c50348e1f73114cd864c9c3a29 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:53:02 +0000 Subject: [PATCH 090/107] fix(blazor): place errors inside struct members on the owning field An error inside a value object (Email.Value) resolved to a boxed copy of the struct, which cannot be a FieldIdentifier model, so DataAnnotationsValidator and Server-side ZodValidator dropped the message and WebAssembly showed it only in the summary. The generated TryResolveMember, the validation bridge and the Blazor field locator now attribute it to the nearest reference-type owner and its member. A nested scalar's messages also use the owning member's display name. --- CHANGELOG.md | 15 ++ docs/wiki/Blazor-Integration.md | 34 ++++ docs/wiki/Microsoft-Extensions-Validation.md | 2 +- docs/wiki/Source-Generator.md | 5 + skills/purview-zodsharp-blazor/SKILL.md | 7 + src/src/Blazor/Sdk/README.md | 3 + src/src/Blazor/ZodFieldLocator.cs | 19 +- src/src/Extensions.Validation/Sdk/README.md | 2 +- .../Extensions.Validation/ZodMemberLocator.cs | 28 ++- .../Helpers/SourceGenLibrary.cs | 19 ++ .../Models/SchemaGenerationModel.cs | 6 + .../ZodSchemaGenerator.Resolution.cs | 28 ++- .../ZodSchemaGenerator.Validation.cs | 19 +- .../ZodSharp/Core/ZodGeneratedValidation.cs | 117 +++++++++++ .../Fixtures/StructMemberModels.cs | 51 +++++ .../StructMemberPlacementTests.cs | 183 ++++++++++++++++++ .../Fixtures/GeneratedModels.cs | 17 ++ ...ValidatableTypeInfoGeneratedSchemaTests.cs | 33 ++++ .../ZodSchemaGeneratorTests.StructMembers.cs | 127 ++++++++++++ .../Core/ZodGeneratedValidationTests.cs | 93 +++++++++ 20 files changed, 787 insertions(+), 21 deletions(-) create mode 100644 src/tests/Blazor.UnitTests/Fixtures/StructMemberModels.cs create mode 100644 src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructMembers.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/ZodGeneratedValidationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2aa82f6..b0f6df0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -371,6 +371,21 @@ Released versions correspond to `v` GitHub releases. `(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. +- **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 diff --git a/docs/wiki/Blazor-Integration.md b/docs/wiki/Blazor-Integration.md index 77a931c4..cdf30545 100644 --- a/docs/wiki/Blazor-Integration.md +++ b/docs/wiki/Blazor-Integration.md @@ -129,6 +129,40 @@ IZodUntypedValidator validator = ZodModelSchema.For(schema); 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 diff --git a/docs/wiki/Microsoft-Extensions-Validation.md b/docs/wiki/Microsoft-Extensions-Validation.md index 45b7ba9b..780df0ed 100644 --- a/docs/wiki/Microsoft-Extensions-Validation.md +++ b/docs/wiki/Microsoft-Extensions-Validation.md @@ -96,7 +96,7 @@ Each ZodSharp error becomes one `Microsoft.Extensions.Validation.ValidationError |---|---| | `Path` | The canonical ZodSharp path prefixed with `ValidateContext.CurrentValidationPath`: `Customer.Address.Street`, `Lines[0].Sku`, `ByCode[abc].Sku`. See [Core Concepts](Core-Concepts.md#paths). | | `Name` | The failing member's name (`Street`); empty for a model-level error. | -| `Container` | The object that owns the member (`order.Customer.Address`). Blazor uses it to place the message on the right field. | +| `Container` | The object that owns the member (`order.Customer.Address`). Blazor uses it to place the message on the right field. Never a struct: an error inside a struct member (`Email.Value`) is attributed to the nearest reference-type owner and its member (`signUp`, `Email`), because a value type cannot be a `FieldIdentifier` model. | | `ErrorMessage` | The ZodSharp message, formatted by `MessageFormatter` or localized (see [Localization](Localization.md)). | `Container` and `Name` come from the generated `TryResolveMember`, so no reflection is needed. Validators that are diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 34f59ad1..487404a2 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -136,6 +136,11 @@ returns `false`; a model-level error resolves to the root with an empty member n Microsoft.Extensions.Validation bridge uses this to give every error the `Container` and `Name` Blazor needs to place the message on the right field. +The container is never a struct. 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 an error inside a struct member resolves to the +nearest reference-type owner and its member: `Email.Value`, where `Email` is a value object on `SignUp`, +resolves to (`signUp`, `"Email"`), and `Stops[1].Latitude` over a `List` to (`signUp`, `"Stops"`). + ### Depth guard Generated validators count the nesting depth as they walk a graph and throw `InvalidOperationException` diff --git a/skills/purview-zodsharp-blazor/SKILL.md b/skills/purview-zodsharp-blazor/SKILL.md index 32bb14d9..481959f0 100644 --- a/skills/purview-zodsharp-blazor/SKILL.md +++ b/skills/purview-zodsharp-blazor/SKILL.md @@ -57,6 +57,13 @@ which is the only way to get nested validation in WebAssembly, where that genera 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 diff --git a/src/src/Blazor/Sdk/README.md b/src/src/Blazor/Sdk/README.md index 7addbeff..02f608f2 100644 --- a/src/src/Blazor/Sdk/README.md +++ b/src/src/Blazor/Sdk/README.md @@ -86,5 +86,8 @@ Limitations: - 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/ZodFieldLocator.cs b/src/src/Blazor/ZodFieldLocator.cs index 152635f0..aecc86e3 100644 --- a/src/src/Blazor/ZodFieldLocator.cs +++ b/src/src/Blazor/ZodFieldLocator.cs @@ -50,7 +50,7 @@ public static FieldIdentifier Locate(object root, ZodPath path, IZodUntypedValid if ( validator.ValidatedType.IsInstanceOfType(root) && validator.TryResolveMember(root, path, out resolvedContainer, out resolvedMember) - && resolvedContainer is not null + && IsFieldOwner(resolvedContainer) ) return new(resolvedContainer, resolvedMember ?? string.Empty); } @@ -70,12 +70,17 @@ public static FieldIdentifier Locate(object root, ZodPath path, IZodUntypedValid return new(container ?? root, member); // The validator followed part of the path itself: its nearest ancestor is better informed than ours. - if (resolvedContainer is not null && !string.IsNullOrEmpty(resolvedMember)) + if (IsFieldOwner(resolvedContainer) && !string.IsNullOrEmpty(resolvedMember)) return new(resolvedContainer, resolvedMember); 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; @@ -115,8 +120,14 @@ static bool Walk(object root, ZodPath path, bool ignoreCase, out object? contain return false; var property = FindCachedProperty(current.GetType(), segment.Name ?? string.Empty, ignoreCase); - container = current; - memberName = property?.Name ?? segment.Name ?? string.Empty; + + // 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; diff --git a/src/src/Extensions.Validation/Sdk/README.md b/src/src/Extensions.Validation/Sdk/README.md index 711a291e..748e3f8c 100644 --- a/src/src/Extensions.Validation/Sdk/README.md +++ b/src/src/Extensions.Validation/Sdk/README.md @@ -43,7 +43,7 @@ per-assembly extension the source generator emits instead of scanning: `builder. | --- | --- | | `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 | +| `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` diff --git a/src/src/Extensions.Validation/ZodMemberLocator.cs b/src/src/Extensions.Validation/ZodMemberLocator.cs index 3bc2981b..6d94b066 100644 --- a/src/src/Extensions.Validation/ZodMemberLocator.cs +++ b/src/src/Extensions.Validation/ZodMemberLocator.cs @@ -41,7 +41,10 @@ out string memberName string resolvedMember; try { - if (validator.TryResolveMember(value, path, out resolvedContainer, out resolvedMember)) + if ( + validator.TryResolveMember(value, path, out resolvedContainer, out resolvedMember) + && IsFieldOwner(resolvedContainer, value) + ) { container = resolvedContainer; memberName = resolvedMember ?? string.Empty; @@ -62,14 +65,20 @@ out string memberName 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. - if (!string.IsNullOrEmpty(resolvedMember)) + // 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 @@ -91,10 +100,17 @@ public static bool Walk(object root, ZodPath path, out object? container, out st if (current is null) return false; - container = current; - memberName = segment.Name ?? string.Empty; + 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(), memberName), static key => + var property = Properties.GetOrAdd((current.GetType(), name), static key => FindProperty(key.Type, key.Name) ); if (property is null || !TryGetValue(property, current, out current)) diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index e404df84..bbfb0d2c 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -734,6 +734,11 @@ rangeAttribute with validationAttributes ), NestedSchemaIsModern = nestedIsModern, + NestedIsScalarValueObject = IsNestedScalarValueObject( + validationKind, + nestedSchemaType, + originalPropertyType + ), ExternalNestedSchemaRequiresAsync = nestedRequiresAsync, AsyncValidationAttributes = ValidationInputsResolver.GetAsyncValidationAttributes(validationAttributes), DispatchKind = dispatchKind, @@ -1210,6 +1215,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, diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 9d8f532c..3fbbc220 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -278,6 +278,12 @@ readonly record struct ZodPropertyDescriptor( /// 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; } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs index 8ef84cbe..10bb5cb6 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs @@ -291,8 +291,11 @@ static void WriteResolveMember(CodeWriter writer, ZodPropertyDescriptor property var caseName = CodeGenHelpers.GetLocalIdentifier(property.Name, $"Case{i}"); writer.Line($"case {dispatchCase.CaseType.AsTypeReference()} {caseName}:"); writer.Return( - dispatchCase.SchemaType.StaticMember( - $"TryResolveMember({caseName}, path, offset + 1, out container, out memberName)" + ReferenceOwned( + dispatchCase.SchemaType.StaticMember( + $"TryResolveMember({caseName}, path, offset + 1, out container, out memberName)" + ), + property ) ); } @@ -333,10 +336,13 @@ static void WriteResolveMember(CodeWriter writer, ZodPropertyDescriptor property writer.IfBlock($"{elementName} is null", body => body.Return("false")); } + var elementCall = elementSchema.StaticMember( + $"TryResolveMember({elementExpression}, path, offset + 2, out container, out memberName)" + ); writer.Return( - elementSchema.StaticMember( - $"TryResolveMember({elementExpression}, path, offset + 2, out container, out memberName)" - ) + property.ElementTypeIsNullableValueType || !property.ElementTypeCanBeNull + ? ReferenceOwned(elementCall, property) + : elementCall ); return; } @@ -358,8 +364,16 @@ static void WriteResolveMember(CodeWriter writer, ZodPropertyDescriptor property writer.IfBlock($"{valueName} is null", body => body.Return("false")); } - writer.Return( - schemaType.StaticMember($"TryResolveMember({valueName}, path, offset + 1, out container, out memberName)") + 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.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs index a9a13f57..c88b5938 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -996,7 +996,18 @@ string resultName { writer.Assignment("var", resultName, NestedValidateCall(property, valueName)); using (writer.IfBlockScope($"!{resultName}.IsSuccess")) - writer.MethodCall($"{GeneratedValidation}.AddNested", "ref errors", resultName, PathOf(property)); + { + if (property.NestedIsScalarValueObject) + writer.MethodCall( + $"{GeneratedValidation}.AddNested", + "ref errors", + resultName, + PathOf(property), + MemberOf(property) + ); + else + writer.MethodCall($"{GeneratedValidation}.AddNested", "ref errors", resultName, PathOf(property)); + } } /// @@ -1123,7 +1134,11 @@ static void WriteAsyncComplex(CodeWriter writer, ZodPropertyDescriptor property) void WriteCall(CodeWriter body) { body.Assignment("var", resultName, NestedValidateAsyncCall(schemaType, valueName)); - body.Assignment("errors", $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)})"); + var owner = property.NestedIsScalarValueObject ? $", {MemberOf(property)}" : string.Empty; + body.Assignment( + "errors", + $"{GeneratedValidation}.WithNested(errors, {resultName}, {PathOf(property)}{owner})" + ); } if (property.IsNullableValueType) diff --git a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs index f6a9088f..23b58e59 100644 --- a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs +++ b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs @@ -285,6 +285,123 @@ public static void AddNested(ref List? errors, in Validation 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; + } + + 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. 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/StructMemberPlacementTests.cs b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs new file mode 100644 index 00000000..5f4f9006 --- /dev/null +++ b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using ZodSharp.Blazor.Fixtures; +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 => + ZodSharp.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/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs index e63f8c93..7bb436ba 100644 --- a/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs @@ -19,3 +19,20 @@ 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 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/ZodValidatableTypeInfoGeneratedSchemaTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs index 7dc35e41..ac17bf0e 100644 --- a/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableTypeInfoGeneratedSchemaTests.cs @@ -61,6 +61,39 @@ public async Task ValidateAsync_GivenValidGeneratedSchema_ReportsNothing(Cancell 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(); 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/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; +} From 8e493eeddad1638a94e5449481030feb2000a835 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:59:29 +0000 Subject: [PATCH 091/107] fix(aspnetcore): deprecate the option-less problemdetails overloads The 2.x overloads that take only a status code, a registry or a lookup (and ToValidationProblem()) cannot see the ZodProblemDetailsOptions an application registers, so their body diverged from the exception handler. There is no ambient options instance to read without a request, so they keep their 2.x output, are marked [Obsolete] with the exactly equivalent options, and point to the HttpContext and options overloads. --- CHANGELOG.md | 20 +- README.md | 2 +- docs/wiki/AspNetCore-Integration.md | 45 +++-- docs/wiki/Guarantees-and-Limitations.md | 2 +- skills/purview-zodsharp-aspnetcore/SKILL.md | 2 +- .../Extensions/ZodSharp/LegacyOverloads.cs | 32 +++ .../ZodSharp/ProblemDetailsExtensions.cs | 7 + .../ZodSharp/ZodExceptionExtensions.cs | 6 + src/src/AspNetCore/Sdk/README.md | 3 +- .../ProblemDetailsMappingPerformanceTests.cs | 9 +- .../ErrorTypeMappingTests.cs | 3 + ...rrorTypeSourceGeneratorIntegrationTests.cs | 3 + .../ZodSharp/LegacyConversionOverloadTests.cs | 189 ++++++++++++++++++ .../ZodSharp/ProblemDetailsExtensionsTests.cs | 3 + .../ZodSharp/ZodExceptionExtensionsTests.cs | 3 + 15 files changed, 309 insertions(+), 20 deletions(-) create mode 100644 src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs create mode 100644 src/tests/AspNetCore.UnitTests/Extensions/ZodSharp/LegacyConversionOverloadTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f6df0c..4aae1153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -353,6 +353,17 @@ Released versions correspond to `v` GitHub releases. 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 @@ -542,7 +553,14 @@ Each note names the old form, the new form, and the edit to make. 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. -- **`[ZodSchema]` and `[ErrorType]` come from `Purview.ZodSharp`.** They were generated into each project as +- **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 diff --git a/README.md b/README.md index 0d1219fa..54de4b62 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The fixture generator script is intentionally run via Bun rather than `npx tsx` - **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. diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index dc6fedd9..5fd02260 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -12,8 +12,9 @@ dotnet add package Purview.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. They all produce the -same body (see [The error contract](#the-error-contract)). +`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. ```csharp using ZodSharp; @@ -29,17 +30,36 @@ The extensions are in the `ZodSharp` namespace and exist for both `ValidationRes | Member | Behaviour | |---|---| -| `ToHttpValidationProblemDetails(int statusCode = 400)` | `HttpValidationProblemDetails` with default options, without resolving `ErrorType`s | -| `ToHttpValidationProblemDetails(ErrorTypeRegistry registry, int statusCode = 400)` | Resolves `ErrorType`s from `registry` and formats their messages | -| `ToHttpValidationProblemDetails(Func lookup, int statusCode = 400)` | Resolves `ErrorType`s through `lookup` | -| `ToHttpValidationProblemDetails(ZodProblemDetailsOptions options, HttpContext? httpContext = null)` | Uses the supplied options | -| `ToHttpValidationProblemDetails(HttpContext httpContext)` | Uses the writer, and therefore the options, registered in the request services (`ValidationResult` only) | +| `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()` | An `IResult` with default options (`ValidationResult` only) | | `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) | + +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: @@ -287,7 +307,7 @@ In the callback, for an `HttpValidationProblemDetails`: | 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()` produce. +`ToValidationProblem(httpContext)` produce. Notes: @@ -536,9 +556,10 @@ Mapping rules: (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 diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 27f95373..50bc5f39 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -8,7 +8,7 @@ - **Cross-platform parity.** The C# implementation is exercised against TypeScript/Zod fixtures (see [Cross-Platform Interop](Cross-Platform-Interop.md)). Array item nullability lines up with Zod: a generated `T[]` rejects null elements like `z.array(T)`, and a nullable element exports the same `items.anyOf` null branch that `z.array(T.nullable())` does. Error `Category` values are Zod's issue codes and `FullPath.ToZodPath()` is Zod's issue path, so the parity extends from validity to the issue shape and on to ProblemDetails keys. - **One path format.** Every error carries a typed `FullPath`, and every host renders it with the one renderer, `ZodPath.ToDottedString()`: members joined with `.`, indexes bracketed (`Items[0].Name`), keys bracketed and quoted when ambiguous (`Map[en]`, `Map["0"]`, `Map["a.b"]`), the root as `""`. Runtime schemas, generated validators, JSON integrations and JSON Schema import produce the same path for the same location, keys are rendered with the invariant culture, and member names default to C# names (matching Microsoft.Extensions.Validation). See [Core Concepts: Paths](Core-Concepts.md#paths). - **One error identity per failure.** The same failure reports the same `Code`, `Category` (the Zod v4 issue code), `Origin`, bounds and message template whichever producer reports it. Re-pathing (`WithPathPrefix`) keeps every field. -- **One ProblemDetails contract.** The endpoint filter, `ZodExceptionHandler`, `ZodTypedResults`, the `ToHttpValidationProblemDetails`/`ToValidationProblem` helpers and (on .NET 11) the Microsoft.Extensions.Validation pipeline all write through `ZodProblemDetailsWriter`, so the same errors produce the same body - keys, `issues`, status - byte for byte apart from `traceId`. See [ASP.NET Core Integration](AspNetCore-Integration.md). +- **One ProblemDetails contract.** The endpoint filter, `ZodExceptionHandler`, `ZodTypedResults`, the `ToHttpValidationProblemDetails`/`ToValidationProblem` helpers (the overloads that take the request or options; the obsolete option-less 2.x overloads use built-in defaults) and (on .NET 11) the Microsoft.Extensions.Validation pipeline all write through `ZodProblemDetailsWriter`, so the same errors produce the same body - keys, `issues`, status - byte for byte apart from `traceId`. See [ASP.NET Core Integration](AspNetCore-Integration.md). - **Culture-independent validation.** Codes, categories, origins, parameters, path keys, bounds and the messages produced at validation time are invariant. Only a host boundary applies a culture, through an `IZodMessageFormatter` in `CultureInfo.CurrentUICulture`. See [Core Concepts: Culture](Core-Concepts.md#culture) and [Localization](Localization.md). - **Messages never throw.** Messages are formatted by `ZodMessageFormat`, which writes unknown placeholders and stray braces literally, so a `{` in a custom message can no longer turn a validation failure into a `FormatException` (at build time in the generator, or at validation time). - **No string input in messages by default.** Built-in messages never quote string input (numeric rules state the failing number, size rules the count), and the `received` parameter holds only the input's type name unless `ZodErrorOptions.IncludeReceivedValues` is enabled (development only). See [Core Concepts: PII](Core-Concepts.md#pii). diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index 9edcbd59..9dc5b7c4 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -200,7 +200,7 @@ app.MapPost("/users", Results, ValidationProblem> (UserDto dto, Http | `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, …)` | 2.x overloads, no DI | +| `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` diff --git a/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs b/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs new file mode 100644 index 00000000..cdeb6a7e --- /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 66abeefd..17c1b092 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ProblemDetailsExtensions.cs @@ -17,6 +17,7 @@ public static class ProblemDetailsExtensions /// Converts a failed validation result into , without resolving /// s. /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( int statusCode = StatusCodes.Status400BadRequest ) @@ -29,6 +30,7 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( /// 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 @@ -43,6 +45,7 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( /// 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 @@ -84,6 +87,7 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails(HttpContext h /// /// Converts a failed validation result into . /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public ValidationProblemDetails ToValidationProblemDetails(int statusCode = StatusCodes.Status400BadRequest) { var details = result.ToHttpValidationProblemDetails(statusCode); @@ -94,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 @@ -107,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 @@ -137,6 +143,7 @@ public ValidationProblemDetails ToValidationProblemDetails( /// 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(); diff --git a/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs b/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs index 0964bf39..ac28ac09 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/ZodExceptionExtensions.cs @@ -18,6 +18,7 @@ public static class ZodExceptionExtensions /// Converts a into , without resolving /// s. /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public HttpValidationProblemDetails ToHttpValidationProblemDetails( int statusCode = StatusCodes.Status400BadRequest ) => @@ -29,6 +30,7 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( /// 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 @@ -44,6 +46,7 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( /// 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 @@ -73,6 +76,7 @@ public HttpValidationProblemDetails ToHttpValidationProblemDetails( /// /// Converts a into . /// + [Obsolete(LegacyOverloads.StatusCodeMessage)] public ValidationProblemDetails ToValidationProblemDetails(int statusCode = StatusCodes.Status400BadRequest) { var details = exception.ToHttpValidationProblemDetails(statusCode); @@ -83,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 @@ -96,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 diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index c50f18bc..e17afa76 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -39,7 +39,8 @@ The endpoint filter, the `ZodException` handler, `ZodTypedResults` and the `ToHt 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). +`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 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/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs b/src/tests/AspNetCore.UnitTests/ErrorTypeMappingTests.cs index 9b51630e..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; 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; From 4e242dc305a30e846b19f2379129ad7cb4d38df3 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:01:26 +0000 Subject: [PATCH 092/107] docs: explain the cp0001 a library sees for the removed embedded attributes ZodSharp 2 generated [Embedded] copies of ZodSchemaAttribute and ErrorTypeAttribute into every compilation, so a library rebuilt against ZodSharp 3 loses them and package validation against a 2.x baseline reports CP0001. The upgrade note explains why the removal is safe and gives the suppression command and entries. --- CHANGELOG.md | 34 +++++++++++++++++-- .../SKILL.md | 7 ++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aae1153..d1470267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -560,12 +560,42 @@ Each note names the old form, the new form, and the edit to make. 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 + `{ 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/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index 79030ecf..d1037f91 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -57,6 +57,13 @@ var validated = UserSchema.Parse(user); // throws ZodException on failure `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"); From 9c8b7eacff06894eb3bf6e2468440fdc0b6b26c0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:12:00 +0000 Subject: [PATCH 093/107] fix(blazor): stop addzodsharpblazor from turning on minimal-api validation The .NET 11 endpoint filter validates every parameter a registered resolver claims, so registering the bridge from AddZodSharpBlazor answered invalid requests before handlers ran. AddValidation() alone adds nothing. AddZodSharpBlazor now registers type validation only; AddZodSharpValidation(configure, validateParameters) and ZodValidationOptions.ValidateParameters choose explicitly, and any registration that asks for parameters wins. --- CHANGELOG.md | 8 +- docs/wiki/Blazor-Integration.md | 25 +++- docs/wiki/Microsoft-Extensions-Validation.md | 16 +++ skills/purview-zodsharp-blazor/SKILL.md | 4 + ...dSharpBlazorServiceCollectionExtensions.cs | 12 +- src/src/Blazor/Sdk/README.md | 6 + src/src/Examples.Blazor/SampleApp.cs | 8 +- ...rpValidationServiceCollectionExtensions.cs | 33 +++++- src/src/Extensions.Validation/Sdk/README.md | 6 + .../ZodParameterValidationRequest.cs | 8 ++ .../ZodValidatableInfoResolver.cs | 23 +++- .../ZodValidationOptions.cs | 16 +++ .../EndpointValidationRegistrationTests.cs | 110 ++++++++++++++++++ ...idationServiceCollectionExtensionsTests.cs | 89 ++++++++++++++ 14 files changed, 351 insertions(+), 13 deletions(-) create mode 100644 src/src/Extensions.Validation/ZodParameterValidationRequest.cs create mode 100644 src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1470267..c96db0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,12 +43,16 @@ Released versions correspond to `v` GitHub releases. 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. References the + `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 - nested objects, collections and dictionaries included - with no + 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, diff --git a/docs/wiki/Blazor-Integration.md b/docs/wiki/Blazor-Integration.md index cdf30545..2363b22a 100644 --- a/docs/wiki/Blazor-Integration.md +++ b/docs/wiki/Blazor-Integration.md @@ -26,9 +26,26 @@ MyApp.Client/ (WASM) -> Purview.ZodSharp.Blazor MyApp.Shared/ (models) -> Purview.ZodSharp (the generator runs here) ``` -`AddZodSharpBlazor(configureValidation, configure)` calls `AddZodSharpValidation` (the -Microsoft.Extensions.Validation bridge), registers `ZodBlazorOptions` and registers `TimeProvider.System` when no -`TimeProvider` is registered. It never registers an `IZodValidationReportSink`. +`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 @@ -284,7 +301,7 @@ builder.Services.AddZodSharpBlazor(); | `/returns` | Static SSR | `FormName` + `[SupplyParameterFromForm]`, server validation of the posted form, and browser-side checks of `[StartsWith]` and `[UUID]` from `AddZodSharpClientValidation()`. | The server also exposes the minimal API the WebAssembly form posts to (`POST /api/customers`, validated by the .NET 11 -pipeline through the bridge `AddZodSharpBlazor` registers) and a culture switch (`/culture/set?culture=fr`). One +pipeline because the server also calls `AddZodSharpValidation()`) and a culture switch (`/culture/set?culture=fr`). One `StringLocalizerZodMessageFormatter` over `SampleMessages.fr.resx` localizes the ProblemDetails, both form routes and the WebAssembly client, which adopts the server-rendered `` at startup so `RunAsync` loads the French satellite assembly. diff --git a/docs/wiki/Microsoft-Extensions-Validation.md b/docs/wiki/Microsoft-Extensions-Validation.md index 780df0ed..48849cff 100644 --- a/docs/wiki/Microsoft-Extensions-Validation.md +++ b/docs/wiki/Microsoft-Extensions-Validation.md @@ -47,6 +47,21 @@ builder.Services.AddValidation(); Calling `AddZodSharpValidation()` more than once registers the resolver once, and every `configure` callback is applied. +### Endpoint validation + +The endpoint filter Microsoft.Extensions.Validation adds to minimal APIs validates every handler parameter that a +registered resolver claims, before the handler runs; it is not switched on by `AddValidation()` itself (called from a +library, `AddValidation()` adds no resolver at all). So `AddZodSharpValidation()` **turns on minimal-API validation +for every ZodSharp-typed parameter**: an invalid body is answered with `400` and the handler never runs. That is +usually the point, but it applies to the whole application: + +- Opt an endpoint or a route group out with `DisableValidation()`. +- Register type validation only with `AddZodSharpValidation(configure, validateParameters: false)`, or set + `ZodValidationOptions.ValidateParameters = false`: Blazor forms and `ValidationOptions.ValidateAsync` still use + ZodSharp, and no endpoint changes. This is what `AddZodSharpBlazor()` registers. +- Any registration that asks for parameters wins (so `AddZodSharpBlazor()` plus `AddZodSharpValidation()`, in either + order, validates endpoints), and an explicit `ValidateParameters` overrides both. + > [!IMPORTANT] > Register every validator before the first validation. The resolver caches lookups per type, including misses, so > a validator registered with the factory after its type was first looked up is never seen. @@ -75,6 +90,7 @@ validated twice. | `PathNamer` | `null` (C# member names) | Renames member segments in `ValidationError.Path`, for example `ZodPathNamer.FromFunc(JsonNamingPolicy.CamelCase.ConvertName)`. Blazor ignores `Path`, so this never affects field placement. | | `MessageFormatter` | `null` | Formats every `ErrorMessage`. When `null`, a `StringLocalizerZodMessageFormatter` is used whenever the validation's service provider resolves an `IStringLocalizerFactory`, and the ZodSharp message otherwise. See [Localization](Localization.md). | | `Sources` | empty | Trim-safe `IZodValidatableInfoSource`s consulted before `IZodSchemaFactory`. | +| `ValidateParameters` | `null` (by registration) | Whether the resolver claims parameters, which turns on minimal-API endpoint validation for ZodSharp types; see [Endpoint validation](#endpoint-validation). `null`: claimed after `AddZodSharpValidation()`, not when only `AddZodSharpBlazor()` registered the bridge. | ```csharp using System.Text.Json; diff --git a/skills/purview-zodsharp-blazor/SKILL.md b/skills/purview-zodsharp-blazor/SKILL.md index 481959f0..4776a301 100644 --- a/skills/purview-zodsharp-blazor/SKILL.md +++ b/skills/purview-zodsharp-blazor/SKILL.md @@ -32,6 +32,10 @@ builder.Services.AddZodSharpBlazor(); 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. diff --git a/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs b/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs index a0616597..8fb122ad 100644 --- a/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs +++ b/src/src/Blazor/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpBlazorServiceCollectionExtensions.cs @@ -23,13 +23,20 @@ public static class ZodSharpBlazorServiceCollectionExtensions /// The for chaining. /// /// - /// Calls AddZodSharpValidation, so the standard <DataAnnotationsValidator /> validates every + /// 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. /// @@ -41,7 +48,8 @@ public IServiceCollection AddZodSharpBlazor( { ArgumentNullException.ThrowIfNull(services); - services.AddZodSharpValidation(configureValidation); + // 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); diff --git a/src/src/Blazor/Sdk/README.md b/src/src/Blazor/Sdk/README.md index 02f608f2..5f3a95df 100644 --- a/src/src/Blazor/Sdk/README.md +++ b/src/src/Blazor/Sdk/README.md @@ -13,6 +13,12 @@ builder.Services.AddZodSharpMyAppSharedSchemas(); // generated, trim-safe regi 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 diff --git a/src/src/Examples.Blazor/SampleApp.cs b/src/src/Examples.Blazor/SampleApp.cs index 3d8a4444..b70b118a 100644 --- a/src/src/Examples.Blazor/SampleApp.cs +++ b/src/src/Examples.Blazor/SampleApp.cs @@ -30,11 +30,15 @@ public static void ConfigureServices(IServiceCollection services) 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 (which also makes ZodSharp the first - // Microsoft.Extensions.Validation resolver, so the minimal API below is validated by the built-in pipeline). + // [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); diff --git a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs index 08b7fbb4..9650a786 100644 --- a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs +++ b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensions.cs @@ -28,12 +28,40 @@ public static class ZodSharpValidationServiceCollectionExtensions /// 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) + 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(); @@ -42,7 +70,8 @@ public IServiceCollection AddZodSharpValidation(Action? co services.TryAddSingleton(static sp => new ZodValidatableInfoResolver( sp.GetService(), - sp.GetRequiredService>().Value + sp.GetRequiredService>().Value, + parametersRequested: sp.GetService() is not null )); services.TryAddEnumerable( ServiceDescriptor.Singleton, ZodValidationOptionsSetup>() diff --git a/src/src/Extensions.Validation/Sdk/README.md b/src/src/Extensions.Validation/Sdk/README.md index 748e3f8c..0fedd9c3 100644 --- a/src/src/Extensions.Validation/Sdk/README.md +++ b/src/src/Extensions.Validation/Sdk/README.md @@ -32,6 +32,11 @@ 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 @@ -96,6 +101,7 @@ kept. Set `ZodValidationOptions.MessageFormatter` to replace it. | `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/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/ZodValidatableInfoResolver.cs b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs index e3c695c0..8cace3e5 100644 --- a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs +++ b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs @@ -38,9 +38,17 @@ public sealed class ZodValidatableInfoResolver : IValidatableInfoResolver /// 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; } /// @@ -48,6 +56,12 @@ public ZodValidatableInfoResolver(IZodSchemaFactory? factory, ZodValidationOptio /// public ZodValidationOptions Options => _options; + /// + /// 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. @@ -85,7 +99,8 @@ public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValida /// /// 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. + /// parameters, and primitive types, are never claimed, and no parameter is claimed when + /// is . /// public bool TryGetValidatableParameterInfo( ParameterInfo parameterInfo, @@ -94,6 +109,12 @@ public bool TryGetValidatableParameterInfo( { ArgumentNullException.ThrowIfNull(parameterInfo); + if (!ValidatesParameters) + { + validatableParameterInfo = null; + return false; + } + validatableParameterInfo = _parameters.GetValue(parameterInfo, CreateParameter); return validatableParameterInfo is not null; } diff --git a/src/src/Extensions.Validation/ZodValidationOptions.cs b/src/src/Extensions.Validation/ZodValidationOptions.cs index aaa3a8c6..1b1293a3 100644 --- a/src/src/Extensions.Validation/ZodValidationOptions.cs +++ b/src/src/Extensions.Validation/ZodValidationOptions.cs @@ -38,6 +38,22 @@ public sealed class ZodValidationOptions /// 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 . /// diff --git a/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs b/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs new file mode 100644 index 00000000..e2b624aa --- /dev/null +++ b/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs @@ -0,0 +1,110 @@ +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 = """{ "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 => + ZodSharp.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/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs index bcf7b3fe..7d10a693 100644 --- a/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs +++ b/src/tests/Extensions.Validation.UnitTests/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpValidationServiceCollectionExtensionsTests.cs @@ -161,4 +161,93 @@ public async Task AddZodSharpValidation_GivenNoFactoryRegistered_RegistersSchema // 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; } From 29611ae45c3aaf9df3eadccc93730f24cdda1750 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:15:06 +0000 Subject: [PATCH 094/107] fix(aspnetcore): keep declaringtype and member out of problemdetails issues Generated validators attach declaringType (a full CLR type name) and member to every error for localization, and issues[].parameters sent both to API clients. They are now omitted by default and stay on ValidationError.Parameters; ZodProblemDetailsOptions.IncludeDiagnosticParameters writes them again. --- CHANGELOG.md | 6 +- docs/wiki/AspNetCore-Integration.md | 4 + skills/purview-zodsharp-aspnetcore/SKILL.md | 2 + .../AspNetCore/ZodProblemDetailsOptions.cs | 10 +++ src/src/AspNetCore/ZodProblemDetailsWriter.cs | 18 ++++- ...dProblemDetailsDiagnosticParameterTests.cs | 78 +++++++++++++++++++ 6 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/tests/AspNetCore.UnitTests/ZodProblemDetailsDiagnosticParameterTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c96db0e2..29a15f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -395,7 +395,11 @@ Released versions correspond to `v` GitHub releases. 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. -- **A scalar value object's messages name the owning member.** A nested Purview.ValueObjects scalar's errors read +- **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 diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index 5fd02260..ee9add18 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -205,6 +205,9 @@ The contract: 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). @@ -382,6 +385,7 @@ does the following: | `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. | diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index 9dc5b7c4..be674ade 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -223,6 +223,8 @@ await writer.WriteAsync(httpContext, problem); - `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. diff --git a/src/src/AspNetCore/ZodProblemDetailsOptions.cs b/src/src/AspNetCore/ZodProblemDetailsOptions.cs index 55c22781..4280137f 100644 --- a/src/src/AspNetCore/ZodProblemDetailsOptions.cs +++ b/src/src/AspNetCore/ZodProblemDetailsOptions.cs @@ -53,6 +53,16 @@ public sealed class ZodProblemDetailsOptions /// 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 diff --git a/src/src/AspNetCore/ZodProblemDetailsWriter.cs b/src/src/AspNetCore/ZodProblemDetailsWriter.cs index 936b7c49..5d1336bc 100644 --- a/src/src/AspNetCore/ZodProblemDetailsWriter.cs +++ b/src/src/AspNetCore/ZodProblemDetailsWriter.cs @@ -539,7 +539,7 @@ void MergeParameters(HttpValidationProblemDetails target, ReadOnlySpan? ProjectParameters(ErrorTypeParameters? parameters) + Dictionary? ProjectParameters(ErrorTypeParameters? parameters) { if (parameters is null || parameters.Count == 0) return null; @@ -564,7 +564,7 @@ void MergeParameters(HttpValidationProblemDetails target, ReadOnlySpan? result = null; foreach (var pair in parameters) { - if (IsUnionParameter(pair.Key) || !TryNormalize(pair.Value, allowCollections: true, out var safe)) + if (!IsClientParameter(pair.Key) || !TryNormalize(pair.Value, allowCollections: true, out var safe)) continue; result ??= new Dictionary(StringComparer.Ordinal); @@ -574,6 +574,18 @@ void MergeParameters(HttpValidationProblemDetails target, ReadOnlySpan + !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); 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(); +} From bd553bcfbd08809d218f0804232099d187422432 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:18:45 +0000 Subject: [PATCH 095/107] docs: explain ca1849 on sync validate calls and say when it is noise Every generated schema now has ValidateAsync, so CA1849 reports each Validate call in async code. The generated Validate remarks now say whether the type requires async validation, and the upgrade notes recommend awaiting ValidateAsync, which completes synchronously for such types, instead of suppressing the analyzer. --- CHANGELOG.md | 9 ++++++- docs/wiki/Source-Generator.md | 5 ++++ .../SKILL.md | 6 +++++ .../ZodSchemaGenerator.Validation.cs | 19 +++++++++++++++ ...ZodSchemaGeneratorTests.AsyncValidation.cs | 24 +++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a15f93..e6657b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -561,7 +561,14 @@ Each note names the old form, the new form, and the edit to make. 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. -- **Option-less ProblemDetails overloads (CS0618).** `result.ToHttpValidationProblemDetails()` and the other overloads +- **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)` or + `validator.Validate(value)` inside async code. For a type whose `{Type}Schema.RequiresAsyncValidation` is `false` + (the generated `Validate` remarks say which) the sync call is correct and never blocks. Either await + `ValidateAsync(value, services, cancellationToken)`, which completes synchronously without allocating for such a + type and also runs asynchronous rules if they are added later (the recommended edit), or suppress CA1849 at the call + site with a justification. ZodSharp does not suppress the analyzer for you, and nothing in generated code can tell + it a call is fine. For a type that requires async validation the warning is real: `Validate` cannot run those rules.- **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 diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 487404a2..ccae6c06 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -259,6 +259,11 @@ var handleOnly = await AccountSchema.ValidateMemberAsync(account, nameof(Account `RequiresAsyncValidation` is `true` for `Account`, and for every type that nests it. A type whose graph has no asynchronous rule completes `ValidateAsync` synchronously, so awaiting it costs nothing. +Because `ValidateAsync` exists, CA1849 (when enabled) reports a `Validate` call in async code. For a type whose +`RequiresAsyncValidation` is `false` the generated `Validate` remarks say the call never blocks, so the warning is +noise there: prefer awaiting `ValidateAsync` (free for such a type, and correct if async rules are added later) over +suppressing it. For a type that requires async validation it is a real finding. + > [!WARNING] > The async custom validation method is mutually exclusive with the `OnZodValidate` refinement hook. A > model must declare exactly one of the two — declaring both is an error (ZODSGEN029). diff --git a/skills/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index d1037f91..6fe36ff9 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -222,6 +222,12 @@ is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A m 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, reports every `Validate` call inside async code because `ValidateAsync` exists. When +`RequiresAsyncValidation` is `false` (the generated `Validate` remarks say so) the sync call is fine; prefer +awaiting `ValidateAsync` there anyway (it completes synchronously, and picks up async rules added later) rather than +suppressing the analyzer. When it is `true`, the warning is real. + ## Automatic enum validation Every non-`[Flags]` enum property is validated automatically: a value that is not a defined member is diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs index c88b5938..2762aa25 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -242,6 +242,25 @@ CancellationToken cancellationToken writer.XmlSummary("Validates an instance of the target type."); writer.XmlParam("value", "The value to validate."); writer.XmlReturn("The validation result."); + + // CA1849 reports every call to this method from async code, because ValidateAsync exists. Whether that matters + // is known here, so the remark says which call is right for this type (the analyzer is the consumer's to keep). + 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. In an", + "asynchronous method, analyzers such as CA1849 suggest ValidateAsync, which for this type completes synchronously", + "without allocating; either call is correct." + ); + } + writer.Line($"public static {resultType} Validate({targetType} value) => ValidateCore(value, 0);"); writer.NewLine(); diff --git a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs index 69cb1422..8da9f5a8 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs @@ -160,6 +160,30 @@ public async Task ValidateAsync_GivenNoAsynchronousRules_CompletesSynchronously( 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 reports Validate in async code whenever ValidateAsync exists; the generated remark says when + // that is noise (no asynchronous rule) and when it is not. + 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) { From c028b8b2ebbee89bdcd7fcc20d3bf06620d9fbe2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:19:14 +0000 Subject: [PATCH 096/107] docs(skills): reword the webassembly aot note on type-level rules --- skills/purview-zodsharp-custom-rules/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/purview-zodsharp-custom-rules/SKILL.md b/skills/purview-zodsharp-custom-rules/SKILL.md index 4bf4c492..f2151be6 100644 --- a/skills/purview-zodsharp-custom-rules/SKILL.md +++ b/skills/purview-zodsharp-custom-rules/SKILL.md @@ -237,7 +237,7 @@ 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); drop no rules to work around it, upgrade instead. +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 From 77ed1872e8333e4a39b3287679331b0f415f4a89 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:05:31 +0000 Subject: [PATCH 097/107] chore(skills): keep installed skills under .agents only and drop the .claude copy The skills installed from other repositories were committed twice, once under .agents/skills/ and once under .claude/skills/. The two trees were identical, so the .claude copy is removed and /.claude/ is now gitignored. This repository is not agent-specific. The installed set was refreshed with the skills CLI, into the universal location only: 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 The six purview-sourcegenerator-* skills are new; AGENTS.md already told contributors to load them, but they were never installed. The build-sdk and engineering-playbook skills are updated to their current content. src/Directory.Build.props sets PurviewSkillsAgents to universal, so the SDK's InstallPurviewSkills target no longer defaults to writing a copy for every agent. InstalledSkillTests checks that the lockfile and .agents/skills/ agree, that no authored skill is copied there, and that neither a .claude/skills/ folder nor a CLAUDE.md exists. --- .agents/skills/purview-architecture/SKILL.md | 40 +- .../references/contract-strategy.md | 236 ++++++++++ .../references/project-responsibilities.md | 20 +- .../purview-buildsdk-configuration/SKILL.md | 9 +- .../references/properties.md | 24 +- .../SKILL.md | 27 +- .../references/blazor.md | 105 +++++ .../references/detection.md | 4 + .../purview-buildsdk-project-layout/SKILL.md | 29 +- .../SKILL.md | 334 +++++++++++++++ .../references/legacy-emitter-migration.md | 58 +++ .../SKILL.md | 142 ++++++ .../SKILL.md | 245 +++++++++++ .../references/merge-internals.md | 139 ++++++ .../purview-sourcegenerator-pipeline/SKILL.md | 231 ++++++++++ .../purview-sourcegenerator-testing/SKILL.md | 404 ++++++++++++++++++ .../references/test-modernisation.md | 63 +++ .../SKILL.md | 230 ++++++++++ .../purview-architecture-testing/SKILL.md | 183 -------- .claude/skills/purview-architecture/SKILL.md | 146 ------- .../references/project-context-discovery.md | 115 ----- .../references/project-responsibilities.md | 107 ----- .../references/proportional-architecture.md | 92 ---- .../skills/purview-buildsdk-adoption/SKILL.md | 114 ----- .../purview-buildsdk-configuration/SKILL.md | 110 ----- .../references/properties.md | 146 ------- .../SKILL.md | 134 ------ .../references/detection.md | 79 ---- .../references/test-types.md | 35 -- .../purview-buildsdk-project-layout/SKILL.md | 136 ------ .../SKILL.md | 111 ----- .../SKILL.md | 166 ------- .../references/repository-root.md | 55 --- .../purview-buildsdk-skill-tests/SKILL.md | 131 ------ .../references/authoring.md | 79 ---- .../skills/purview-buildsdk-testing/SKILL.md | 121 ------ .../references/treenode-filter.md | 107 ----- .../purview-engineering-principles/SKILL.md | 171 -------- .claude/skills/purview-persistence/SKILL.md | 106 ----- .../references/guid-generation.md | 93 ---- .../references/postgresql.md | 98 ----- .../references/sqlserver.md | 101 ----- .claude/skills/purview-telemetry/SKILL.md | 123 ------ .../references/observability-levels.md | 93 ---- .../purview-testing-conventions/SKILL.md | 357 ---------------- .../acceptance-and-container-tests.md | 117 ----- .gitignore | 3 + skills-lock.json | 44 +- src/Directory.Build.props | 3 + .../InstalledSkillTests.cs | 170 ++++++++ 50 files changed, 2540 insertions(+), 3446 deletions(-) create mode 100644 .agents/skills/purview-architecture/references/contract-strategy.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/blazor.md create mode 100644 .agents/skills/purview-sourcegenerator-codewriter/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md create mode 100644 .agents/skills/purview-sourcegenerator-diagnostics/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-packaging/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md create mode 100644 .agents/skills/purview-sourcegenerator-pipeline/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-testing/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md create mode 100644 .agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md delete mode 100644 .claude/skills/purview-architecture-testing/SKILL.md delete mode 100644 .claude/skills/purview-architecture/SKILL.md delete mode 100644 .claude/skills/purview-architecture/references/project-context-discovery.md delete mode 100644 .claude/skills/purview-architecture/references/project-responsibilities.md delete mode 100644 .claude/skills/purview-architecture/references/proportional-architecture.md delete mode 100644 .claude/skills/purview-buildsdk-adoption/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-configuration/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-configuration/references/properties.md delete mode 100644 .claude/skills/purview-buildsdk-project-detection/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-project-detection/references/detection.md delete mode 100644 .claude/skills/purview-buildsdk-project-detection/references/test-types.md delete mode 100644 .claude/skills/purview-buildsdk-project-layout/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-repository-review/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-skill-installation/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-skill-installation/references/repository-root.md delete mode 100644 .claude/skills/purview-buildsdk-skill-tests/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-skill-tests/references/authoring.md delete mode 100644 .claude/skills/purview-buildsdk-testing/SKILL.md delete mode 100644 .claude/skills/purview-buildsdk-testing/references/treenode-filter.md delete mode 100644 .claude/skills/purview-engineering-principles/SKILL.md delete mode 100644 .claude/skills/purview-persistence/SKILL.md delete mode 100644 .claude/skills/purview-persistence/references/guid-generation.md delete mode 100644 .claude/skills/purview-persistence/references/postgresql.md delete mode 100644 .claude/skills/purview-persistence/references/sqlserver.md delete mode 100644 .claude/skills/purview-telemetry/SKILL.md delete mode 100644 .claude/skills/purview-telemetry/references/observability-levels.md delete mode 100644 .claude/skills/purview-testing-conventions/SKILL.md delete mode 100644 .claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md create mode 100644 src/tests/Repository.UnitTests/InstalledSkillTests.cs diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index 8d4b5a80..c04f3d61 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -2,9 +2,10 @@ 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 — 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. + 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 @@ -110,7 +111,34 @@ The rules that matter most: When a boundary is worth having, make it fail the build rather than the review — see `purview-architecture-testing`. -## 5. DRY, applied where it pays +## 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: @@ -126,7 +154,7 @@ Duplication that matters: validation repeated per entry point, a domain rule res 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. -## 6. Telemetry is not an advanced topic +## 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`. @@ -136,6 +164,8 @@ small telemetry — it does not get none. See `purview-telemetry`. - 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 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-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index 1bf0a1a0..d74d2cf4 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -15,7 +15,7 @@ Do not restate either here or in another skill; link to them. | `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 | Core, 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 | @@ -25,13 +25,26 @@ Do not restate either here or in another skill; link to them. | `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 | Contracts, ServiceDefaults, 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 | @@ -39,8 +52,7 @@ buys something — it is not a list of projects to create. | `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` | A client compiles against it without taking the server | The API host is the only consumer | -| `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `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 | diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index f0ee7156..d82768b3 100644 --- a/.agents/skills/purview-buildsdk-configuration/SKILL.md +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -1,6 +1,6 @@ --- 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, 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. +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 @@ -27,6 +27,7 @@ Pre-import properties include: - `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 @@ -46,6 +47,9 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `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 — @@ -58,7 +62,8 @@ name: `PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, `RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, -`RootPackageJson`. +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. Ask MSBuild for one directly: diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md index 93a0d9b3..1fb1f81b 100644 --- a/.agents/skills/purview-buildsdk-configuration/references/properties.md +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -81,6 +81,24 @@ forces SourceLink's CI-mode dirty-repository checks. | `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 | @@ -101,7 +119,7 @@ forces SourceLink's CI-mode dirty-repository checks. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | -| `PurviewBuildSdkVersionForGlobalJson` | detected SDK version, else `1.0.0` | | +| `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 | @@ -134,6 +152,10 @@ Exported to analyzers and generators as `build_property.`: - `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`, diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md index 2d0d9862..8573ca40 100644 --- a/.agents/skills/purview-buildsdk-project-detection/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, 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. +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 @@ -26,7 +26,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, `IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, -`IsWebSdkProject`, `IsAspireHostProject`. +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. ## Detection rules @@ -67,6 +68,24 @@ Recognised by **exact name**, never by substring. The two lists are in | 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 @@ -94,6 +113,8 @@ Recognised by **exact name**, never by substring. The two lists are in 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`. @@ -107,6 +128,8 @@ surface in command-line builds and in Visual Studio. The code-fix assembly ships | `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`, 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 index bf75823d..85fde09c 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -64,6 +64,10 @@ dual-role component that wants normal library symbol packaging. | 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 diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md index 55a019cd..6c19c3d4 100644 --- a/.agents/skills/purview-buildsdk-project-layout/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -1,6 +1,6 @@ --- 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, 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. +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 @@ -103,7 +103,32 @@ Test projects probe for their target, in order: - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects -pick up `../SharedTesting*/SharedTesting*.csproj`. +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 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..92350db8 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,245 @@ +--- +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 declared as copy-to-output content beside the generator's `bin`, and + content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what + lets Visual Studio load the dependent code-fix provider from its own `bin` without a + `FileNotFoundException` for the framework assembly. +- 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/.claude/skills/purview-architecture-testing/SKILL.md b/.claude/skills/purview-architecture-testing/SKILL.md deleted file mode 100644 index 0619225f..00000000 --- a/.claude/skills/purview-architecture-testing/SKILL.md +++ /dev/null @@ -1,183 +0,0 @@ ---- -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/.claude/skills/purview-architecture/SKILL.md b/.claude/skills/purview-architecture/SKILL.md deleted file mode 100644 index 8d4b5a80..00000000 --- a/.claude/skills/purview-architecture/SKILL.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -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 — 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. 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. - -## 6. 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? -- 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/.claude/skills/purview-architecture/references/project-context-discovery.md b/.claude/skills/purview-architecture/references/project-context-discovery.md deleted file mode 100644 index d40569a3..00000000 --- a/.claude/skills/purview-architecture/references/project-context-discovery.md +++ /dev/null @@ -1,115 +0,0 @@ -# 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/.claude/skills/purview-architecture/references/project-responsibilities.md b/.claude/skills/purview-architecture/references/project-responsibilities.md deleted file mode 100644 index 1bf0a1a0..00000000 --- a/.claude/skills/purview-architecture/references/project-responsibilities.md +++ /dev/null @@ -1,107 +0,0 @@ -# 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 | 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 | 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. - -## 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` | A client compiles against it without taking the server | The API host is the only consumer | -| `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/.claude/skills/purview-architecture/references/proportional-architecture.md b/.claude/skills/purview-architecture/references/proportional-architecture.md deleted file mode 100644 index 3ed7f553..00000000 --- a/.claude/skills/purview-architecture/references/proportional-architecture.md +++ /dev/null @@ -1,92 +0,0 @@ -# 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/.claude/skills/purview-buildsdk-adoption/SKILL.md b/.claude/skills/purview-buildsdk-adoption/SKILL.md deleted file mode 100644 index b1f222c5..00000000 --- a/.claude/skills/purview-buildsdk-adoption/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -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/.claude/skills/purview-buildsdk-configuration/SKILL.md b/.claude/skills/purview-buildsdk-configuration/SKILL.md deleted file mode 100644 index f0ee7156..00000000 --- a/.claude/skills/purview-buildsdk-configuration/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -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, 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` -- 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` | - -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`. - -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/.claude/skills/purview-buildsdk-configuration/references/properties.md b/.claude/skills/purview-buildsdk-configuration/references/properties.md deleted file mode 100644 index 93a0d9b3..00000000 --- a/.claude/skills/purview-buildsdk-configuration/references/properties.md +++ /dev/null @@ -1,146 +0,0 @@ -# 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` | | - -## 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` | detected SDK version, else `1.0.0` | | -| `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` -- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` -- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` -- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, - `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` -- `PurviewSuppressCopyRetryWarnings` -- `PurviewSkillsSourceFolder`, - `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, - `PurviewSkillsRunner` -- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, - `AutoGeneratedAssemblyIdentifierFile` diff --git a/.claude/skills/purview-buildsdk-project-detection/SKILL.md b/.claude/skills/purview-buildsdk-project-detection/SKILL.md deleted file mode 100644 index 2d0d9862..00000000 --- a/.claude/skills/purview-buildsdk-project-detection/SKILL.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, 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`. - -## 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` | - -## 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). - -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 | - -`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/.claude/skills/purview-buildsdk-project-detection/references/detection.md b/.claude/skills/purview-buildsdk-project-detection/references/detection.md deleted file mode 100644 index bf75823d..00000000 --- a/.claude/skills/purview-buildsdk-project-detection/references/detection.md +++ /dev/null @@ -1,79 +0,0 @@ -# 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` | - -## 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/.claude/skills/purview-buildsdk-project-detection/references/test-types.md b/.claude/skills/purview-buildsdk-project-detection/references/test-types.md deleted file mode 100644 index dace44fc..00000000 --- a/.claude/skills/purview-buildsdk-project-detection/references/test-types.md +++ /dev/null @@ -1,35 +0,0 @@ -# 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/.claude/skills/purview-buildsdk-project-layout/SKILL.md b/.claude/skills/purview-buildsdk-project-layout/SKILL.md deleted file mode 100644 index 55a019cd..00000000 --- a/.claude/skills/purview-buildsdk-project-layout/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -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, 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`. - -## 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/.claude/skills/purview-buildsdk-repository-review/SKILL.md b/.claude/skills/purview-buildsdk-repository-review/SKILL.md deleted file mode 100644 index ce1fd294..00000000 --- a/.claude/skills/purview-buildsdk-repository-review/SKILL.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -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/.claude/skills/purview-buildsdk-skill-installation/SKILL.md b/.claude/skills/purview-buildsdk-skill-installation/SKILL.md deleted file mode 100644 index 66c20a33..00000000 --- a/.claude/skills/purview-buildsdk-skill-installation/SKILL.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -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/.claude/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.claude/skills/purview-buildsdk-skill-installation/references/repository-root.md deleted file mode 100644 index e90b695c..00000000 --- a/.claude/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ /dev/null @@ -1,55 +0,0 @@ -# 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/.claude/skills/purview-buildsdk-skill-tests/SKILL.md b/.claude/skills/purview-buildsdk-skill-tests/SKILL.md deleted file mode 100644 index 4043ac4f..00000000 --- a/.claude/skills/purview-buildsdk-skill-tests/SKILL.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -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/.claude/skills/purview-buildsdk-skill-tests/references/authoring.md b/.claude/skills/purview-buildsdk-skill-tests/references/authoring.md deleted file mode 100644 index 69d4ee45..00000000 --- a/.claude/skills/purview-buildsdk-skill-tests/references/authoring.md +++ /dev/null @@ -1,79 +0,0 @@ -# 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/.claude/skills/purview-buildsdk-testing/SKILL.md b/.claude/skills/purview-buildsdk-testing/SKILL.md deleted file mode 100644 index 5a5a7d53..00000000 --- a/.claude/skills/purview-buildsdk-testing/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -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/.claude/skills/purview-buildsdk-testing/references/treenode-filter.md b/.claude/skills/purview-buildsdk-testing/references/treenode-filter.md deleted file mode 100644 index 35d032bc..00000000 --- a/.claude/skills/purview-buildsdk-testing/references/treenode-filter.md +++ /dev/null @@ -1,107 +0,0 @@ -# `--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/.claude/skills/purview-engineering-principles/SKILL.md b/.claude/skills/purview-engineering-principles/SKILL.md deleted file mode 100644 index ba4dd9bd..00000000 --- a/.claude/skills/purview-engineering-principles/SKILL.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -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/.claude/skills/purview-persistence/SKILL.md b/.claude/skills/purview-persistence/SKILL.md deleted file mode 100644 index 4667fa29..00000000 --- a/.claude/skills/purview-persistence/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -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/.claude/skills/purview-persistence/references/guid-generation.md b/.claude/skills/purview-persistence/references/guid-generation.md deleted file mode 100644 index 432053a2..00000000 --- a/.claude/skills/purview-persistence/references/guid-generation.md +++ /dev/null @@ -1,93 +0,0 @@ -# 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/.claude/skills/purview-persistence/references/postgresql.md b/.claude/skills/purview-persistence/references/postgresql.md deleted file mode 100644 index cd557ecc..00000000 --- a/.claude/skills/purview-persistence/references/postgresql.md +++ /dev/null @@ -1,98 +0,0 @@ -# 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/.claude/skills/purview-persistence/references/sqlserver.md b/.claude/skills/purview-persistence/references/sqlserver.md deleted file mode 100644 index c7282955..00000000 --- a/.claude/skills/purview-persistence/references/sqlserver.md +++ /dev/null @@ -1,101 +0,0 @@ -# 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/.claude/skills/purview-telemetry/SKILL.md b/.claude/skills/purview-telemetry/SKILL.md deleted file mode 100644 index 6d831731..00000000 --- a/.claude/skills/purview-telemetry/SKILL.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -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/.claude/skills/purview-telemetry/references/observability-levels.md b/.claude/skills/purview-telemetry/references/observability-levels.md deleted file mode 100644 index df97d785..00000000 --- a/.claude/skills/purview-telemetry/references/observability-levels.md +++ /dev/null @@ -1,93 +0,0 @@ -# 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/.claude/skills/purview-testing-conventions/SKILL.md b/.claude/skills/purview-testing-conventions/SKILL.md deleted file mode 100644 index 1fe32320..00000000 --- a/.claude/skills/purview-testing-conventions/SKILL.md +++ /dev/null @@ -1,357 +0,0 @@ ---- -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/.claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md deleted file mode 100644 index e8b91109..00000000 --- a/.claude/skills/purview-testing-conventions/references/acceptance-and-container-tests.md +++ /dev/null @@ -1,117 +0,0 @@ -# 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/.gitignore b/.gitignore index 3ee7c942..87387da9 100644 --- a/.gitignore +++ b/.gitignore @@ -664,3 +664,6 @@ 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/skills-lock.json b/skills-lock.json index 732a07f6..3bd69121 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,7 +5,7 @@ "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "04dff518cc8553f7ff05a7871737bf8edeea1f92f32fa1b5cff8af94a9818fd4" + "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", @@ -23,19 +23,19 @@ "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "55f96148b07e03f292f06a786a9733ae6df6d971870af48caf32212a3b58e46d" + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "1f251609d5e0c219a9eb627ce27fc38f845e1ab826a2b0bc0985af8ec1a069a2" + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "d88899ee89edf9615af4b8406b50d53ffb9cec63ed2a9a4ace6d9a322aeef059" + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", @@ -73,6 +73,42 @@ "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": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + }, + "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", diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d65017be..da63cd35 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -11,6 +11,9 @@ true https://github.com/purview-dev/zodsharp $(NoWarn);CA1031;CA1307;CA1510; + + universal 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." + ); + } +} From ef6bd647ebe09f9e8501721937f7cb5eaba5c091 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:05:32 +0000 Subject: [PATCH 098/107] docs: describe the committed .agents skills and how to update them AGENTS.md said .agents/skills/ was gitignored. It is committed, together with skills-lock.json, and is written only by the skills CLI with -a universal. A new Installed skills section lists each skill, its source and when to load it, gives the commands to add, update and restore them, and states the rule that no .claude/ folder or CLAUDE.md is committed. The scattered npx skills add hints now point at that section, and the copilot instructions say the same. --- .github/copilot-instructions.md | 2 +- AGENTS.md | 62 +++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 11 deletions(-) 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/AGENTS.md b/AGENTS.md index 8fd20830..8b37ff93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,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 @@ -48,7 +48,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 @@ -85,7 +85,7 @@ 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 @@ -93,7 +93,7 @@ The `[ZodSchema]` attribute (`ZodSharp.ZodSchemaAttribute`, defined in the core 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). @@ -160,15 +160,57 @@ Commits must follow [Conventional Commits](https://www.conventionalcommits.org/) - 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 | + +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 From 332dd9d0dc58203ae998db38917d87d1a1b933d7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:30:32 +0000 Subject: [PATCH 099/107] docs: state that this repository's own skills are for consumers, never installed here AGENTS.md now says plainly that skills/ is authored for consumers of the packages, so it is never installed into this repository's .agents/skills/; InstalledSkillTests enforces it. --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8b37ff93..e93beb19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,8 @@ source, so a clone has them without running anything. | `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 From 930e0bf662303d6c2044ef0500c132b4f972826a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 17:12:10 +0000 Subject: [PATCH 100/107] build: bumped shared build --- .config/dotnet-tools.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 +} From 98a9e61ea0595670324e19118af427e361743e2e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 19:13:10 +0000 Subject: [PATCH 101/107] feat: added a supressor for CA1849 when using Schema Validate vs. ValidateAsync --- CHANGELOG.md | 22 +- docs/wiki/Core-Concepts.md | 3 + docs/wiki/Performance.md | 2 + docs/wiki/Source-Generator-Diagnostics.md | 9 +- docs/wiki/Source-Generator.md | 11 +- skills/purview-zodsharp-core/SKILL.md | 4 + skills/purview-zodsharp-diagnostics/SKILL.md | 7 + .../SKILL.md | 10 +- .../ZodOpenApiSchemaTransformer.cs | 2 +- .../ZodClientValidationProvider.cs | 10 +- .../ZodClientValidationRules.cs | 2 +- ...ntValidationServiceCollectionExtensions.cs | 2 +- .../Extensions/ZodSharp/LegacyOverloads.cs | 4 +- .../ZodEndpointValidationFilterFactory.cs | 11 +- .../AspNetCore/ZodProblemDetailsOptions.cs | 2 +- src/src/AspNetCore/ZodProblemDetailsWriter.cs | 56 ++--- .../AspNetCore/ZodRequestValidationReport.cs | 7 +- .../AspNetCore/ZodValidationPipelineGuard.cs | 11 +- src/src/Blazor/ZodFieldLocator.cs | 3 + src/src/Blazor/ZodModelSchema.cs | 2 +- src/src/Blazor/ZodValidationMessage.cs | 3 +- src/src/Blazor/ZodValidationProblemDetails.cs | 4 +- src/src/Blazor/ZodValidationSummary.cs | 3 +- src/src/Blazor/ZodValidator.cs | 4 + .../Properties/launchSettings.json | 12 + .../Properties/launchSettings.json | 12 + src/src/Examples.MinimalApi/SampleOptions.cs | 3 + .../RegisterCustomerRequest.cs | 3 + .../ZodValidateContextExtensions.cs | 2 +- .../StringLocalizerZodMessageFormatter.cs | 2 +- .../ZodCompositeValidatableTypeInfo.cs | 4 +- .../Extensions.Validation/ZodErrorEmitter.cs | 12 +- .../ZodValidatableInfoResolver.cs | 18 +- .../ZodValidatableTypeInfo.cs | 13 +- .../ZodValidationReport.cs | 6 +- .../NewtonsoftJsonExtensions.cs | 3 + .../Helpers/ExternalSchemaResolver.cs | 2 +- ...SourceGenLibrary.CustomValidationMethod.cs | 2 +- .../Helpers/SourceGenLibrary.cs | 7 +- .../ZodSchemaGenerator.BuildSchema.cs | 144 ++++++++++-- .../ZodSchemaGenerator.Registration.cs | 30 ++- .../ZodSchemaGenerator.Resolution.cs | 39 +++- .../ZodSchemaGenerator.Validation.cs | 206 ++++++++++++++--- .../ZodSyncValidationSuppressor.cs | 115 ++++++++++ .../Json/JsonDeserializationErrors.cs | 3 + .../Json/ZodJsonValidationException.cs | 14 +- src/src/ZodSharp/Core/ValidationError.cs | 23 +- .../ZodSharp/Core/ZodGeneratedValidation.cs | 7 +- src/src/ZodSharp/Core/ZodPath.cs | 10 +- src/src/ZodSharp/Core/ZodTypeNames.cs | 6 + src/src/ZodSharp/Core/ZodValidationDepth.cs | 8 +- .../JsonSchema/JsonSchemaDefinition.cs | 4 +- .../JsonSchema/ToJsonSchemaConverter.cs | 4 +- .../ZodOptionsValidationRegistration.cs | 15 +- src/src/ZodSharp/Schemas/RefineCtx.cs | 24 +- .../ZodSharp/Schemas/ZodDiscriminatedUnion.cs | 7 +- .../DocumentCrossCheckTests.cs | 159 +++++++++---- .../FixtureApi.cs | 2 +- .../Fixtures/OpenApiFixtures.cs | 12 +- .../Fixtures/PetUnion.cs | 2 +- ...odOpenApiSchemaTransformerTests.Mapping.cs | 11 +- .../ProblemDetailsContractTests.cs | 3 +- .../ZodEndpointValidationTests.cs | 2 +- .../Blazor.UnitTests/ComponentMarkupTests.cs | 6 +- .../EndpointValidationRegistrationTests.cs | 5 +- .../Blazor.UnitTests/Fixtures/TestSupport.cs | 3 + .../Blazor.UnitTests/GeneratedSchemaTests.cs | 2 +- .../ProblemDetailsMappingTests.cs | 14 +- .../Ssr/ClientValidationRulesTests.cs | 16 +- .../Blazor.UnitTests/Ssr/StaticSsrTests.cs | 6 +- .../StructMemberPlacementTests.cs | 2 +- .../CustomerEndpointTests.cs | 7 +- .../SampleAppFactory.cs | 4 +- .../SampleAppTests.cs | 6 +- .../Fixtures/GeneratedModels.cs | 2 +- .../Fixtures/TestHelpers.cs | 6 + .../Fixtures/Unions.cs | 2 +- .../ZodValidatableParameterInfoTests.cs | 5 +- .../Json/DualPackageCallTests.cs | 3 +- .../Json/NewtonsoftJsonErrorTests.cs | 23 +- .../Schema/FromJsonSchemaParserErrorTests.cs | 53 ++++- .../Repository.UnitTests/ReleaseGateTests.cs | 40 ++-- .../CrossAssemblyMarkerAttributeTests.cs | 6 + .../MSBuildSwitchGlobalConfigTests.cs | 5 +- ...ZodSchemaGeneratorTests.AsyncValidation.cs | 4 +- ...aGeneratorTests.JsonSchemaCrossPlatform.cs | 4 +- .../ZodSyncValidationSuppressorTests.cs | 209 ++++++++++++++++++ .../Schema/FromJsonSchemaParserErrorTests.cs | 60 ++++- .../Json/SystemTextJsonConverterTests.cs | 10 +- .../Json/SystemTextJsonErrorTests.cs | 74 +++++-- .../TrimSafety.Verification/CoreChecks.cs | 4 +- .../IntegrationChecks.cs | 10 +- src/tests/TrimSafety.Verification/Models.cs | 4 +- .../Core/ZodMessageFormatTests.cs | 6 +- .../ToJsonSchemaVendorExtensionTests.cs | 4 +- 95 files changed, 1358 insertions(+), 392 deletions(-) create mode 100644 src/src/Examples.Blazor/Properties/launchSettings.json create mode 100644 src/src/Examples.MinimalApi/Properties/launchSettings.json create mode 100644 src/src/SourceGenerators/ZodSyncValidationSuppressor.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSyncValidationSuppressorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e6657b18..2888db70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -214,6 +214,11 @@ Released versions correspond to `v` GitHub releases. 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. ### Breaking behaviour changes - **Dropped net8.0 and net9.0; supported target frameworks are net10.0 and net11.0.** Every package ships @@ -561,14 +566,15 @@ Each note names the old form, the new form, and the edit to make. 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)` or - `validator.Validate(value)` inside async code. For a type whose `{Type}Schema.RequiresAsyncValidation` is `false` - (the generated `Validate` remarks say which) the sync call is correct and never blocks. Either await - `ValidateAsync(value, services, cancellationToken)`, which completes synchronously without allocating for such a - type and also runs asynchronous rules if they are added later (the recommended edit), or suppress CA1849 at the call - site with a justification. ZodSharp does not suppress the analyzer for you, and nothing in generated code can tell - it a call is fine. For a type that requires async validation the warning is real: `Validate` cannot run those rules.- **Option-less ProblemDetails overloads (CS0618).** `result.ToHttpValidationProblemDetails()` and the other overloads +- **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 diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 3cb5f0db..20bd1dce 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -20,6 +20,9 @@ ValidationResult result = schema.Validate(value); | `Parse(TInput value)` | Returns the validated `TOutput`, or throws `ZodException` on failure (via `GetValueOrThrow()`). | | `ValidateAsync(TInput value, CancellationToken)` | `ValueTask` wrapper around `Validate`. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` when already cancelled. Genuinely async work only happens in the source generator's custom async validation. | +Reach for `Validate` even inside an async method: it is the cheaper call, and the analyzer package suppresses CA1849 +for it (`ZODSUP001`). `ValidateAsync` is what runs a type's asynchronous rules, which is the only reason to await it. + ## ValidationResult `ValidationResult` is a `readonly record struct` in `ZodSharp.Core`: diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index 7b956e54..0cdc1cc6 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -127,6 +127,8 @@ What the design guarantees regardless of the numbers: Blazor field costs one member, not the whole graph. - **Synchronous fast path.** `{Type}Schema.RequiresAsyncValidation` is a compile-time constant. When nothing in the graph is asynchronous, `ValidateAsync` completes synchronously (it returns `new ValueTask<…>(Validate(…))`). + Prefer `Validate` even inside async code: it skips the `ValueTask` and costs nothing, and CA1849 is suppressed for + it (`ZODSUP001`). - **Errors cost on failure only.** Paths, bounds and parameters are built when a rule fails; messages are formatted with the invariant culture at validation time and re-formatted for a user's culture only at a host boundary, so success stays allocation-free. diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index fe5072b0..c5df848d 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -60,4 +60,11 @@ The `[ErrorType]` generator, shipped in the same analyzer assembly, reports `ZOD ## Suppressing -Diagnostics can be suppressed per-project or per-site with the standard `#pragma warning disable ZODSGEN006` / `NoWarn` mechanisms. Refer to the analyzer's shipped release notes (`AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project) for the canonical catalog. \ No newline at end of file +Diagnostics can be suppressed per-project or per-site with the standard `#pragma warning disable ZODSGEN006` / `NoWarn` mechanisms. Refer to the analyzer's shipped release notes (`AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project) for the canonical catalog. + +The package also ships one **suppression** that is not a reportable rule: `ZODSUP001` removes `CA1849` ("call async +methods when in an async method") for ZodSharp's synchronous validation entry points — generated `Validate` and +`ValidateMember`, the validator adapters and the fluent schemas. CA1849 fires there only because `ValidateAsync` +exists; the synchronous pipeline is the cheap path and never blocks, and a type whose graph has asynchronous rules is +already covered by `ZODSGEN048`. The suppression is by call target, so `Task.Wait`, `Thread.Sleep` and another +library's `Validate`/`ValidateAsync` pair still report CA1849. diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index ccae6c06..56c8a34a 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -259,10 +259,13 @@ var handleOnly = await AccountSchema.ValidateMemberAsync(account, nameof(Account `RequiresAsyncValidation` is `true` for `Account`, and for every type that nests it. A type whose graph has no asynchronous rule completes `ValidateAsync` synchronously, so awaiting it costs nothing. -Because `ValidateAsync` exists, CA1849 (when enabled) reports a `Validate` call in async code. For a type whose -`RequiresAsyncValidation` is `false` the generated `Validate` remarks say the call never blocks, so the warning is -noise there: prefer awaiting `ValidateAsync` (free for such a type, and correct if async rules are added later) over -suppressing it. For a type that requires async validation it is a real finding. +Because `ValidateAsync` exists, CA1849 (when enabled) would report every `Validate` and `ValidateMember` call inside +async code. It does not: the analyzer package ships the `ZODSUP001` suppressor, which removes those reports by call +target. The synchronous call is the cheap path (no `ValueTask`, no state machine) and never blocks. `ValidateAsync` +is the call to make when the graph has asynchronous rules — `RequiresAsyncValidation` is the compile-time answer and +`ZODSGEN048` says so at build time; a synchronous `Validate` of such a type throws rather than skip them. Nothing +else is suppressed: CA1849 still reports for `Task.Wait`, `Thread.Sleep` and another library's +`Validate`/`ValidateAsync` pair. > [!WARNING] > The async custom validation method is mutually exclusive with the `OnZodValidate` refinement hook. A diff --git a/skills/purview-zodsharp-core/SKILL.md b/skills/purview-zodsharp-core/SKILL.md index 060ef445..ca10d04d 100644 --- a/skills/purview-zodsharp-core/SKILL.md +++ b/skills/purview-zodsharp-core/SKILL.md @@ -56,6 +56,10 @@ if (result.IsSuccess) 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`: diff --git a/skills/purview-zodsharp-diagnostics/SKILL.md b/skills/purview-zodsharp-diagnostics/SKILL.md index 068d4c68..eb584b5c 100644 --- a/skills/purview-zodsharp-diagnostics/SKILL.md +++ b/skills/purview-zodsharp-diagnostics/SKILL.md @@ -138,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-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index 6fe36ff9..dc5d0745 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -223,10 +223,12 @@ the member), then the custom method, and merges the errors. It completes synchro 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, reports every `Validate` call inside async code because `ValidateAsync` exists. When -`RequiresAsyncValidation` is `false` (the generated `Validate` remarks say so) the sync call is fine; prefer -awaiting `ValidateAsync` there anyway (it completes synchronously, and picks up async rules added later) rather than -suppressing the analyzer. When it is `true`, the warning is real. +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 diff --git a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs index bb5a7bb9..4de24a6d 100644 --- a/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs +++ b/src/src/AspNetCore.OpenApi/ZodOpenApiSchemaTransformer.cs @@ -66,7 +66,7 @@ CancellationToken cancellationToken return Task.CompletedTask; } - void TransformProperty( + static void TransformProperty( OpenApiSchema schema, JsonPropertyInfo property, JsonTypeInfo propertyType, diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs index 25849271..840f5ff6 100644 --- a/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationProvider.cs @@ -38,8 +38,6 @@ public sealed class ZodClientValidationProvider : ClientValidationProvider const string RulesAttribute = "data-rules"; static readonly ConcurrentDictionary<(Type Container, string Field), List> AttributeRules = new(); - - readonly ClientValidationProvider? _inner; readonly ZodClientValidationOptions _options; readonly IZodSchemaFactory? _factory; @@ -57,7 +55,7 @@ public ZodClientValidationProvider( { ArgumentNullException.ThrowIfNull(options); - _inner = inner is ZodClientValidationProvider { _inner: var unwrapped } ? unwrapped : inner; + Inner = inner is ZodClientValidationProvider { Inner: var unwrapped } ? unwrapped : inner; _options = options; _factory = factory; } @@ -65,7 +63,7 @@ public ZodClientValidationProvider( /// /// Gets the decorated provider. /// - public ClientValidationProvider? Inner => _inner; + public ClientValidationProvider? Inner { get; } /// public override RenderFragment? RenderClientValidationRules( @@ -76,7 +74,7 @@ IReadOnlyDictionary renderedFields ArgumentNullException.ThrowIfNull(editContext); ArgumentNullException.ThrowIfNull(renderedFields); - var inner = _inner?.RenderClientValidationRules(editContext, renderedFields); + var inner = Inner?.RenderClientValidationRules(editContext, renderedFields); var own = CollectRules(editContext, renderedFields); if (own.Count == 0) return inner; @@ -197,7 +195,7 @@ static string Merge(string? innerJson, List<(string Name, IReadOnlyList written = new(StringComparer.Ordinal); + HashSet written = [with(StringComparer.Ordinal)]; if (innerJson is not null) { using var document = JsonDocument.Parse(innerJson); diff --git a/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs b/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs index 30907d4c..7c1318fd 100644 --- a/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs +++ b/src/src/AspNetCore/ClientValidation/ZodClientValidationRules.cs @@ -521,7 +521,7 @@ static void Add( params (string Key, string? Value)[] parameters ) { - Dictionary values = new(StringComparer.Ordinal); + Dictionary values = [with(StringComparer.Ordinal)]; foreach (var (key, value) in parameters) { if (value is not null) diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs index 7723de70..6236ed46 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ZodSharpClientValidationServiceCollectionExtensions.cs @@ -67,7 +67,7 @@ public IServiceCollection AddZodSharpClientValidation(Action d.ServiceType == typeof(ClientValidationProvider) && !d.IsKeyedService diff --git a/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs b/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs index cdeb6a7e..2cd49530 100644 --- a/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs +++ b/src/src/AspNetCore/Extensions/ZodSharp/LegacyOverloads.cs @@ -2,9 +2,9 @@ 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 +/// 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 +/// handler and . Their behaviour is unchanged; they are kept for source and /// binary compatibility. /// static class LegacyOverloads diff --git a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs index d01391d6..8a75cb71 100644 --- a/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs +++ b/src/src/AspNetCore/ZodEndpointValidationFilterFactory.cs @@ -181,10 +181,9 @@ ZodEndpointValidationOptions options errors.Add(target.Rebase(error)); } - if (errors is not null) - return new ZodProblemDetailsResult(writer, writer.Create(CollectionsMarshal.AsSpan(errors), httpContext)); - - return await next(invocation).ConfigureAwait(false); + return errors is not null + ? new ZodProblemDetailsResult(writer, writer.Create(CollectionsMarshal.AsSpan(errors), httpContext)) + : await next(invocation).ConfigureAwait(false); } internal static ZodProblemDetailsResult CreateBindingFailureResult( @@ -244,8 +243,12 @@ static ValidationError CreateBindingError(string? name, bool isBody) 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); } diff --git a/src/src/AspNetCore/ZodProblemDetailsOptions.cs b/src/src/AspNetCore/ZodProblemDetailsOptions.cs index 4280137f..939618c1 100644 --- a/src/src/AspNetCore/ZodProblemDetailsOptions.cs +++ b/src/src/AspNetCore/ZodProblemDetailsOptions.cs @@ -65,7 +65,7 @@ public sealed class ZodProblemDetailsOptions /// /// 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 + /// produced by Microsoft.Extensions.Validation and merged by ) also get an /// issues entry, with code . Defaults to /// true. /// diff --git a/src/src/AspNetCore/ZodProblemDetailsWriter.cs b/src/src/AspNetCore/ZodProblemDetailsWriter.cs index 5d1336bc..9ed59062 100644 --- a/src/src/AspNetCore/ZodProblemDetailsWriter.cs +++ b/src/src/AspNetCore/ZodProblemDetailsWriter.cs @@ -54,8 +54,9 @@ public sealed class ZodProblemDetailsWriter const int MaxUnionDepth = 8; - static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) - { + static readonly HashSet ReservedNames = + [ + with(StringComparer.OrdinalIgnoreCase), "type", "title", "status", @@ -64,7 +65,7 @@ public sealed class ZodProblemDetailsWriter "errors", "issues", "traceId", - }; + ]; static readonly Action LogReservedParameter = LoggerMessage.Define< string, @@ -74,8 +75,6 @@ public sealed class ZodProblemDetailsWriter new EventId(1, "ZodReservedParameter"), "The validation error parameter '{Parameter}' collides with a reserved ProblemDetails member and was written as '{Renamed}'." ); - - readonly ZodProblemDetailsOptions _options; readonly IZodMessageFormatter? _formatter; readonly ILogger? _logger; JsonNamingPolicy? _cachedPolicy; @@ -107,7 +106,7 @@ public ZodProblemDetailsWriter(ZodProblemDetailsOptions options, IZodMessageForm { ArgumentNullException.ThrowIfNull(options); - _options = options; + Options = options; _formatter = formatter; _logger = logger; } @@ -115,7 +114,7 @@ public ZodProblemDetailsWriter(ZodProblemDetailsOptions options, IZodMessageForm /// /// Gets the options this writer applies. /// - public ZodProblemDetailsOptions Options => _options; + public ZodProblemDetailsOptions Options { get; } /// /// Returns the writer registered in , or creates one from the registered @@ -209,8 +208,8 @@ public void Apply( internal void ProjectExternalIssues(HttpValidationProblemDetails problemDetails) { if ( - !_options.IncludeIssues - || !_options.ProjectDataAnnotationsIssues + !Options.IncludeIssues + || !Options.ProjectDataAnnotationsIssues || problemDetails.Errors.Count == 0 || problemDetails.Extensions.ContainsKey("issues") ) @@ -263,7 +262,7 @@ public async ValueTask WriteAsync( ArgumentNullException.ThrowIfNull(httpContext); ArgumentNullException.ThrowIfNull(problemDetails); - httpContext.Response.StatusCode = problemDetails.Status ?? _options.DefaultStatusCode; + httpContext.Response.StatusCode = problemDetails.Status ?? Options.DefaultStatusCode; if (httpContext.RequestServices?.GetService() is { } service) { @@ -295,8 +294,8 @@ void Populate( bool mergeExistingErrors ) { - Dictionary> grouped = new(StringComparer.Ordinal); - var issues = _options.IncludeIssues ? new List(errors.Length) : null; + Dictionary> grouped = [with(StringComparer.Ordinal)]; + var issues = Options.IncludeIssues ? new List(errors.Length) : null; var highest = CollectErrors(errors, httpContext, grouped, issues); @@ -321,7 +320,7 @@ bool mergeExistingErrors if (issues is not null) target.Extensions["issues"] = issues.ToArray(); - if (_options.MergeParametersIntoExtensions) + if (Options.MergeParametersIntoExtensions) MergeParameters(target, errors); var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier; @@ -361,7 +360,7 @@ void MergeExistingErrors( List? issues ) { - var project = issues is not null && _options.ProjectDataAnnotationsIssues; + var project = issues is not null && Options.ProjectDataAnnotationsIssues; foreach (var pair in target.Errors) { if (grouped.ContainsKey(pair.Key)) @@ -380,14 +379,14 @@ void MergeExistingErrors( int ComputeStatus(ReadOnlySpan errors, ErrorType? highest) { - var status = highest?.HttpStatus ?? _options.DefaultStatusCode; - if (_options.StatusCodeSelector is null && _options.StatusCodeOverride is null) + 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) + if (Options.StatusCodeSelector is { } selector) status = selector(snapshot); - if (_options.StatusCodeOverride is { } statusOverride) + if (Options.StatusCodeOverride is { } statusOverride) status = statusOverride(snapshot, status); return status; @@ -398,17 +397,21 @@ int ComputeStatus(ReadOnlySpan errors, ErrorType? highest) if (string.IsNullOrEmpty(code)) return null; - if (_options.ErrorTypeResolver is { } resolver) + // 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; + 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) + if (Options.FormatMessages && errorType?.MessageFormat is { Length: > 0 } template) { var templated = error with { MessageFormat = template }; return _formatter is null @@ -567,7 +570,7 @@ void MergeParameters(HttpValidationProblemDetails target, ReadOnlySpan(StringComparer.Ordinal); + result ??= [with(StringComparer.Ordinal)]; result[JsonNamingPolicy.CamelCase.ConvertName(pair.Key)] = safe; } @@ -579,7 +582,7 @@ void MergeParameters(HttpValidationProblemDetails target, ReadOnlySpan !IsUnionParameter(key) && ( - _options.IncludeDiagnosticParameters + Options.IncludeDiagnosticParameters || !( string.Equals(key, ZodIssueParameters.DeclaringType, StringComparison.Ordinal) || string.Equals(key, ZodIssueParameters.Member, StringComparison.Ordinal) @@ -604,7 +607,7 @@ static object[] ToSegments(ZodPath path, IZodPathNamer? namer) ZodPathSegmentKind.Member => namer?.GetName(segment.Name ?? string.Empty) ?? segment.Name ?? string.Empty, - _ => segment.Name ?? string.Empty, + ZodPathSegmentKind.Key or _ => segment.Name ?? string.Empty, }; } @@ -695,7 +698,7 @@ IFormattable formattable when value.GetType().IsPrimitive || value is Half or In IZodPathNamer? ResolveNamer(HttpContext? httpContext) { - if (_options.PathNaming != ZodPathNaming.Json) + if (Options.PathNaming != ZodPathNaming.Json) return null; var jsonOptions = httpContext?.RequestServices?.GetService>(); @@ -717,6 +720,8 @@ IFormattable formattable when value.GetType().IsPrimitive || value is Half or In 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) @@ -725,6 +730,7 @@ options is not null { return typed; } +#pragma warning restore IDE0046 return ZodProblemDetailsJsonContext.Default.HttpValidationProblemDetails; } diff --git a/src/src/AspNetCore/ZodRequestValidationReport.cs b/src/src/AspNetCore/ZodRequestValidationReport.cs index c40a5b8d..0ff51e70 100644 --- a/src/src/AspNetCore/ZodRequestValidationReport.cs +++ b/src/src/AspNetCore/ZodRequestValidationReport.cs @@ -15,18 +15,17 @@ namespace ZodSharp.AspNetCore; public sealed class ZodRequestValidationReport { readonly Lock _lock = new(); - ImmutableArray _errors = []; /// /// 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 => _errors; + public ImmutableArray Errors { get; private set; } = []; /// /// Gets whether any error was reported. /// - public bool HasErrors => !_errors.IsEmpty; + public bool HasErrors => !Errors.IsEmpty; /// /// Gets the report for , or when nothing was reported. @@ -49,7 +48,7 @@ public void Add(ImmutableArray errors) return; lock (_lock) - _errors = _errors.AddRange(errors); + Errors = Errors.AddRange(errors); } internal static ZodRequestValidationReport GetOrCreate(HttpContext httpContext) diff --git a/src/src/AspNetCore/ZodValidationPipelineGuard.cs b/src/src/AspNetCore/ZodValidationPipelineGuard.cs index 818943d7..e5c748be 100644 --- a/src/src/AspNetCore/ZodValidationPipelineGuard.cs +++ b/src/src/AspNetCore/ZodValidationPipelineGuard.cs @@ -59,8 +59,12 @@ sealed class ZodValidationPipelineGuard } 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, @@ -75,10 +79,9 @@ sealed class ZodValidationPipelineGuard public bool Covers(ParameterInfo parameter) { // The pipeline never validates a parameter the container resolves. - if (_isService?.IsService(parameter.ParameterType) == true) - return false; - - return _options.TryGetValidatableParameterInfo(parameter, out var info) && info.GetType().Assembly == _bridge; + return _isService?.IsService(parameter.ParameterType) != true + && _options.TryGetValidatableParameterInfo(parameter, out var info) + && info.GetType().Assembly == _bridge; } /// diff --git a/src/src/Blazor/ZodFieldLocator.cs b/src/src/Blazor/ZodFieldLocator.cs index aecc86e3..171e8a1b 100644 --- a/src/src/Blazor/ZodFieldLocator.cs +++ b/src/src/Blazor/ZodFieldLocator.cs @@ -70,8 +70,11 @@ public static FieldIdentifier Locate(object root, ZodPath path, IZodUntypedValid 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); } diff --git a/src/src/Blazor/ZodModelSchema.cs b/src/src/Blazor/ZodModelSchema.cs index 81fe3bd6..5f065ffa 100644 --- a/src/src/Blazor/ZodModelSchema.cs +++ b/src/src/Blazor/ZodModelSchema.cs @@ -97,7 +97,7 @@ public ValueTask> ValidateMemberAsync( string memberName, IServiceProvider? services, CancellationToken cancellationToken - ) => new(ImmutableArray.Empty); + ) => new([]); public bool TryResolveMember(object value, ZodPath path, out object? container, out string memberName) { diff --git a/src/src/Blazor/ZodValidationMessage.cs b/src/src/Blazor/ZodValidationMessage.cs index e06b6f39..c6ee4e70 100644 --- a/src/src/Blazor/ZodValidationMessage.cs +++ b/src/src/Blazor/ZodValidationMessage.cs @@ -161,8 +161,7 @@ public void Dispose() void Detach() { - if (_previousEditContext is not null) - _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + _previousEditContext?.OnValidationStateChanged -= _validationStateChangedHandler; } } diff --git a/src/src/Blazor/ZodValidationProblemDetails.cs b/src/src/Blazor/ZodValidationProblemDetails.cs index c75be7f4..40cdb20c 100644 --- a/src/src/Blazor/ZodValidationProblemDetails.cs +++ b/src/src/Blazor/ZodValidationProblemDetails.cs @@ -178,7 +178,7 @@ public ValidationError ToValidationError() Dictionary? parameters = null; if (Parameters is { Count: > 0 }) { - parameters = new(StringComparer.Ordinal); + parameters = [with(StringComparer.Ordinal)]; foreach (var (name, value) in Parameters) parameters[name] = ToScalar(value); } @@ -189,7 +189,7 @@ public ValidationError ToValidationError() foreach (var option in unionErrors) options.Add([.. (option ?? []).Select(static issue => issue.ToValidationError())]); - parameters ??= new(StringComparer.Ordinal); + parameters ??= [with(StringComparer.Ordinal)]; parameters[ZodIssueParameters.UnionErrors] = options.MoveToImmutable(); } diff --git a/src/src/Blazor/ZodValidationSummary.cs b/src/src/Blazor/ZodValidationSummary.cs index 6ad3916d..f437271d 100644 --- a/src/src/Blazor/ZodValidationSummary.cs +++ b/src/src/Blazor/ZodValidationSummary.cs @@ -228,7 +228,6 @@ public void Dispose() void Detach() { - if (_previousEditContext is not null) - _previousEditContext.OnValidationStateChanged -= _validationStateChangedHandler; + _previousEditContext?.OnValidationStateChanged -= _validationStateChangedHandler; } } diff --git a/src/src/Blazor/ZodValidator.cs b/src/src/Blazor/ZodValidator.cs index 207357f6..4921d4ce 100644 --- a/src/src/Blazor/ZodValidator.cs +++ b/src/src/Blazor/ZodValidator.cs @@ -184,8 +184,12 @@ public void Dispose() 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 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.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/SampleOptions.cs b/src/src/Examples.MinimalApi/SampleOptions.cs index 40ea40c0..16dda31c 100644 --- a/src/src/Examples.MinimalApi/SampleOptions.cs +++ b/src/src/Examples.MinimalApi/SampleOptions.cs @@ -39,8 +39,11 @@ 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( diff --git a/src/src/Examples.Shared/RegisterCustomerRequest.cs b/src/src/Examples.Shared/RegisterCustomerRequest.cs index eaadad6b..caff833e 100644 --- a/src/src/Examples.Shared/RegisterCustomerRequest.cs +++ b/src/src/Examples.Shared/RegisterCustomerRequest.cs @@ -40,8 +40,11 @@ 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( diff --git a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs index d3fc1b5c..240538c4 100644 --- a/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs +++ b/src/src/Extensions.Validation/Extensions/Microsoft/Extensions/Validation/ZodValidateContextExtensions.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.Validation; /// public static class ZodValidateContextExtensions { - static readonly ConditionalWeakTable Reports = new(); + static readonly ConditionalWeakTable Reports = []; extension(ValidateContext context) { diff --git a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs index bd092467..57a0b3cb 100644 --- a/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs +++ b/src/src/Extensions.Validation/StringLocalizerZodMessageFormatter.cs @@ -180,7 +180,7 @@ public string Format(in ZodValidationError error, Type? declaringType, CultureIn static ZodValidationError WithDisplayName(in ZodValidationError error, string displayName) { - Dictionary parameters = new(StringComparer.Ordinal); + Dictionary parameters = [with(StringComparer.Ordinal)]; if (error.Parameters is not null) { foreach (var pair in error.Parameters) diff --git a/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs b/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs index 6f8b160b..325e44ab 100644 --- a/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs +++ b/src/src/Extensions.Validation/ZodCompositeValidatableTypeInfo.cs @@ -16,7 +16,7 @@ namespace ZodSharp.Extensions.Validation; sealed class ZodCompositeValidatableTypeInfo(ZodValidatableTypeInfo zod, ZodValidatableInfoResolver owner) : IValidatableTypeInfo { - static readonly ConditionalWeakTable SecondPassContexts = new(); + static readonly ConditionalWeakTable SecondPassContexts = []; static readonly object Marker = new(); public ZodValidatableTypeInfo Zod => zod; @@ -100,7 +100,7 @@ static void Merge(ValidateContext second, ValidateContext context) } static bool Contains( - IReadOnlyDictionary>? errors, + IReadOnlyDictionary>? errors, string path, string message ) diff --git a/src/src/Extensions.Validation/ZodErrorEmitter.cs b/src/src/Extensions.Validation/ZodErrorEmitter.cs index 64c61abd..2a0896b9 100644 --- a/src/src/Extensions.Validation/ZodErrorEmitter.cs +++ b/src/src/Extensions.Validation/ZodErrorEmitter.cs @@ -116,12 +116,12 @@ public static void Emit( static IZodMessageFormatter? ResolveFormatter(ValidateContext context, ZodValidationOptions options) { - if (options.MessageFormatter is not null) - return options.MessageFormatter; - - return context.ServiceProvider?.GetService(typeof(IStringLocalizerFactory)) is IStringLocalizerFactory factory - ? new StringLocalizerZodMessageFormatter(factory, context.ValidationOptions.LocalizerProvider) - : null; + return options.MessageFormatter + ?? ( + context.ServiceProvider?.GetService(typeof(IStringLocalizerFactory)) is IStringLocalizerFactory factory + ? new StringLocalizerZodMessageFormatter(factory, context.ValidationOptions.LocalizerProvider) + : null + ); } static string FormatMessage( diff --git a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs index 8cace3e5..aaa39921 100644 --- a/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs +++ b/src/src/Extensions.Validation/ZodValidatableInfoResolver.cs @@ -27,9 +27,8 @@ namespace ZodSharp.Extensions.Validation; public sealed class ZodValidatableInfoResolver : IValidatableInfoResolver { readonly IZodSchemaFactory? _factory; - readonly ZodValidationOptions _options; readonly ConcurrentDictionary _types = new(); - readonly ConditionalWeakTable _parameters = new(); + readonly ConditionalWeakTable _parameters = []; ValidationOptions? _attachedTo; /// @@ -47,14 +46,14 @@ bool parametersRequested ) { _factory = factory; - _options = options ?? new(); - ValidatesParameters = _options.ValidateParameters ?? parametersRequested; + Options = options ?? new(); + ValidatesParameters = Options.ValidateParameters ?? parametersRequested; } /// /// Gets the bridge options. /// - public ZodValidationOptions Options => _options; + public ZodValidationOptions Options { get; } /// /// Gets whether this resolver claims parameters, so Microsoft.Extensions.Validation's endpoint filter validates @@ -73,7 +72,7 @@ public bool TryGetValidator(Type type, [NotNullWhen(true)] out IZodUntypedValida { ArgumentNullException.ThrowIfNull(type); - foreach (var source in _options.Sources) + foreach (var source in Options.Sources) { if (source.TryGetValidator(type, out validator) && validator is not null) return true; @@ -150,8 +149,8 @@ public bool TryGetValidatableParameterInfo( 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; + 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 @@ -190,8 +189,11 @@ public bool TryGetValidatableParameterInfo( 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; } diff --git a/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs b/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs index 7db4e27b..78168c9b 100644 --- a/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs +++ b/src/src/Extensions.Validation/ZodValidatableTypeInfo.cs @@ -26,7 +26,6 @@ namespace ZodSharp.Extensions.Validation; /// public sealed class ZodValidatableTypeInfo : IValidatableTypeInfo { - readonly ZodValidationOptions _options; readonly ConcurrentDictionary _properties = new(StringComparer.Ordinal); /// @@ -39,7 +38,7 @@ public ZodValidatableTypeInfo(IZodUntypedValidator validator, ZodValidationOptio ArgumentNullException.ThrowIfNull(validator); Validator = validator; - _options = options ?? new(); + Options = options ?? new(); } /// @@ -52,7 +51,7 @@ public ZodValidatableTypeInfo(IZodUntypedValidator validator, ZodValidationOptio /// public Type ValidatedType => Validator.ValidatedType; - internal ZodValidationOptions Options => _options; + internal ZodValidationOptions Options { get; } /// /// @@ -102,7 +101,7 @@ internal void ValidateCore(object? value, ValidateContext context, string? value ); var errors = value is null ? NullValue() : Validator.Validate(value); - ZodErrorEmitter.Emit(Validator, value, errors, context, _options, valueKey, prefixWithKey); + ZodErrorEmitter.Emit(Validator, value, errors, context, Options, valueKey, prefixWithKey); } internal Task ValidateCoreAsync( @@ -121,14 +120,14 @@ internal Task ValidateCoreAsync( if (value is null) { - ZodErrorEmitter.Emit(Validator, value, NullValue(), context, _options, valueKey, prefixWithKey); + 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); + ZodErrorEmitter.Emit(Validator, value, pending.Result, context, Options, valueKey, prefixWithKey); return Task.CompletedTask; } } @@ -149,7 +148,7 @@ bool prefixWithKey ) { var errors = await pending.ConfigureAwait(false); - ZodErrorEmitter.Emit(Validator, value, errors, context, _options, valueKey, prefixWithKey); + 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 diff --git a/src/src/Extensions.Validation/ZodValidationReport.cs b/src/src/Extensions.Validation/ZodValidationReport.cs index 46198de5..4e05180a 100644 --- a/src/src/Extensions.Validation/ZodValidationReport.cs +++ b/src/src/Extensions.Validation/ZodValidationReport.cs @@ -17,7 +17,6 @@ public sealed class ZodValidationReport { readonly Lock _lock = new(); ImmutableArray _errors = []; - ImmutableHashSet _keys = ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); /// /// Creates an empty report. @@ -44,14 +43,15 @@ public ZodValidationReport(IEnumerable errors) /// public bool HasErrors => !_errors.IsEmpty; - internal ImmutableHashSet Keys => _keys; + 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); + Keys = Keys.Union(keys); } } } diff --git a/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs b/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs index 513ec5d3..b1a4c3b4 100644 --- a/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs +++ b/src/src/NewtonsoftJson/NewtonsoftJsonExtensions.cs @@ -35,8 +35,11 @@ public static ValidationResult DeserializeAndValidate( 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 return TryDeserialize(json, settings, out var deserialized, out var errors) ? schema.Validate(deserialized) diff --git a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs index d11f125b..18565047 100644 --- a/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs +++ b/src/src/SourceGenerators/Helpers/ExternalSchemaResolver.cs @@ -177,7 +177,7 @@ type.TypeKind is TypeKind.Class or TypeKind.Struct && !type.IsStatic && !( ValidationInputsResolver.RoslynFeatures.IsUnion(type) - && !ZodSharp.SourceGenerators.Models.ZodSchemaAttributeData.FromAttributeData(type, out _).Exists + && !Models.ZodSchemaAttributeData.FromAttributeData(type, out _).Exists ); /// diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs index 2e5aab8e..65dc4db5 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.CustomValidationMethod.cs @@ -136,7 +136,7 @@ AttributeData zodSchemaAttribute static EquatableArray GetAsyncMembers(IMethodSymbol method) { var builder = ImmutableArray.CreateBuilder(); - HashSet seen = new(StringComparer.Ordinal); + HashSet seen = [with(StringComparer.Ordinal)]; foreach (var attribute in method.GetAttributes()) { if ( diff --git a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index bbfb0d2c..77706073 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -771,10 +771,9 @@ externalSchemas is null : property.Type; targetType = targetType is null ? null : TypeHelpers.UnwrapNullableType(targetType); - if (targetType is not INamedTypeSymbol namedType || externalSchemas.IsSchemaOwnedByThisCompilation(namedType)) - return (true, false); - - return externalSchemas.GetExistingSchemaFeatures(namedType); + return targetType is not INamedTypeSymbol namedType || externalSchemas.IsSchemaOwnedByThisCompilation(namedType) + ? (true, false) + : externalSchemas.GetExistingSchemaFeatures(namedType); } /// diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs index 79a47aa5..22c2dab4 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.BuildSchema.cs @@ -277,11 +277,11 @@ CancellationToken cancellationToken var schemaName = schema.SchemaType.Name; 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 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 = @@ -392,14 +392,27 @@ CancellationToken cancellationToken ); body.XmlSummary("Gets whether some rules only run asynchronously.") - .Line($"public bool RequiresAsyncValidation => {schemaName}.RequiresAsyncValidation;") + .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.") - .Line( - $"public bool IsValidatableMember(string memberName) => {schemaName}.IsValidatableMember(memberName);" + .MethodExpression( + "IsValidatableMember", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public, + expression => expression.Write($"{schemaName}.IsValidatableMember(memberName)"), + declaration => + declaration with + { + Parameters = [new("memberName", PurviewTypeLibrary.System.String)], + } ) .NewLine(); @@ -407,8 +420,20 @@ CancellationToken cancellationToken .XmlParam("value", "The containing object.") .XmlParam("memberName", "The C# member name.") .XmlReturn("The member's errors.") - .Line( - $"public {resultType} ValidateMember({targetType} value, string memberName) => {schemaName}.ValidateMember(value, memberName);" + .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(); @@ -418,8 +443,31 @@ CancellationToken cancellationToken .XmlParam("services", "The caller's services, if any.") .XmlParam("cancellationToken", "The cancellation token.") .XmlReturn("The member's errors.") - .Line( - $"public {resultTaskType} ValidateMemberAsync({targetType} value, string memberName, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default) => {schemaName}.ValidateMemberAsync(value, memberName, services, cancellationToken);" + .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(); @@ -429,8 +477,31 @@ CancellationToken cancellationToken .XmlParam("container", "The owning object.") .XmlParam("memberName", "The owning member name.") .XmlReturn("True when the full path was followed.") - .Line( - $"public bool TryResolveMember({targetType} value, {ZodPathType} path, out object? container, out string memberName) => {schemaName}.TryResolveMember(value, path, out container, out memberName);" + .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(); @@ -438,8 +509,26 @@ CancellationToken cancellationToken .XmlParam("path", "The error path.") .XmlParam("namingPolicy", "The naming policy for members without an explicit JSON name.") .XmlReturn("The renamed path.") - .Line( - $"public {ZodPathType} ToJsonNamedPath({ZodPathType} path, global::System.Func? namingPolicy = null) => {schemaName}.ToJsonNamedPath(path, namingPolicy);" + .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(); @@ -462,6 +551,9 @@ CancellationToken cancellationToken } 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}));" ); @@ -474,18 +566,32 @@ CancellationToken cancellationToken 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 servicesParameter = custom.RequiresServices - ? $", {ServiceProviderType} services" - : string.Empty; var servicesArgument = custom.RequiresServices ? ", services" : string.Empty; body.XmlSummary("Invokes the custom validation method for the generated schema."); - body.Line( - $"internal {resultTaskType} {CustomValidationBridgeName}({targetType} value{servicesParameter}, {CancellationTokenType} cancellationToken) => {custom.MethodName}(value{servicesArgument}, cancellationToken);" + 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(); } @@ -495,8 +601,8 @@ CancellationToken cancellationToken body.IfBlock($"value is {targetType} typed", ifBody => ifBody.Return("typed")); if (schema.TargetCanBeNull) body.IfBlock("value is null", ifBody => ifBody.Return("default!")); - body.Line( - $"throw new global::System.ArgumentException($\"Value of type '{{value?.GetType().FullName}}' cannot be validated as '{schema.TargetType.Name}'.\", nameof(value));" + body.Throw( + $"new global::System.ArgumentException($\"Value of type '{{value?.GetType().FullName}}' cannot be validated as '{schema.TargetType.Name}'.\", nameof(value))" ); } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs index 2b88c168..0f57f88d 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Registration.cs @@ -54,7 +54,7 @@ IReadOnlyList schemas using (writer.OpenBlockScope($"public static {factoryType} Register({factoryType} factory)")) { writer.IfBlock("factory is null", body => - body.Line("throw new global::System.ArgumentNullException(nameof(factory));") + body.Throw("new global::System.ArgumentNullException(nameof(factory))") ); foreach (var schema in registrable) @@ -97,8 +97,32 @@ IReadOnlyList schemas ); writer.XmlParam("services", "The service collection."); writer.XmlReturn("The service collection, for chaining."); - writer.Line( - $"public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddZodSharp{identifier}Schemas(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) => services.AddZodSchemaRegistration(static factory => global::{RegistrationNamespace}.{className}.Register(factory));" + 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, + }, + ], + } ); } } diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs index 10bb5cb6..dcbfc693 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs @@ -42,7 +42,18 @@ CancellationToken cancellationToken if (names.Count == 0) { - writer.Line("public static string? Get(string memberName) => null;"); + 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 { @@ -76,6 +87,7 @@ CancellationToken cancellationToken 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) { @@ -93,8 +105,29 @@ CancellationToken cancellationToken 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.Line( - $"public static bool TryResolveMember({targetType} value, {ZodPathType} path, out object? container, out string memberName) => TryResolveMember(value, path, 0, out container, out memberName);" + 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(); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs index 2762aa25..6d79b5a7 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -35,6 +35,10 @@ static void GeneratePathsClass(CodeWriter writer, ZodSchemaDescriptor schema, Ca ) ) { + TypeReference zodPathReference = new( + new TypeIdentity("ZodPath", TypeLibraryGenerator.ZodSharpCoreNamespace) + ); + foreach (var property in schema.Properties) { cancellationToken.ThrowIfCancellationRequested(); @@ -43,9 +47,28 @@ static void GeneratePathsClass(CodeWriter writer, ZodSchemaDescriptor schema, Ca var name = property.Value.Name; writer.XmlSummary($"The path of {CodeWriter.XmlSee(name)}."); - writer.Line( - $"public static {(IsInheritedStaticMemberName(name) ? "new " : string.Empty)}readonly {ZodPathType} {GetPathMemberName(name)} = {ZodPathType}.Member({name.StringLiteral()});" - ); + 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()})", + } + ); + } } } @@ -71,11 +94,20 @@ CancellationToken cancellationToken ) { var schema = outputContext.ZodSchema; - var memberType = "global::ZodSharp.Core.ZodGeneratedMember"; + TypeReference memberTypeReference = new( + new TypeIdentity("ZodGeneratedMember", TypeLibraryGenerator.ZodSharpCoreNamespace) + ); var declaringType = GetDeclaringTypeName(schema); - writer.Line( - $"static readonly {memberType} {TypeMemberFieldName} = new({declaringType.StringLiteral()}, \"\", {schema.TargetType.Name.StringLiteral()}, {ZodPathType}.Root);" + 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) @@ -91,14 +123,30 @@ CancellationToken cancellationToken var resolver = property.DisplayNameResourceExpression is { } resource ? $", static () => {resource}" : string.Empty; - writer.Line( - $"static readonly {memberType} {MemberOf(property)} = new({declaringType.StringLiteral()}, {property.Name.StringLiteral()}, {property.DisplayName.StringLiteral()}, {PathOf(property)}{resolver});" + 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.Line( - $"static readonly global::System.ComponentModel.DataAnnotations.AsyncValidationAttribute {CodeGenHelpers.GetAsyncAttributeFieldName(property.Name, i)} = {property.AsyncValidationAttributes[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], + } ); } } @@ -107,7 +155,16 @@ CancellationToken cancellationToken schema.CustomValidationMethod.Value.InvocationKind == CustomValidationInvocationKind.DefinedOnSchemaValidator ) - writer.Line($"static {schema.SchemaType.Name}Validator? {CustomValidatorFieldName};"); + writer.Field( + new FieldDeclarationOptions( + CustomValidatorFieldName, + new TypeIdentity($"{schema.SchemaType.Name}Validator", null).AsTypeReference().Nullable(writer) + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + } + ); writer.NewLine(); } @@ -135,6 +192,9 @@ static bool HasMemberRules(ZodPropertyDescriptor property, bool validateEnumValu 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) @@ -146,6 +206,7 @@ static bool HasMemberRules(ZodPropertyDescriptor property, bool validateEnumValu { return true; } +#pragma warning restore IDE0046 return HasKindRules(property, validateEnumValues); } @@ -230,21 +291,32 @@ CancellationToken cancellationToken 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.Line($"public const bool RequiresAsyncValidation = {(requiresAsync ? "true" : "false")};"); + 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 reports every call to this method from async code, because ValidateAsync exists. Whether that matters - // is known here, so the remark says which call is right for this type (the analyzer is the consumer's to keep). + // 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( @@ -255,13 +327,18 @@ CancellationToken cancellationToken else { writer.XmlRemarks( - "Every rule in this graph is synchronous (RequiresAsyncValidation is false), so this call never blocks. In an", - "asynchronous method, analyzers such as CA1849 suggest ValidateAsync, which for this type completes synchronously", - "without allocating; either call is correct." + "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.Line($"public static {resultType} Validate({targetType} value) => ValidateCore(value, 0);"); + 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( @@ -312,8 +389,25 @@ CancellationToken cancellationToken writer.XmlParam("services", "The caller's services, for service-aware rules."); writer.XmlParam("cancellationToken", "The cancellation token."); writer.XmlReturn("The validation result."); - writer.Line( - $"public static {resultTaskType} ValidateAsync({targetType} value, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default) => ValidateCoreAsync(value, services, 0, cancellationToken);" + 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(); @@ -329,8 +423,23 @@ CancellationToken cancellationToken if (!requiresAsync) { - writer.Line( - $"public static {resultTaskType} ValidateCoreAsync({targetType} value, {ServiceProviderType}? services, int depth, {CancellationTokenType} cancellationToken) => new(ValidateCore(value, depth));" + 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(); } @@ -338,8 +447,24 @@ CancellationToken cancellationToken { if (adapterParameter.Length > 0) { - writer.Line( - $"public static {resultTaskType} ValidateCoreAsync({targetType} value, {ServiceProviderType}? services, int depth, {CancellationTokenType} cancellationToken) => ValidateCoreAsync(value, services, depth, cancellationToken, null);" + 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(); } @@ -556,6 +681,9 @@ CancellationToken cancellationToken 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 @@ -653,8 +781,32 @@ CancellationToken cancellationToken if (!hasAsyncMemberWork) { - writer.Line( - $"public static {resultTaskType} ValidateMemberAsync({targetType} value, string memberName, {ServiceProviderType}? services = null, {CancellationTokenType} cancellationToken = default) => new(ValidateMember(value, memberName));" + 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; @@ -671,7 +823,7 @@ CancellationToken cancellationToken ) { if (usesAdapter) - writer.Line($"{schema.SchemaType.Name}Validator? validator = null;"); + writer.Assignment($"{schema.SchemaType.Name}Validator? validator", "null"); HashSet asyncMemberNames = new(asyncMembers, StringComparer.Ordinal); 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/Json/JsonDeserializationErrors.cs b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs index 51a943f9..f75b7962 100644 --- a/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs +++ b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs @@ -165,8 +165,11 @@ static string DescribeExpected(JsonTypeInfo root, ZodPath path) { 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; } diff --git a/src/src/SystemTextJson/Json/ZodJsonValidationException.cs b/src/src/SystemTextJson/Json/ZodJsonValidationException.cs index 069ccc9b..2c315163 100644 --- a/src/src/SystemTextJson/Json/ZodJsonValidationException.cs +++ b/src/src/SystemTextJson/Json/ZodJsonValidationException.cs @@ -19,8 +19,6 @@ public sealed class ZodJsonValidationException : JsonException { const string DefaultMessage = "Validation failed."; - readonly ImmutableArray _errors; - /// /// Initializes a new instance with no errors. /// @@ -34,7 +32,7 @@ public ZodJsonValidationException() public ZodJsonValidationException(string? message) : base(message) { - _errors = []; + Errors = []; } /// @@ -45,7 +43,7 @@ public ZodJsonValidationException(string? message) public ZodJsonValidationException(string? message, Exception? innerException) : base(message, innerException) { - _errors = []; + Errors = []; } /// @@ -58,7 +56,7 @@ public ZodJsonValidationException(IEnumerable errors) ZodJsonValidationException(ImmutableArray errors) : base(BuildMessage(errors)) { - _errors = errors; + Errors = errors; } /// @@ -71,10 +69,10 @@ public ImmutableArray Errors { var prefix = ZodPath.ParseJsonPath(Path); if (prefix.IsRoot) - return _errors; + return field; - var builder = ImmutableArray.CreateBuilder(_errors.Length); - foreach (var error in _errors) + var builder = ImmutableArray.CreateBuilder(field.Length); + foreach (var error in field) builder.Add(error.WithPathPrefix(prefix)); return builder.MoveToImmutable(); diff --git a/src/src/ZodSharp/Core/ValidationError.cs b/src/src/ZodSharp/Core/ValidationError.cs index 17de390b..f75afa0f 100644 --- a/src/src/ZodSharp/Core/ValidationError.cs +++ b/src/src/ZodSharp/Core/ValidationError.cs @@ -8,7 +8,6 @@ namespace ZodSharp.Core; /// public readonly record struct ValidationError { - readonly ZodPath _fullPath; readonly ErrorBound? _lower; readonly ErrorBound? _upper; @@ -42,17 +41,13 @@ public readonly record struct ValidationError /// their digits (["items", "0", "name"]), matching Zod. Prefer , which keeps the /// segment kinds. /// - public ImmutableArray Path => _fullPath.LegacySegments; + 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 => _fullPath; - init => _fullPath = value; - } + public ZodPath FullPath { get; init; } /// /// The origin category for structured issues: the Zod type of the failing value (e.g. "string", "number", "array", "record"). @@ -188,7 +183,7 @@ public static ValidationError Create( Code = code; Category = category; Message = message; - _fullPath = path; + FullPath = path; Parameters = Wrap(parameters); Origin = origin; Minimum = minimum; @@ -202,11 +197,7 @@ public static ValidationError Create( /// /// The outer segment. /// The re-pathed error. - public ValidationError WithPathPrefix(ZodPathSegment segment) => - this with - { - FullPath = _fullPath.Prepend(segment), - }; + public ValidationError WithPathPrefix(ZodPathSegment segment) => this with { FullPath = FullPath.Prepend(segment) }; /// /// Returns a copy of this error located under , keeping every other field. @@ -214,7 +205,7 @@ this with /// The outer path. /// The re-pathed error. public ValidationError WithPathPrefix(ZodPath prefix) => - prefix.IsRoot ? this : this with { FullPath = _fullPath.Prepend(prefix) }; + prefix.IsRoot ? this : this with { FullPath = FullPath.Prepend(prefix) }; /// /// Returns a copy of this error with the supplied parameters, keeping every other field. @@ -234,7 +225,7 @@ public bool Equals(ValidationError other) => && 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) + && FullPath.Equals(other.FullPath) && Minimum == other.Minimum && Maximum == other.Maximum && Inclusive == other.Inclusive @@ -248,7 +239,7 @@ public override int GetHashCode() => Code, Category, Message, - _fullPath, + FullPath, Origin, HashCode.Combine(Minimum, Maximum, Inclusive, _lower, _upper), Parameters?.Count ?? 0 diff --git a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs index 23b58e59..9d785e1f 100644 --- a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs +++ b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs @@ -17,7 +17,6 @@ namespace ZodSharp.Core; [EditorBrowsable(EditorBrowsableState.Never)] public sealed class ZodGeneratedMember { - readonly string _displayName; readonly Func? _displayNameResolver; /// @@ -38,7 +37,7 @@ public ZodGeneratedMember( { DeclaringType = declaringType ?? throw new ArgumentNullException(nameof(declaringType)); Member = member ?? throw new ArgumentNullException(nameof(member)); - _displayName = displayName ?? throw new ArgumentNullException(nameof(displayName)); + DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName)); Path = path; _displayNameResolver = displayNameResolver; } @@ -58,10 +57,10 @@ public string DisplayName get { if (_displayNameResolver is null) - return _displayName; + return field; var resolved = _displayNameResolver(); - return string.IsNullOrEmpty(resolved) ? _displayName : resolved; + return string.IsNullOrEmpty(resolved) ? field : resolved; } } } diff --git a/src/src/ZodSharp/Core/ZodPath.cs b/src/src/ZodSharp/Core/ZodPath.cs index b7f9617b..56dd340f 100644 --- a/src/src/ZodSharp/Core/ZodPath.cs +++ b/src/src/ZodSharp/Core/ZodPath.cs @@ -53,12 +53,11 @@ sealed class FuncNamer(Func convert) : IZodPathNamer public readonly struct ZodPath : IEquatable { readonly ImmutableArray _segments; - readonly ImmutableArray _legacy; ZodPath(ImmutableArray segments, ImmutableArray legacy) { _segments = segments; - _legacy = legacy; + LegacySegments = legacy; } /// @@ -90,7 +89,7 @@ sealed class FuncNamer(Func convert) : IZodPathNamer /// /// Gets the legacy string projection: members and keys as-is, indexes as invariant digits. /// - public ImmutableArray LegacySegments => _legacy.IsDefault ? [] : _legacy; + public ImmutableArray LegacySegments => field.IsDefault ? [] : field; /// /// Creates a path from typed segments. @@ -245,7 +244,7 @@ public string ToJsonPointer(IZodPathNamer? namer = null) { ZodPathSegmentKind.Index => segment.Index.ToString(CultureInfo.InvariantCulture), ZodPathSegmentKind.Member => namer?.GetName(segment.Name!) ?? segment.Name!, - _ => segment.Name!, + ZodPathSegmentKind.Key or _ => segment.Name!, }; builder.Append( text.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal) @@ -384,8 +383,11 @@ static ZodPathSegment ClassifyLegacy(string? 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); } diff --git a/src/src/ZodSharp/Core/ZodTypeNames.cs b/src/src/ZodSharp/Core/ZodTypeNames.cs index 9b064f45..eb687f3d 100644 --- a/src/src/ZodSharp/Core/ZodTypeNames.cs +++ b/src/src/ZodSharp/Core/ZodTypeNames.cs @@ -99,8 +99,11 @@ public static string Of(Type? type) 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; } @@ -123,8 +126,11 @@ static string OfComposite(Type type) 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; } diff --git a/src/src/ZodSharp/Core/ZodValidationDepth.cs b/src/src/ZodSharp/Core/ZodValidationDepth.cs index 2f0a8f18..d803227e 100644 --- a/src/src/ZodSharp/Core/ZodValidationDepth.cs +++ b/src/src/ZodSharp/Core/ZodValidationDepth.cs @@ -19,8 +19,6 @@ public static class ZodValidationDepth /// public const int DefaultMaxDepth = 32; - static int CurrentMaxDepth = DefaultMaxDepth; - /// /// Gets or sets the maximum nesting depth. Process-wide; hosts that configure /// ValidationOptions.MaxDepth may mirror the value here. @@ -28,13 +26,13 @@ public static class ZodValidationDepth /// The value is zero or negative. public static int MaxDepth { - get => Volatile.Read(ref CurrentMaxDepth); + get => Volatile.Read(ref field); set { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); - Volatile.Write(ref CurrentMaxDepth, value); + Volatile.Write(ref field, value); } - } + } = DefaultMaxDepth; /// /// Throws when has reached . diff --git a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs index 233c58cd..109a249b 100644 --- a/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs +++ b/src/src/ZodSharp/JsonSchema/JsonSchemaDefinition.cs @@ -202,9 +202,9 @@ public void SetVendorExtension(string name, object? value) if (!name.StartsWith("x-", StringComparison.Ordinal)) throw new ArgumentException("A vendor extension name must start with 'x-'.", nameof(name)); - _vendorExtensions ??= new(StringComparer.Ordinal); + _vendorExtensions ??= [with(StringComparer.Ordinal)]; _vendorExtensions[name] = value; } - static readonly Dictionary EmptyVendorExtensions = new(StringComparer.Ordinal); + static readonly Dictionary EmptyVendorExtensions = [with(StringComparer.Ordinal)]; } diff --git a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs index f5a8f272..332c43f5 100644 --- a/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs +++ b/src/src/ZodSharp/JsonSchema/ToJsonSchemaConverter.cs @@ -26,8 +26,8 @@ public static JsonSchemaDefinition Convert(IZodSchema schema, ToJsonSch /// /// /// A ZodSharp schema, a generated validator (), or an adapter that wraps one - /// (, ), as returned by - /// . + /// (, ), as returned by + /// . /// /// Conversion options /// diff --git a/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs b/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs index 39c6e94f..4c2357de 100644 --- a/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs +++ b/src/src/ZodSharp/Options/ZodOptionsValidationRegistration.cs @@ -31,17 +31,10 @@ public static string Format(string? optionsName, string? configurationSection, i if (!string.IsNullOrEmpty(optionsName)) builder.Append(optionsName).Append(": "); - string location; - if (!string.IsNullOrEmpty(configurationSection)) - { - location = error.FullPath.IsRoot - ? configurationSection - : configurationSection + ":" + error.FullPath.ToConfigurationKey(); - } - else - { - location = error.FullPath.ToDottedString(); - } + var location = + string.IsNullOrEmpty(configurationSection) ? error.FullPath.ToDottedString() + : error.FullPath.IsRoot ? configurationSection + : configurationSection + ":" + error.FullPath.ToConfigurationKey(); builder .Append(location.Length == 0 ? "(root)" : location) diff --git a/src/src/ZodSharp/Schemas/RefineCtx.cs b/src/src/ZodSharp/Schemas/RefineCtx.cs index 9b58dfbd..d99afd86 100644 --- a/src/src/ZodSharp/Schemas/RefineCtx.cs +++ b/src/src/ZodSharp/Schemas/RefineCtx.cs @@ -14,7 +14,13 @@ namespace ZodSharp.Schemas; /// 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. /// -public sealed class RefineCtx +/// +/// 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, 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. @@ -29,22 +35,10 @@ public sealed class RefineCtx public RefineCtx(T value, ImmutableArray path) : this(value, ZodPath.FromLegacy(path)) { } - /// - /// 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 RefineCtx(T value, ZodPath path) - { - Value = value; - FullPath = path; - } - /// /// The value being validated. /// - public T Value { get; } + public T Value { get; } = value; /// /// The base path under which issues are reported, in the legacy string form. Prefer . @@ -54,7 +48,7 @@ public RefineCtx(T value, ZodPath path) /// /// The typed base path under which issues are reported. /// - public ZodPath FullPath { get; } + public ZodPath FullPath { get; } = path; /// /// The issues added by the refinement so far. diff --git a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs index cbe9b253..d1550650 100644 --- a/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs +++ b/src/src/ZodSharp/Schemas/ZodDiscriminatedUnion.cs @@ -33,7 +33,6 @@ ImmutableDictionary> options // 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 _optionKeys = [.. options.Keys.Order(StringComparer.Ordinal)]; readonly ImmutableArray _readers = []; @@ -53,7 +52,7 @@ ImmutableArray readers ) : this(discriminator, options) { - _optionKeys = optionOrder; + OptionKeys = optionOrder; _readers = readers; } @@ -70,7 +69,7 @@ ImmutableArray readers internal ImmutableDictionary> Options => options; - internal ImmutableArray OptionKeys => _optionKeys; + internal ImmutableArray OptionKeys { get; } = [.. options.Keys.Order(StringComparer.Ordinal)]; /// /// Parses and validates the value using the discriminated union. @@ -116,7 +115,7 @@ ValidationError DiscriminatorError(string code, string template) => new Dictionary(StringComparer.Ordinal) { [ZodIssueParameters.Discriminator] = discriminator, - [ZodIssueParameters.Options] = _optionKeys, + [ZodIssueParameters.Options] = OptionKeys, } ); diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs b/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs index 35863421..102d61e9 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/DocumentCrossCheckTests.cs @@ -26,7 +26,7 @@ public sealed record Payload(string Component, string Json, bool Accepted, strin [ 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", @@ -38,92 +38,172 @@ public sealed record Payload(string Component, string Json, bool Accepted, strin Accepted: true, "every constraint satisfied" ), - new("StringRulesRequest", """{ "name": "ab" }""", Accepted: true, "optional members absent"), - new("StringRulesRequest", """{ "name": "a" }""", Accepted: false, "minLength"), - new("StringRulesRequest", """{ "name": "abcdefghijk" }""", Accepted: false, "maxLength"), + 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", """{ "name": "ab", "email": "not-an-email" }""", Accepted: false, "format: email"), - new("StringRulesRequest", """{ "name": "ab", "legacyEmail": "nope" }""", Accepted: false, "[EmailAddress]"), - new("StringRulesRequest", """{ "name": "ab", "slug": "ABC" }""", Accepted: false, "pattern"), - new("StringRulesRequest", """{ "name": "ab", "prefixed": "xab" }""", Accepted: false, "startsWith"), - new("StringRulesRequest", """{ "name": "ab", "suffixed": "yzx" }""", Accepted: false, "endsWith"), - new("StringRulesRequest", """{ "name": "ab", "containing": "nope" }""", Accepted: false, "includes"), - new("StringRulesRequest", """{ "name": "ab", "bracketed": "abx" }""", Accepted: false, "allOf patterns"), + 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", - """{ "name": "ab", "identifier": "6f1c2d3e-4a5b-1c6d-8e7f-0123456789ab" }""", + /*lang=json,strict*/"""{ "name": "ab", "identifier": "6f1c2d3e-4a5b-1c6d-8e7f-0123456789ab" }""", Accepted: false, "uuid version" ), - new("StringRulesRequest", """{ "name": "ab", "addressV4": "::1" }""", Accepted: false, "format: ipv4"), - new("StringRulesRequest", """{ "name": "ab", "addressV6": "10.0.0.1" }""", Accepted: false, "format: ipv6"), + 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", - """{ "name": "ab", "timestamp": "2024-01-01T10:00:00+01:00" }""", + /*lang=json,strict*/"""{ "name": "ab", "timestamp": "2024-01-01T10:00:00+01:00" }""", Accepted: false, "UTC date-time" ), - new("StringRulesRequest", """{ "name": "ab", "day": "2024-13-01" }""", Accepted: false, "format: date"), - new("StringRulesRequest", """{ "name": "ab", "timeOfDay": "25:00" }""", Accepted: false, "time pattern"), - new("StringRulesRequest", """{ "name": "ab", "colour": "blue" }""", Accepted: false, "enum"), - new("StringRulesRequest", """{ "name": "ab", "username": "admin" }""", Accepted: false, "not enum"), + 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", - """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", Accepted: true, "every constraint satisfied" ), new( "NumberRulesRequest", - """{ "count": 0, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 0, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", Accepted: false, "minimum" ), new( "NumberRulesRequest", - """{ "count": 11, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 11, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", Accepted: false, "maximum" ), new( "NumberRulesRequest", - """{ "count": 5, "ratio": 0, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 5, "ratio": 0, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", Accepted: false, "exclusiveMinimum" ), new( "NumberRulesRequest", - """{ "count": 5, "ratio": 100, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 5, "ratio": 100, "bounded": 5, "step": 10, "whole": 3, "safe": 7 }""", Accepted: false, "exclusiveMaximum" ), new( "NumberRulesRequest", - """{ "count": 5, "ratio": 50.5, "bounded": 7, "step": 10, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 7, "step": 10, "whole": 3, "safe": 7 }""", Accepted: false, "maximum (LessThanOrEqual)" ), new( "NumberRulesRequest", - """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 7, "whole": 3, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 7, "whole": 3, "safe": 7 }""", Accepted: false, "multipleOf" ), new( "NumberRulesRequest", - """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3.5, "safe": 7 }""", + /*lang=json,strict*/"""{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3.5, "safe": 7 }""", Accepted: false, "integer" ), new( "NumberRulesRequest", - """{ "count": 5, "ratio": 50.5, "bounded": 5, "step": 10, "whole": 3, "safe": 30000000000 }""", + /*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" }, @@ -135,37 +215,37 @@ public sealed record Payload(string Component, string Json, bool Accepted, strin ), new( "ShapesRequest", - """{ "tags": [], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + /*lang=json,strict*/"""{ "tags": [], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", Accepted: false, "minItems" ), new( "ShapesRequest", - """{ "tags": ["a","b","c","d"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + /*lang=json,strict*/"""{ "tags": ["a","b","c","d"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", Accepted: false, "maxItems" ), new( "ShapesRequest", - """{ "tags": ["a"], "scores": { "a": 1, "b": 2, "c": 3 }, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [] }""", + /*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", - """{ "tags": ["a"], "scores": {}, "reference": null, "title": "t", "home": { "street": "Main" }, "others": [] }""", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": {}, "reference": null, "title": "t", "home": { "street": "Main" }, "others": [] }""", Accepted: false, "null where ZodSharp requires a value" ), new( "ShapesRequest", - """{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "" }, "others": [] }""", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "" }, "others": [] }""", Accepted: false, "nested minLength" ), new( "ShapesRequest", - """{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [{ "street": "S", "postCode": "abc" }] }""", + /*lang=json,strict*/"""{ "tags": ["a"], "scores": {}, "reference": "r", "title": "t", "home": { "street": "Main" }, "others": [{ "street": "S", "postCode": "abc" }] }""", Accepted: false, "nested collection pattern" ), @@ -208,10 +288,15 @@ 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", """{ "name": "ab", "token": "x" }""", Accepted: false, "jwt"); + Payload jwt = new( + "StringRulesRequest", /*lang=json,strict*/ + """{ "name": "ab", "token": "x" }""", + Accepted: false, + "jwt" + ); Payload cidr = new( "StringRulesRequest", - """{ "name": "ab", "network": "10.0.0.1" }""", + /*lang=json,strict*/"""{ "name": "ab", "network": "10.0.0.1" }""", Accepted: false, "cidr" ); diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs b/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs index dda9a01c..d4be7320 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/FixtureApi.cs @@ -37,7 +37,7 @@ public static void RegisterRuntimeSchemas(IServiceCollection services) => services.AddZodSharp(options => { options.ScanAssemblies.Add(typeof(FixtureApi).Assembly); - options.ConfigureFactory = factory => factory.Register(new PercentageSchema()); + options.ConfigureFactory = factory => factory.Register(new PercentageSchema()); }); public static void Map(WebApplication app) diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs index e2f680a5..76deb549 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/OpenApiFixtures.cs @@ -236,9 +236,9 @@ System.Text.Json.JsonSerializerOptions options } /// A hand-written runtime schema that describes its own JSON Schema. -public sealed class PercentageSchema : ZodSharp.Core.ZodType, ZodSharp.JsonSchema.IJsonSchemaProvider +public sealed class PercentageSchema : Core.ZodType, JsonSchema.IJsonSchemaProvider { - public ZodSharp.JsonSchema.JsonSchemaDefinition BuildJsonSchema() => + public JsonSchema.JsonSchemaDefinition BuildJsonSchema() => new() { Type = "number", @@ -246,12 +246,10 @@ public ZodSharp.JsonSchema.JsonSchemaDefinition BuildJsonSchema() => Maximum = 100, }; - protected override ZodSharp.Core.ValidationResult ParseInternal(Percentage value) => + protected override Core.ValidationResult ParseInternal(Percentage value) => value.Value is >= 0 and <= 100 - ? ZodSharp.Core.ValidationResult.Success(value) - : ZodSharp.Core.ValidationResult.Failure( - new ZodSharp.Core.ValidationError("too_big", "Out of range", []) - ); + ? Core.ValidationResult.Success(value) + : Core.ValidationResult.Failure(new Core.ValidationError("too_big", "Out of range", [])); } [ZodSchema] diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs index 1cadacee..b00ed8af 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/Fixtures/PetUnion.cs @@ -1,4 +1,4 @@ namespace ZodSharp.AspNetCore.OpenApi.Fixtures; /// A C# 15 union: the platform describes it with anyOf over the case schemas. -public union PetUnion(CatCase, DogCase); \ No newline at end of file +public readonly union PetUnion(CatCase, DogCase); diff --git a/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs index c7f8e3df..7eb431a9 100644 --- a/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs +++ b/src/tests/AspNetCore.OpenApi.UnitTests/ZodOpenApiSchemaTransformerTests.Mapping.cs @@ -47,7 +47,7 @@ public sealed record MappingRow( "bracketed", "allOf", null, - """[{"pattern":"^ab"},{"pattern":"yz$"}]""" + /*lang=json,strict*/"""[{"pattern":"^ab"},{"pattern":"yz$"}]""" ), new("Several patterns", "StringRulesRequest", "bracketed", "pattern", null, null), new("StartsWith", "StringRulesRequest", "prefixed", "pattern", null, "\"^ab\""), @@ -72,7 +72,14 @@ public sealed record MappingRow( 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, """{"enum":["admin"]}"""), + new( + "DeniedValues", + "StringRulesRequest", + "username", + "not", + null, /*lang=json,strict*/ + """{"enum":["admin"]}""" + ), new("Enum", "Shade", null, "enum", null, "[0,1]"), new( "Display(Description)", diff --git a/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs index 656c6899..90c8c058 100644 --- a/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs +++ b/src/tests/AspNetCore.UnitTests/ProblemDetailsContractTests.cs @@ -16,7 +16,8 @@ namespace ZodSharp.AspNetCore; /// public class ProblemDetailsContractTests { - const string ExpectedContract = """ + const string ExpectedContract = /*lang=json,strict*/ + """ { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", diff --git a/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs index 99ef6593..fa204b78 100644 --- a/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs +++ b/src/tests/AspNetCore.UnitTests/ZodEndpointValidationTests.cs @@ -29,7 +29,7 @@ static void AddServices( options.ConfigureFactory = factory => { factory.Register(orderValidator ?? new OrderRequestValidator()); - factory.Register(new CustomerQueryValidator()); + factory.Register(new CustomerQueryValidator()); } ); services.AddZodSharpProblemDetails(options => diff --git a/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs b/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs index 44db2b9b..1134fa35 100644 --- a/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs +++ b/src/tests/Blazor.UnitTests/ComponentMarkupTests.cs @@ -116,9 +116,11 @@ public async Task ZodValidationMessage_GivenTemplate_RendersItForRichErrors() }; 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 = error => + + RenderFragment Template(ValidationError error) => builder => { builder.OpenElement(0, "strong"); @@ -130,7 +132,7 @@ public async Task ZodValidationMessage_GivenTemplate_RendersItForRichErrors() var zod = context.Render>(parameters => parameters .Add(static p => p.For, () => order.Reference) - .Add(static p => p.Template, template) + .Add(static p => p.Template, Template) .AddCascadingValue(editContext) ); diff --git a/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs b/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs index e2b624aa..b679cf9b 100644 --- a/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs +++ b/src/tests/Blazor.UnitTests/EndpointValidationRegistrationTests.cs @@ -16,7 +16,8 @@ namespace ZodSharp.Blazor; /// public class EndpointValidationRegistrationTests { - const string InvalidCustomer = """{ "email": "not-an-email" }"""; + const string InvalidCustomer = /*lang=json,strict*/ + """{ "email": "not-an-email" }"""; [Test] public async Task AddZodSharpBlazor_GivenAnEndpointBindingAZodSchemaType_LeavesTheEndpointUnvalidated( @@ -92,7 +93,7 @@ CancellationToken cancellationToken var builder = WebApplication.CreateSlimBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddZodSharpFactory(static factory => - ZodSharp.Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) ); register(builder.Services); diff --git a/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs b/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs index cc2dd120..fbfafd35 100644 --- a/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs +++ b/src/tests/Blazor.UnitTests/Fixtures/TestSupport.cs @@ -128,8 +128,11 @@ 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) diff --git a/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs index 38f7d645..caa0486d 100644 --- a/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs +++ b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs @@ -226,6 +226,6 @@ public async Task DataAnnotationsValidator_GivenValidGeneratedModel_IsValid() static BunitContext CreateContext() => FormHost.CreateContext(static factory => - ZodSharp.Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) ); } diff --git a/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs b/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs index 5b89d4c1..b4bd8d3c 100644 --- a/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs +++ b/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs @@ -234,7 +234,7 @@ CancellationToken cancellationToken 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"} @@ -257,10 +257,18 @@ CancellationToken cancellationToken } [Test] - [Arguments(HttpStatusCode.OK, "application/json", """{"errors":{"a":["b"]}}""")] + [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", """{"title":"Not Found","status":404}""")] + [Arguments( + HttpStatusCode.NotFound, + "application/problem+json", /*lang=json,strict*/ + """{"title":"Not Found","status":404}""" + )] public async Task TryReadZodValidationProblemAsync_GivenNonValidationResponse_ReturnsNull( HttpStatusCode status, string mediaType, diff --git a/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs b/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs index 2c5f2969..c359c0f2 100644 --- a/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs +++ b/src/tests/Blazor.UnitTests/Ssr/ClientValidationRulesTests.cs @@ -114,7 +114,7 @@ public async Task RenderClientValidationRules_GivenInnerPayload_MergesRulesIntoT // Arrange GeneratedOrder order = new(); FixedProvider inner = new( - """{"fields":[{"name":"Model.Reference","rules":[{"name":"required","message":"Required"}]}]}""" + /*lang=json,strict*/"""{"fields":[{"name":"Model.Reference","rules":[{"name":"required","message":"Required"}]}]}""" ); ZodClientValidationProvider provider = new(inner, new()); EditContext editContext = new(order); @@ -130,7 +130,7 @@ public async Task RenderClientValidationRules_GivenInnerPayload_MergesRulesIntoT await Assert .That(json) .IsEqualTo( - """{"fields":[{"name":"Model.Reference","rules":[{"name":"required","message":"Required"},{"name":"zod-startswith","message":"String must start with \u0027ORD-\u0027","params":{"prefix":"ORD-","comparison":"ordinal"}}]}]}""" + /*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"}}]}]}""" ); } @@ -139,7 +139,9 @@ public async Task RenderClientValidationRules_GivenNoZodRules_ReturnsTheInnerFra { // Arrange Order order = new(); - FixedProvider inner = new("""{"fields":[]}"""); + FixedProvider inner = new( /*lang=json,strict*/ + """{"fields":[]}""" + ); ZodClientValidationProvider provider = new(inner, new()); EditContext editContext = new(order); Dictionary fields = new() @@ -172,18 +174,16 @@ static string ReadRules(RenderFragment? fragment) } #pragma warning restore BL0006 - sealed class FixedProvider : ClientValidationProvider + sealed class FixedProvider(string json) : ClientValidationProvider { - public FixedProvider(string json) => - Fragment = builder => + public RenderFragment Fragment { get; } = + builder => { builder.OpenElement(0, "blazor-client-validation-data"); builder.AddAttribute(1, "data-rules", json); builder.CloseElement(); }; - public RenderFragment Fragment { get; } - public override RenderFragment? RenderClientValidationRules( EditContext editContext, IReadOnlyDictionary renderedFields diff --git a/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs b/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs index b8ea33e9..1e1be325 100644 --- a/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs +++ b/src/tests/Blazor.UnitTests/Ssr/StaticSsrTests.cs @@ -75,7 +75,7 @@ CancellationToken cancellationToken { services.AddRazorComponents(); services.AddZodSharpFactory(static factory => - ZodSharp.Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) ); services.AddZodSharpBlazor(); }, @@ -184,7 +184,7 @@ static Task StartAsync(CancellationToken cancellationToken) => services.AddZodSharpClientValidation(); services.AddRazorComponents(); services.AddZodSharpFactory(static factory => - ZodSharp.Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) ); services.AddZodSharpBlazor(); }, @@ -221,7 +221,7 @@ internal static Dictionary> ReadRules(IDocument document) ?? throw new InvalidOperationException("No client validation payload was rendered."); using var json = JsonDocument.Parse(element.GetAttribute("data-rules") ?? "{}"); - Dictionary> fields = new(StringComparer.Ordinal); + Dictionary> fields = [with(StringComparer.Ordinal)]; foreach (var field in json.RootElement.GetProperty("fields").EnumerateArray()) { List rules = []; diff --git a/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs index 5f4f9006..6e9a1b3a 100644 --- a/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs +++ b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs @@ -165,7 +165,7 @@ static async Task AssertPlacedOnOwningMembersAsync(RenderFragment validator, Can static BunitContext CreateContext() => FormHost.CreateContext(static factory => - ZodSharp.Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) + Generated.PurviewZodSharpBlazorUnitTestsZodSharpSchemas.Register(factory) ); // The switch trimming sets: the reflection fallbacks are unavailable, as in a Blazor WebAssembly client. Tests that diff --git a/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs b/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs index 08542265..1a7abbe4 100644 --- a/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs +++ b/src/tests/Examples.Blazor.IntegrationTests/CustomerEndpointTests.cs @@ -48,7 +48,7 @@ CancellationToken cancellationToken // Act var (status, mediaType, problem) = await PostCustomerAsync( client, - """{ "name": "A", "email": "not-an-email", "age": 5 }""", + /*lang=json,strict*/"""{ "name": "A", "email": "not-an-email", "age": 5 }""", cancellationToken ); @@ -76,7 +76,7 @@ CancellationToken cancellationToken // Act var (status, _, problem) = await PostCustomerAsync( client, - $$"""{ "name": "Ann", "email": "{{InMemoryCustomerEmailRegistry.SeededEmail}}", "age": 30 }""", + /*lang=json,strict*/$$"""{ "name": "Ann", "email": "{{InMemoryCustomerEmailRegistry.SeededEmail}}", "age": 30 }""", cancellationToken, acceptLanguage: "fr" ); @@ -96,7 +96,8 @@ public async Task PostCustomers_GivenValidCustomer_RegistersTheEmail(Cancellatio // Arrange await using SampleAppFactory factory = new(); using var client = factory.CreateClient(); - const string customer = """{ "name": "Ann", "email": "ann@example.com", "age": 30 }"""; + const string customer = /*lang=json,strict*/ + """{ "name": "Ann", "email": "ann@example.com", "age": 30 }"""; // Act var (first, _, _) = await PostCustomerAsync(client, customer, cancellationToken); diff --git a/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs b/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs index b7fe834a..1cd253db 100644 --- a/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs +++ b/src/tests/Examples.Blazor.IntegrationTests/SampleAppFactory.cs @@ -99,13 +99,13 @@ public static Dictionary> ReadClientRules(IDocument doc ?? throw new InvalidOperationException("No client validation payload was rendered."); using var json = JsonDocument.Parse(element.GetAttribute("data-rules") ?? "{}"); - Dictionary> fields = new(StringComparer.Ordinal); + 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 = new(StringComparer.Ordinal); + Dictionary parameters = [with(StringComparer.Ordinal)]; if (rule.TryGetProperty("params", out var values)) { foreach (var value in values.EnumerateObject()) diff --git a/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs b/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs index 16f54d26..ff0ac16f 100644 --- a/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs +++ b/src/tests/Examples.MinimalApi.IntegrationTests/SampleAppTests.cs @@ -12,7 +12,8 @@ namespace ZodSharp.Examples.MinimalApi; /// public partial class SampleAppTests { - const string InvalidOrder = """ + const string InvalidOrder = /*lang=json,strict*/ + """ { "customerEmail": "not-an-email", "shippingAddress": { "street": "1 Main Street", "city": "", "postCode": "LS1 1AA" }, @@ -21,7 +22,8 @@ public partial class SampleAppTests } """; - const string ValidOrder = """ + const string ValidOrder = /*lang=json,strict*/ + """ { "customerEmail": "ann@example.com", "shippingAddress": { "street": "1 Main Street", "city": "Leeds", "postCode": "LS1 1AA" }, diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs index 7bb436ba..c6a5f2eb 100644 --- a/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/GeneratedModels.cs @@ -22,7 +22,7 @@ public class GeneratedAddress /// A struct member: its errors are located inside a value type, which cannot own a field. [ZodSchema] -public partial record struct GeneratedPostcode +public readonly partial record struct GeneratedPostcode { [Required] [RegularExpression("^[0-9]{5}$")] diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs index bb2a9330..3c9141b6 100644 --- a/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/TestHelpers.cs @@ -41,8 +41,11 @@ public static ValidateContext CreateContext(this IServiceProvider 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; } @@ -91,8 +94,11 @@ public LocalizedString this[string name] { // 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) diff --git a/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs b/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs index f3778e20..11ae2a65 100644 --- a/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs +++ b/src/tests/Extensions.Validation.UnitTests/Fixtures/Unions.cs @@ -3,4 +3,4 @@ namespace ZodSharp.Extensions.Validation.Fixtures; /// /// A union with no validator of its own, whose case is validated by ZodSharp. /// -public union Shipment(Order, Address); +public readonly union Shipment(Order, Address); diff --git a/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs b/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs index 91e951f1..550c2135 100644 --- a/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs +++ b/src/tests/Extensions.Validation.UnitTests/ZodValidatableParameterInfoTests.cs @@ -129,12 +129,15 @@ public async Task Validate_GivenCollectionParameter_ValidatesSynchronously() static (Microsoft.Extensions.DependencyInjection.ServiceProvider Provider, IValidatableParameterInfo Info) Create( string parameter, - ZodSharp.Core.ValidationError error + 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); } diff --git a/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs b/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs index 094de158..5d76d6ee 100644 --- a/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs +++ b/src/tests/JsonInterop.UnitTests/Json/DualPackageCallTests.cs @@ -11,7 +11,8 @@ namespace ZodSharp.Json; /// public class DualPackageCallTests { - const string PersonJson = """{"Name":"Ada"}"""; + const string PersonJson = /*lang=json,strict*/ + """{"Name":"Ada"}"""; [Test] public async Task DeserializeAndValidate_GivenBothPackagesAndNoOptions_ResolvesToSystemTextJson( diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs index 836028d9..56b45f8d 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/NewtonsoftJsonErrorTests.cs @@ -18,7 +18,8 @@ public async Task DeserializeAndValidate_GivenUnconvertibleMemberValue_ReportsPa // Defect J5 // Arrange PassThroughSchema schema = new(); - const string json = """{"Name":"Ada","Age":"forty-two"}"""; + const string json = /*lang=json,strict*/ + """{"Name":"Ada","Age":"forty-two"}"""; // Act var result = schema.DeserializeAndValidate(json); @@ -42,7 +43,8 @@ public async Task DeserializeAndValidate_GivenBadArrayElement_ReportsIndexedPath // Defect J5 // Arrange PassThroughSchema schema = new(); - const string json = """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}"""; + const string json = /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}"""; // Act var result = schema.DeserializeAndValidate(json); @@ -58,7 +60,10 @@ await Assert } [Test] - [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}""", "Members[1].Age")] + [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, @@ -121,7 +126,11 @@ public async Task DeserializeAndValidateAsync_GivenSchemaWithAsyncOnlyFailure_Ru // Defect J6 // Arrange AsyncOnlyFailingSchema schema = new(); - using MemoryStream stream = new(Encoding.UTF8.GetBytes("""{"Name":"Ada","Age":1}""")); + using MemoryStream stream = new( + Encoding.UTF8.GetBytes( /*lang=json,strict*/ + """{"Name":"Ada","Age":1}""" + ) + ); // Act var result = await schema.DeserializeAndValidateAsync(stream); @@ -138,7 +147,8 @@ public async Task DeserializeAndValidate_GivenNestedValidatingConverterFailure_R // Arrange PassThroughSchema schema = new(); JsonSerializerSettings settings = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; - const string json = """{"Owner":{"Name":"","Age":30}}"""; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; // Act var result = schema.DeserializeAndValidate(json, settings); @@ -160,7 +170,8 @@ public async Task DeserializeObject_GivenValidatingConverterFailure_ThrowsExcept // Defect J4 // Arrange JsonSerializerSettings settings = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; - const string json = """{"Owner":{"Name":"","Age":30}}"""; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; // Act var exception = Assert.Throws(() => diff --git a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs index daebb9aa..4f01d504 100644 --- a/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs +++ b/src/tests/NewtonsoftJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs @@ -16,7 +16,9 @@ public async Task Validate_GivenInvalidArrayElement_PrefixesTypedIndexAndKeepsEv { // Defect J7 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"type":"array","items":{"type":"array","minItems":2}}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","items":{"type":"array","minItems":2}}""" + ); // Act var result = schema.Validate(JToken.Parse("[[1,2],[1]]")); @@ -34,7 +36,9 @@ public async Task Validate_GivenTooFewItems_MirrorsZodArrayLengthFailure() { // Defect J8 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"type":"array","minItems":2,"maxItems":3}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","minItems":2,"maxItems":3}""" + ); // Act var result = schema.Validate(JToken.Parse("[1]")); @@ -55,7 +59,9 @@ public async Task Validate_GivenTooManyItems_MirrorsZodArrayLengthFailure() { // Defect J8 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"type":"array","maxItems":1}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","maxItems":1}""" + ); // Act var result = schema.Validate(JToken.Parse("[1,2]")); @@ -70,12 +76,36 @@ public async Task Validate_GivenTooManyItems_MirrorsZodArrayLengthFailure() } [Test] - [Arguments("""{"type":"string"}""", "42", "Expected string, but got number")] - [Arguments("""{"type":"number"}""", "\"x\"", "Expected number, but got string")] - [Arguments("""{"type":"boolean"}""", "{}", "Expected boolean, but got object")] - [Arguments("""{"type":"object"}""", "[]", "Expected object, but got array")] - [Arguments("""{"type":"array"}""", "true", "Expected array, but got boolean")] - [Arguments("""{"type":"null"}""", "1", "Expected null, but got number")] + [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 @@ -97,7 +127,9 @@ public async Task Validate_GivenLiteralMismatch_DoesNotEchoTheReceivedValue() { // Defect J9 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"const":"admin"}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"const":"admin"}""" + ); // Act var result = schema.Validate(JToken.Parse("\"secret-value\"")); @@ -114,6 +146,7 @@ public async Task Validate_GivenInvalidRecordValue_PrefixesKeySegmentAndKeepsEve // Defect J10 // Arrange var schema = FromJsonSchemaParser.Parse( + /*lang=json,strict*/ """{"type":"object","additionalProperties":{"type":"array","minItems":2}}""" ); diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs index b6d428e0..cbabdf71 100644 --- a/src/tests/Repository.UnitTests/ReleaseGateTests.cs +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -57,19 +57,19 @@ public static IEnumerable> Leaks() => new( "a deps.json library entry", "PackedApp.deps.json", - """{"libraries":{"Purview.SourceGeneratorFramework/1.2.2":{"type":"package"}}}""" + /*lang=json,strict*/"""{"libraries":{"Purview.SourceGeneratorFramework/1.2.2":{"type":"package"}}}""" ), () => new( "a runtimeconfig.json reference", "PackedApp.runtimeconfig.json", - """{"runtimeOptions":{"configProperties":{"Microsoft.CodeAnalysis.Leak":true}}}""" + /*lang=json,strict*/"""{"runtimeOptions":{"configProperties":{"Microsoft.CodeAnalysis.Leak":true}}}""" ), () => new( "a blazor.boot.json asset", "wwwroot/_framework/blazor.boot.json", - """{"resources":{"assembly":{"Purview.ZodSharp.SourceGenerators.wasm":"sha256-x"}}}""" + /*lang=json,strict*/"""{"resources":{"assembly":{"Purview.ZodSharp.SourceGenerators.wasm":"sha256-x"}}}""" ), () => new( @@ -189,7 +189,7 @@ CancellationToken cancellationToken { // Arrange using TemporaryDirectory output = new(); - var path = directoryExists ? output.Path : System.IO.Path.Combine(output.Path, "missing"); + var path = directoryExists ? output.Path : Path.Combine(output.Path, "missing"); // Act var result = await RunLeakScanAsync([path], cancellationToken); @@ -211,10 +211,7 @@ CancellationToken cancellationToken ) { // Arrange - var workflows = Directory.GetFiles( - System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows"), - "*.yml" - ); + var workflows = Directory.GetFiles(Path.Combine(RepositoryRoot.Value, ".github", "workflows"), "*.yml"); // Act List publishing = []; @@ -227,7 +224,7 @@ CancellationToken cancellationToken if (!body.Contains("dotnet publish", StringComparison.Ordinal)) continue; - var name = $"{System.IO.Path.GetFileName(workflow)}:{job}"; + var name = $"{Path.GetFileName(workflow)}:{job}"; publishing.Add(name); if ( !body.Contains( @@ -254,7 +251,7 @@ CancellationToken cancellationToken { // Arrange var text = await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), cancellationToken ); @@ -280,7 +277,7 @@ CancellationToken cancellationToken { // Arrange var json = await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), + Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), cancellationToken ); using var document = JsonDocument.Parse( @@ -337,10 +334,17 @@ static TemporaryDirectory CleanOutput() new( "clean deps.json", "PackedApp.deps.json", - """{"libraries":{"Purview.ZodSharp/3.0.0":{"type":"package"},"Microsoft.Extensions.Options/11.0.0":{"type":"package"}}}""" + /*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 runtimeconfig", "PackedApp.runtimeconfig.json", """{"runtimeOptions":{}}""")); Write( output.Path, new( @@ -355,8 +359,8 @@ static TemporaryDirectory CleanOutput() static void Write(string root, OutputFile file) { - var path = System.IO.Path.Combine(root, file.RelativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + 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) { @@ -380,7 +384,7 @@ static async Task RunLeakScanAsync( "-NoProfile", "-NonInteractive", "-File", - System.IO.Path.Combine(RepositoryRoot.Value, LeakScanScript), + Path.Combine(RepositoryRoot.Value, LeakScanScript), "-Path", .. paths, .. AllowArguments(allow), @@ -427,8 +431,8 @@ static string FindRepositoryRoot() ) { if ( - File.Exists(System.IO.Path.Combine(directory.FullName, "AGENTS.md")) - && File.Exists(System.IO.Path.Combine(directory.FullName, LeakScanScript)) + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, LeakScanScript)) ) return directory.FullName; } diff --git a/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs b/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs index bfd450ec..1606f1d5 100644 --- a/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs +++ b/src/tests/SourceGenerators.UnitTests/CrossAssemblyMarkerAttributeTests.cs @@ -302,8 +302,11 @@ CancellationToken cancellationToken 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); } @@ -312,12 +315,15 @@ 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(); } diff --git a/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs b/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs index 749eed48..afece00a 100644 --- a/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs +++ b/src/tests/SourceGenerators.UnitTests/MSBuildSwitchGlobalConfigTests.cs @@ -164,8 +164,11 @@ static ImmutableDictionary ParseGlobalOptions(string globalConfi { 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; } @@ -218,7 +221,7 @@ sealed class GlobalConfigOptionsProvider(ImmutableDictionary glo sealed class DictionaryOptions(ImmutableDictionary options) : AnalyzerConfigOptions { - public static readonly DictionaryOptions Empty = new(ImmutableDictionary.Empty); + 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/ZodSchemaGeneratorTests.AsyncValidation.cs b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs index 8da9f5a8..1faaf161 100644 --- a/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs +++ b/src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.AsyncValidation.cs @@ -171,8 +171,8 @@ public async Task Validate_GivenRequiresAsyncValidation_DocumentsWhichCallIsRigh CancellationToken cancellationToken ) { - // Arrange - CA1849 reports Validate in async code whenever ValidateAsync exists; the generated remark says when - // that is noise (no asynchronous rule) and when it is not. + // 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 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/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 index 50556c7c..168328eb 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/Schema/FromJsonSchemaParserErrorTests.cs @@ -16,7 +16,9 @@ public async Task Validate_GivenInvalidArrayElement_PrefixesTypedIndexAndKeepsEv { // Defect J7 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"type":"array","items":{"type":"array","minItems":2}}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","items":{"type":"array","minItems":2}}""" + ); // Act var result = schema.Validate(Parse("[[1,2],[1]]")); @@ -34,7 +36,9 @@ public async Task Validate_GivenTooFewItems_MirrorsZodArrayLengthFailure() { // Defect J8 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"type":"array","minItems":2,"maxItems":3}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","minItems":2,"maxItems":3}""" + ); // Act var result = schema.Validate(Parse("[1]")); @@ -55,7 +59,9 @@ public async Task Validate_GivenTooManyItems_MirrorsZodArrayLengthFailure() { // Defect J8 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"type":"array","maxItems":1}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"type":"array","maxItems":1}""" + ); // Act var result = schema.Validate(Parse("[1,2]")); @@ -70,12 +76,36 @@ public async Task Validate_GivenTooManyItems_MirrorsZodArrayLengthFailure() } [Test] - [Arguments("""{"type":"string"}""", "42", "Expected string, but got number")] - [Arguments("""{"type":"number"}""", "\"x\"", "Expected number, but got string")] - [Arguments("""{"type":"boolean"}""", "{}", "Expected boolean, but got object")] - [Arguments("""{"type":"object"}""", "[]", "Expected object, but got array")] - [Arguments("""{"type":"array"}""", "true", "Expected array, but got boolean")] - [Arguments("""{"type":"null"}""", "1", "Expected null, but got number")] + [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 @@ -97,7 +127,9 @@ public async Task Validate_GivenLiteralMismatch_DoesNotEchoTheReceivedValue() { // Defect J9 // Arrange - var schema = FromJsonSchemaParser.Parse("""{"const":"admin"}"""); + var schema = FromJsonSchemaParser.Parse( /*lang=json,strict*/ + """{"const":"admin"}""" + ); // Act var result = schema.Validate(Parse("\"secret-value\"")); @@ -114,11 +146,15 @@ public async Task Validate_GivenInvalidRecordValue_PrefixesKeySegmentAndKeepsEve // Defect J10 // Arrange var schema = FromJsonSchemaParser.Parse( - """{"type":"object","additionalProperties":{"type":"array","minItems":2}}""" + /*lang=json,strict*/"""{"type":"object","additionalProperties":{"type":"array","minItems":2}}""" ); // Act - var result = schema.Validate(Parse("""{"a.b":[1]}""")); + var result = schema.Validate( + Parse( /*lang=json,strict*/ + """{"a.b":[1]}""" + ) + ); // Assert var error = result.Errors.Single(); 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 index 1354ffb8..42f5b09e 100644 --- a/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs +++ b/src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonErrorTests.cs @@ -18,7 +18,8 @@ public async Task DeserializeAndValidate_GivenUnconvertibleMemberValue_ReportsPa // Defect J1 // Arrange PassThroughSchema schema = new(); - const string json = """{"Name":"Ada","Age":"forty-two"}"""; + const string json = /*lang=json,strict*/ + """{"Name":"Ada","Age":"forty-two"}"""; // Act var result = schema.DeserializeAndValidate(json); @@ -44,7 +45,8 @@ public async Task DeserializeAndValidate_GivenJsonTypeInfoAndBadElement_ReportsI PassThroughSchema schema = new(); JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; var typeInfo = (JsonTypeInfo)options.TypeInfoResolver.GetTypeInfo(typeof(Team), options)!; - const string json = """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":true}]}"""; + const string json = /*lang=json,strict*/ + """{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":true}]}"""; // Act var result = schema.DeserializeAndValidate(json, typeInfo); @@ -63,7 +65,10 @@ await Assert [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("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":1,}]}""", "Members[1]")] + [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, @@ -74,7 +79,7 @@ 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(System.Text.Encoding.UTF8.GetBytes(json)); + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); // Act var fromString = schema.DeserializeAndValidate(json).Errors.Single(); @@ -92,10 +97,19 @@ await schema.DeserializeAndValidateAsync(stream, cancellationToken: cancellation // 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("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":"x"}]}""", "Members[1].Age")] - [Arguments("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":true}]}""", "Members[1].Age")] + [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("""{"Members":[{"Name":"Ada","Age":1},{"Name":"Bob","Age":1,}]}""", "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 @@ -113,12 +127,30 @@ string expectedPath } [Test] - [Arguments("""{"Members":[{"Name":"Ada","Age":true}]}""", "number")] - [Arguments("""{"Members":[{"Name":true,"Age":1}]}""", "string")] - [Arguments("""{"Scores":{"a":"x"}}""", "number")] - [Arguments("""{"Grid":[["x"]]}""", "number")] - [Arguments("""{"Payload":5}""", "string")] - [Arguments("""{"Members":5}""", "array")] + [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 @@ -163,7 +195,9 @@ public async Task DeserializeAndValidate_GivenTypeTheSerializerCannotHandle_Retu PassThroughSchema schema = new(); // Act - var result = schema.DeserializeAndValidate("""{"Sides":3}"""); + var result = schema.DeserializeAndValidate( /*lang=json,strict*/ + """{"Sides":3}""" + ); // Assert await Assert.That(result.IsSuccess).IsFalse(); @@ -178,7 +212,8 @@ public async Task DeserializeAndValidate_GivenNestedValidatingConverterFailure_R // Arrange PassThroughSchema schema = new(); JsonSerializerOptions options = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; - const string json = """{"Owner":{"Name":"","Age":30}}"""; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; // Act var result = schema.DeserializeAndValidate(json, options); @@ -200,7 +235,8 @@ public async Task Deserialize_GivenValidatingConverterFailure_ThrowsZodJsonValid // Defect J4 // Arrange JsonSerializerOptions options = new() { Converters = { new PersonSchema().CreateValidatingConverter() } }; - const string json = """{"Owner":{"Name":"","Age":30}}"""; + const string json = /*lang=json,strict*/ + """{"Owner":{"Name":"","Age":30}}"""; // Act var exception = Assert.Throws(() => @@ -218,7 +254,11 @@ public async Task DeserializeAndValidateAsync_GivenUnconvertibleValueInStream_Re // Defect J1 // Arrange PassThroughSchema schema = new(); - using MemoryStream stream = new(Encoding.UTF8.GetBytes("""{"Name":"Ada","Age":[1]}""")); + using MemoryStream stream = new( + Encoding.UTF8.GetBytes( /*lang=json,strict*/ + """{"Name":"Ada","Age":[1]}""" + ) + ); // Act var result = await schema.DeserializeAndValidateAsync(stream); diff --git a/src/tests/TrimSafety.Verification/CoreChecks.cs b/src/tests/TrimSafety.Verification/CoreChecks.cs index f9e36ffc..0acb66f8 100644 --- a/src/tests/TrimSafety.Verification/CoreChecks.cs +++ b/src/tests/TrimSafety.Verification/CoreChecks.cs @@ -39,7 +39,7 @@ public static void RuntimeSchemas(Harness h) var native = Z.NativeUnion(Z.String(), Z.Number()); h.Expect(native.Validate("x").IsSuccess, "a native union accepts an option"); - var compiled = ZodSharp.Expressions.CompiledValidator.Compile(email); + var compiled = Expressions.CompiledValidator.Compile(email); h.Expect(compiled("someone@example.com").IsSuccess, "CompiledValidator binds the schema without codegen"); } @@ -77,7 +77,7 @@ public static void PocoDiscriminatedUnion(Harness h) public static void Registration(Harness h) { ZodSchemaFactory generated = new(); - ZodSharp.Generated.ZodSharpTrimSafetyVerificationZodSharpSchemas.Register(generated); + Generated.ZodSharpTrimSafetyVerificationZodSharpSchemas.Register(generated); h.Expect(generated.Resolve() is not null, "the generated Register names every validator"); ZodSchemaFactory scanned = new(); diff --git a/src/tests/TrimSafety.Verification/IntegrationChecks.cs b/src/tests/TrimSafety.Verification/IntegrationChecks.cs index 24b52bf1..b54b9874 100644 --- a/src/tests/TrimSafety.Verification/IntegrationChecks.cs +++ b/src/tests/TrimSafety.Verification/IntegrationChecks.cs @@ -39,10 +39,16 @@ public static void SystemTextJson(Harness h) CustomerSchemaValidator schema = new(); var typeInfo = HarnessJsonContext.Default.Customer; - var valid = schema.DeserializeAndValidate("""{"name":"Ada","age":36,"tags":["a"]}""", typeInfo); + 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("""{"name":"A","age":200,"tags":["a","b","c"]}""", typeInfo); + 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); diff --git a/src/tests/TrimSafety.Verification/Models.cs b/src/tests/TrimSafety.Verification/Models.cs index c41bde21..f535bb58 100644 --- a/src/tests/TrimSafety.Verification/Models.cs +++ b/src/tests/TrimSafety.Verification/Models.cs @@ -45,7 +45,7 @@ sealed class Order : IValidatableObject public string? Notes { get; set; } - public IEnumerable Validate( + public IEnumerable Validate( ValidationContext validationContext ) { @@ -93,7 +93,7 @@ sealed class Dog } /// A C# union member, dispatched to the case's generated schema. -union Pet(Cat, Dog); +readonly union Pet(Cat, Dog); /// A JSON request model, deserialized through a source-generated . [ZodSchema] diff --git a/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs index 573209c6..e5d4ce52 100644 --- a/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs +++ b/src/tests/ZodSharp.UnitTests/Core/ZodMessageFormatTests.cs @@ -63,8 +63,8 @@ public async Task Format_GivenEscapedBraces_WritesSingleBraces() public async Task Format_GivenFormatSpecifier_UsesInvariantCultureByDefault() { // Arrange - var previous = System.Globalization.CultureInfo.CurrentCulture; - System.Globalization.CultureInfo.CurrentCulture = new("de-DE"); + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new("de-DE"); try { // Act @@ -75,7 +75,7 @@ public async Task Format_GivenFormatSpecifier_UsesInvariantCultureByDefault() } finally { - System.Globalization.CultureInfo.CurrentCulture = previous; + CultureInfo.CurrentCulture = previous; } } diff --git a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs index f6dfd8b4..125bc7ee 100644 --- a/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs +++ b/src/tests/ZodSharp.UnitTests/JsonSchema/ToJsonSchemaVendorExtensionTests.cs @@ -112,10 +112,10 @@ public async Task SetVendorExtension_GivenNameWithoutPrefix_Throws() JsonSchemaDefinition schema = new(); // Act - var act = () => schema.SetVendorExtension("zod-thing", true); + void Act() => schema.SetVendorExtension("zod-thing", true); // Assert - await Assert.That(act).Throws(); + await Assert.That(Act).Throws(); } [Test] From 4b950f45576c8ab4c618d897d7ed98d61e3c303c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 12:11:51 +0000 Subject: [PATCH 102/107] fix(gen): struct collections where being compared to null --- docs/wiki/Source-Generator-Diagnostics.md | 2 +- docs/wiki/Source-Generator.md | 34 +- scripts/test-packed-generator.ps1 | 20 +- .../SKILL.md | 7 + src/Directory.Build.props | 4 + .../AnalyzerReleases.Unshipped.md | 3 + .../Helpers/DiagnosticLibrary.cs | 4 +- .../Helpers/GenerationSettingsResolver.cs | 43 ++- .../Helpers/PropertyLibrary.cs | 2 + .../Helpers/SourceGenLibrary.cs | 49 ++- .../Models/SchemaGenerationModel.cs | 10 + .../SourceGenerators/SourceGenerators.csproj | 1 + src/src/SourceGenerators/ZodSchemaAnalyzer.cs | 2 +- ...ZodSchemaGenerator.CollectionValidators.cs | 48 ++- .../ZodSchemaGenerator.CustomRules.cs | 17 +- .../ZodSchemaGenerator.Resolution.cs | 9 +- .../ZodSchemaGenerator.Validation.cs | 27 +- .../ZodSharp/CollectionBehaviorAttribute.cs | 10 +- .../ZodSharp/DefaultImmutableArrayBehavior.cs | 14 + src/src/ZodSharp/Sdk/README.md | 10 + .../buildTransitive/Purview.ZodSharp.props | 3 + .../ZodSharpSchemaDefaultsAttribute.cs | 8 + ...dSchemaAnalyzerTests.CollectionBehavior.cs | 44 +++ ...dSchemaGeneratorTests.StructCollections.cs | 303 ++++++++++++++++++ 24 files changed, 634 insertions(+), 40 deletions(-) create mode 100644 src/src/ZodSharp/DefaultImmutableArrayBehavior.cs create mode 100644 src/tests/SourceGenerators.UnitTests/ZodSchemaGeneratorTests.StructCollections.cs diff --git a/docs/wiki/Source-Generator-Diagnostics.md b/docs/wiki/Source-Generator-Diagnostics.md index c5df848d..ab9f60c5 100644 --- a/docs/wiki/Source-Generator-Diagnostics.md +++ b/docs/wiki/Source-Generator-Diagnostics.md @@ -44,7 +44,7 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato | ZODSGEN043 | (info) A built-in rule marked `[ZodRule]` does not generate a validation attribute, because a constructor parameter cannot be represented as an attribute property; the rule is still reachable through the runtime/fluent API. A derived name that collides with `System.ComponentModel.DataAnnotations` is *not* skipped — the attribute is emitted under a `Zod` suffix instead | | ZODSGEN044 | A `[ZodSchema]` type that uses an automatic `[Scalar]` form (`[Scalar]` or `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's generated `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `[ZodSchema(GenerateValidateMethod = false)]` suppresses the `Validate` method the value-object generator calls, or when the declared underlying type cannot be represented | | ZODSGEN045 | (warning) A type that exports JSON Schema references a nested `[ZodSchema]` type whose export is disabled (`[ZodSchema(GenerateJsonSchema = false)]` or the assembly-level `ZodSharpGenerateJsonSchemas=false` switch), so the parent's exported schema falls back to an empty schema for that property (`"Property '{0}' references type '{1}', which disables JSON Schema generation; the exported schema cannot describe it"`) | -| ZODSGEN046 | (warning) A `[CollectionBehavior]` attribute is applied to a property that is not a collection of nullable elements (or a dictionary of nullable values), so the setting can never take effect | +| ZODSGEN046 | (warning) A `[CollectionBehavior]` attribute has no applicable option: `AllowNulls` requires nullable elements/values; `DefaultImmutableArrayBehavior` applies to `ImmutableArray` (including nullable wrappers) | | ZODSGEN047 | (info) `[ValidatableType]` (Microsoft.Extensions.Validation) on a `[ZodSchema]` type is redundant: the ZodSharp bridge resolves the generated validator first, and the attribute only makes the Microsoft.Extensions.Validation generator emit a second, DataAnnotations-only validator for the same type. A code fix removes the attribute | | ZODSGEN048 | (warning) A type has rules that only run asynchronously (an `AsyncValidationAttribute` member or an `IAsyncValidatableObject` target) but a synchronous `Validate` is generated; that `Validate` throws `InvalidOperationException` rather than skip them, so call `ValidateAsync` | | 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 (`"Property '{0}' is typed as the closed type '{1}', but the derived type '{2}' has no [ZodSchema], ..."`); add `[ZodSchema]` to the derived type | diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index 56c8a34a..9ffb12de 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -338,6 +338,7 @@ MSBuild switches: | `ZodSharpEnumSerialization` | `Number` | `String` makes enums export as their member names, matching a global `JsonStringEnumConverter` | | `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | | `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collection properties may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDefaultImmutableArrayBehavior` | `ValidationError` | handles uninitialized `ImmutableArray` as missing, or `TreatAsEmpty` to run collection rules against an empty array | | `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; the matching converter must also reject non-UTC input. Both modes export an exact sentinel pattern | Each failure of a generated options validator reads `{path}: {message} ({code})` (`Bucket: Bucket is required @@ -469,10 +470,41 @@ public class RepositoryInventoryResponse ``` > [!NOTE] -> `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` on a property that is not a collection of nullable elements (or a dictionary of nullable values) is reported as `ZODSGEN046`. +> `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` with options that cannot affect the property is reported as `ZODSGEN046`. A collection whose elements may be null exports its `items` schema with a `null` branch, mirroring how a nullable property is exported. A dictionary exports `{ "type": "object", "additionalProperties": … }` — see [JSON Schema Export](JsonSchema-Export.md#dictionaries). +### Struct collections and default immutable arrays + +Struct-backed collections are validated without reference-type null checks. Nullable struct collections +are unwrapped only when present, for count rules, nested validation, asynchronous validation and member +resolution. Element nullability is independent of collection nullability. + +An uninitialized `ImmutableArray` (`IsDefault`) is treated as **missing**, not enumerated: `[Required]` +reports `missing_field` (honoring its configured message), a nullable member accepts it unless required, +and a non-nullable member reports `invalid_type`. `ImmutableArray.Empty` is present and valid unless a +count rule rejects it. + +Use `DefaultImmutableArrayBehavior.TreatAsEmpty` to validate an uninitialized array as empty instead: + +```csharp +[assembly: ZodSharpSchemaDefaults( + DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty)] + +[ZodSchema] +public class Inventory +{ + [CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.ValidationError)] + public ImmutableArray Items { get; set; } +} +``` + +Precedence: the property's `[CollectionBehavior(DefaultImmutableArrayBehavior = ...)]` (also supported on +positional record parameters), the assembly attribute, the `ZodSharpDefaultImmutableArrayBehavior` MSBuild +property (`ValidationError` or `TreatAsEmpty`), then `ValidationError`. These options are independent of +`AllowNulls`. `TreatAsEmpty` does not accept a missing nullable wrapper as empty and does not mutate the +model. It normalizes the value used by generated collection rules; JSON Schema remains an array schema. + ## DateTimeOffset wire form The JSON Schema generated for `[NonSentinel] DateTimeOffset` depends on the wire form assumed for the value. 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/purview-zodsharp-source-generator/SKILL.md b/skills/purview-zodsharp-source-generator/SKILL.md index dc5d0745..9d359faf 100644 --- a/skills/purview-zodsharp-source-generator/SKILL.md +++ b/skills/purview-zodsharp-source-generator/SKILL.md @@ -103,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 @@ -274,6 +280,7 @@ type conflicts (`ZODSGEN050`). | `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/src/Directory.Build.props b/src/Directory.Build.props index da63cd35..81a05d78 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -18,6 +18,10 @@ + + true + + diff --git a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md index d50fc1a8..6555705f 100644 --- a/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerators/AnalyzerReleases.Unshipped.md @@ -5,3 +5,6 @@ ; across when the next version ships. ; ; 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/Helpers/DiagnosticLibrary.cs b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs index ff29aea3..75666145 100644 --- a/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGenerators/Helpers/DiagnosticLibrary.cs @@ -478,12 +478,12 @@ 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 diff --git a/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs b/src/src/SourceGenerators/Helpers/GenerationSettingsResolver.cs index bae04135..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) @@ -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); @@ -235,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 }; @@ -262,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 @@ -290,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 a40c9e7a..f791c98f 100644 --- a/src/src/SourceGenerators/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerators/Helpers/PropertyLibrary.cs @@ -31,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.cs b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs index 77706073..28cf94d9 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenLibrary.cs @@ -730,6 +730,8 @@ rangeAttribute with dateTimeOffsetSerialization ) { + IsImmutableArray = IsImmutableArray(originalPropertyType), + DefaultImmutableArrayBehavior = GetDefaultImmutableArrayBehavior(validationAttributes), DisplayNameResourceExpression = ValidationInputsResolver.GetDisplayNameResourceExpression( validationAttributes ), @@ -1034,6 +1036,7 @@ candidate is null /// static ITypeSymbol? GetCollectionItemTypeSymbol(ITypeSymbol type) { + type = TypeHelpers.UnwrapNullableType(type); if (type is IArrayTypeSymbol arrayType) return arrayType.ElementType; @@ -1176,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 @@ -1194,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) @@ -1271,7 +1312,7 @@ 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) @@ -1280,7 +1321,7 @@ static LengthAccessor ClassifyLengthAccessor(ITypeSymbol propertyType) // errors report it: a dictionary is a record, every other collection an array. var origin = TryGetMapArguments(namedType, out _, out _) ? "record" : "array"; - if (TypeHelpers.IsOrImplements(namedType, TypeLibrary.System.Collections.Generic.ICollection)) + if (TypeHelpers.HasAccessibleCountProperty(namedType)) return new("propertyValue.Count", origin, true); if (TypeHelpers.IsOrImplements(namedType, TypeLibrary.System.Collections.IEnumerable)) diff --git a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs index 3fbbc220..c74ef56e 100644 --- a/src/src/SourceGenerators/Models/SchemaGenerationModel.cs +++ b/src/src/SourceGenerators/Models/SchemaGenerationModel.cs @@ -58,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. @@ -266,6 +272,10 @@ readonly record struct ZodPropertyDescriptor( 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. diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj index bcdd5fe5..d71ff624 100644 --- a/src/src/SourceGenerators/SourceGenerators.csproj +++ b/src/src/SourceGenerators/SourceGenerators.csproj @@ -20,6 +20,7 @@ + diff --git a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs index b3011b02..f84d96b1 100644 --- a/src/src/SourceGenerators/ZodSchemaAnalyzer.cs +++ b/src/src/SourceGenerators/ZodSchemaAnalyzer.cs @@ -578,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.CollectionValidators.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs index 8559d1d0..5f06d7e5 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.CollectionValidators.cs @@ -5,6 +5,48 @@ namespace ZodSharp.SourceGenerators; partial class ZodSchemaGenerator { + 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; @@ -38,8 +80,7 @@ static void GenerateCollectionSizeValidations(CodeWriter writer, ZodPropertyDesc 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); @@ -181,8 +222,7 @@ static void GenerateCollectionElementValidation(CodeWriter writer, ZodPropertyDe var collectionValueName = CodeGenHelpers.GetLocalIdentifier(property.Name, "Collection"); - writer.Assignment("var", collectionValueName, $"value.{property.Name}"); - using (writer.IfBlockScope($"{collectionValueName} is not null")) + using (CollectionValueScope(writer, property, collectionValueName)) { if (property.IsMap) GenerateMapEntryValidation(writer, property, hasNestedSchema, rejectNullItems, collectionValueName); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs b/src/src/SourceGenerators/ZodSchemaGenerator.CustomRules.cs index 7adaa45d..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++) { @@ -91,12 +94,14 @@ CustomRuleDescriptor requiredRule PathOf(property), MemberOf(property), GetFallbackOrigin(property), - invalidCondition: $"value.{propertyName} is null || {scalarExpression}.{scalarPropertyName} is null" + 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. @@ -109,7 +114,7 @@ CustomRuleDescriptor requiredRule PathOf(property), MemberOf(property), GetFallbackOrigin(property), - invalidCondition: $"value.{propertyName} is null" + invalidCondition: MissingValueCondition(property) ); return; } @@ -119,7 +124,9 @@ CustomRuleDescriptor requiredRule requiredRule, ruleVariable, identityVariable, - $"value.{propertyName}", + property.ValidationKind == PropertyValidationKind.Collection + ? CollectionValueExpression(property) + : $"value.{propertyName}", PathOf(property), MemberOf(property), GetFallbackOrigin(property) diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs index dcbfc693..61258c1a 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Resolution.cs @@ -353,8 +353,15 @@ static void WriteResolveMember(CodeWriter writer, ZodPropertyDescriptor property 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}(value.{property.Name}, path[offset + 1], out var {elementName})", + $"!{GeneratedValidation}.{lookup}({CollectionValueExpression(property)}, path[offset + 1], out var {elementName})", body => body.Return("false") ); diff --git a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs index 6d79b5a7..46cb9c7a 100644 --- a/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs +++ b/src/src/SourceGenerators/ZodSchemaGenerator.Validation.cs @@ -231,7 +231,9 @@ static bool HasKindRules(ZodPropertyDescriptor property, bool validateEnumValues || Exists(attributes.MinLength, static length => length.Exists && length.Length > 0) || Exists(attributes.MaxLength, static length => length.Exists && length.Length >= 0) ) - ) || RejectsNullItems(property), + ) + || RejectsNullItems(property) + || RejectsDefaultArray(property), PropertyValidationKind.Enum => IsEnumValidationEmitted(property, validateEnumValues), PropertyValidationKind.Complex or PropertyValidationKind.Unsupported => false, _ => false, @@ -934,7 +936,11 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor // 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) + if ( + (property.CanBeNull || RejectsDefaultArray(property)) + && attributes.Required.ShouldProcess + && attributes.Required.Value.Exists + ) { var required = attributes.Required.Value; @@ -944,7 +950,7 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor property.PropertyType.SpecialType == SpecialType.System_String && !required.AllowEmptyStrings; var comparison = rejectsBlankStrings ? $"string.IsNullOrWhiteSpace(value.{propertyName})" - : $"value.{propertyName} is null"; + : 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. @@ -970,12 +976,12 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor block = writer.ElseScope(); GenerateValueSetValidations(writer, property, emitEnumRule); } - else if (!property.IsDeclaredNullable && property.CanBeNull) + 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($"value.{propertyName} is null")) + using (writer.IfBlockScope(MissingValueCondition(property))) { WriteAddError( writer, @@ -993,7 +999,11 @@ static void GeneratePropertyValidation(CodeWriter writer, ZodPropertyDescriptor { GenerateValueSetValidations(writer, property, emitEnumRule); if (property.IsDeclaredNullable) - block = writer.IfBlockScope($"value.{propertyName} is not null"); + block = writer.IfBlockScope( + RejectsDefaultArray(property) + ? $"!({MissingValueCondition(property)})" + : $"value.{propertyName} is not null" + ); } GenerateTypeSpecificValidations(writer, property, emitEnumRule, skipRequiredRule: hasRequiredRule); @@ -1035,7 +1045,7 @@ static bool TryGetRequiredRule(ZodPropertyDescriptor property, out CustomRuleDes { requiredRule = default; - if (!property.CanBeNull) + if (!property.CanBeNull && !RejectsDefaultArray(property)) return false; foreach (var rule in property.CustomRules) @@ -1333,8 +1343,7 @@ static void WriteAsyncCollection(CodeWriter writer, ZodPropertyDescriptor proper var schemaType = property.NestedSchemaType!.Value; var itemExpression = property.ElementTypeIsNullableValueType ? $"{itemName}.Value" : itemName; - writer.Assignment("var", collectionName, $"value.{property.Name}"); - using (writer.IfBlockScope($"{collectionName} is not null")) + using (CollectionValueScope(writer, property, collectionName)) { if (property.IsMap) { 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/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/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index ffa55d3b..1822b919 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -129,6 +129,16 @@ Enum properties are validated automatically: a value that is not a defined membe 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`. +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 diff --git a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props index 6c50f920..12484c13 100644 --- a/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props +++ b/src/src/ZodSharp/Sdk/buildTransitive/Purview.ZodSharp.props @@ -24,6 +24,9 @@ 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/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/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/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(); + } +} From 3ae3f47970b60242bd7c0ac3ee9908e3101e9202 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 22:29:06 +0000 Subject: [PATCH 103/107] chore: removed old docs --- Directory.Packages.props | 6 ++--- docs/wiki/index.md | 14 ---------- mkdocs.yml | 57 ---------------------------------------- 3 files changed, 3 insertions(+), 74 deletions(-) delete mode 100644 docs/wiki/index.md delete mode 100644 mkdocs.yml diff --git a/Directory.Packages.props b/Directory.Packages.props index 00baca88..9192b52e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,9 +2,9 @@ 5.9.0 5.9.0 - 1.75.0 + 1.76.0 1.2.2 - 10.0.13 + 10.0.14 - + diff --git a/docs/wiki/index.md b/docs/wiki/index.md deleted file mode 100644 index f256b6ba..00000000 --- a/docs/wiki/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# ZodSharp - -Composable, type-safe schema validation for .NET inspired by Zod. - -[Documentation overview](Home.md){ .md-button .md-button--primary } -[Get started](Getting-Started.md){ .md-button } - -On .NET 11, ZodSharp validates minimal APIs and Blazor forms through the platform's own pipeline: see -[Microsoft.Extensions.Validation](Microsoft-Extensions-Validation.md), [ASP.NET Core Integration](AspNetCore-Integration.md), -[OpenAPI Integration](OpenAPI-Integration.md), [Blazor Integration](Blazor-Integration.md) and -[Localization](Localization.md). - -Every package is trim- and Native AOT-safe, and the APIs that cannot be are annotated with the alternative: see -[Trimming and Native AOT](Trimming-and-AOT.md). diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index a5016078..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,57 +0,0 @@ -site_name: ZodSharp -site_description: Developer documentation for ZodSharp -repo_url: https://github.com/purview-dev/zodsharp -edit_uri: edit/main/docs/wiki/ -docs_dir: docs/wiki - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: cyan - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: cyan - features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] - -nav: - - Overview: index.md - - Home: Home.md - - Getting Started: Getting-Started.md - - Core Concepts: Core-Concepts.md - - Fluent Schema API: Fluent-Schema-API.md - - String Validation: String-Validation.md - - Number Validation: Number-Validation.md - - Object Validation: Object-Validation.md - - Arrays and Other Schemas: Arrays-and-Other-Schemas.md - - Unions and Discriminated Unions: Unions-and-Discriminated-Unions.md - - Composition and Transforms: Composition-and-Transforms.md - - Custom Rules: Custom-Rules.md - - Validation Rules Reference: Validation-Rules-Reference.md - - Value Objects Integration: Value-Objects-Integration.md - - Compiled Validators and Caching: Compiled-Validators-and-Caching.md - - JSON Schema Export: JsonSchema-Export.md - - JSON Schema Import: JsonSchema-Import.md - - System.Text.Json Integration: SystemTextJson-Integration.md - - Newtonsoft.Json Integration: NewtonsoftJson-Integration.md - - ASP.NET Core Integration: AspNetCore-Integration.md - - Microsoft.Extensions.Validation: Microsoft-Extensions-Validation.md - - OpenAPI Integration: OpenAPI-Integration.md - - Blazor Integration: Blazor-Integration.md - - Localization: Localization.md - - Dependency Injection: Dependency-Injection.md - - Source Generator: Source-Generator.md - - Source Generator DataAnnotations: Source-Generator-DataAnnotations.md - - Source Generator Diagnostics: Source-Generator-Diagnostics.md - - Cross-Platform Interop: Cross-Platform-Interop.md - - Trimming and Native AOT: Trimming-and-AOT.md - - Performance: Performance.md - - Guarantees and Limitations: Guarantees-and-Limitations.md - - Release Flow: Release-Flow.md - - Contributing: Contributing.md - -plugins: [search, techdocs-core] -markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] From 040ac200980e741c924e4ffff7b1ebfaa66f8bc8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 15:44:50 +0000 Subject: [PATCH 104/107] feat: added .Http package for final Aot/ Browser compatible release --- .config/purview-build.json | 6 + .config/wasm-size-baseline.json | 8 +- AGENTS.md | 3 +- CHANGELOG.md | 29 +- Directory.Packages.props | 4 +- README.md | 1 + docs/wiki/Blazor-Integration.md | 30 +- docs/wiki/Home.md | 2 +- docs/wiki/Http-Integration.md | 129 +++++++ docs/wiki/SystemTextJson-Integration.md | 12 + docs/wiki/Trimming-and-AOT.md | 11 + docs/wiki/_Sidebar.md | 1 + package.json | 4 +- skills/purview-zodsharp-blazor/SKILL.md | 24 +- skills/purview-zodsharp-core/SKILL.md | 1 + skills/purview-zodsharp-json/SKILL.md | 43 ++- src/ZodSharp.slnx | 2 + src/src/Blazor/Blazor.csproj | 2 + .../Forms/ZodEditContextExtensions.cs | 108 ++++-- src/src/Blazor/Sdk/README.md | 15 +- src/src/Examples.Blazor.Client/Contracts.cs | 7 +- .../CustomerAPIClient.cs | 121 ++++++ .../Examples.Blazor.Client.csproj | 11 + .../Pages/RegisterCustomer.razor | 88 +---- src/src/Examples.Blazor.Client/Program.cs | 4 + .../RegisterCustomerForm.razor | 106 ++++++ .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Home.razor | 7 +- .../Pages/RegisterCustomerServer.razor | 17 + src/src/Examples.Blazor/SampleApp.cs | 29 ++ .../Net/Http/ZodHttpClientExtensions.cs | 349 ++++++++++++++++++ .../Net/Http/ZodHttpContentExtensions.cs | 87 +++++ .../Http/ZodHttpResponseMessageExtensions.cs | 4 +- src/src/Http/Http.csproj | 37 ++ src/src/Http/Sdk/README.md | 145 ++++++++ src/src/Http/ZodJsonContent.cs | 121 ++++++ .../ZodValidationProblemDetails.cs | 17 +- src/src/SystemTextJson/Sdk/README.md | 20 + src/src/SystemTextJson/SystemTextJson.csproj | 3 + .../SystemTextJsonExtensions.cs | 88 +++++ src/src/ZodSharp/Sdk/README.md | 1 + src/src/ZodSharp/ZodSharp.csproj | 10 +- .../Blazor.UnitTests/GeneratedSchemaTests.cs | 1 + .../ProblemDetailsMappingTests.cs | 112 +++--- .../StructMemberPlacementTests.cs | 1 + .../CustomerAPIClientTests.cs | 226 ++++++++++++ src/tests/Http.UnitTests/Fixtures/Order.cs | 79 ++++ .../Http.UnitTests/Http.UnitTests.csproj | 26 ++ .../ServiceAwareValidationTests.cs | 173 +++++++++ .../ZodHttpClientExtensionsTests.cs | 116 ++++++ .../ZodHttpContentExtensionsTests.cs | 124 +++++++ .../ZodValidationProblemDetailsTests.cs | 253 +++++++++++++ .../Json/SystemTextJsonServiceAwareTests.cs | 138 +++++++ .../TrimSafety.Verification/HttpChecks.cs | 78 ++++ src/tests/TrimSafety.Verification/Program.cs | 1 + .../TrimSafety.Verification.csproj | 2 + tests/e2e/blazor-sample.e2e.mjs | 41 ++ 57 files changed, 2865 insertions(+), 214 deletions(-) create mode 100644 docs/wiki/Http-Integration.md create mode 100644 src/src/Examples.Blazor.Client/CustomerAPIClient.cs create mode 100644 src/src/Examples.Blazor.Client/RegisterCustomerForm.razor create mode 100644 src/src/Examples.Blazor/Components/Pages/RegisterCustomerServer.razor create mode 100644 src/src/Http/Extensions/System/Net/Http/ZodHttpClientExtensions.cs create mode 100644 src/src/Http/Extensions/System/Net/Http/ZodHttpContentExtensions.cs rename src/src/{Blazor => Http}/Extensions/System/Net/Http/ZodHttpResponseMessageExtensions.cs (95%) create mode 100644 src/src/Http/Http.csproj create mode 100644 src/src/Http/Sdk/README.md create mode 100644 src/src/Http/ZodJsonContent.cs rename src/src/{Blazor => Http}/ZodValidationProblemDetails.cs (92%) create mode 100644 src/tests/Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs create mode 100644 src/tests/Http.UnitTests/Fixtures/Order.cs create mode 100644 src/tests/Http.UnitTests/Http.UnitTests.csproj create mode 100644 src/tests/Http.UnitTests/ServiceAwareValidationTests.cs create mode 100644 src/tests/Http.UnitTests/ZodHttpClientExtensionsTests.cs create mode 100644 src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs create mode 100644 src/tests/Http.UnitTests/ZodValidationProblemDetailsTests.cs create mode 100644 src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonServiceAwareTests.cs create mode 100644 src/tests/TrimSafety.Verification/HttpChecks.cs diff --git a/.config/purview-build.json b/.config/purview-build.json index 3616a1c5..cd1f9282 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -55,6 +55,12 @@ "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", diff --git a/.config/wasm-size-baseline.json b/.config/wasm-size-baseline.json index 72506641..e8e2fcf4 100644 --- a/.config/wasm-size-baseline.json +++ b/.config/wasm-size-baseline.json @@ -2,8 +2,10 @@ "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": 28837, - "Purview.ZodSharp.Extensions.Validation.wasm": 6741, - "Purview.ZodSharp.wasm": 24062 + "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/AGENTS.md b/AGENTS.md index e93beb19..b9bff242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,8 @@ 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 (+ static-SSR client rules on net11) Blazor/ Blazor forms integration (net11): ZodValidator, message components, zodsharp-validation.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2888db70..278f561f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,10 +59,33 @@ Released versions correspond to `v` GitHub releases. 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. `HttpResponseMessage.TryReadZodValidationProblemAsync()` - (source-generated JSON, trim-safe) and `EditContext.AddZodProblemDetails` map API validation problems onto fields, - cleared per field on change. Ships `zodsharp-validation.js`, browser validators that mirror the C# rules + `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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 9192b52e..f84dd403 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,7 +36,7 @@ - + @@ -52,7 +52,7 @@ - + ` over `CreateOrderRequest` - a nested `ShippingAddress`, a list of `Lines` and a dictionary of `AdditionalAddresses` - with field-level validation and each message next to its input. | -| `/register` | Interactive WebAssembly | Route 2: ``, `ZodValidationMessage ShowCode`, the service-aware async email-uniqueness rule (its registry calls the server from the browser) with a pending indicator from `IsValidationPending`, and a button that posts without validating so the server's ProblemDetails are mapped back with `AddZodProblemDetails`. | +| `/register` | Interactive WebAssembly | Route 2: `RegisterCustomerForm` - ``, `ZodValidationMessage ShowCode`, the service-aware async email-uniqueness rule (its registry calls the server from the browser) with a pending indicator from `IsValidationPending`. It registers through `CustomerAPIClient` ([HttpClient Integration](Http-Integration.md)): `PostAsJsonValidatedAsync` with the browser's services, `ReadFromJsonAndValidateAsync` for the response, and a button that posts without validating so the server's ProblemDetails come back through `TryReadZodValidationProblemAsync` onto the fields with `AddZodErrors`. | +| `/register-server` | Interactive Server | The same `RegisterCustomerForm` on the server. `CustomerAPIClient` is a typed client from `IHttpClientFactory`, created in the circuit's scope, calling the API service-to-service; the uniqueness rule resolves the server's own registry. | | `/returns` | Static SSR | `FormName` + `[SupplyParameterFromForm]`, server validation of the posted form, and browser-side checks of `[StartsWith]` and `[UUID]` from `AddZodSharpClientValidation()`. | The server also exposes the minimal API the WebAssembly form posts to (`POST /api/customers`, validated by the .NET 11 @@ -316,12 +333,17 @@ just wasm-size # trimmed WebAssembly publish (fails on IL2xxx/IL3xxx) + - `Examples.Blazor.IntegrationTests` hosts the sample in memory (`WebApplicationFactory`): the static-SSR page's `data-rules` carry `zod-startswith` and `zod-uuid`, an invalid nested and collection POST re-renders each message next to its input (AngleSharp), and `POST /api/customers` returns the ZodValidationProblemDetails contract, - localized for `Accept-Language: fr`. + localized for `Accept-Language: fr`. `CustomerAPIClientTests` resolves the server's `CustomerAPIClient` registration + and drives it against the API: a valid registration, an invalid one and a taken address rejected without a request, + and an unvalidated post returning the server's errors. - The `wasm-trimmed` PR job publishes the client trimmed with `-warnaserror` and checks the Brotli size of each `Purview.ZodSharp*` assembly against `.config/wasm-size-baseline.json` (more than 10% growth fails; `scripts/check-wasm-size.ps1 -Update` rewrites the baseline after an intended change). - The nightly `wasm-nightly.yml` workflow publishes the client with `RunAOTCompilation=true` (the `wasm-tools` - workload) and runs `tests/e2e/blazor-sample.e2e.mjs` against the published sample in Chromium. + workload) and runs `tests/e2e/blazor-sample.e2e.mjs` against the published sample in Chromium, including a + registration from each of `/register` and `/register-server`. Run it locally with + `npm install --no-save --no-package-lock playwright-core@1`, the published sample on `http://127.0.0.1:5090`, and + `E2E_BROWSER_CHANNEL=msedge node tests/e2e/blazor-sample.e2e.mjs`. - The client does not reference Microsoft.Extensions.Validation directly and has no `[ValidatableType]`; nested, collection and dictionary validation still works in the browser because the ZodSharp resolver supplies the type info. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index d2ca5826..9339fad5 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -67,6 +67,6 @@ This wiki is the project documentation hub for the core API, source generator, J - **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod. - **Compile-time source generation** — the `[ZodSchema]` attribute turns a class, struct, or record into a zero-allocation static validator, honouring DataAnnotations attributes such as `[Required]`, `[Length]`, `[Range]`, and `[EmailAddress]`. - **Value object scalars** — a `Purview.ValueObjects` `[Scalar]` can be validated as a unit with `[ZodSchema]`; a rule written against the scalar's underlying value is adapted automatically. See [Value Objects Integration](Value-Objects-Integration.md). -- **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` (ProblemDetails); on .NET 11, `Purview.ZodSharp.Extensions.Validation` (minimal APIs and Blazor through Microsoft.Extensions.Validation), `Purview.ZodSharp.AspNetCore.OpenApi` (OpenAPI transformers) and `Purview.ZodSharp.Blazor`. +- **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, `Purview.ZodSharp.Http` (`HttpClient`, WebAssembly-safe), and `Purview.ZodSharp.AspNetCore` (ProblemDetails); on .NET 11, `Purview.ZodSharp.Extensions.Validation` (minimal APIs and Blazor through Microsoft.Extensions.Validation), `Purview.ZodSharp.AspNetCore.OpenApi` (OpenAPI transformers) and `Purview.ZodSharp.Blazor`. - **Cross-platform tests** — a shared TypeScript/Zod fixture set is generated into the repo and asserted against from both the C# test suite and a vitest suite. - **Multi-target** — packages target `net10.0` and `net11.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. \ No newline at end of file diff --git a/docs/wiki/Http-Integration.md b/docs/wiki/Http-Integration.md new file mode 100644 index 00000000..1af0c9d8 --- /dev/null +++ b/docs/wiki/Http-Integration.md @@ -0,0 +1,129 @@ +# HttpClient Integration + +`Purview.ZodSharp.Http` validates JSON bodies on both sides of an `HttpClient` call and reads the validation problems a +ZodSharp API returns back into ZodSharp errors. It targets `net10.0` and `net11.0`, references only the base framework +(no ASP.NET Core shared framework), and is browser-safe: it is the package a Blazor WebAssembly client, a Blazor Server +component, a MAUI app or a service-to-service client uses to talk to an API built with +[ASP.NET Core Integration](AspNetCore-Integration.md). + +```bash +dotnet add package Purview.ZodSharp.Http +``` + +Every API takes a `JsonTypeInfo`, typically from a source-generated `JsonSerializerContext`. There are deliberately +no `JsonSerializerOptions` overloads, so nothing resolves a contract reflectively; see +[Trimming and Native AOT](Trimming-and-AOT.md). + +## Sending + +```csharp +var sent = await http.PostAsJsonValidatedAsync( + "api/orders", order, new OrderSchemaValidator(), AppJsonContext.Default.Order); +``` + +| API | Behaviour | +| --- | --- | +| `PostAsJsonValidatedAsync`, `PutAsJsonValidatedAsync`, `PatchAsJsonValidatedAsync` (`string?` or `Uri?`) | Validates with `ValidateAsync` (async refinements run). Invalid: a failed `ValidationResult` and **no request**. Valid: the response, whatever its status. | +| `ZodJsonContent.Create`, `ZodJsonContent.CreateAsync` | The validated `JsonContent` (`application/json; charset=utf-8` unless a media type is given), for any other request shape. | + +The body is the *validated* value (`result.Value`), so a schema's transforms apply before serialization. + +## Service-aware rules + +A rule that needs services - an `[ZodAsyncMember]` uniqueness check resolving a repository, a lookup that calls another +API - only runs when the validator is given an `IServiceProvider`. Each API has an overload that takes an +`IZodSchemaValidator` (a generated `{TypeName}SchemaValidator`) and the services: + +```csharp +var sent = await http.PostAsJsonValidatedAsync( + "api/customers", customer, new RegisterCustomerRequestSchemaValidator(), + AppJsonContext.Default.RegisterCustomerRequest, services, cancellationToken); + +var read = await response.Content.ReadFromJsonAndValidateAsync( + new CustomerSchemaValidator(), AppJsonContext.Default.Customer, services, cancellationToken); + +var content = await ZodJsonContent.CreateAsync( + new OrderSchemaValidator(), order, AppJsonContext.Default.Order, services); +``` + +| Where | Pass | +| --- | --- | +| Blazor WebAssembly | the component's (or typed client's) injected `IServiceProvider`: the browser's services | +| Blazor Server | the same injection: the circuit's services | +| A minimal API or controller | `HttpContext.RequestServices` | +| A worker or console app | a scope's `ServiceProvider` | + +A service-aware rule is never skipped silently: a generated validator whose model has one throws +`InvalidOperationException` ("... needs an IServiceProvider. Call ValidateAsync with the caller's services.") when it is +used through an overload without services, so the mistake surfaces on the first call rather than as an unchecked +request. `Purview.ZodSharp.SystemTextJson` has the same overloads for `DeserializeAndValidateAsync` and +`ValidateAndSerializeAsync`. + +## Receiving + +```csharp +var result = await response.Content.ReadFromJsonAndValidateAsync( + new OrderSchemaValidator(), AppJsonContext.Default.Order); +``` + +`ReadFromJsonAndValidateAsync` deserializes and validates in one pass, exactly like `DeserializeAndValidateAsync` in +[System.Text.Json Integration](SystemTextJson-Integration.md): malformed JSON, a `null` document or an unconvertible +value is one `invalid_json` error with the serializer's path, never an exception. The body is read as UTF-8, the +encoding RFC 8259 requires for JSON exchanged over a network. + +## Validation problems + +```csharp +if (await response.TryReadZodValidationProblemAsync() is { } problem) +{ + ImmutableArray errors = problem.ToValidationErrors(); + ValidationResult result = problem.ToValidationResult(); +} +``` + +- `TryReadZodValidationProblemAsync` returns `null` unless the response is unsuccessful, JSON + (`application/problem+json`, `application/json` or `*+json`) and carries `errors` or `issues`. `Status` falls back to + the response status. +- `ToValidationErrors` rebuilds one error per `issues` entry with its code, category, typed path (`segments`), origin, + bounds, parameters and union options; without `issues` it falls back to one `custom` error per `errors` message, with + the path parsed from the dotted key. +- The types live in `ZodSharp.Http` (`ZodValidationProblemDetails`, `ZodValidationProblemIssue`); the extensions live in + `System.Net.Http`. + +`Purview.ZodSharp.Http.UnitTests` starts a server with `AddZodSharpProblemDetails()` and proves every field it writes +(code, category, typed path, origin, numeric and exclusive bounds, parameters, union options, the root path) reads back +unchanged. + +## A typed client for every render mode + +Wrap the calls in one typed client and register it for each host, so the same rules guard the request wherever it is +made: + +```csharp +// Blazor WebAssembly (Program.cs of the client): the browser's HttpClient and services. +builder.Services.AddScoped(); + +// The server (Blazor Server, a worker, another API): a typed client from IHttpClientFactory. It is created in the +// requesting scope, so the IServiceProvider it receives is the circuit's or the request's. +services.AddHttpClient(http => http.BaseAddress = new("https://api.example.com/")); +``` + +In Blazor, place either kind of rejection on the form's fields with +[Blazor Integration](Blazor-Integration.md)'s `EditContext.AddZodErrors(errors)` (rejected before sending) or +`EditContext.AddZodProblemDetails(problem)` (rejected by the server). Both use the typed paths, so `Lines[0].Quantity` +lands on `order.Lines[0].Quantity`. + +## The sample + +`src/src/Examples.Blazor` uses this package end to end: + +- `Examples.Blazor.Client/CustomerAPIClient.cs` - the typed client: `PostAsJsonValidatedAsync` with the caller's + services (so the async email-uniqueness rule runs before sending), `TryReadZodValidationProblemAsync` for a server + rejection, and `ReadFromJsonAndValidateAsync` for the response. +- `Examples.Blazor.Client/RegisterCustomerForm.razor` - one form, `` plus `AddZodErrors` for whichever + side rejected the registration. +- `/register` renders it in **Interactive WebAssembly** (posting from the browser); `/register-server` renders it in + **Interactive Server**, where `IHttpClientFactory` creates the client and it calls the API service-to-service. +- `Examples.Blazor.IntegrationTests/CustomerAPIClientTests.cs` resolves the server's typed-client registration and drives + it against the in-memory API, counting the requests actually sent; `tests/e2e/blazor-sample.e2e.mjs` drives both pages + in a browser. diff --git a/docs/wiki/SystemTextJson-Integration.md b/docs/wiki/SystemTextJson-Integration.md index ad0c2473..48b0647b 100644 --- a/docs/wiki/SystemTextJson-Integration.md +++ b/docs/wiki/SystemTextJson-Integration.md @@ -71,6 +71,18 @@ catch (ZodJsonValidationException ex) | `ValidateAndSerializeAsync` | `ValueTask> ValidateAndSerializeAsync(this IZodSchema schema, T value, Stream output, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)` | | `CreateValidatingConverter` | `JsonConverter CreateValidatingConverter(this IZodSchema schema)` | +Each method also has a trim- and AOT-safe overload that takes a `JsonTypeInfo` in place of the options. The two +async methods have a service-aware overload on `IZodSchemaValidator` (a generated `{TypeName}SchemaValidator`), so +rules that resolve services run: + +| Member | Signature | +|---|---| +| `DeserializeAndValidateAsync` | `ValueTask> DeserializeAndValidateAsync(this IZodSchemaValidator validator, Stream jsonStream, JsonTypeInfo jsonTypeInfo, IServiceProvider? services, CancellationToken cancellationToken = default)` | +| `ValidateAndSerializeAsync` | `ValueTask> ValidateAndSerializeAsync(this IZodSchemaValidator validator, T value, Stream output, JsonTypeInfo jsonTypeInfo, IServiceProvider? services, CancellationToken cancellationToken = default)` | + +The package is browser-safe (Blazor WebAssembly). For `HttpClient` bodies and server validation problems, see +[HttpClient Integration](Http-Integration.md). + ## Failure codes A failure to read the JSON (malformed JSON, a value that cannot be converted, a `null` document, or a type the serializer cannot handle — `JsonException`, `NotSupportedException`, `InvalidOperationException`) produces one `ValidationError` with code `invalid_json` (`SystemTextJsonExtensions.InvalidJsonCode`) and category `invalid_type`. Its `FullPath` is the location the serializer reported (`$.items[1].age` becomes `items[1].age`), its `line` and `position` parameters hold the serializer's zero-based line number and byte position in the line, and its message is the fixed `"The JSON value could not be converted to {expected}."`, where `expected` is a Zod type name (`string`, `number`, `object`, `array`, …) found by walking the failing path through the serializer contract, or the generic `"the expected type"` when it cannot be described (for example malformed JSON read into a `Dictionary`). The serializer's own message, which names CLR types and can quote the input, is never surfaced. A failure raised by a validating converter is returned as the schema's own errors, located under the converter's path. diff --git a/docs/wiki/Trimming-and-AOT.md b/docs/wiki/Trimming-and-AOT.md index c955784c..75fcf03d 100644 --- a/docs/wiki/Trimming-and-AOT.md +++ b/docs/wiki/Trimming-and-AOT.md @@ -13,6 +13,16 @@ plain consumer. Each native binary then runs runtime schemas, generated validato the Microsoft.Extensions.Validation bridge, ProblemDetails and options validation, and exits non-zero on any mismatch. Run it with `just verify-trim`; the `verify-trim` PR job runs it on Linux. +## Blazor WebAssembly + +The packages a WebAssembly client downloads are marked browser-safe (`PurviewBrowserSafe`), so trim, AOT and +browser-unsupported (`CA1416`) diagnostics fail their own builds: `Purview.ZodSharp`, `Purview.ZodSharp.SystemTextJson`, +`Purview.ZodSharp.Http`, `Purview.ZodSharp.Extensions.Validation` and `Purview.ZodSharp.Blazor`. `just wasm-size` (CI: +`wasm-trimmed`, nightly `wasm-aot`) publishes the sample client trimmed with warnings as errors and checks each +assembly against a size budget. `Purview.ZodSharp.AspNetCore` and `Purview.ZodSharp.AspNetCore.OpenApi` are +server-only: they reference the ASP.NET Core shared framework, which WebAssembly cannot load. A client reads what they +write through `Purview.ZodSharp.Http`. + ## Safe everywhere These need nothing from the application: @@ -25,6 +35,7 @@ These need nothing from the application: | Metadata registration | `RegisterFromAssembly`, `AddZodSharpAssembly`, `ScanAssemblies`, `ScanLoadedAssemblies` (they read `[ZodSchemaGenerated]`, whose `typeof` roots each validator) | | JSON Schema | `Z.ToJsonSchema`, `{Type}Schema.BuildJsonSchema()`, `Z.FromJsonSchema` (System.Text.Json), `JsonSchemaSerializerOptions` | | System.Text.Json | the `JsonTypeInfo` overloads of `DeserializeAndValidate`, `DeserializeAndValidateAsync`, `ValidateAndSerialize`, `ValidateAndSerializeAsync`; `CreateValidatingConverter` | +| HttpClient | everything in `Purview.ZodSharp.Http`: `PostAsJsonValidatedAsync`/`PutAsJsonValidatedAsync`/`PatchAsJsonValidatedAsync`, `ZodJsonContent`, `ReadFromJsonAndValidateAsync`, `TryReadZodValidationProblemAsync` (it has no reflection-based overloads) | | ASP.NET Core | `AddZodSharp`, `AddZodSharpProblemDetails`, `ZodProblemDetailsWriter`, `WithZodSharpValidation`, `ZodTypedResults` | | Validation bridge | `AddZodSharpValidation`, `ValidationOptions.ValidateAsync` | | Options | `AddZodSchemaValidator`, `ValidateWithZod(section)` | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 48cc1c19..0e8492de 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -16,6 +16,7 @@ - [JSON Schema Import](JsonSchema-Import.md) - [System.Text.Json Integration](SystemTextJson-Integration.md) - [Newtonsoft.Json Integration](NewtonsoftJson-Integration.md) +- [HttpClient Integration](Http-Integration.md) - [ASP.NET Core Integration](AspNetCore-Integration.md) - [Microsoft.Extensions.Validation](Microsoft-Extensions-Validation.md) - [OpenAPI Integration](OpenAPI-Integration.md) diff --git a/package.json b/package.json index 94bb02cb..6f2d9767 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "3.0.0-prerelease.1", + "version": "3.0.0-prerelease.2", "private": true, "license": "MIT", "author": { @@ -28,4 +28,4 @@ "bun": ">=1.4.2" }, "packageManager": "bun@1.4.2" -} \ No newline at end of file +} diff --git a/skills/purview-zodsharp-blazor/SKILL.md b/skills/purview-zodsharp-blazor/SKILL.md index 4776a301..8392598e 100644 --- a/skills/purview-zodsharp-blazor/SKILL.md +++ b/skills/purview-zodsharp-blazor/SKILL.md @@ -101,7 +101,29 @@ if (await response.TryReadZodValidationProblemAsync() is { } 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. +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 diff --git a/skills/purview-zodsharp-core/SKILL.md b/skills/purview-zodsharp-core/SKILL.md index ca10d04d..da002e69 100644 --- a/skills/purview-zodsharp-core/SKILL.md +++ b/skills/purview-zodsharp-core/SKILL.md @@ -22,6 +22,7 @@ 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 diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index 76591221..f4f15936 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -4,7 +4,8 @@ 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, the invalid_json error and - converter exceptions, x-zod vendor extensions for OpenAPI, and sharing a contract with TypeScript Zod. + 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 @@ -33,6 +34,46 @@ deserialize/serialize extensions (`SystemTextJsonExtensions`, `NewtonsoftJsonExt 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 ```csharp diff --git a/src/ZodSharp.slnx b/src/ZodSharp.slnx index 9363502b..4644f7a9 100644 --- a/src/ZodSharp.slnx +++ b/src/ZodSharp.slnx @@ -23,6 +23,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/src/src/Blazor/Blazor.csproj b/src/src/Blazor/Blazor.csproj index 1fb1dbc2..b849fb3c 100644 --- a/src/src/Blazor/Blazor.csproj +++ b/src/src/Blazor/Blazor.csproj @@ -40,6 +40,8 @@ + + diff --git a/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs b/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs index 3f19529a..13fef33f 100644 --- a/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs +++ b/src/src/Blazor/Extensions/Microsoft/AspNetCore/Components/Forms/ZodEditContextExtensions.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using ZodSharp.Blazor; using ZodSharp.Core; +using ZodSharp.Http; namespace Microsoft.AspNetCore.Components.Forms; @@ -73,47 +74,44 @@ public void AddZodProblemDetails( bool clearOnFieldChange = true ) { - ArgumentNullException.ThrowIfNull(editContext); ArgumentNullException.ThrowIfNull(problem); + editContext.AddZodErrors(problem.ToValidationErrors(), store, clearOnFieldChange); + } - var errors = ZodFormErrors.GetOrCreate(editContext); - ClearServerErrors(errors); - - var target = store ?? (errors.ServerStore ??= new ValidationMessageStore(editContext)); - var validator = errors.Validator?.RootValidator; - foreach (var error in problem.ToValidationErrors()) - { - 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(); - } - }; - } + /// + /// 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); - editContext.NotifyValidationStateChanged(); + 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 - /// server errors mapped by AddZodProblemDetails. + /// errors added by AddZodProblemDetails and AddZodErrors. /// public void ClearZodErrors() { @@ -129,6 +127,48 @@ public void ClearZodErrors() } } + 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) diff --git a/src/src/Blazor/Sdk/README.md b/src/src/Blazor/Sdk/README.md index 5f3a95df..59097017 100644 --- a/src/src/Blazor/Sdk/README.md +++ b/src/src/Blazor/Sdk/README.md @@ -67,7 +67,20 @@ if (await response.TryReadZodValidationProblemAsync() is { } problem) ``` Reads Purview.ZodSharp.AspNetCore's `issues[]` (typed `segments`), or a plain `errors` dictionary, with -source-generated JSON metadata (trim- and AOT-safe). +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 diff --git a/src/src/Examples.Blazor.Client/Contracts.cs b/src/src/Examples.Blazor.Client/Contracts.cs index 8f8ffd6b..6b0f22c6 100644 --- a/src/src/Examples.Blazor.Client/Contracts.cs +++ b/src/src/Examples.Blazor.Client/Contracts.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using ZodSharp.Rules; namespace ZodSharp.Examples.Blazor.Client; @@ -10,10 +11,12 @@ namespace ZodSharp.Examples.Blazor.Client; sealed record EmailStatus(bool Registered); /// -/// The response of a successful POST /api/customers. +/// The response of a successful POST /api/customers. The client validates it as it reads it +/// (ReadFromJsonAndValidateAsync), so a malformed response is an error rather than a half-filled value. /// /// The registered address. -sealed record CustomerRegistered(string Email); +[ZodSchema] +sealed partial record CustomerRegistered([property: Email] string Email); /// /// Source-generated JSON metadata for the sample's HTTP contract, so the trimmed WebAssembly client serializes without diff --git a/src/src/Examples.Blazor.Client/CustomerAPIClient.cs b/src/src/Examples.Blazor.Client/CustomerAPIClient.cs new file mode 100644 index 00000000..d5cbf0d5 --- /dev/null +++ b/src/src/Examples.Blazor.Client/CustomerAPIClient.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using ZodSharp.Core; + +namespace ZodSharp.Examples.Blazor.Client; + +/// +/// What happened to a registration. +/// +enum RegistrationOutcome +{ + /// The server registered the customer, and its response validated. + Registered, + + /// The request was invalid, so nothing was sent. + RejectedBeforeSending, + + /// The server answered with a validation problem. + RejectedByServer, + + /// The server answered successfully, but its body did not validate. + InvalidResponse, +} + +/// +/// The result of . +/// +/// What happened. +/// The registered customer, when . +/// The errors, placed onto the form with EditContext.AddZodErrors. +/// The HTTP status of a server rejection. +sealed record CustomerRegistration( + RegistrationOutcome Outcome, + CustomerRegistered? Customer, + ImmutableArray Errors, + int? Status = null +); + +/// +/// The typed client for POST /api/customers, built on Purview.ZodSharp.Http. One class serves every render mode: +/// in WebAssembly it posts from the browser; for the Interactive Server page IHttpClientFactory creates it on the +/// server, where it calls the API service-to-service. The it validates with are the +/// caller's - the browser's, or the circuit's - so the service-aware email-uniqueness rule resolves the +/// that fits where it runs. +/// +/// The client for the sample's API. +/// The services service-aware rules resolve from. +sealed class CustomerAPIClient(HttpClient http, IServiceProvider services) +{ + /// + /// The registration endpoint, relative to the API's base address. + /// + public static readonly Uri RegisterUri = new("api/customers", UriKind.Relative); + + static readonly RegisterCustomerRequestSchemaValidator RequestValidator = new(); + static readonly CustomerRegisteredSchemaValidator ResponseValidator = new(); + + /// + /// Registers a customer. + /// + /// The registration. + /// + /// (the default) to validate before posting, so an invalid registration is never sent; + /// to post it as it is and let the server reject it. + /// + /// Cancels the validation and the request. + /// The outcome, with the errors to show. + public async Task RegisterAsync( + RegisterCustomerRequest request, + bool validateBeforeSending = true, + CancellationToken cancellationToken = default + ) + { + HttpResponseMessage response; + if (validateBeforeSending) + { + // Validated with the caller's services, so the async uniqueness rule runs too: a malformed or taken address + // never leaves the client. This holds for any caller, not only a form that has already validated. + var sent = await http.PostAsJsonValidatedAsync( + RegisterUri, + request, + RequestValidator, + ClientJsonContext.Default.RegisterCustomerRequest, + services, + cancellationToken + ); + if (!sent.IsSuccess) + return new(RegistrationOutcome.RejectedBeforeSending, null, sent.Errors); + + response = sent.Value; + } + else + { + response = await http.PostAsJsonAsync( + RegisterUri, + request, + ClientJsonContext.Default.RegisterCustomerRequest, + cancellationToken + ); + } + + using (response) + { + // The server validates every request; its ProblemDetails use the same codes and paths as the client. + if (await response.TryReadZodValidationProblemAsync(cancellationToken) is { } problem) + return new(RegistrationOutcome.RejectedByServer, null, problem.ToValidationErrors(), problem.Status); + + response.EnsureSuccessStatusCode(); + + // Validated as it is read: a malformed response is an error, not a half-filled value. + var registered = await response.Content.ReadFromJsonAndValidateAsync( + ResponseValidator, + ClientJsonContext.Default.CustomerRegistered, + cancellationToken + ); + + return registered.IsSuccess + ? new(RegistrationOutcome.Registered, registered.Value, []) + : new(RegistrationOutcome.InvalidResponse, null, registered.Errors); + } + } +} diff --git a/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj b/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj index 41e4bd6d..20a99a49 100644 --- a/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj +++ b/src/src/Examples.Blazor.Client/Examples.Blazor.Client.csproj @@ -43,7 +43,16 @@ --> + + + + @@ -52,6 +61,8 @@ as a Blazor Web App's server always references its client. --> + + diff --git a/src/src/Examples.Blazor.Client/Pages/RegisterCustomer.razor b/src/src/Examples.Blazor.Client/Pages/RegisterCustomer.razor index 8816788c..6d547dbd 100644 --- a/src/src/Examples.Blazor.Client/Pages/RegisterCustomer.razor +++ b/src/src/Examples.Blazor.Client/Pages/RegisterCustomer.razor @@ -1,7 +1,5 @@ @page "/register" @rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) -@implements IDisposable -@inject HttpClient Http Interactive WebAssembly - ZodValidator @@ -9,82 +7,14 @@

Interactive WebAssembly, route 2: <ZodValidator> validates in the browser as you type (debounced by 300 ms) and keeps every error's code, shown after each message. The email is checked for - uniqueness by a service-aware async rule - try @InMemoryCustomerEmailRegistry.SeededEmail. The - second button posts without validating, so the server's ProblemDetails are mapped back onto the fields. + uniqueness by a service-aware async rule - try @InMemoryCustomerEmailRegistry.SeededEmail. +

+

+ Registering posts from the browser with Purview.ZodSharp.Http: PostAsJsonValidatedAsync + validates again with the browser's services, so an invalid registration is never sent, and the response is read + with ReadFromJsonAndValidateAsync. The second button posts without validating, so the server's + ProblemDetails are read with TryReadZodValidationProblemAsync and mapped back onto the fields. The + Interactive Server page renders the same form.

- - - -
- - - -
- -
- - - @if (_editContext.IsValidationPending(() => _model.Email)) - { - Checking... - } - -
- -
- - - -
- - - - - -
- -@if (_status is not null) -{ -

@_status

-} - -@code { - RegisterCustomerRequest _model = new(); - EditContext _editContext = default!; - string? _status; - - protected override void OnInitialized() - { - _editContext = new(_model); - _editContext.OnValidationStateChanged += OnValidationStateChanged; - } - - void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e) => StateHasChanged(); - - async Task PostAsync() - { - _status = null; - using var response = await Http.PostAsJsonAsync( - "api/customers", - _model, - ClientJsonContext.Default.RegisterCustomerRequest - ); - - // The server always validates: its errors use the same contract, so they land on the same fields. They replace - // whatever the browser reported, so a field never shows the same message twice. - if (await response.TryReadZodValidationProblemAsync() is { } problem) - { - _editContext.ClearZodErrors(); - _editContext.AddZodProblemDetails(problem); - _status = $"The server rejected the registration ({problem.Status})."; - return; - } - - response.EnsureSuccessStatusCode(); - var registered = await response.Content.ReadFromJsonAsync(ClientJsonContext.Default.CustomerRegistered); - _status = $"Registered {registered?.Email}."; - } - - public void Dispose() => _editContext.OnValidationStateChanged -= OnValidationStateChanged; -} + diff --git a/src/src/Examples.Blazor.Client/Program.cs b/src/src/Examples.Blazor.Client/Program.cs index 25207829..4b943079 100644 --- a/src/src/Examples.Blazor.Client/Program.cs +++ b/src/src/Examples.Blazor.Client/Program.cs @@ -13,6 +13,10 @@ // asks the server; on the server it is the in-memory store itself. builder.Services.AddScoped(); +// The typed client RegisterCustomerForm posts through (Purview.ZodSharp.Http). Here it uses the browser's HttpClient and +// services; the server registers the same class with IHttpClientFactory for the Interactive Server page. +builder.Services.AddScoped(); + // The same two calls as the server Program.cs: the generated, trim-safe registration of the Examples.Shared // [ZodSchema] models (no assembly scanning), then ZodSharp for Blazor forms. builder.Services.AddZodSharpZodSharpExamplesSharedSchemas(); diff --git a/src/src/Examples.Blazor.Client/RegisterCustomerForm.razor b/src/src/Examples.Blazor.Client/RegisterCustomerForm.razor new file mode 100644 index 00000000..bfc3814c --- /dev/null +++ b/src/src/Examples.Blazor.Client/RegisterCustomerForm.razor @@ -0,0 +1,106 @@ +@implements IDisposable +@inject CustomerAPIClient Customers + +@* The registration form, shared by the Interactive WebAssembly page (/register) and the Interactive Server page + (/register-server): the same component, validator and CustomerAPIClient run in the browser and on the server. *@ + + + +
+ + + +
+ +
+ + + @if (_editContext.IsValidationPending(() => _model.Email)) + { + Checking... + } + +
+ +
+ + + +
+ + + + + +
+ +@if (_status is not null) +{ +

@_status

+} + +@code { + readonly RegisterCustomerRequest _model = new(); + EditContext _editContext = default!; + string? _status; + RegistrationOutcome? _outcome; + bool _busy; + + protected override void OnInitialized() + { + _editContext = new(_model); + _editContext.OnValidationStateChanged += OnValidationStateChanged; + } + + void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e) => StateHasChanged(); + + // The form has validated, and CustomerAPIClient validates again before sending - with the same services, so the + // uniqueness check runs - because it never relies on its caller having done so. + Task RegisterAsync() => SendAsync(validateBeforeSending: true); + + // Skips every client check, so the server's ProblemDetails are mapped back onto the same fields. + Task PostUnvalidatedAsync() => SendAsync(validateBeforeSending: false); + + async Task SendAsync(bool validateBeforeSending) + { + _busy = true; + _status = null; + try + { + Show(await Customers.RegisterAsync(_model, validateBeforeSending)); + } + finally + { + _busy = false; + } + } + + void Show(CustomerRegistration registration) + { + // Whatever was reported before is replaced, so a field never shows the same message twice. + _editContext.ClearZodErrors(); + _outcome = registration.Outcome; + + switch (registration.Outcome) + { + case RegistrationOutcome.Registered: + _status = $"Registered {registration.Customer!.Email}."; + break; + case RegistrationOutcome.RejectedBeforeSending: + _editContext.AddZodErrors(registration.Errors); + _status = "Not sent: the registration is invalid."; + break; + case RegistrationOutcome.RejectedByServer: + _editContext.AddZodErrors(registration.Errors); + _status = $"The server rejected the registration ({registration.Status})."; + break; + case RegistrationOutcome.InvalidResponse: + _status = $"The server's response was invalid: {registration.Errors[0].Message}"; + break; + } + } + + public void Dispose() => _editContext.OnValidationStateChanged -= OnValidationStateChanged; +} diff --git a/src/src/Examples.Blazor/Components/Layout/MainLayout.razor b/src/src/Examples.Blazor/Components/Layout/MainLayout.razor index 4e73b6a5..d1977eed 100644 --- a/src/src/Examples.Blazor/Components/Layout/MainLayout.razor +++ b/src/src/Examples.Blazor/Components/Layout/MainLayout.razor @@ -7,6 +7,7 @@ ZodSharp Blazor sample Interactive Server Interactive WebAssembly + Interactive Server + HttpClient Static SSR
+ /// 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); + + T? deserialized; + try + { + deserialized = await JsonSerializer + .DeserializeAsync(jsonStream, jsonTypeInfo, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (JsonDeserializationErrors.TryMap(ex, jsonTypeInfo, out var errors)) + { + return ValidationResult.Failure(errors); + } + + return deserialized == null + ? ValidationResult.Failure(JsonDeserializationErrors.NullValue(jsonTypeInfo)) + : await validator.ValidateAsync(deserialized, services, cancellationToken).ConfigureAwait(false); + } + /// /// Validates a value and serializes it to a JSON string, using a supplied contract. /// @@ -182,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. /// diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 1822b919..e68bf3f8 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -8,6 +8,7 @@ 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) diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index c736d788..d804b783 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -22,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/tests/Blazor.UnitTests/GeneratedSchemaTests.cs b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs index caa0486d..bf487cbb 100644 --- a/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs +++ b/src/tests/Blazor.UnitTests/GeneratedSchemaTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Validation; using ZodSharp.Blazor.Fixtures; using ZodSharp.Extensions.Validation; +using ZodSharp.Http; namespace ZodSharp.Blazor; diff --git a/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs b/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs index b4bd8d3c..2bffc2f7 100644 --- a/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs +++ b/src/tests/Blazor.UnitTests/ProblemDetailsMappingTests.cs @@ -1,5 +1,3 @@ -using System.Net; -using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Http; @@ -8,13 +6,14 @@ 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: TryReadZodValidationProblemAsync and -/// AddZodProblemDetails. +/// Server validation problems mapped onto forms with AddZodProblemDetails. Reading them +/// (TryReadZodValidationProblemAsync) is covered by Purview.ZodSharp.Http's tests. /// public class ProblemDetailsMappingTests { @@ -126,6 +125,47 @@ await Assert .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() { @@ -225,70 +265,6 @@ public async Task ClearZodErrors_GivenServerErrors_RemovesThem() await Assert.That(editContext.GetZodErrors()).IsEmpty(); } - [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(); - } - static System.Text.Json.JsonElement Json(string json) => System.Text.Json.JsonElement.Parse(json); static async Task StartServerAsync( diff --git a/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs index 6e9a1b3a..e32dc890 100644 --- a/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs +++ b/src/tests/Blazor.UnitTests/StructMemberPlacementTests.cs @@ -1,6 +1,7 @@ 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; 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/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/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..e7eaeeb4 --- /dev/null +++ b/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs @@ -0,0 +1,124 @@ +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 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/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/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/Program.cs b/src/tests/TrimSafety.Verification/Program.cs index 7fb55ddf..a21c3810 100644 --- a/src/tests/TrimSafety.Verification/Program.cs +++ b/src/tests/TrimSafety.Verification/Program.cs @@ -11,6 +11,7 @@ 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); diff --git a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj index d94f896c..789e0039 100644 --- a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj +++ b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj @@ -55,6 +55,7 @@ + @@ -95,6 +96,7 @@ > + diff --git a/tests/e2e/blazor-sample.e2e.mjs b/tests/e2e/blazor-sample.e2e.mjs index 7f4a8a0b..6c8beaf2 100644 --- a/tests/e2e/blazor-sample.e2e.mjs +++ b/tests/e2e/blazor-sample.e2e.mjs @@ -54,6 +54,11 @@ async function messagesFor(id) { ); } +/** 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`); @@ -88,6 +93,42 @@ try { ); }); + 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. From d0431177f2baa9a288ad03ab22375ff34d5e5d66 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 15:45:19 +0000 Subject: [PATCH 105/107] chore: updated skills --- .../purview-sourcegenerator-packaging/SKILL.md | 12 ++++++++---- skills-lock.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md index 92350db8..a0e7f802 100644 --- a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -76,10 +76,14 @@ identity. Both are `IsRoslynComponent`; only the generator references the framew - 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 declared as copy-to-output content beside the generator's `bin`, and - content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what - lets Visual Studio load the dependent code-fix provider from its own `bin` without a - `FileNotFoundException` for the framework assembly. +- 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 diff --git a/skills-lock.json b/skills-lock.json index 3bd69121..49155c15 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -89,7 +89,7 @@ "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", - "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + "computedHash": "ee2440f1e0aa0cea731eb3eae2ea3c4e9ea77502867abc21e9f0a3c61f20c640" }, "purview-sourcegenerator-pipeline": { "source": "purview-dev/sourcegenerator-framework", From bd8ed53d8cb32b29071ba32d4b6986b9f64b1f79 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 16:13:29 +0000 Subject: [PATCH 106/107] fix(ci): exit 0 from pack-rule proof so a rejection run's exit code doesn't fail the step --- scripts/test-pack-validation-rules.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 index f1b94bfa..8eba5da9 100644 --- a/scripts/test-pack-validation-rules.ps1 +++ b/scripts/test-pack-validation-rules.ps1 @@ -177,3 +177,6 @@ if ($failures.Count -gt 0) { } 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 From 4af13d627e7b88c88117041d46c3cd77e941f481 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 2 Nov 2026 17:16:58 +0000 Subject: [PATCH 107/107] fix: better integration for strict vs. non-strict in aot --- CHANGELOG.md | 18 + docs/wiki/AspNetCore-Integration.md | 60 +++ docs/wiki/Http-Integration.md | 6 +- docs/wiki/SystemTextJson-Integration.md | 19 + skills/purview-zodsharp-aspnetcore/SKILL.md | 8 + skills/purview-zodsharp-json/SKILL.md | 6 + .../ServiceCollectionExtensions.cs | 18 + src/src/AspNetCore/ZodExceptionHandler.cs | 63 ++- .../ZodProblemDetailsJsonContext.cs | 1 + .../AspNetCore/ZodProblemDetailsOptions.cs | 30 ++ src/src/AspNetCore/ZodProblemDetailsWriter.cs | 41 ++ .../ZodValidationProblemDetailsCustomizer.cs | 23 + .../Json/JsonDeserializationErrors.cs | 27 ++ .../ZodSharp/Core/ZodGeneratedValidation.cs | 2 +- src/src/ZodSharp/Core/ZodJsonExceptions.cs | 257 ++++++++++ .../Fixtures/StrictValueObjects.cs | 110 +++++ .../AspNetCore.UnitTests/Fixtures/TestApp.cs | 5 +- ...trictDeserializationProblemDetailsTests.cs | 437 ++++++++++++++++++ .../Fixtures/StrictValueObjects.cs | 62 +++ .../ZodHttpContentExtensionsTests.cs | 30 ++ .../SystemTextJsonStrictValueObjectTests.cs | 146 ++++++ src/tests/TrimSafety.Verification/Program.cs | 5 + .../StrictDeserializationChecks.cs | 181 ++++++++ .../TrimSafety.Verification.csproj | 5 + .../Core/ZodJsonExceptionsTests.cs | 301 ++++++++++++ 25 files changed, 1854 insertions(+), 7 deletions(-) create mode 100644 src/src/ZodSharp/Core/ZodJsonExceptions.cs create mode 100644 src/tests/AspNetCore.UnitTests/Fixtures/StrictValueObjects.cs create mode 100644 src/tests/AspNetCore.UnitTests/StrictDeserializationProblemDetailsTests.cs create mode 100644 src/tests/Http.UnitTests/Fixtures/StrictValueObjects.cs create mode 100644 src/tests/SystemTextJson.UnitTests/Json/SystemTextJsonStrictValueObjectTests.cs create mode 100644 src/tests/TrimSafety.Verification/StrictDeserializationChecks.cs create mode 100644 src/tests/ZodSharp.UnitTests/Core/ZodJsonExceptionsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 278f561f..9ce85743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -242,6 +242,24 @@ Released versions correspond to `v` GitHub releases. 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 diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index ee9add18..d821b067 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -390,9 +390,69 @@ does the following: | `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 (and, on .NET 11, > the pipeline enrichment). It does **not** register `IZodSchemaFactory`. If you also want DI-based validator resolution, register the diff --git a/docs/wiki/Http-Integration.md b/docs/wiki/Http-Integration.md index 1af0c9d8..8b879340 100644 --- a/docs/wiki/Http-Integration.md +++ b/docs/wiki/Http-Integration.md @@ -68,8 +68,10 @@ var result = await response.Content.ReadFromJsonAndValidateAsync( `ReadFromJsonAndValidateAsync` deserializes and validates in one pass, exactly like `DeserializeAndValidateAsync` in [System.Text.Json Integration](SystemTextJson-Integration.md): malformed JSON, a `null` document or an unconvertible -value is one `invalid_json` error with the serializer's path, never an exception. The body is read as UTF-8, the -encoding RFC 8259 requires for JSON exchanged over a network. +value is one `invalid_json` error with the serializer's path, never an exception. A value a strict value object +converter rejects (`JsonException(inner: ZodException)`) is reported as that `ZodException`'s errors under the member +that held it (`CustomerEmail.Value`), not as `invalid_json`. The body is read as UTF-8, the encoding RFC 8259 requires +for JSON exchanged over a network. ## Validation problems diff --git a/docs/wiki/SystemTextJson-Integration.md b/docs/wiki/SystemTextJson-Integration.md index 48b0647b..2fc17d1c 100644 --- a/docs/wiki/SystemTextJson-Integration.md +++ b/docs/wiki/SystemTextJson-Integration.md @@ -108,6 +108,25 @@ var error = result.Errors[0]; // error.Parameters["line"], ["position"] - where the serializer stopped ``` +### Values rejected by a strict converter + +A converter that rejects a value with ZodSharp errors - a Purview.ValueObjects strict converter throws +`new JsonException(message, zodException)` - is not a failure to read JSON. `DeserializeAndValidate*` (and the Http +package's `ReadFromJsonAndValidateAsync`) return those errors under the member that held the value, with JSON names +mapped back to member names through the supplied `JsonTypeInfo`, and an error at the value object's own `Value` +named after that member: + +```csharp +// {"customerEmail":"not-an-email","quantity":5} +var result = await validator.DeserializeAndValidateAsync(stream, AppJsonContext.Default.PlaceOrderRequest); +// result.Errors[0].FullPath -> CustomerEmail.Value +// result.Errors[0].Message -> "Field 'CustomerEmail' must be a valid email address." +``` + +The errors are exactly those validating the hydrated model reports. `ZodJsonExceptions` (core package) exposes the +same re-rooting for any exception (`TryGetErrors`, `TryReroot`, `Reroot`, `ToMemberPath`). A bare `ZodException` +thrown from a converter still propagates unchanged. + These are the same errors ASP.NET Core hosts project into ProblemDetails (see [ASP.NET Core Integration](AspNetCore-Integration.md)); OpenAPI documents describe the response shape (see [OpenAPI Integration](OpenAPI-Integration.md)). ## JSON Schema import diff --git a/skills/purview-zodsharp-aspnetcore/SKILL.md b/skills/purview-zodsharp-aspnetcore/SKILL.md index be674ade..a9de1304 100644 --- a/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -206,6 +206,14 @@ app.MapPost("/users", Results, ValidationProblem> (UserDto dto, Http 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 diff --git a/skills/purview-zodsharp-json/SKILL.md b/skills/purview-zodsharp-json/SKILL.md index f4f15936..0b33e215 100644 --- a/skills/purview-zodsharp-json/SKILL.md +++ b/skills/purview-zodsharp-json/SKILL.md @@ -109,6 +109,12 @@ serializer's own message is never surfaced, because it names CLR types and can q 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 ```csharp diff --git a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index e58de3b8..a95b0d89 100644 --- a/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/src/AspNetCore/Extensions/Microsoft/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Reflection; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -162,6 +163,7 @@ public IServiceCollection AddZodSharpProblemDetails( AddProblemDetailsWriter(services); services.AddExceptionHandler(); + AddBindingFailureHandling(services); #if NET11_0_OR_GREATER AddValidationPipelineEnrichment(services); #endif @@ -194,6 +196,22 @@ static void AddValidationPipelineEnrichment(IServiceCollection services) } #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(); diff --git a/src/src/AspNetCore/ZodExceptionHandler.cs b/src/src/AspNetCore/ZodExceptionHandler.cs index aa4a2904..178503f9 100644 --- a/src/src/AspNetCore/ZodExceptionHandler.cs +++ b/src/src/AspNetCore/ZodExceptionHandler.cs @@ -1,7 +1,13 @@ +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; @@ -15,6 +21,17 @@ namespace ZodSharp.AspNetCore; /// 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 { @@ -51,12 +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 problem = _writer.Create(zodException.Errors, httpContext); + var problem = _writer.Create(errors, httpContext); await _writer.WriteAsync(httpContext, problem, exception).ConfigureAwait(false); return true; } + + /// + /// 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 index fd967a65..d4bfcde3 100644 --- a/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs +++ b/src/src/AspNetCore/ZodProblemDetailsJsonContext.cs @@ -17,6 +17,7 @@ namespace ZodSharp.AspNetCore; /// [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(HttpValidationProblemDetails))] +[JsonSerializable(typeof(Microsoft.AspNetCore.Mvc.ProblemDetails))] [JsonSerializable(typeof(ValidationIssue[]))] [JsonSerializable(typeof(ValidationIssue[][]))] [JsonSerializable(typeof(Dictionary))] diff --git a/src/src/AspNetCore/ZodProblemDetailsOptions.cs b/src/src/AspNetCore/ZodProblemDetailsOptions.cs index 939618c1..d2d55acb 100644 --- a/src/src/AspNetCore/ZodProblemDetailsOptions.cs +++ b/src/src/AspNetCore/ZodProblemDetailsOptions.cs @@ -88,6 +88,35 @@ public sealed class ZodProblemDetailsOptions ///
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 . /// @@ -110,5 +139,6 @@ internal ZodProblemDetailsOptions Clone() => DefaultStatusCode = DefaultStatusCode, StatusCodeSelector = StatusCodeSelector, StatusCodeOverride = StatusCodeOverride, + HandleBindingFailures = HandleBindingFailures, }; } diff --git a/src/src/AspNetCore/ZodProblemDetailsWriter.cs b/src/src/AspNetCore/ZodProblemDetailsWriter.cs index 9ed59062..b9884175 100644 --- a/src/src/AspNetCore/ZodProblemDetailsWriter.cs +++ b/src/src/AspNetCore/ZodProblemDetailsWriter.cs @@ -287,6 +287,47 @@ await httpContext .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, diff --git a/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs b/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs index d125c78d..1c41df9e 100644 --- a/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs +++ b/src/src/AspNetCore/ZodValidationProblemDetailsCustomizer.cs @@ -1,6 +1,7 @@ #if NET11_0_OR_GREATER using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using ZodSharp.Core; namespace ZodSharp.AspNetCore; @@ -42,6 +43,28 @@ internal static void Customize(ProblemDetailsContext context) 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); diff --git a/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs index f75b7962..02eb5167 100644 --- a/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs +++ b/src/src/SystemTextJson/Json/JsonDeserializationErrors.cs @@ -20,6 +20,11 @@ namespace ZodSharp.Json; /// 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 { @@ -48,12 +53,23 @@ public static bool TryMap(Exception exception, JsonTypeInfo? typeInfo, out Immut 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; @@ -79,6 +95,17 @@ public static bool TryMap(Exception exception, JsonTypeInfo? typeInfo, out Immut 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); diff --git a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs index 9d785e1f..d4eeb646 100644 --- a/src/src/ZodSharp/Core/ZodGeneratedValidation.cs +++ b/src/src/ZodSharp/Core/ZodGeneratedValidation.cs @@ -366,7 +366,7 @@ ref string memberName return resolved; } - static ValidationError NameAfterOwner(in ValidationError error, string displayName) + internal static ValidationError NameAfterOwner(in ValidationError error, string displayName) { var parameters = error.Parameters; if ( 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/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 index caef6a40..97247594 100644 --- a/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs +++ b/src/tests/AspNetCore.UnitTests/Fixtures/TestApp.cs @@ -24,10 +24,11 @@ public sealed partial class TestApp : IAsyncDisposable public static async Task StartAsync( Action configure, Action map, - CancellationToken cancellationToken + CancellationToken cancellationToken, + string? environment = null ) { - var builder = WebApplication.CreateSlimBuilder(); + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { EnvironmentName = environment }); builder.WebHost.UseTestServer(); builder.Logging.ClearProviders(); configure(builder); 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/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/ZodHttpContentExtensionsTests.cs b/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs index e7eaeeb4..bbc9f76a 100644 --- a/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs +++ b/src/tests/Http.UnitTests/ZodHttpContentExtensionsTests.cs @@ -84,6 +84,36 @@ CancellationToken cancellationToken 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) { 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/Program.cs b/src/tests/TrimSafety.Verification/Program.cs index a21c3810..0fa32e28 100644 --- a/src/tests/TrimSafety.Verification/Program.cs +++ b/src/tests/TrimSafety.Verification/Program.cs @@ -15,5 +15,10 @@ 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 index 789e0039..fbef804f 100644 --- a/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj +++ b/src/tests/TrimSafety.Verification/TrimSafety.Verification.csproj @@ -52,6 +52,11 @@ 11.0.0-rc.3.26522.148 + + + + + 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; +}