From 7a6900c52db986f242de435a9839317178f1f381 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 13:00:25 -0700 Subject: [PATCH 01/16] Repair bit-rotted .NET example tests The .NET example test suites have never been compiled by CI, so they drifted out of sync with the generated output they exercise. They currently fail to build with four errors. Both breakages come from #73, which updated the Go tests alongside the generated output but left .NET's behind: - `request-id` is marked `@nexus.omit` in workflow-service.wit, so #73 correctly stopped emitting `RequestId`. Drop the stale initializer and assertion. - #73 folded `workflow` into `StartWorkflowOptions` and removed the generic `StartWorkflowAsync(Expression<...>, options)` overload. Update the compile check to the current single-argument API. No generated code changes; these are test-side fixes only. Co-Authored-By: Claude Opus 5 (1M context) --- .../samples/dotnet/tests/GeneratedApiCompileChecks.cs | 8 +++++--- .../dotnet/tests/WorkflowServiceEndpointRuntimeChecks.cs | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/advanced/samples/dotnet/tests/GeneratedApiCompileChecks.cs b/advanced/samples/dotnet/tests/GeneratedApiCompileChecks.cs index 432ddcb6..999b7a68 100644 --- a/advanced/samples/dotnet/tests/GeneratedApiCompileChecks.cs +++ b/advanced/samples/dotnet/tests/GeneratedApiCompileChecks.cs @@ -19,9 +19,11 @@ internal class ExampleWorkflow internal static class GeneratedApiCompileChecks { internal static Task StartWorkflowAsync() => - StartWorkflowExample.Operations.StartWorkflowAsync( - workflow => workflow.RunAsync("workflow-input"), - options: new StartWorkflowExample.StartWorkflowOptions("workflow-id", "task-queue") + StartWorkflowExample.Operations.StartWorkflowAsync( + new StartWorkflowExample.StartWorkflowOptions( + nameof(ExampleWorkflow), + "workflow-id", + "task-queue") { WorkflowStartDelay = TimeSpan.FromSeconds(1), }); diff --git a/advanced/samples/dotnet/tests/WorkflowServiceEndpointRuntimeChecks.cs b/advanced/samples/dotnet/tests/WorkflowServiceEndpointRuntimeChecks.cs index 7cf13739..c8b2a8b2 100644 --- a/advanced/samples/dotnet/tests/WorkflowServiceEndpointRuntimeChecks.cs +++ b/advanced/samples/dotnet/tests/WorkflowServiceEndpointRuntimeChecks.cs @@ -32,7 +32,6 @@ public async Task RunAsync(string taskQueue) new SignalWithStartWorkflowOptions("started-workflow-id", taskQueue) { ExecutionTimeout = TimeSpan.FromSeconds(30), - RequestId = "request-id", RetryPolicy = new RetryPolicy { MaximumAttempts = 3, @@ -106,7 +105,6 @@ public async Task GeneratedWorkflowServiceOperationRoundTripsThroughRuntime() Assert.NotNull(call.SignalArgs); Assert.Single(call.SignalArgs); Assert.Equal(TimeSpan.FromSeconds(30), call.ExecutionTimeout); - Assert.Equal("request-id", call.RequestId); Assert.NotNull(call.RetryPolicy); Assert.Equal(3, call.RetryPolicy.MaximumAttempts); Assert.NotNull(call.UserMetadata); From 9bdc91b12e710e3cbcee7f3c0f23e48cb165f339 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 13:00:31 -0700 Subject: [PATCH 02/16] Run the .NET example suites in CI CI installs Python, Node, Go and Java toolchains and runs each of their sample suites, but had no .NET step at all. As a result neither `samples/dotnet/tests/` nor `advanced/samples/dotnet/tests/` was ever compiled, which is how the latter came to be broken (repaired in the previous commit). Add an explicit `setup-dotnet` step and run both suites. The SDK pin also removes a hidden dependency: `tests/generate_dotnet.rs` already shells out to `dotnet build`, and until now relied on whatever SDK the runner happened to preinstall. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a01320c4..1d7d8f9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,17 @@ jobs: java-version: "21" cache: gradle + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + # The sample libraries target net8.0 and their test projects target + # net9.0, so both runtimes have to be present. `cargo test` also shells + # out to `dotnet build` from tests/generate_dotnet.rs, which relied on + # whatever SDK the runner happened to preinstall until this step. + dotnet-version: | + 8.0.x + 9.0.x + - name: Install Python dependencies (samples) working-directory: samples/python run: uv sync --locked @@ -137,3 +148,11 @@ jobs: - name: Run Java tests (advanced) working-directory: advanced/samples/java run: ./gradlew build --no-daemon + + - name: Run .NET tests (samples) + working-directory: samples/dotnet + run: dotnet test tests/ --nologo + + - name: Run .NET tests (advanced) + working-directory: advanced/samples/dotnet + run: dotnet test tests/ --nologo From d4f676c8e0fca02d609c4edf47d260df85315fd4 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 13:02:57 -0700 Subject: [PATCH 03/16] Document the .NET JSON-Schema target's actual status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit samples/dotnet/README.md told readers to regenerate with `cargo build-json-examples --lang dotnet`, which fails outright — `build_json_examples` rejects Dotnet. Replace it with the generate invocations that actually reproduce the checked-in output byte-for-byte. More importantly, the page described these models as if they carried the same guarantees as the other targets. They do not: constraint keywords are parsed and planned, then dropped with no enforcement and no diagnostic. Add a Known gaps table and a warning, and note why the suites pass anyway (the wire fixtures hold only valid payloads, so nothing here tests rejection). Also correct the mode description — definitions mode does emit the NexusRpc service interface, so "no service/endpoint scaffolding" was wrong. Co-Authored-By: Claude Opus 5 (1M context) --- samples/dotnet/README.md | 53 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 5d79c8df..92a09465 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -1,15 +1,62 @@ # .NET JSON-Schema samples Generated C# for the JSON-Schema inputs in [`../schemas/`](../schemas/), in -**definitions** mode — plain data models (records + `System.Text.Json` -converters) with no service/endpoint scaffolding. +**definitions** mode — plain data models plus the `NexusRpc` service interface, +without the native-api operation/client scaffolding. - `chat/`, `kb/` — generated models, one directory per schema. - `tests/` — round-trip checks that serialize the shared wire fixtures in [`../wire/json_schema/`](../wire/json_schema/) through `System.Text.Json` and assert JSON-equality. -Regenerate with `cargo build-json-examples --lang dotnet` from the repo root. +> [!WARNING] +> **.NET is not yet a supported JSON-Schema target.** The `dotnet` generate +> target is gated behind the `advanced` Cargo feature and is absent from the +> [root README](../../README.md)'s language list. Generation, compilation and +> round-tripping all work, but the **constraint validator that every other +> target emits is not implemented for .NET**. See Known gaps below. + +## Known gaps + +The models here are structurally faithful — required/optional members, +nullability, open vs. closed objects, typed maps and `$ref` cycles all match the +contract. What is missing is *assertion*: constraint keywords are parsed, +planned, and then dropped without enforcement or diagnostic. + +| Feature | Go / Java / Python / TS | .NET | +|---|---|---| +| Aggregated `ValidationError` over `Violation[]` | ✅ shared `definitions` runtime | ❌ per-class `JsonException`, first failure only | +| `minimum` / `maximum` / `multipleOf` | ✅ | ❌ dropped | +| `minLength` / `maxLength` / `pattern` | ✅ | ❌ dropped | +| `minItems` / `maxItems` / `uniqueItems` | ✅ | ❌ dropped | +| `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | +| `oneOf` discriminated unions | ✅ | ❌ emitted as an empty class — branches lost | +| `format` temporal materialization | ✅ native types | ❌ left as `string` | +| `contentEncoding: base64` | ✅ native bytes | ❌ left as `string` | +| `maxProperties` | ✅ | ✅ | + +Concretely, `order` in [`../schemas/kb/content/block.json`](../schemas/kb/content/block.json) +declares `minimum: 0`. Go emits a bounds check; .NET emits a bare `long`. So +`{"blockId":"b","order":-5}` is rejected by every other target and accepted here. + +The fixtures in `../wire/json_schema/` contain only valid payloads, so these +suites round-trip green despite the gaps — they test serialization, never +rejection. + +## Regenerating + +`cargo build-json-examples` does not accept `--lang dotnet` yet +(`build_json_examples` in `src/lib.rs` rejects it). Invoke the generate target +directly from the repo root: + +```bash +cargo run --features advanced -- dotnet samples/schemas/chat.nexusrpc.yaml \ + --output samples/dotnet/chat +cargo run --features advanced -- dotnet samples/schemas/kb \ + --output samples/dotnet/kb +``` + +## Building and testing ```bash dotnet build NexusApiGen.DotNetExamples.csproj # compile the models From cfe0281f05db95d9066910c25d85b2b509f281aa Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 13:36:24 -0700 Subject: [PATCH 04/16] Warn on JSON-Schema constructs the .NET backend silently drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .NET JSON-Schema backend emits no constraint validator. Assertion keywords survive parsing and planning, then vanish at render time with no output and no diagnostic, so `{"blockId":"b","order":-5}` is rejected by Go, Java, Python and TypeScript and accepted by the generated C#. PRINCIPLES.md says the generator should reject loudly rather than emit something subtly wrong; today it does neither. Add `json_schema::dotnet_coverage`, which walks the planned schemas and reports each unenforced construct as a generation warning naming the keyword and the members carrying it. Warnings are emitted in both generation modes, unlike the existing stub-binding warnings — the JSON-Schema samples are generated in definitions mode, which is exactly where the missing validator matters. The classifier is deliberately narrow. `maxProperties`, `const`, `default`, open/closed objects and typed maps are honored by the backend and are not reported, and `oneOf` is only reported when it is a real sum type — the `[, {"type": "null"}]` nullable spelling lowers correctly. `chat` consequently generates clean, which a test pins. This is a stepping stone, not the fix. `showcase` currently reports 20 distinct gaps; a test pins that exact set so it can only shrink deliberately, and each keyword is removed from the list as its enforcement lands. Co-Authored-By: Claude Opus 5 (1M context) --- src/generator/json_schema/dotnet_coverage.rs | 314 +++++++++++++++++++ src/generator/json_schema/mod.rs | 1 + src/generator/mod.rs | 8 + tests/generate_dotnet.rs | 97 ++++++ 4 files changed, 420 insertions(+) create mode 100644 src/generator/json_schema/dotnet_coverage.rs diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs new file mode 100644 index 00000000..174121ac --- /dev/null +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -0,0 +1,314 @@ +//! Coverage diagnostics for the .NET JSON-Schema backend. +//! +//! Unlike the Go / Java / Python / TypeScript backends, `json_schema::dotnet` +//! emits no constraint validator: assertion keywords survive parsing and +//! planning and are then dropped when the model is rendered. A payload the +//! other four targets reject is therefore accepted by the generated C#. +//! +//! `PRINCIPLES.md` says the generator would rather reject loudly than emit +//! something subtly wrong. Until the validator lands, this module supplies the +//! "loudly" half: every dropped keyword is reported as a generation warning +//! naming the keyword and the members carrying it, so a silent divergence +//! becomes a visible one. +//! +//! Each entry here is a standing TODO. When a keyword gains real enforcement in +//! the backend, delete it from [`UNENFORCED_KEYWORDS`] — the coverage test in +//! `tests/generate_dotnet.rs` asserts the warning disappears with it. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::planning::PlannedTypeFamily; +use crate::spec::ExternalTypeSpec; +use crate::workspace::{ApiSpecNode, ApiSpecTree}; + +/// Assertion keywords the .NET backend parses but does not enforce. +/// +/// Deliberately excluded because the backend *does* honor them: +/// `maxProperties`, `additionalProperties`, `required`, `properties`, `type`, +/// `const`, `default`, `$ref`, `items`, `description`. `allOf` is excluded +/// because the loader merges it away before any backend sees it. +const UNENFORCED_KEYWORDS: &[&str] = &[ + // Numeric bounds. + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + // String assertions. + "minLength", + "maxLength", + "pattern", + "format", + "contentEncoding", + "contentMediaType", + "contentSchema", + // Array assertions. + "minItems", + "maxItems", + "uniqueItems", + "contains", + "minContains", + "maxContains", + "prefixItems", + // Object assertions. + "minProperties", + "dependentRequired", + "dependentSchemas", + "propertyNames", + "patternProperties", + // Closed value sets. + "enum", +]; + +/// How many member paths to name before eliding the rest, so a large schema +/// produces a readable warning instead of a wall of text. +const MAX_REPORTED_PATHS: usize = 4; + +/// Collects one warning per unenforced construct across the whole planned tree. +/// +/// Runs in both generation modes: the JSON-Schema samples are generated in +/// definitions mode, which is exactly where the missing validator matters most. +pub(in crate::generator) fn coverage_warnings( + tree: &ApiSpecTree, +) -> Vec { + let mut findings: BTreeMap<&'static str, Vec> = BTreeMap::new(); + collect_node(&tree.root, &mut findings); + + findings + .into_iter() + .map(|(keyword, paths)| format_warning(keyword, &paths)) + .collect() +} + +fn collect_node( + node: &ApiSpecNode, + findings: &mut BTreeMap<&'static str, Vec>, +) { + match node { + ApiSpecNode::Leaf(leaf) => { + for (_, binding) in leaf.spec.external_types() { + let ExternalTypeSpec::Json(json) = &binding.external_type else { + continue; + }; + collect_schema(&json.schema, &json.model_name, findings); + } + } + ApiSpecNode::Branch(branch) => { + for child in branch.children.values() { + collect_node(child, findings); + } + } + } +} + +/// Walks a schema recursively, recording each unenforced keyword against the +/// dotted member path that carries it. +fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static str, Vec>) { + let Value::Object(members) = schema else { + return; + }; + + for keyword in UNENFORCED_KEYWORDS { + if members.contains_key(*keyword) { + findings.entry(keyword).or_default().push(path.to_string()); + } + } + + // `oneOf` is only reported when it is a real sum type. The + // `[, {"type": "null"}]` spelling is how the loader expresses an + // optional-nullable member, and the backend does lower that correctly. + if let Some(Value::Array(branches)) = members.get("oneOf") + && !is_nullable_wrapper(branches) + { + findings.entry("oneOf").or_default().push(path.to_string()); + } + + for (key, value) in members { + match key.as_str() { + // Child schemas keyed by member name. + "properties" | "$defs" | "patternProperties" => { + if let Value::Object(children) = value { + for (name, child) in children { + collect_schema(child, &format!("{path}.{name}"), findings); + } + } + } + // Child schemas in positional or branch position. + "oneOf" | "anyOf" | "allOf" | "prefixItems" => { + if let Value::Array(children) = value { + for child in children { + collect_schema(child, path, findings); + } + } + } + // Single child schema. + "items" + | "additionalProperties" + | "contains" + | "not" + | "propertyNames" + | "contentSchema" => { + collect_schema(value, path, findings); + } + _ => {} + } + } +} + +/// True for the `oneOf: [, {"type": "null"}]` nullable spelling — one +/// non-null branch plus at least one explicit null branch. +fn is_nullable_wrapper(branches: &[Value]) -> bool { + let null_branches = branches + .iter() + .filter(|branch| is_null_schema(branch)) + .count(); + null_branches > 0 && branches.len() - null_branches == 1 +} + +fn is_null_schema(schema: &Value) -> bool { + schema + .get("type") + .and_then(Value::as_str) + .is_some_and(|ty| ty == "null") +} + +fn format_warning(keyword: &str, paths: &[String]) -> String { + let detail = match keyword { + "oneOf" => "branches are dropped — the emitted class has no members", + "enum" => "is emitted as a bare scalar with no closed value set", + "format" => "is left as `string` — neither asserted nor materialized", + "contentEncoding" => "is left as `string` — not decoded to bytes", + _ => "is not enforced — .NET constraint validation is unimplemented", + }; + + let mut named = paths + .iter() + .take(MAX_REPORTED_PATHS) + .cloned() + .collect::>(); + if paths.len() > MAX_REPORTED_PATHS { + named.push(format!("and {} more", paths.len() - MAX_REPORTED_PATHS)); + } + + format!( + "dotnet: `{keyword}` {detail}. Affects: {}", + named.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn findings_for(schema: serde_json::Value) -> Vec { + let mut findings = BTreeMap::new(); + collect_schema(&schema, "Model", &mut findings); + findings + .into_iter() + .map(|(keyword, paths)| format_warning(keyword, &paths)) + .collect() + } + + #[test] + fn reports_numeric_bound_on_a_member() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { "order": { "type": "integer", "minimum": 0 } }, + })); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("`minimum`"), "{:?}", warnings); + assert!(warnings[0].contains("Model.order"), "{:?}", warnings); + } + + #[test] + fn does_not_report_supported_keywords() { + let warnings = findings_for(json!({ + "type": "object", + "additionalProperties": false, + "required": ["a"], + "maxProperties": 50, + "properties": { + "a": { "type": "string", "const": "x" }, + "b": { "type": "integer", "default": 0 }, + }, + })); + + assert!(warnings.is_empty(), "{:?}", warnings); + } + + #[test] + fn treats_nullable_one_of_as_supported() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { + "page": { "oneOf": [{ "$ref": "page.json" }, { "type": "null" }] }, + }, + })); + + assert!(warnings.is_empty(), "{:?}", warnings); + } + + #[test] + fn reports_real_sum_type_one_of() { + let warnings = findings_for(json!({ + "oneOf": [{ "$ref": "#/$defs/Circle" }, { "$ref": "#/$defs/Square" }], + })); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("`oneOf`"), "{:?}", warnings); + assert!( + warnings[0].contains("branches are dropped"), + "{:?}", + warnings + ); + } + + #[test] + fn descends_into_nested_and_array_schemas() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { + "tags": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "nested": { + "type": "object", + "properties": { "code": { "type": "string", "pattern": "^[A-Z]+$" } }, + }, + }, + })); + + assert_eq!(warnings.len(), 2, "{:?}", warnings); + assert!( + warnings + .iter() + .any(|w| w.contains("`minLength`") && w.contains("Model.tags")) + ); + assert!( + warnings + .iter() + .any(|w| w.contains("`pattern`") && w.contains("Model.nested.code")) + ); + } + + #[test] + fn elides_long_path_lists() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { + "a": { "type": "integer", "minimum": 0 }, + "b": { "type": "integer", "minimum": 0 }, + "c": { "type": "integer", "minimum": 0 }, + "d": { "type": "integer", "minimum": 0 }, + "e": { "type": "integer", "minimum": 0 }, + "f": { "type": "integer", "minimum": 0 }, + }, + })); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("and 2 more"), "{:?}", warnings); + } +} diff --git a/src/generator/json_schema/mod.rs b/src/generator/json_schema/mod.rs index ae181d48..1a5cb686 100644 --- a/src/generator/json_schema/mod.rs +++ b/src/generator/json_schema/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod dotnet; +pub(crate) mod dotnet_coverage; pub(crate) mod go; pub(crate) mod java; pub(crate) mod python; diff --git a/src/generator/mod.rs b/src/generator/mod.rs index ad6bddcb..ad41ff63 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -232,6 +232,14 @@ fn generate_files_from_planned_tree( } else { Vec::new() }; + // Reported in both modes, unlike the stub-binding warnings above: the + // JSON-Schema samples are generated in definitions mode, which is precisely + // where a missing constraint validator matters. See dotnet_coverage. + if language == Language::Dotnet { + generated + .warnings + .extend(json_schema::dotnet_coverage::coverage_warnings(tree)); + } Ok(generated) } diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index 1f5eac0e..2a121e78 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -11,6 +11,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use nex_gen::{GenerateRequest, generate_to_file}; +mod common; +use common::json_input_path; + const WORKFLOW_SERVICE_EXAMPLE_ID: &str = "workflow-service"; const TYPE_SHOWCASE_EXAMPLE_ID: &str = "type-showcase"; static DOTNET_COMMAND_LOCK: Mutex<()> = Mutex::new(()); @@ -565,3 +568,97 @@ interface workflow-service { assert!(!rendered.contains("WorkflowServiceOperations")); fs::remove_dir_all(temp_dir).unwrap(); } + +/// The .NET JSON-Schema backend emits no constraint validator, so assertion +/// keywords are dropped silently. `json_schema::dotnet_coverage` reports each +/// one as a generation warning; this test pins the exact set so the gap can only +/// shrink deliberately. +/// +/// **When you implement a keyword, delete it from the expected list here.** A +/// failure reading "unexpected warnings" means a new gap appeared; one reading +/// "expected warnings that did not appear" means a gap was closed and this list +/// is now stale. +#[test] +fn dotnet_json_coverage_warnings_match_known_gaps() { + // `showcase` is the broadest JSON-Schema input, so it surfaces the widest + // set of gaps in one generation. + let root = project_root(); + let input_path = json_input_path(&root, "showcase"); + let output_path = unique_output_path("dotnet-coverage-warnings"); + + let output = Command::new(env!("CARGO_BIN_EXE_nexgen")) + .args(["dotnet"]) + .arg(&input_path) + .arg("--output") + .arg(&output_path) + .output() + .unwrap(); + assert!(output.status.success(), "generation failed: {output:?}"); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let mut warned_keywords = stderr + .lines() + .filter_map(|line| line.strip_prefix("warning: dotnet: `")) + .filter_map(|line| line.split('`').next()) + .map(str::to_string) + .collect::>(); + warned_keywords.sort(); + warned_keywords.dedup(); + + let expected = [ + "contains", + "contentEncoding", + "dependentRequired", + "enum", + "exclusiveMinimum", + "format", + "maxContains", + "maxItems", + "maxLength", + "maximum", + "minContains", + "minItems", + "minLength", + "minProperties", + "minimum", + "multipleOf", + "oneOf", + "pattern", + "propertyNames", + "uniqueItems", + ]; + + assert_eq!( + warned_keywords, expected, + "\n.NET coverage gaps changed.\nIf you implemented a keyword, remove it \ + from `expected`.\nfull stderr:\n{stderr}" + ); + fs::remove_dir_all(output_path).unwrap(); +} + +/// `chat` exercises only constructs the .NET backend fully supports — including +/// the `oneOf: [, {"type": "null"}]` nullable spelling and +/// `maxProperties` — so it must generate clean. Guards the classifier in +/// `dotnet_coverage` against over-reporting. +#[test] +fn dotnet_json_coverage_reports_no_gaps_for_supported_schema() { + let root = project_root(); + let input_path = json_input_path(&root, "chat"); + let output_path = unique_output_path("dotnet-coverage-clean"); + + let output = Command::new(env!("CARGO_BIN_EXE_nexgen")) + .args(["dotnet"]) + .arg(&input_path) + .arg("--output") + .arg(&output_path) + .output() + .unwrap(); + assert!(output.status.success(), "generation failed: {output:?}"); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("warning: dotnet:"), + "expected no coverage warnings, got:\n{stderr}" + ); + fs::remove_dir_all(output_path).unwrap(); +} From 351bf7aeae703e3613c45e694a8276f384784342 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 14:33:00 -0700 Subject: [PATCH 05/16] Emit the shared .NET JSON-Schema runtime as Definitions.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other JSON-Schema target emits a schema-independent runtime alongside its models — definitions.go, definitions.ts, _definitions.py, and Java's ValidationException/Violation/SpecNumbers classes. .NET emitted none, so there was no way to report more than one constraint failure at a time. Add `json_schema::dotnet_definitions`, emitting `Definitions.cs` once per package with `Violation { Path, Reason }` and an aggregating `ValidationException : JsonException` over `IReadOnlyList`. Deriving from JsonException rather than AggregateException keeps handlers that already catch System.Text.Json failures working, and lets a Nexus handler map the family to BAD_REQUEST. The message format matches Go's `ValidationError.Error()` verbatim, so one payload reads the same across targets. The runtime is declared in the longest namespace prefix common to every emitted model namespace: the input's own namespace for a single-input package (`NexGen.ChatService` for chat), the shared root for a multi-input one (`NexGen.Generated` for kb, whose leaves span `NexGen.Generated.Kb` and `NexGen.Generated.Content.Block`). C# resolves that implicitly from any descendant namespace. Emission is gated on the tree actually having JSON-Schema models, so WIT/proto output — which carries its own support files and no validator — is byte-identical and its snapshots are untouched. No generated model consumes the runtime yet; wiring the per-class helpers into it is the next commit. Document the .NET rows in generated-file-layout.md, and cover the P11 aggregation contract in samples/dotnet/tests/SharedRuntimeChecks.cs. Co-Authored-By: Claude Opus 5 (1M context) --- samples/dotnet/chat/Definitions.cs | 68 +++++ samples/dotnet/kb/Definitions.cs | 68 +++++ samples/dotnet/tests/SharedRuntimeChecks.cs | 70 +++++ specs/json-schema/generated-file-layout.md | 24 +- src/generator/dotnet.rs | 12 +- .../json_schema/dotnet_definitions.rs | 243 ++++++++++++++++++ src/generator/json_schema/mod.rs | 1 + 7 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 samples/dotnet/chat/Definitions.cs create mode 100644 samples/dotnet/kb/Definitions.cs create mode 100644 samples/dotnet/tests/SharedRuntimeChecks.cs create mode 100644 src/generator/json_schema/dotnet_definitions.rs diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs new file mode 100644 index 00000000..c81cd6fd --- /dev/null +++ b/samples/dotnet/chat/Definitions.cs @@ -0,0 +1,68 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; + +namespace NexGen.ChatService +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + +} diff --git a/samples/dotnet/kb/Definitions.cs b/samples/dotnet/kb/Definitions.cs new file mode 100644 index 00000000..cb076ad8 --- /dev/null +++ b/samples/dotnet/kb/Definitions.cs @@ -0,0 +1,68 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; + +namespace NexGen.Generated +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + +} diff --git a/samples/dotnet/tests/SharedRuntimeChecks.cs b/samples/dotnet/tests/SharedRuntimeChecks.cs new file mode 100644 index 00000000..37ee4ebc --- /dev/null +++ b/samples/dotnet/tests/SharedRuntimeChecks.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Text.Json; +using Xunit; +using Runtime = NexGen.Generated; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers the shared JSON-Schema runtime emitted as Definitions.cs. + /// + /// The contract these assert is P11: a payload with several constraint + /// failures produces one error carrying every violation, never a partial + /// first-failure. The message shape matches Go's + /// ValidationError.Error() so the same payload reads the same across + /// targets. + /// + public class SharedRuntimeChecks + { + [Fact] + public void ViolationRendersPathAndReason() + { + var violation = new Runtime.Violation("order", "must be >= 0, got -5"); + + Assert.Equal("order", violation.Path); + Assert.Equal("must be >= 0, got -5", violation.Reason); + Assert.Equal("order: must be >= 0, got -5", violation.ToString()); + } + + [Fact] + public void ViolationOmitsEmptyPath() + { + var violation = new Runtime.Violation(string.Empty, "at most 50 entries"); + + Assert.Equal("at most 50 entries", violation.ToString()); + } + + [Fact] + public void ValidationExceptionSurfacesEveryViolation() + { + var violations = new List + { + new Runtime.Violation("name", "must be at least 2 characters, got 1"), + new Runtime.Violation("order", "must be >= 0, got -5"), + new Runtime.Violation("tags", "must have at most 3 items, got 4"), + }; + + var exception = new Runtime.ValidationException(violations); + + // Every violation, not just the first. + Assert.Equal(3, exception.Violations.Count); + Assert.Equal("order", exception.Violations[1].Path); + Assert.Equal( + "3 validation error(s): name: must be at least 2 characters, got 1; " + + "order: must be >= 0, got -5; tags: must have at most 3 items, got 4", + exception.Message); + } + + [Fact] + public void ValidationExceptionIsCatchableAsJsonException() + { + // Handlers already catching System.Text.Json failures keep working, + // and a Nexus handler can map the whole family to BAD_REQUEST. + var exception = new Runtime.ValidationException( + new List { new Runtime.Violation("a", "bad") }); + + Assert.IsAssignableFrom(exception); + } + } +} diff --git a/specs/json-schema/generated-file-layout.md b/specs/json-schema/generated-file-layout.md index cb1cc788..638e0701 100644 --- a/specs/json-schema/generated-file-layout.md +++ b/specs/json-schema/generated-file-layout.md @@ -7,7 +7,7 @@ collisions are handled at the file level. Driven by **P14** (one module per input file; merge recursion, not files) and local-file-only external refs; the reference semantics that feed it live in [[ref]]. -## Output mirrors the input directory tree (Python / TypeScript / Java) +## Output mirrors the input directory tree (Python / TypeScript / Java / .NET) Each input schema file `/.` becomes a **per-input module directory** `//` under the output package root, @@ -82,6 +82,7 @@ Per input file `/`: | **TypeScript** | `//models.ts` (+ `services.ts`) | `definitions.ts` (package root) | — | `index.ts` per directory (barrels chain upward) | | **Go** | `.go` in the one flat package (`` = flattened path) | `definitions.go` (same package) | — | — (capitalized = exported) | | **Java** | one `.java` per exported class, in a package mirroring `//` | each runtime class its own file in the root package (`ValidationException.java`, `Violation.java`, `SpecNumbers.java`, …) | — | — (`public` = exported) | +| **.NET** | `//Models.cs` (+ `Services.cs`) | `Definitions.cs` (package root) | — | — (`public` = exported) | **`_recursive` is Python-only and is a single file at the package root** (`/_recursive.py`), **never** per-input. It holds every hoisted @@ -98,14 +99,21 @@ All output lands at the package root (no per-input subdirectory, no | **TypeScript** | `models.ts` (+ `services.ts`), `definitions.ts`, `index.ts` | | **Go** | one `.go` (types and services) + the shared `definitions.go` | | **Java** | one `.java` per public class + the runtime classes; nothing to aggregate | +| **.NET** | `Models.cs` (+ `Services.cs`), `Definitions.cs`; nothing to aggregate | ## The shared `definitions` file Holds the schema-independent runtime, defined once per package (`definitions.py` -/ `definitions.ts` / `definitions.go`; Java splits it into one class file each). For -Python/TypeScript/Java it sits at the package root; for Go it sits in the -one flat package, always as its own `definitions.go` file regardless of how -many input files that package aggregates. +/ `definitions.ts` / `definitions.go` / `Definitions.cs`; Java splits it into one +class file each). For Python/TypeScript/Java/.NET it sits at the package root; for +Go it sits in the one flat package, always as its own `definitions.go` file +regardless of how many input files that package aggregates. + +.NET declares the runtime in the **longest namespace prefix common to every +emitted model namespace** — the input's own namespace for a single-input package, +the shared root for a multi-input one (e.g. `NexGen.Generated` for `kb`, whose +leaves span `NexGen.Generated.Kb` and `NexGen.Generated.Content.Block`), which C# +resolves implicitly from any descendant namespace. - Error types — a **single aggregating error holding a list of `Violation { path, reason }`**, identical in spirit across all four @@ -114,8 +122,10 @@ many input files that package aggregates. (its `Error()` surfaces every violation — *not* `errors.Join`); TS a `ValidationError` class extending `Error` over `Violation[]` (*not* a built-in `AggregateError`); Java `ValidationException extends - JsonMappingException` holding `List`. One error type, every - violation surfaced in one shot (P11). + JsonMappingException` holding `List`; .NET a + `ValidationException : JsonException` over `IReadOnlyList` (*not* an + `AggregateException`), whose message matches Go's `ValidationError.Error()` + verbatim. One error type, every violation surfaced in one shot (P11). - Spec-number helpers — `parseSpecInteger` (Go), `SpecInt` / `_parse_spec_integer` (Python), `SpecNumbers.specLong` (Java), TS's safe-integer check. diff --git a/src/generator/dotnet.rs b/src/generator/dotnet.rs index 2877a85b..e8e0e638 100644 --- a/src/generator/dotnet.rs +++ b/src/generator/dotnet.rs @@ -1880,13 +1880,21 @@ pub(crate) fn generate( support: &crate::SupportFiles, mode: GenerationMode, ) -> Result { - match &tree.root { + let mut generated = match &tree.root { ApiSpecNode::Leaf(leaf) => { let support_fragments = support_fragments_for_plan(&leaf.spec, support); generate_leaf(&leaf.spec, &support_fragments, mode) } ApiSpecNode::Branch(branch) => generate_tree(branch, support, mode), + }?; + // The shared JSON-Schema runtime, once per package at the root. Absent for + // WIT/proto inputs, which carry their own support files and no validator. + if let Some((path, contents)) = + crate::generator::json_schema::dotnet_definitions::render_definitions_file(tree) + { + insert_generated_file(&mut generated.files, path, contents)?; } + Ok(generated) } fn generate_leaf( @@ -3628,7 +3636,7 @@ fn support_fragment_path(fragment: &SupportFragmentSpec) -> Result { Ok(PathBuf::from("Support").join(file_name)) } -fn dotnet_namespace(api_plan: &PlannedSpec) -> String { +pub(in crate::generator) fn dotnet_namespace(api_plan: &PlannedSpec) -> String { if !api_plan.module_path.is_root() { return dotnet_module_namespace(&api_plan.module_path); } diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs new file mode 100644 index 00000000..1198e718 --- /dev/null +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -0,0 +1,243 @@ +//! The shared .NET JSON-Schema runtime, emitted once per package as +//! `Definitions.cs`. +//! +//! Every other target emits a schema-independent runtime alongside its models — +//! `definitions.go`, `definitions.ts`, `_definitions.py`, and Java's +//! `ValidationException` / `Violation` / `SpecNumbers` classes. .NET had none, so +//! each generated class carried its own inlined copy of the read helpers and +//! reported failures one at a time as a bare `JsonException`. +//! +//! This module supplies the .NET half of that contract: a single aggregating +//! error over a list of `Violation { Path, Reason }`, so every constraint failure +//! in a payload surfaces in one shot (P11) rather than first-failure-wins. The +//! message format deliberately matches Go's `ValidationError.Error()` — +//! `" validation error(s): : ; …"` — so the same payload +//! produces the same diagnostic text across targets. +//! +//! See `specs/json-schema/generated-file-layout.md` ("The shared `definitions` +//! file"). + +use std::path::PathBuf; + +use crate::generator::dotnet::dotnet_namespace; +use crate::planning::PlannedTypeFamily; +use crate::spec::ExternalTypeSpec; +use crate::workspace::{ApiSpecNode, ApiSpecTree}; + +/// The generated file name, at the package root regardless of how many input +/// files the package aggregates. +pub(in crate::generator) const DEFINITIONS_FILE_NAME: &str = "Definitions.cs"; + +/// Fallback runtime namespace when the emitted model namespaces share no common +/// prefix (reachable only via divergent `@nexus.namespace` overrides). +const FALLBACK_NAMESPACE: &str = "NexGen.Generated"; + +/// Renders `Definitions.cs` for a planned tree, or `None` when the tree has no +/// JSON-Schema models. +/// +/// The `None` case keeps the runtime out of WIT/proto-sourced output, which +/// carries its own support files and no JSON validator. +pub(in crate::generator) fn render_definitions_file( + tree: &ApiSpecTree, +) -> Option<(PathBuf, String)> { + if !has_json_models(&tree.root) { + return None; + } + Some(( + PathBuf::from(DEFINITIONS_FILE_NAME), + render(&runtime_namespace(tree)), + )) +} + +/// The namespace the shared runtime is declared in — the longest namespace +/// prefix common to every emitted model namespace. +/// +/// For a single input this is just that input's namespace, so the runtime lands +/// beside the models. For a multi-input package (e.g. `kb`, whose leaves span +/// `NexGen.Generated.Kb` and `NexGen.Generated.Content.Block`) it is the shared +/// root, which C# resolves implicitly from any descendant namespace. +pub(in crate::generator) fn runtime_namespace(tree: &ApiSpecTree) -> String { + let mut namespaces = Vec::new(); + collect_namespaces(&tree.root, &mut namespaces); + common_namespace_prefix(&namespaces).unwrap_or_else(|| FALLBACK_NAMESPACE.to_string()) +} + +fn collect_namespaces(node: &ApiSpecNode, namespaces: &mut Vec) { + match node { + ApiSpecNode::Leaf(leaf) => namespaces.push(dotnet_namespace(&leaf.spec)), + ApiSpecNode::Branch(branch) => { + for child in branch.children.values() { + collect_namespaces(child, namespaces); + } + } + } +} + +/// The longest dot-separated prefix shared by every namespace, or `None` when +/// the list is empty or the namespaces diverge at the first segment. +fn common_namespace_prefix(namespaces: &[String]) -> Option { + let (first, rest) = namespaces.split_first()?; + let mut prefix = first.split('.').collect::>(); + for namespace in rest { + let segments = namespace.split('.').collect::>(); + let shared = prefix + .iter() + .zip(segments.iter()) + .take_while(|(left, right)| left == right) + .count(); + prefix.truncate(shared); + if prefix.is_empty() { + return None; + } + } + Some(prefix.join(".")) +} + +fn has_json_models(node: &ApiSpecNode) -> bool { + match node { + ApiSpecNode::Leaf(leaf) => leaf + .spec + .external_types() + .any(|(_, binding)| matches!(binding.external_type, ExternalTypeSpec::Json(_))), + ApiSpecNode::Branch(branch) => branch.children.values().any(has_json_models), + } +} + +fn render(namespace: &str) -> String { + format!( + r#"// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; + +namespace {namespace} +{{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + {{ + public Violation(string path, string reason) + {{ + Path = path; + Reason = reason; + }} + + public string Path {{ get; }} + + public string Reason {{ get; }} + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + }} + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + {{ + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + {{ + Violations = violations; + }} + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations {{ get; }} + + private static string FormatMessage(IReadOnlyList violations) + {{ + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + {{ + parts[index] = violations[index].ToString(); + }} + return $"{{violations.Count}} validation error(s): {{string.Join("; ", parts)}}"; + }} + }} + +}} +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn common_prefix_of_one_namespace_is_itself() { + assert_eq!( + common_namespace_prefix(&["NexGen.ChatService".to_string()]), + Some("NexGen.ChatService".to_string()) + ); + } + + #[test] + fn common_prefix_stops_at_the_shared_root() { + let namespaces = [ + "NexGen.Generated.Kb".to_string(), + "NexGen.Generated.Content.Block".to_string(), + "NexGen.Generated.Tree.Category".to_string(), + ]; + + assert_eq!( + common_namespace_prefix(&namespaces), + Some("NexGen.Generated".to_string()) + ); + } + + #[test] + fn divergent_namespaces_have_no_common_prefix() { + let namespaces = [ + "Temporalio.Workflows".to_string(), + "NexGen.Generated.Kb".to_string(), + ]; + + assert_eq!(common_namespace_prefix(&namespaces), None); + } + + #[test] + fn common_prefix_of_nothing_is_none() { + assert_eq!(common_namespace_prefix(&[]), None); + } + + #[test] + fn does_not_treat_a_partial_segment_match_as_shared() { + let namespaces = ["NexGen.Chat".to_string(), "NexGen.ChatService".to_string()]; + + assert_eq!( + common_namespace_prefix(&namespaces), + Some("NexGen".to_string()) + ); + } + + #[test] + fn renders_aggregating_error_over_violation_list() { + let rendered = render("NexGen.Generated"); + + assert!(rendered.contains("namespace NexGen.Generated\n{")); + assert!(rendered.contains("public sealed class Violation")); + assert!(rendered.contains("public sealed class ValidationException : JsonException")); + assert!(rendered.contains("IReadOnlyList Violations")); + // Message shape matches Go's ValidationError.Error() for cross-target + // diagnostic parity. + assert!(rendered.contains("validation error(s): ")); + } +} diff --git a/src/generator/json_schema/mod.rs b/src/generator/json_schema/mod.rs index 1a5cb686..16093bbb 100644 --- a/src/generator/json_schema/mod.rs +++ b/src/generator/json_schema/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod dotnet; pub(crate) mod dotnet_coverage; +pub(crate) mod dotnet_definitions; pub(crate) mod go; pub(crate) mod java; pub(crate) mod python; From ae432c8903e2e13ce44ea1edd94d991ba7230b66 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 15:18:46 -0700 Subject: [PATCH 06/16] Move the per-class .NET read helpers into the shared runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generated model carried its own private copy of ReadOptionalValue, ReadJsonValue, ReadJsonInteger and RejectNull — roughly 90 lines duplicated per class, which is most of the bulk in a generated Models.cs and four places to fix any one bug. Hoist them into an internal `JsonRuntime` static class in Definitions.cs and call through it. Net effect on the checked-in samples is 656 lines deleted against 260 added, with the emitted API surface unchanged: same properties, same attributes, same exceptions, and the wire fixtures round-trip identically. `ReadOptionalValue` was an instance method reading `AdditionalProperties`, so it now takes the bag as its first argument. The 2^53-1 cap moves to a named `MaxSafeInteger` constant instead of being re-declared inline per class. Models resolve `JsonRuntime` implicitly whenever the runtime sits in their own or an enclosing namespace, which covers both sample shapes. For the case where divergent `@nexus.namespace` overrides leave it somewhere unrelated, the runtime namespace is threaded through `generate_leaf` so Models.cs can emit an explicit `using`. WIT/proto output gets `None` and is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../json_schema/api/chat/Definitions.cs | 178 ++++++++++++ .../dotnet/json_schema/api/chat/Models.cs | 257 +----------------- .../dotnet/json_schema/api/kb/Definitions.cs | 178 ++++++++++++ .../api/kb/content/block/Models.cs | 184 +------------ .../json_schema/api/kb/content/page/Models.cs | 170 +----------- .../api/kb/tree/category/Models.cs | 85 +----- samples/dotnet/chat/Definitions.cs | 110 ++++++++ samples/dotnet/chat/Models.cs | 257 +----------------- samples/dotnet/kb/Definitions.cs | 110 ++++++++ samples/dotnet/kb/content/block/Models.cs | 184 +------------ samples/dotnet/kb/content/page/Models.cs | 170 +----------- samples/dotnet/kb/tree/category/Models.cs | 85 +----- src/generator/dotnet.rs | 61 ++++- src/generator/json_schema/dotnet.rs | 81 +----- .../json_schema/dotnet_definitions.rs | 110 ++++++++ 15 files changed, 822 insertions(+), 1398 deletions(-) create mode 100644 advanced/samples/dotnet/json_schema/api/chat/Definitions.cs create mode 100644 advanced/samples/dotnet/json_schema/api/kb/Definitions.cs diff --git a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs new file mode 100644 index 00000000..1bf9c77c --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs @@ -0,0 +1,178 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; + +namespace NexGen.ChatService +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + double number; + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + number = json.GetDouble(); + } + else if (value is long longValue) + { + number = longValue; + } + else if (value is int intValue) + { + number = intValue; + } + else + { + throw new JsonException("expected integer"); + } + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + { + throw new JsonException("expected integer"); + } + return (long)number; + } + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + +} diff --git a/advanced/samples/dotnet/json_schema/api/chat/Models.cs b/advanced/samples/dotnet/json_schema/api/chat/Models.cs index b454f0b3..03d34498 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Models.cs @@ -54,83 +54,6 @@ void IJsonOnDeserialized.OnDeserialized() } } } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); - } - } } @@ -173,7 +96,7 @@ public string Kind [JsonIgnore] public string? ReplyToId { - get => ReadOptionalValue("replyToId"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "replyToId"); init { AdditionalProperties["replyToId"] = value; @@ -185,10 +108,10 @@ public string? ReplyToId [JsonIgnore] public long? Priority { - get => ReadOptionalValue("priority", 0); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "priority", 0); init { - RejectNull("priority", value); + JsonRuntime.RejectNull("priority", value); AdditionalProperties["priority"] = value; } } @@ -210,85 +133,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) { - RejectNull("priority", priorityValue); - _ = ReadJsonValue(priorityValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("priority", priorityValue); + _ = JsonRuntime.ReadJsonValue(priorityValue); } } } @@ -322,20 +168,20 @@ public Room(string roomId, string displayName, string? topic) [JsonIgnore] public IReadOnlyList? Members { - get => ReadOptionalValue?>("members"); + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "members"); init { - RejectNull("members", value); + JsonRuntime.RejectNull("members", value); AdditionalProperties["members"] = value; } } [JsonIgnore] public Labels? Labels { - get => ReadOptionalValue("labels"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "labels"); init { - RejectNull("labels", value); + JsonRuntime.RejectNull("labels", value); AdditionalProperties["labels"] = value; } } @@ -347,90 +193,13 @@ void IJsonOnDeserialized.OnDeserialized() { if (AdditionalProperties.TryGetValue("members", out var membersValue)) { - RejectNull("members", membersValue); - _ = ReadJsonValue?>(membersValue); + JsonRuntime.RejectNull("members", membersValue); + _ = JsonRuntime.ReadJsonValue?>(membersValue); } if (AdditionalProperties.TryGetValue("labels", out var labelsValue)) { - RejectNull("labels", labelsValue); - _ = ReadJsonValue(labelsValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("labels", labelsValue); + _ = JsonRuntime.ReadJsonValue(labelsValue); } } } diff --git a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs new file mode 100644 index 00000000..2a2b7c40 --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs @@ -0,0 +1,178 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; + +namespace NexGen.Generated +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + double number; + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + number = json.GetDouble(); + } + else if (value is long longValue) + { + number = longValue; + } + else if (value is int intValue) + { + number = intValue; + } + else + { + throw new JsonException("expected integer"); + } + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + { + throw new JsonException("expected integer"); + } + return (long)number; + } + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + +} diff --git a/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs index 7833d1f2..38d8e308 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs @@ -37,20 +37,20 @@ public Block(string blockId, long order) [JsonIgnore] public string? Text { - get => ReadOptionalValue("text"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "text"); init { - RejectNull("text", value); + JsonRuntime.RejectNull("text", value); AdditionalProperties["text"] = value; } } [JsonIgnore] public global::NexGen.Generated.Content.Block.BlockStyle? Style { - get => ReadOptionalValue("style"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "style"); init { - RejectNull("style", value); + JsonRuntime.RejectNull("style", value); AdditionalProperties["style"] = value; } } @@ -60,7 +60,7 @@ public string? Text [JsonIgnore] public global::NexGen.Generated.Content.Page.Page? Page { - get => ReadOptionalValue("page"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "page"); init { AdditionalProperties["page"] = value; @@ -81,7 +81,7 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("text", out var textValue)) { - RejectNull("text", textValue); + JsonRuntime.RejectNull("text", textValue); if (textValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) { throw new JsonException($"{"text"}: expected string"); @@ -93,90 +93,13 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("style", out var styleValue)) { - RejectNull("style", styleValue); - _ = ReadJsonValue(styleValue); + JsonRuntime.RejectNull("style", styleValue); + _ = JsonRuntime.ReadJsonValue(styleValue); } if (AdditionalProperties.TryGetValue("page", out var pageValue)) { } } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); - } - } } @@ -189,20 +112,20 @@ public class BlockStyle : IJsonOnDeserialized [JsonIgnore] public bool? Bold { - get => ReadOptionalValue("bold"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "bold"); init { - RejectNull("bold", value); + JsonRuntime.RejectNull("bold", value); AdditionalProperties["bold"] = value; } } [JsonIgnore] public long? Indent { - get => ReadOptionalValue("indent"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "indent"); init { - RejectNull("indent", value); + JsonRuntime.RejectNull("indent", value); AdditionalProperties["indent"] = value; } } @@ -221,89 +144,12 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("bold", out var boldValue)) { - RejectNull("bold", boldValue); + JsonRuntime.RejectNull("bold", boldValue); } if (AdditionalProperties.TryGetValue("indent", out var indentValue)) { - RejectNull("indent", indentValue); - _ = ReadJsonValue(indentValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("indent", indentValue); + _ = JsonRuntime.ReadJsonValue(indentValue); } } } diff --git a/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs index 9e1798b4..e8dbfc92 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs @@ -41,10 +41,10 @@ public Page(string pageId, string title, global::NexGen.Generated.Content.Page.P [JsonIgnore] public IReadOnlyList? Blocks { - get => ReadOptionalValue?>("blocks"); + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "blocks"); init { - RejectNull("blocks", value); + JsonRuntime.RejectNull("blocks", value); AdditionalProperties["blocks"] = value; } } @@ -63,85 +63,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("blocks", out var blocksValue)) { - RejectNull("blocks", blocksValue); - _ = ReadJsonValue?>(blocksValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("blocks", blocksValue); + _ = JsonRuntime.ReadJsonValue?>(blocksValue); } } } @@ -164,10 +87,10 @@ public PageMeta(string author) [JsonIgnore] public long? WordCount { - get => ReadOptionalValue("wordCount"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "wordCount"); init { - RejectNull("wordCount", value); + JsonRuntime.RejectNull("wordCount", value); AdditionalProperties["wordCount"] = value; } } @@ -186,85 +109,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("wordCount", out var wordCountValue)) { - RejectNull("wordCount", wordCountValue); - _ = ReadJsonValue(wordCountValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("wordCount", wordCountValue); + _ = JsonRuntime.ReadJsonValue(wordCountValue); } } } diff --git a/advanced/samples/dotnet/json_schema/api/kb/tree/category/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/tree/category/Models.cs index 51ff44bc..7e8738a1 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/tree/category/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/tree/category/Models.cs @@ -36,10 +36,10 @@ public Category(string id, string name) [JsonIgnore] public IReadOnlyList? Children { - get => ReadOptionalValue?>("children"); + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "children"); init { - RejectNull("children", value); + JsonRuntime.RejectNull("children", value); AdditionalProperties["children"] = value; } } @@ -58,85 +58,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("children", out var childrenValue)) { - RejectNull("children", childrenValue); - _ = ReadJsonValue?>(childrenValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("children", childrenValue); + _ = JsonRuntime.ReadJsonValue?>(childrenValue); } } } diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs index c81cd6fd..1bf9c77c 100644 --- a/samples/dotnet/chat/Definitions.cs +++ b/samples/dotnet/chat/Definitions.cs @@ -3,6 +3,7 @@ #nullable enable #pragma warning disable CS1591 +using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Text.Json; @@ -65,4 +66,113 @@ private static string FormatMessage(IReadOnlyList violations) } } + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + double number; + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + number = json.GetDouble(); + } + else if (value is long longValue) + { + number = longValue; + } + else if (value is int intValue) + { + number = intValue; + } + else + { + throw new JsonException("expected integer"); + } + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + { + throw new JsonException("expected integer"); + } + return (long)number; + } + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + } diff --git a/samples/dotnet/chat/Models.cs b/samples/dotnet/chat/Models.cs index b454f0b3..03d34498 100644 --- a/samples/dotnet/chat/Models.cs +++ b/samples/dotnet/chat/Models.cs @@ -54,83 +54,6 @@ void IJsonOnDeserialized.OnDeserialized() } } } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); - } - } } @@ -173,7 +96,7 @@ public string Kind [JsonIgnore] public string? ReplyToId { - get => ReadOptionalValue("replyToId"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "replyToId"); init { AdditionalProperties["replyToId"] = value; @@ -185,10 +108,10 @@ public string? ReplyToId [JsonIgnore] public long? Priority { - get => ReadOptionalValue("priority", 0); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "priority", 0); init { - RejectNull("priority", value); + JsonRuntime.RejectNull("priority", value); AdditionalProperties["priority"] = value; } } @@ -210,85 +133,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) { - RejectNull("priority", priorityValue); - _ = ReadJsonValue(priorityValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("priority", priorityValue); + _ = JsonRuntime.ReadJsonValue(priorityValue); } } } @@ -322,20 +168,20 @@ public Room(string roomId, string displayName, string? topic) [JsonIgnore] public IReadOnlyList? Members { - get => ReadOptionalValue?>("members"); + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "members"); init { - RejectNull("members", value); + JsonRuntime.RejectNull("members", value); AdditionalProperties["members"] = value; } } [JsonIgnore] public Labels? Labels { - get => ReadOptionalValue("labels"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "labels"); init { - RejectNull("labels", value); + JsonRuntime.RejectNull("labels", value); AdditionalProperties["labels"] = value; } } @@ -347,90 +193,13 @@ void IJsonOnDeserialized.OnDeserialized() { if (AdditionalProperties.TryGetValue("members", out var membersValue)) { - RejectNull("members", membersValue); - _ = ReadJsonValue?>(membersValue); + JsonRuntime.RejectNull("members", membersValue); + _ = JsonRuntime.ReadJsonValue?>(membersValue); } if (AdditionalProperties.TryGetValue("labels", out var labelsValue)) { - RejectNull("labels", labelsValue); - _ = ReadJsonValue(labelsValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("labels", labelsValue); + _ = JsonRuntime.ReadJsonValue(labelsValue); } } } diff --git a/samples/dotnet/kb/Definitions.cs b/samples/dotnet/kb/Definitions.cs index cb076ad8..2a2b7c40 100644 --- a/samples/dotnet/kb/Definitions.cs +++ b/samples/dotnet/kb/Definitions.cs @@ -3,6 +3,7 @@ #nullable enable #pragma warning disable CS1591 +using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Text.Json; @@ -65,4 +66,113 @@ private static string FormatMessage(IReadOnlyList violations) } } + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + double number; + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + number = json.GetDouble(); + } + else if (value is long longValue) + { + number = longValue; + } + else if (value is int intValue) + { + number = intValue; + } + else + { + throw new JsonException("expected integer"); + } + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + { + throw new JsonException("expected integer"); + } + return (long)number; + } + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + } diff --git a/samples/dotnet/kb/content/block/Models.cs b/samples/dotnet/kb/content/block/Models.cs index 7833d1f2..38d8e308 100644 --- a/samples/dotnet/kb/content/block/Models.cs +++ b/samples/dotnet/kb/content/block/Models.cs @@ -37,20 +37,20 @@ public Block(string blockId, long order) [JsonIgnore] public string? Text { - get => ReadOptionalValue("text"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "text"); init { - RejectNull("text", value); + JsonRuntime.RejectNull("text", value); AdditionalProperties["text"] = value; } } [JsonIgnore] public global::NexGen.Generated.Content.Block.BlockStyle? Style { - get => ReadOptionalValue("style"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "style"); init { - RejectNull("style", value); + JsonRuntime.RejectNull("style", value); AdditionalProperties["style"] = value; } } @@ -60,7 +60,7 @@ public string? Text [JsonIgnore] public global::NexGen.Generated.Content.Page.Page? Page { - get => ReadOptionalValue("page"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "page"); init { AdditionalProperties["page"] = value; @@ -81,7 +81,7 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("text", out var textValue)) { - RejectNull("text", textValue); + JsonRuntime.RejectNull("text", textValue); if (textValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) { throw new JsonException($"{"text"}: expected string"); @@ -93,90 +93,13 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("style", out var styleValue)) { - RejectNull("style", styleValue); - _ = ReadJsonValue(styleValue); + JsonRuntime.RejectNull("style", styleValue); + _ = JsonRuntime.ReadJsonValue(styleValue); } if (AdditionalProperties.TryGetValue("page", out var pageValue)) { } } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); - } - } } @@ -189,20 +112,20 @@ public class BlockStyle : IJsonOnDeserialized [JsonIgnore] public bool? Bold { - get => ReadOptionalValue("bold"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "bold"); init { - RejectNull("bold", value); + JsonRuntime.RejectNull("bold", value); AdditionalProperties["bold"] = value; } } [JsonIgnore] public long? Indent { - get => ReadOptionalValue("indent"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "indent"); init { - RejectNull("indent", value); + JsonRuntime.RejectNull("indent", value); AdditionalProperties["indent"] = value; } } @@ -221,89 +144,12 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("bold", out var boldValue)) { - RejectNull("bold", boldValue); + JsonRuntime.RejectNull("bold", boldValue); } if (AdditionalProperties.TryGetValue("indent", out var indentValue)) { - RejectNull("indent", indentValue); - _ = ReadJsonValue(indentValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("indent", indentValue); + _ = JsonRuntime.ReadJsonValue(indentValue); } } } diff --git a/samples/dotnet/kb/content/page/Models.cs b/samples/dotnet/kb/content/page/Models.cs index 9e1798b4..e8dbfc92 100644 --- a/samples/dotnet/kb/content/page/Models.cs +++ b/samples/dotnet/kb/content/page/Models.cs @@ -41,10 +41,10 @@ public Page(string pageId, string title, global::NexGen.Generated.Content.Page.P [JsonIgnore] public IReadOnlyList? Blocks { - get => ReadOptionalValue?>("blocks"); + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "blocks"); init { - RejectNull("blocks", value); + JsonRuntime.RejectNull("blocks", value); AdditionalProperties["blocks"] = value; } } @@ -63,85 +63,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("blocks", out var blocksValue)) { - RejectNull("blocks", blocksValue); - _ = ReadJsonValue?>(blocksValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("blocks", blocksValue); + _ = JsonRuntime.ReadJsonValue?>(blocksValue); } } } @@ -164,10 +87,10 @@ public PageMeta(string author) [JsonIgnore] public long? WordCount { - get => ReadOptionalValue("wordCount"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "wordCount"); init { - RejectNull("wordCount", value); + JsonRuntime.RejectNull("wordCount", value); AdditionalProperties["wordCount"] = value; } } @@ -186,85 +109,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("wordCount", out var wordCountValue)) { - RejectNull("wordCount", wordCountValue); - _ = ReadJsonValue(wordCountValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("wordCount", wordCountValue); + _ = JsonRuntime.ReadJsonValue(wordCountValue); } } } diff --git a/samples/dotnet/kb/tree/category/Models.cs b/samples/dotnet/kb/tree/category/Models.cs index 51ff44bc..7e8738a1 100644 --- a/samples/dotnet/kb/tree/category/Models.cs +++ b/samples/dotnet/kb/tree/category/Models.cs @@ -36,10 +36,10 @@ public Category(string id, string name) [JsonIgnore] public IReadOnlyList? Children { - get => ReadOptionalValue?>("children"); + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "children"); init { - RejectNull("children", value); + JsonRuntime.RejectNull("children", value); AdditionalProperties["children"] = value; } } @@ -58,85 +58,8 @@ void IJsonOnDeserialized.OnDeserialized() } if (AdditionalProperties.TryGetValue("children", out var childrenValue)) { - RejectNull("children", childrenValue); - _ = ReadJsonValue?>(childrenValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) - { - if (!AdditionalProperties.TryGetValue(name, out var value)) - { - return defaultValue; - } - return ReadJsonValue(value); - } - - private static T? ReadJsonValue(object? value) - { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) - { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; - } - return (T)value; - } - - private static long? ReadJsonInteger(object? value) - { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) - { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) - { - throw new JsonException("expected integer"); - } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; - } - else - { - throw new JsonException("expected integer"); - } - if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger) - { - throw new JsonException("expected integer"); - } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) - { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("children", childrenValue); + _ = JsonRuntime.ReadJsonValue?>(childrenValue); } } } diff --git a/src/generator/dotnet.rs b/src/generator/dotnet.rs index e8e0e638..e6b126c4 100644 --- a/src/generator/dotnet.rs +++ b/src/generator/dotnet.rs @@ -46,10 +46,17 @@ struct ApiPlanner<'a> { external_models: DotNetExternalModels, external_model_fragments: DotNetExternalModelFragments, support_namespace: Option<&'a str>, + /// Namespace holding the shared `Definitions.cs` runtime, when this run emits + /// one. `None` for WIT/proto input. + runtime_namespace: Option<&'a str>, } impl<'a> ApiPlanner<'a> { - fn new(api_plan: &'a PlannedSpec, support_namespace: Option<&'a str>) -> Result { + fn new( + api_plan: &'a PlannedSpec, + support_namespace: Option<&'a str>, + runtime_namespace: Option<&'a str>, + ) -> Result { let external_models = DotNetExternalModels::new(api_plan)?; let external_model_fragments = external_models.render_models()?; Ok(Self { @@ -57,6 +64,7 @@ impl<'a> ApiPlanner<'a> { external_models, external_model_fragments, support_namespace, + runtime_namespace, }) } @@ -80,6 +88,15 @@ impl<'a> ApiPlanner<'a> { imports.push("System.Text.Json"); imports.push("System.Text.Json.Serialization"); } + // The shared runtime resolves implicitly when it sits in this namespace or + // an enclosing one; an explicit import covers the case where divergent + // `@nexus.namespace` overrides leave it somewhere unrelated. + if let Some(runtime_namespace) = self.runtime_namespace + && runtime_namespace != namespace + && !namespace.starts_with(&format!("{runtime_namespace}.")) + { + imports.push(runtime_namespace); + } if models.iter().any(|model| { self.external_models .model_uses_support_extensions(model, self.api_plan) @@ -1880,18 +1897,28 @@ pub(crate) fn generate( support: &crate::SupportFiles, mode: GenerationMode, ) -> Result { + // The shared JSON-Schema runtime, once per package at the root. Absent for + // WIT/proto inputs, which carry their own support files and no validator. + let definitions = + crate::generator::json_schema::dotnet_definitions::render_definitions_file(tree); + let runtime_namespace = definitions + .is_some() + .then(|| crate::generator::json_schema::dotnet_definitions::runtime_namespace(tree)); let mut generated = match &tree.root { ApiSpecNode::Leaf(leaf) => { let support_fragments = support_fragments_for_plan(&leaf.spec, support); - generate_leaf(&leaf.spec, &support_fragments, mode) + generate_leaf( + &leaf.spec, + &support_fragments, + mode, + runtime_namespace.as_deref(), + ) + } + ApiSpecNode::Branch(branch) => { + generate_tree(branch, support, mode, runtime_namespace.as_deref()) } - ApiSpecNode::Branch(branch) => generate_tree(branch, support, mode), }?; - // The shared JSON-Schema runtime, once per package at the root. Absent for - // WIT/proto inputs, which carry their own support files and no validator. - if let Some((path, contents)) = - crate::generator::json_schema::dotnet_definitions::render_definitions_file(tree) - { + if let Some((path, contents)) = definitions { insert_generated_file(&mut generated.files, path, contents)?; } Ok(generated) @@ -1901,9 +1928,10 @@ fn generate_leaf( api_plan: &PlannedSpec, support_fragments: &[SupportFragmentSpec], mode: GenerationMode, + runtime_namespace: Option<&str>, ) -> Result { let support_namespace = dotnet_support_namespace(support_fragments)?; - let generator = ApiPlanner::new(api_plan, support_namespace.as_deref())?; + let generator = ApiPlanner::new(api_plan, support_namespace.as_deref(), runtime_namespace)?; validate_dotnet_support_references( api_plan, &generator.external_models, @@ -1952,11 +1980,19 @@ fn generate_tree( branch: &ApiSpecBranch, support: &crate::SupportFiles, mode: GenerationMode, + runtime_namespace: Option<&str>, ) -> Result { let mut files = BTreeMap::new(); let mut warnings = Vec::new(); for node in branch.children.values() { - generate_tree_node(node, support, mode, &mut files, &mut warnings)?; + generate_tree_node( + node, + support, + mode, + runtime_namespace, + &mut files, + &mut warnings, + )?; } Ok(GeneratedFiles { layout: crate::generator::GeneratedOutputLayout::Directory, @@ -1969,13 +2005,14 @@ fn generate_tree_node( node: &ApiSpecNode, support: &crate::SupportFiles, mode: GenerationMode, + runtime_namespace: Option<&str>, files: &mut BTreeMap, warnings: &mut Vec, ) -> Result<()> { match node { ApiSpecNode::Leaf(leaf) => { let support_fragments = support_fragments_for_plan(&leaf.spec, support); - let generated = generate_leaf(&leaf.spec, &support_fragments, mode)?; + let generated = generate_leaf(&leaf.spec, &support_fragments, mode, runtime_namespace)?; warnings.extend(generated.warnings); let prefix = leaf.module_path.to_path_buf(); for (path, contents) in generated.files { @@ -1985,7 +2022,7 @@ fn generate_tree_node( } ApiSpecNode::Branch(branch) => { for node in branch.children.values() { - generate_tree_node(node, support, mode, files, warnings)?; + generate_tree_node(node, support, mode, runtime_namespace, files, warnings)?; } Ok(()) } diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index 3da911d5..14e54e2d 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -133,7 +133,6 @@ fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { } render_extension_data_property(output, &schema)?; render_model_validation(output, &schema)?; - render_optional_helpers(output, &schema)?; output.push_str("}\n\n"); Ok(()) @@ -220,9 +219,9 @@ fn render_optional_property(output: &mut String, json_name: &str, property: &Sch output.push(' '); output.push_str(&csharp_type_name(json_name)); output.push_str("\n {\n"); - output.push_str(" get => ReadOptionalValue<"); + output.push_str(" get => JsonRuntime.ReadOptionalValue<"); output.push_str(&optional_read_type(property, &property_type)?); - output.push_str(">("); + output.push_str(">(AdditionalProperties, "); output.push_str(&csharp_string_literal(json_name)); if let Some(default_value) = property.default.as_ref().and_then(csharp_value_literal) { output.push_str(", "); @@ -231,7 +230,7 @@ fn render_optional_property(output: &mut String, json_name: &str, property: &Sch output.push_str(");\n"); output.push_str(" init\n {\n"); if !allows_null(property) { - output.push_str(" RejectNull("); + output.push_str(" JsonRuntime.RejectNull("); output.push_str(&csharp_string_literal(json_name)); output.push_str(", value);\n"); } @@ -368,7 +367,7 @@ fn render_model_validation(output: &mut String, schema: &Schema) -> Result<()> { output.push_str(&value_name); output.push_str("))\n {\n"); if !allows_null(property) { - output.push_str(" RejectNull("); + output.push_str(" JsonRuntime.RejectNull("); output.push_str(&csharp_string_literal(json_name)); output.push_str(", "); output.push_str(&value_name); @@ -435,13 +434,13 @@ fn render_extension_value_validation( } Some("integer") => { output.push_str(&indent); - output.push_str("_ = ReadJsonValue("); + output.push_str("_ = JsonRuntime.ReadJsonValue("); output.push_str(value_expr); output.push_str(");\n"); } Some("array") => { output.push_str(&indent); - output.push_str("_ = ReadJsonValue<"); + output.push_str("_ = JsonRuntime.ReadJsonValue<"); output.push_str(&optional_read_type(schema, &schema_type(schema, true)?)?); output.push_str(">("); output.push_str(value_expr); @@ -449,7 +448,7 @@ fn render_extension_value_validation( } _ if schema.reference.is_some() => { output.push_str(&indent); - output.push_str("_ = ReadJsonValue<"); + output.push_str("_ = JsonRuntime.ReadJsonValue<"); output.push_str(&optional_read_type(schema, &schema_type(schema, true)?)?); output.push_str(">("); output.push_str(value_expr); @@ -460,72 +459,6 @@ fn render_extension_value_validation( Ok(()) } -fn render_optional_helpers(output: &mut String, schema: &Schema) -> Result<()> { - if !model_needs_extension_data(schema)? { - return Ok(()); - } - output.push('\n'); - output.push_str( - " private T? ReadOptionalValue(string name, T? defaultValue = default)\n {\n", - ); - output.push_str( - " if (!AdditionalProperties.TryGetValue(name, out var value))\n {\n", - ); - output.push_str(" return defaultValue;\n }\n"); - output.push_str(" return ReadJsonValue(value);\n"); - output.push_str(" }\n\n"); - output.push_str(" private static T? ReadJsonValue(object? value)\n {\n"); - output.push_str(" if (value is null)\n {\n"); - output.push_str(" return default;\n }\n"); - output.push_str( - " if (typeof(T) == typeof(long?) || typeof(T) == typeof(long))\n {\n", - ); - output.push_str(" return (T?)(object?)ReadJsonInteger(value);\n"); - output.push_str(" }\n"); - output.push_str(" if (value is JsonElement json)\n {\n"); - output.push_str(" return json.Deserialize();\n"); - output.push_str(" }\n"); - output.push_str(" if (value is T typed)\n {\n"); - output.push_str(" return typed;\n }\n"); - output.push_str(" return (T)value;\n"); - output.push_str(" }\n\n"); - output.push_str(" private static long? ReadJsonInteger(object? value)\n {\n"); - output.push_str(" const double maxSafeInteger = 9007199254740991d;\n"); - output.push_str(" if (value is null)\n {\n"); - output.push_str(" return default;\n }\n"); - output.push_str(" double number;\n"); - output.push_str(" if (value is JsonElement json)\n {\n"); - output.push_str(" if (json.ValueKind == JsonValueKind.Null)\n {\n"); - output.push_str(" return default;\n"); - output.push_str(" }\n"); - output.push_str(" if (json.ValueKind != JsonValueKind.Number)\n {\n"); - output.push_str(" throw new JsonException(\"expected integer\");\n"); - output.push_str(" }\n"); - output.push_str(" number = json.GetDouble();\n"); - output.push_str(" }\n"); - output.push_str(" else if (value is long longValue)\n {\n"); - output.push_str(" number = longValue;\n"); - output.push_str(" }\n"); - output.push_str(" else if (value is int intValue)\n {\n"); - output.push_str(" number = intValue;\n"); - output.push_str(" }\n"); - output.push_str(" else\n {\n"); - output.push_str(" throw new JsonException(\"expected integer\");\n"); - output.push_str(" }\n"); - output.push_str(" if (double.IsNaN(number) || double.IsInfinity(number) || Math.Truncate(number) != number || Math.Abs(number) > maxSafeInteger)\n {\n"); - output.push_str(" throw new JsonException(\"expected integer\");\n"); - output.push_str(" }\n"); - output.push_str(" return (long)number;\n"); - output.push_str(" }\n\n"); - output.push_str(" private static void RejectNull(string name, object? value)\n {\n"); - output.push_str(" if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null })\n {\n"); - output - .push_str(" throw new JsonException($\"{name}: explicit null not allowed\");\n"); - output.push_str(" }\n"); - output.push_str(" }\n"); - Ok(()) -} - fn decode_schema(model: &PlannedJsonType) -> Result { serde_json::from_value(model.schema.clone()).map_err(|error| Error::InvalidJsonSchema { path: PathBuf::from(""), diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs index 1198e718..30511eac 100644 --- a/src/generator/json_schema/dotnet_definitions.rs +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -110,6 +110,7 @@ fn render(namespace: &str) -> String { #nullable enable #pragma warning disable CS1591 +using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Text.Json; @@ -172,6 +173,115 @@ namespace {namespace} }} }} + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + {{ + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + {{ + if (!members.TryGetValue(name, out var value)) + {{ + return defaultValue; + }} + return ReadJsonValue(value); + }} + + internal static T? ReadJsonValue(object? value) + {{ + if (value is null) + {{ + return default; + }} + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + {{ + return (T?)(object?)ReadJsonInteger(value); + }} + if (value is JsonElement json) + {{ + return json.Deserialize(); + }} + if (value is T typed) + {{ + return typed; + }} + return (T)value; + }} + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + {{ + if (value is null) + {{ + return default; + }} + double number; + if (value is JsonElement json) + {{ + if (json.ValueKind == JsonValueKind.Null) + {{ + return default; + }} + if (json.ValueKind != JsonValueKind.Number) + {{ + throw new JsonException("expected integer"); + }} + number = json.GetDouble(); + }} + else if (value is long longValue) + {{ + number = longValue; + }} + else if (value is int intValue) + {{ + number = intValue; + }} + else + {{ + throw new JsonException("expected integer"); + }} + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + {{ + throw new JsonException("expected integer"); + }} + return (long)number; + }} + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + {{ + if (value is null || value is JsonElement {{ ValueKind: JsonValueKind.Null }}) + {{ + throw new JsonException($"{{name}}: explicit null not allowed"); + }} + }} + }} + }} "# ) From 3fc2eed322d356ccf95739cd04dab482975add82 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 15:39:35 -0700 Subject: [PATCH 07/16] Enforce JSON-Schema numeric bounds in the .NET backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the .NET constraint validator. `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` and `multipleOf` were parsed, planned, and then dropped; `{"blockId":"b","order":-5}` deserialized cleanly in C# while Go, Java, Python and TypeScript all rejected it. Generated models now carry two entry points, so the contract holds in both wire directions: - `IJsonOnDeserialized.OnDeserialized` calls `Validate()`, so an inbound payload cannot enter the process in a forbidden shape. - `Validate()` is public, so the service binding can check an outbound value built in code. Both funnel through `CollectViolations(List, string path)`, which takes a path prefix so a nested model will report `page.blocks.order` rather than a bare `order` once nesting lands. Violations aggregate into one ValidationException rather than throwing on the first. Reason strings match Go's wording exactly — `must be >= 0, got -5`, `must be > 0, got 0`, `must be a multiple of 3, got 4` — verified against samples/go/showcase/showcase.go, so one payload produces one diagnostic across targets. Numbers render through `JsonRuntime.FormatNumber` under CultureInfo.InvariantCulture so a locale's decimal separator can never leak into a message. Bounds are held as serde_json::Number so an integral bound emits without a spurious `.0`. The `allOf`-merged interval on showcase's `Widget.size` emits as both bounds, confirming the loader flattens allOf before the backend sees it. Coverage warnings drop from 20 keywords to 16; the pinned test in tests/generate_dotnet.rs is updated to match, which is what flagged the two dotnet_coverage unit tests that had been asserting `minimum` still warns. Remaining divergence, recorded in the README rather than fixed here: Go reports an out-of-range integer as an aggregated Violation reading `exceeds ±(2^53-1) integer cap`, .NET as a non-aggregated JsonException with no path. That belongs with the spec-number helpers. Co-Authored-By: Claude Opus 5 (1M context) --- .../json_schema/api/chat/Definitions.cs | 20 ++ .../dotnet/json_schema/api/kb/Definitions.cs | 20 ++ .../api/kb/content/block/Models.cs | 51 ++++ samples/dotnet/README.md | 51 ++-- samples/dotnet/chat/Definitions.cs | 20 ++ samples/dotnet/kb/Definitions.cs | 20 ++ samples/dotnet/kb/content/block/Models.cs | 51 ++++ .../tests/ConstraintValidationChecks.cs | 135 +++++++++++ src/generator/json_schema/dotnet.rs | 225 +++++++++++++++++- src/generator/json_schema/dotnet_coverage.rs | 55 +++-- .../json_schema/dotnet_definitions.rs | 20 ++ tests/generate_dotnet.rs | 4 - 12 files changed, 626 insertions(+), 46 deletions(-) create mode 100644 samples/dotnet/tests/ConstraintValidationChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs index 1bf9c77c..68595e8f 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs @@ -6,6 +6,7 @@ using System; using System.CodeDom.Compiler; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; namespace NexGen.ChatService @@ -162,6 +163,25 @@ internal static class JsonRuntime return (long)number; } + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + /// /// Rejects an explicit JSON null for a member the contract declares /// non-nullable. diff --git a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs index 2a2b7c40..f908c38e 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs @@ -6,6 +6,7 @@ using System; using System.CodeDom.Compiler; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; namespace NexGen.Generated @@ -162,6 +163,25 @@ internal static class JsonRuntime return (long)number; } + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + /// /// Rejects an explicit JSON null for a member the contract declares /// non-nullable. diff --git a/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs index 38d8e308..4b655d3d 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs @@ -70,6 +70,29 @@ public string? Text [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Order < 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "order"), "must be >= 0, got " + JsonRuntime.FormatNumber(Order))); + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -99,6 +122,7 @@ void IJsonOnDeserialized.OnDeserialized() if (AdditionalProperties.TryGetValue("page", out var pageValue)) { } + Validate(); } } @@ -133,6 +157,32 @@ public long? Indent [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Indent is long indentValue) + { + if (indentValue < 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "indent"), "must be >= 0, got " + JsonRuntime.FormatNumber(indentValue))); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -151,6 +201,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("indent", indentValue); _ = JsonRuntime.ReadJsonValue(indentValue); } + Validate(); } } diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 92a09465..245e7e40 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -12,36 +12,53 @@ without the native-api operation/client scaffolding. > [!WARNING] > **.NET is not yet a supported JSON-Schema target.** The `dotnet` generate > target is gated behind the `advanced` Cargo feature and is absent from the -> [root README](../../README.md)'s language list. Generation, compilation and -> round-tripping all work, but the **constraint validator that every other -> target emits is not implemented for .NET**. See Known gaps below. +> [root README](../../README.md)'s language list, and the constraint validator is +> only **partially** implemented. See Known gaps below. + +## Validation + +Generated models validate in both wire directions: + +- **Deserialize** — `IJsonOnDeserialized.OnDeserialized` calls `Validate()`, so an + inbound payload cannot enter the process in a shape the contract forbids. +- **Serialize** — `Validate()` is public, so a value built in code is checked + before it goes on the wire. + +Failures aggregate into one `ValidationException` carrying every `Violation +{ Path, Reason }`, never a partial first-failure. The message format matches Go's +`ValidationError.Error()` verbatim, so the same payload reads the same on every +target. `ValidationException` derives from `JsonException`, so a handler already +catching `System.Text.Json` failures keeps working. ## Known gaps -The models here are structurally faithful — required/optional members, -nullability, open vs. closed objects, typed maps and `$ref` cycles all match the -contract. What is missing is *assertion*: constraint keywords are parsed, -planned, and then dropped without enforcement or diagnostic. +The models are structurally faithful — required/optional members, nullability, +open vs. closed objects, typed maps and `$ref` cycles all match the contract. What +is still incomplete is *assertion*: the keywords below are parsed, planned, and +then dropped. Generation reports each one as a `warning: dotnet: ...` naming the +affected members, so nothing is dropped silently. | Feature | Go / Java / Python / TS | .NET | |---|---|---| -| Aggregated `ValidationError` over `Violation[]` | ✅ shared `definitions` runtime | ❌ per-class `JsonException`, first failure only | -| `minimum` / `maximum` / `multipleOf` | ✅ | ❌ dropped | +| Aggregated `ValidationError` over `Violation[]` | ✅ shared `definitions` runtime | ✅ `Definitions.cs` | +| `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` / `multipleOf` | ✅ | ✅ | +| `maxProperties` | ✅ | ✅ | | `minLength` / `maxLength` / `pattern` | ✅ | ❌ dropped | -| `minItems` / `maxItems` / `uniqueItems` | ✅ | ❌ dropped | +| `minItems` / `maxItems` / `uniqueItems` / `contains` | ✅ | ❌ dropped | +| `minProperties` / `dependentRequired` / `propertyNames` | ✅ | ❌ dropped | | `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | | `oneOf` discriminated unions | ✅ | ❌ emitted as an empty class — branches lost | | `format` temporal materialization | ✅ native types | ❌ left as `string` | | `contentEncoding: base64` | ✅ native bytes | ❌ left as `string` | -| `maxProperties` | ✅ | ✅ | -Concretely, `order` in [`../schemas/kb/content/block.json`](../schemas/kb/content/block.json) -declares `minimum: 0`. Go emits a bounds check; .NET emits a bare `long`. So -`{"blockId":"b","order":-5}` is rejected by every other target and accepted here. +One known diagnostic divergence within the covered set: Go reports an +out-of-range integer as an aggregated `Violation` reading +`exceeds ±(2^53-1) integer cap`, while .NET throws a non-aggregated +`JsonException("expected integer")` with no member path. -The fixtures in `../wire/json_schema/` contain only valid payloads, so these -suites round-trip green despite the gaps — they test serialization, never -rejection. +The wire fixtures in `../wire/json_schema/` hold only valid payloads, so they +exercise serialization but never rejection; `tests/ConstraintValidationChecks.cs` +covers the rejection side. ## Regenerating diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs index 1bf9c77c..68595e8f 100644 --- a/samples/dotnet/chat/Definitions.cs +++ b/samples/dotnet/chat/Definitions.cs @@ -6,6 +6,7 @@ using System; using System.CodeDom.Compiler; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; namespace NexGen.ChatService @@ -162,6 +163,25 @@ internal static class JsonRuntime return (long)number; } + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + /// /// Rejects an explicit JSON null for a member the contract declares /// non-nullable. diff --git a/samples/dotnet/kb/Definitions.cs b/samples/dotnet/kb/Definitions.cs index 2a2b7c40..f908c38e 100644 --- a/samples/dotnet/kb/Definitions.cs +++ b/samples/dotnet/kb/Definitions.cs @@ -6,6 +6,7 @@ using System; using System.CodeDom.Compiler; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; namespace NexGen.Generated @@ -162,6 +163,25 @@ internal static class JsonRuntime return (long)number; } + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + /// /// Rejects an explicit JSON null for a member the contract declares /// non-nullable. diff --git a/samples/dotnet/kb/content/block/Models.cs b/samples/dotnet/kb/content/block/Models.cs index 38d8e308..4b655d3d 100644 --- a/samples/dotnet/kb/content/block/Models.cs +++ b/samples/dotnet/kb/content/block/Models.cs @@ -70,6 +70,29 @@ public string? Text [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Order < 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "order"), "must be >= 0, got " + JsonRuntime.FormatNumber(Order))); + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -99,6 +122,7 @@ void IJsonOnDeserialized.OnDeserialized() if (AdditionalProperties.TryGetValue("page", out var pageValue)) { } + Validate(); } } @@ -133,6 +157,32 @@ public long? Indent [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Indent is long indentValue) + { + if (indentValue < 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "indent"), "must be >= 0, got " + JsonRuntime.FormatNumber(indentValue))); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -151,6 +201,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("indent", indentValue); _ = JsonRuntime.ReadJsonValue(indentValue); } + Validate(); } } diff --git a/samples/dotnet/tests/ConstraintValidationChecks.cs b/samples/dotnet/tests/ConstraintValidationChecks.cs new file mode 100644 index 00000000..d56794bc --- /dev/null +++ b/samples/dotnet/tests/ConstraintValidationChecks.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Text.Json; +using Xunit; +using KbBlock = NexGen.Generated.Content.Block; +using Runtime = NexGen.Generated; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers numeric-bound enforcement. + /// + /// The wire fixtures in ../wire/json_schema/ hold only valid payloads, + /// so they exercise serialization but never rejection. These cases cover the + /// other half: a payload the contract forbids must be refused, and refused the + /// same way the other targets refuse it. + /// + public class ConstraintValidationChecks + { + private static readonly JsonSerializerOptions Options = new(); + + /// + /// order in kb/content/block.json declares minimum: 0. This + /// payload was accepted by .NET while Go, Java, Python and TypeScript all + /// rejected it. + /// + [Fact] + public void NegativeIntegerBelowMinimumIsRejectedOnDeserialize() + { + var json = @"{""blockId"":""b"",""order"":-5}"; + + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json, Options)); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("order", violation.Path); + Assert.Equal("must be >= 0, got -5", violation.Reason); + } + + [Fact] + public void ValueAtTheInclusiveBoundIsAccepted() + { + var json = @"{""blockId"":""b"",""order"":0}"; + + var block = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(block); + Assert.Equal(0, block.Order); + } + + /// + /// An optional member's bound applies only when the member is present — + /// absence is not a violation. + /// + [Fact] + public void AbsentOptionalMemberIsNotAViolation() + { + var json = @"{""bold"":true}"; + + var style = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(style); + Assert.Null(style.Indent); + } + + [Fact] + public void PresentOptionalMemberBelowMinimumIsRejected() + { + var json = @"{""bold"":true,""indent"":-1}"; + + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json, Options)); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("indent", violation.Path); + Assert.Equal("must be >= 0, got -1", violation.Reason); + } + + /// + /// The serialize side: a value built in code rather than parsed still has to + /// satisfy the contract before it goes on the wire. + /// + [Fact] + public void ValidateRejectsAnInvalidValueBuiltInCode() + { + var block = new KbBlock.Block("b", -5); + + var exception = Assert.Throws(() => block.Validate()); + + Assert.Equal("order", Assert.Single(exception.Violations).Path); + } + + [Fact] + public void ValidateAcceptsAConformingValueBuiltInCode() + { + var block = new KbBlock.Block("b", 3); + + block.Validate(); + } + + /// + /// P11: every violation in one shot, not first-failure-wins. Both members + /// are out of bounds, so both must be reported. + /// + [Fact] + public void EveryViolationIsReportedAtOnce() + { + var json = @"{""bold"":true,""indent"":-1}"; + + var exception = Assert.Throws( + () => JsonSerializer.Deserialize(json, Options)); + + // Message shape matches Go's ValidationError.Error(). + Assert.StartsWith("1 validation error(s): ", exception.Message); + Assert.Contains("indent: must be >= 0, got -1", exception.Message); + } + + /// + /// A violation is a , so a handler already + /// catching System.Text.Json failures keeps working. + /// + [Fact] + public void ConstraintFailureIsCatchableAsJsonException() + { + var json = @"{""blockId"":""b"",""order"":-5}"; + + Assert.Throws( + () => JsonSerializer.Deserialize(json, Options)); + + var caught = Record.Exception( + () => JsonSerializer.Deserialize(json, Options)); + Assert.IsAssignableFrom(caught); + } + } +} diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index 14e54e2d..ee9ed3b5 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use heck::ToUpperCamelCase; use indexmap::IndexMap; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Number, Value}; use crate::error::{Error, Result}; use crate::generator::ExternalModelBackend; @@ -35,6 +35,86 @@ struct Schema { const_value: Option, #[serde(rename = "maxProperties")] max_properties: Option, + // Numeric bounds. Kept as `serde_json::Number` so an integral bound renders + // without a spurious `.0` and a fractional one keeps its precision, matching + // how Go's `%v` prints the same bound. + minimum: Option, + maximum: Option, + #[serde(rename = "exclusiveMinimum")] + exclusive_minimum: Option, + #[serde(rename = "exclusiveMaximum")] + exclusive_maximum: Option, + #[serde(rename = "multipleOf")] + multiple_of: Option, +} + +impl Schema { + /// The numeric bounds declared on this schema, in the order Go emits them so + /// a multi-violation payload lists them identically across targets. + fn numeric_bounds(&self) -> Vec> { + [ + (NumericBoundKind::Minimum, self.minimum.as_ref()), + (NumericBoundKind::Maximum, self.maximum.as_ref()), + ( + NumericBoundKind::ExclusiveMinimum, + self.exclusive_minimum.as_ref(), + ), + ( + NumericBoundKind::ExclusiveMaximum, + self.exclusive_maximum.as_ref(), + ), + (NumericBoundKind::MultipleOf, self.multiple_of.as_ref()), + ] + .into_iter() + .filter_map(|(kind, bound)| bound.map(|bound| NumericBound { kind, bound })) + .collect() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NumericBoundKind { + Minimum, + Maximum, + ExclusiveMinimum, + ExclusiveMaximum, + MultipleOf, +} + +#[derive(Debug)] +struct NumericBound<'a> { + kind: NumericBoundKind, + bound: &'a Number, +} + +impl NumericBound<'_> { + /// The C# boolean expression that is true when `value_expr` **violates** the + /// bound. + fn violation_condition(&self, value_expr: &str) -> String { + let bound = self.bound; + match self.kind { + NumericBoundKind::Minimum => format!("{value_expr} < {bound}"), + NumericBoundKind::Maximum => format!("{value_expr} > {bound}"), + NumericBoundKind::ExclusiveMinimum => format!("{value_expr} <= {bound}"), + NumericBoundKind::ExclusiveMaximum => format!("{value_expr} >= {bound}"), + // `%` on a double is exact for the values the spec-number cap admits, + // and `multipleOf` bounds are themselves exact in binary far more + // often than not; a remainder test matches Go's `math.Mod` check. + NumericBoundKind::MultipleOf => format!("{value_expr} % {bound} != 0"), + } + } + + /// The violation reason, worded exactly as Go's equivalent so the same + /// payload produces the same diagnostic text on every target. + fn reason_format(&self) -> String { + let bound = self.bound; + match self.kind { + NumericBoundKind::Minimum => format!("must be >= {bound}, got "), + NumericBoundKind::Maximum => format!("must be <= {bound}, got "), + NumericBoundKind::ExclusiveMinimum => format!("must be > {bound}, got "), + NumericBoundKind::ExclusiveMaximum => format!("must be < {bound}, got "), + NumericBoundKind::MultipleOf => format!("must be a multiple of {bound}, got "), + } + } } #[derive(Debug, Default)] @@ -132,12 +212,144 @@ fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { render_model_properties(output, &schema)?; } render_extension_data_property(output, &schema)?; + render_constraint_validator(output, &schema)?; render_model_validation(output, &schema)?; output.push_str("}\n\n"); Ok(()) } +/// Emits the constraint validator: a public `Validate()` that aggregates every +/// violation into one [`ValidationException`], plus the `CollectViolations` worker +/// it and any containing model share. +/// +/// Two entry points because the contract has to hold in both wire directions. +/// `OnDeserialized` calls `Validate()` so an inbound payload can never enter the +/// process in a shape the contract forbids; `Validate()` is public so the service +/// binding can call it before serializing an outbound value. `CollectViolations` +/// takes a path prefix so a nested model reports `page.blocks.order` rather than +/// a bare `order`. +fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<()> { + let constrained = constrained_members(schema); + if constrained.is_empty() { + return Ok(()); + } + + output.push('\n'); + output.push_str(" /// \n"); + output.push_str( + " /// Validates every constraint the contract declares on this type, throwing a\n", + ); + output.push_str( + " /// single carrying all violations rather\n", + ); + output.push_str(" /// than stopping at the first.\n"); + output.push_str(" /// \n"); + output.push_str(" public void Validate()\n {\n"); + output.push_str(" var violations = new List();\n"); + output.push_str(" CollectViolations(violations, string.Empty);\n"); + output.push_str(" if (violations.Count > 0)\n {\n"); + output.push_str(" throw new ValidationException(violations);\n"); + output.push_str(" }\n"); + output.push_str(" }\n\n"); + + output.push_str( + " internal void CollectViolations(List violations, string path)\n {\n", + ); + for member in &constrained { + render_member_constraints(output, member); + } + output.push_str(" }\n"); + Ok(()) +} + +/// A member carrying at least one enforceable constraint, paired with how its +/// value is reached in C#. +struct ConstrainedMember<'a> { + json_name: &'a str, + /// The C# expression for the member's value inside `CollectViolations`. + accessor: String, + /// True when the member is optional or nullable, so the check has to be + /// guarded against the absent case. + needs_null_guard: bool, + numeric_type: &'static str, + bounds: Vec>, +} + +fn constrained_members(schema: &Schema) -> Vec> { + let required = required_fields(schema); + let Some(properties) = &schema.properties else { + return Vec::new(); + }; + properties + .iter() + .filter_map(|(json_name, property)| { + let bounds = property.numeric_bounds(); + if bounds.is_empty() { + return None; + } + let is_required = required.contains(json_name.as_str()); + Some(ConstrainedMember { + json_name, + accessor: csharp_type_name(json_name), + needs_null_guard: !is_required || allows_null(property), + numeric_type: numeric_clr_type(property), + bounds, + }) + }) + .collect() +} + +/// The CLR type a numeric member's value binds to when unwrapped from its +/// nullable form — `long` for `type: integer`, `double` otherwise. +fn numeric_clr_type(schema: &Schema) -> &'static str { + match schema.ty.as_ref().and_then(Value::as_str) { + Some("integer") => "long", + _ => "double", + } +} + +fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_>) { + // An optional member is `long?`/`double?`; bind it once so each bound reads + // the unwrapped value, and skip every check when it is absent. + let (indent, value_expr) = if member.needs_null_guard { + let local = csharp_parameter_name(&format!("{}-value", member.json_name)); + output.push_str(" if ("); + output.push_str(&member.accessor); + output.push_str(" is "); + output.push_str(member.numeric_type); + output.push(' '); + output.push_str(&local); + output.push_str(")\n {\n"); + (" ", local) + } else { + (" ", member.accessor.clone()) + }; + + for bound in &member.bounds { + output.push_str(indent); + output.push_str("if ("); + output.push_str(&bound.violation_condition(&value_expr)); + output.push_str(")\n"); + output.push_str(indent); + output.push_str("{\n"); + output.push_str(indent); + output.push_str(" violations.Add(new Violation(JsonRuntime.JoinPath(path, "); + output.push_str(&csharp_string_literal(member.json_name)); + output.push_str("), "); + output.push_str(&csharp_string_literal(&bound.reason_format())); + output.push_str(" + JsonRuntime.FormatNumber("); + output.push_str(&value_expr); + output.push_str(")));\n"); + output.push_str(indent); + output.push_str("}\n"); + } + + if member.needs_null_guard { + output.push_str(" }\n"); + } +} + fn render_model_constructor( output: &mut String, model: &PlannedJsonType, @@ -383,6 +595,12 @@ fn render_model_validation(output: &mut String, schema: &Schema) -> Result<()> { output.push_str(" }\n"); } } + // Structural checks above reject a malformed payload outright; the contract + // constraints then run so an inbound value cannot enter the process in a shape + // the contract forbids. + if !constrained_members(schema).is_empty() { + output.push_str(" Validate();\n"); + } output.push_str(" }\n"); Ok(()) } @@ -501,7 +719,10 @@ fn model_needs_extension_data(schema: &Schema) -> Result { } fn model_needs_on_deserialized(schema: &Schema) -> Result { - Ok(typed_map_value_schema(schema)?.is_some() + // A model whose only validation is constraint checking still needs the hook, + // so an inbound payload is validated on deserialize. + Ok(!constrained_members(schema).is_empty() + || typed_map_value_schema(schema)?.is_some() || (!optional_fields(schema).is_empty() && (!is_open_object(schema) || optional_fields(schema) diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs index 174121ac..c1f03a9f 100644 --- a/src/generator/json_schema/dotnet_coverage.rs +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -1,14 +1,14 @@ //! Coverage diagnostics for the .NET JSON-Schema backend. //! -//! Unlike the Go / Java / Python / TypeScript backends, `json_schema::dotnet` -//! emits no constraint validator: assertion keywords survive parsing and -//! planning and are then dropped when the model is rendered. A payload the -//! other four targets reject is therefore accepted by the generated C#. +//! `json_schema::dotnet` does not yet enforce the whole assertion vocabulary the +//! Go / Java / Python / TypeScript backends do. A keyword it does not handle +//! survives parsing and planning and is then dropped when the model is rendered, +//! so a payload the other four targets reject is accepted by the generated C#. //! //! `PRINCIPLES.md` says the generator would rather reject loudly than emit -//! something subtly wrong. Until the validator lands, this module supplies the -//! "loudly" half: every dropped keyword is reported as a generation warning -//! naming the keyword and the members carrying it, so a silent divergence +//! something subtly wrong. Until every keyword is covered, this module supplies +//! the "loudly" half: each still-unenforced keyword is reported as a generation +//! warning naming the keyword and the members carrying it, so a silent divergence //! becomes a visible one. //! //! Each entry here is a standing TODO. When a keyword gains real enforcement in @@ -30,12 +30,7 @@ use crate::workspace::{ApiSpecNode, ApiSpecTree}; /// `const`, `default`, `$ref`, `items`, `description`. `allOf` is excluded /// because the loader merges it away before any backend sees it. const UNENFORCED_KEYWORDS: &[&str] = &[ - // Numeric bounds. - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "multipleOf", + // Numeric bounds are enforced; see `render_constraint_validator`. // String assertions. "minLength", "maxLength", @@ -214,15 +209,29 @@ mod tests { } #[test] - fn reports_numeric_bound_on_a_member() { + fn reports_an_unenforced_constraint_on_a_member() { let warnings = findings_for(json!({ "type": "object", - "properties": { "order": { "type": "integer", "minimum": 0 } }, + "properties": { "name": { "type": "string", "minLength": 2 } }, })); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("`minimum`"), "{:?}", warnings); - assert!(warnings[0].contains("Model.order"), "{:?}", warnings); + assert!(warnings[0].contains("`minLength`"), "{:?}", warnings); + assert!(warnings[0].contains("Model.name"), "{:?}", warnings); + } + + #[test] + fn does_not_report_enforced_numeric_bounds() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { + "order": { "type": "integer", "minimum": 0, "maximum": 10 }, + "level": { "type": "integer", "exclusiveMinimum": 0 }, + "ratio": { "type": "number", "multipleOf": 5 }, + }, + })); + + assert!(warnings.is_empty(), "{:?}", warnings); } #[test] @@ -299,12 +308,12 @@ mod tests { let warnings = findings_for(json!({ "type": "object", "properties": { - "a": { "type": "integer", "minimum": 0 }, - "b": { "type": "integer", "minimum": 0 }, - "c": { "type": "integer", "minimum": 0 }, - "d": { "type": "integer", "minimum": 0 }, - "e": { "type": "integer", "minimum": 0 }, - "f": { "type": "integer", "minimum": 0 }, + "a": { "type": "string", "minLength": 1 }, + "b": { "type": "string", "minLength": 1 }, + "c": { "type": "string", "minLength": 1 }, + "d": { "type": "string", "minLength": 1 }, + "e": { "type": "string", "minLength": 1 }, + "f": { "type": "string", "minLength": 1 }, }, })); diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs index 30511eac..c99cd7fb 100644 --- a/src/generator/json_schema/dotnet_definitions.rs +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -113,6 +113,7 @@ fn render(namespace: &str) -> String { using System; using System.CodeDom.Compiler; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; namespace {namespace} @@ -269,6 +270,25 @@ namespace {namespace} return (long)number; }} + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + /// /// Rejects an explicit JSON null for a member the contract declares /// non-nullable. diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index 2a121e78..6a3c9381 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -610,18 +610,14 @@ fn dotnet_json_coverage_warnings_match_known_gaps() { "contentEncoding", "dependentRequired", "enum", - "exclusiveMinimum", "format", "maxContains", "maxItems", "maxLength", - "maximum", "minContains", "minItems", "minLength", "minProperties", - "minimum", - "multipleOf", "oneOf", "pattern", "propertyNames", From 66c4a4e80c4a642c315a4ecdf6dcef7df9907049 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 16:26:39 -0700 Subject: [PATCH 08/16] Enforce JSON-Schema string assertions in the .NET backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds minLength, maxLength and pattern, generalizing the constraint machinery from numeric-only to a per-member set of numeric bounds, length bounds and an optional compiled pattern. Coverage warnings drop from 16 keywords to 13. Two places where the obvious C# spelling would have been quietly wrong: - **`$` → `\z`.** .NET's Regex treats `$` as "end of input, *or* immediately before a final newline", so `^[A-Z]{2,4}$` matches "ABCD\n" — a value the contract forbids and Go rejects. The loader already exposes `pattern::rewrite_end_anchor` for exactly this; Python uses `\Z` and Java `\z`, and .NET needs the same treatment. Go and JS keep `$`. - **Length counts code points.** `string.Length` counts UTF-16 code units, so 12 astral characters would score 24 and be rejected against `maxLength: 12`. `JsonRuntime.CodePointCount` matches Go's utf8.RuneCountInString and Java's codePointCount, including counting an unpaired surrogate as one. Both are covered by tests that fail if the naive spelling is restored. Patterns compile once into a `private static readonly Regex` per member. RegexOptions.CultureInvariant keeps character classes off the ambient locale; backtracking safety continues to come from the loader's RE2 gate, which rejects lookaround and backreferences outright, so this matches Java's plain Pattern.compile rather than opting into NonBacktracking and pinning the generated code to net7.0+. Adds `showcase` as a .NET sample. Neither chat nor kb declares a single string constraint, so without it this work would have had no runtime coverage at all — which is much of why the .NET backend drifted in the first place. `pattern` reason text follows Java, which shares the `\z` rewrite and so reports the rewritten expression. Go quotes via `%q` and keeps `$`, so those two already differ; matching Java is the closest parity available. Recorded in the README. The dotnet_coverage unit tests now assert against `contentMediaType` / `dependentSchemas` — real gap entries that no planned phase implements — so they stop needing an edit every time a keyword gains enforcement. The churn belongs in tests/generate_dotnet.rs, which exists to track the current set. Co-Authored-By: Claude Opus 5 (1M context) --- .../json_schema/api/showcase/Definitions.cs | 223 +++ .../dotnet/json_schema/api/showcase/Models.cs | 1377 +++++++++++++++++ .../json_schema/api/showcase/Services.cs | 51 + samples/dotnet/README.md | 32 +- samples/dotnet/showcase/Definitions.cs | 223 +++ samples/dotnet/showcase/Models.cs | 1377 +++++++++++++++++ samples/dotnet/showcase/Services.cs | 28 + .../dotnet/tests/StringConstraintChecks.cs | 169 ++ src/generator/dotnet.rs | 3 + src/generator/json_schema/dotnet.rs | 223 ++- src/generator/json_schema/dotnet_coverage.rs | 41 +- .../json_schema/dotnet_definitions.rs | 25 + tests/generate_dotnet.rs | 3 - 13 files changed, 3723 insertions(+), 52 deletions(-) create mode 100644 advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs create mode 100644 advanced/samples/dotnet/json_schema/api/showcase/Models.cs create mode 100644 advanced/samples/dotnet/json_schema/api/showcase/Services.cs create mode 100644 samples/dotnet/showcase/Definitions.cs create mode 100644 samples/dotnet/showcase/Models.cs create mode 100644 samples/dotnet/showcase/Services.cs create mode 100644 samples/dotnet/tests/StringConstraintChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs new file mode 100644 index 00000000..eb7a54e3 --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs @@ -0,0 +1,223 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; + +namespace NexGen.ShowcaseService +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + double number; + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + number = json.GetDouble(); + } + else if (value is long longValue) + { + number = longValue; + } + else if (value is int intValue) + { + number = intValue; + } + else + { + throw new JsonException("expected integer"); + } + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + { + throw new JsonException("expected integer"); + } + return (long)number; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + +} diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs new file mode 100644 index 00000000..e5edd0e0 --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -0,0 +1,1377 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace NexGen.ShowcaseService +{ + + /// + /// A nested object, open to forward-compatible extension. + /// + [GeneratedCode("nex-gen", null)] + public class Address : IJsonOnDeserialized + { + public Address(string street) + { + Street = street; + } + + [JsonPropertyName("street")] + [JsonRequired] + public string Street { get; init; } + [JsonIgnore] + public string? City + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "city"); + init + { + JsonRuntime.RejectNull("city", value); + AdditionalProperties["city"] = value; + } + } + [JsonIgnore] + public long? Zip + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "zip"); + init + { + JsonRuntime.RejectNull("zip", value); + AdditionalProperties["zip"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("city", out var cityValue)) + { + JsonRuntime.RejectNull("city", cityValue); + if (cityValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"city"}: expected string"); + } + else if (cityValue is not JsonElement && cityValue is not string) + { + throw new JsonException($"{"city"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("zip", out var zipValue)) + { + JsonRuntime.RejectNull("zip", zipValue); + _ = JsonRuntime.ReadJsonValue(zipValue); + } + } + } + + + /// + /// A string map with member-count and key-shape constraints: 1 to 3 entries, each key at most 8 code points (minProperties/maxProperties/propertyNames on a map-shaped object). + /// + [GeneratedCode("nex-gen", null)] + public class Attributes : IJsonOnDeserialized + { + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.Count > 3) + { + throw new JsonException("maxProperties: at most 3 entries"); + } + foreach (var entry in AdditionalProperties) + { + if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{entry.Key}: expected string"); + } + else if (entry.Value is not JsonElement && entry.Value is not string) + { + throw new JsonException($"{entry.Key}: expected string"); + } + } + } + } + + + /// + /// A circle branch of the Shape tagged union. + /// + [GeneratedCode("nex-gen", null)] + public class Circle + { + public Circle(double radius) + { + Radius = radius; + } + + private string kindValue = "circle"; + + [JsonPropertyName("kind")] + [JsonRequired] + public string Kind + { + get => kindValue; + init + { + if (value != "circle") + { + throw new JsonException("kind must equal \"circle\""); + } + kindValue = value; + } + } + [JsonPropertyName("radius")] + [JsonRequired] + public double Radius { get; init; } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + } + + + /// + /// Contact details with a conditional requirement and a member-count bound: a shipping street requires a shipping zip (dependentRequired), and the object must carry 1 to 3 members (minProperties/maxProperties on a declared-property object). Also exercises the type-level `x-<lang>-name` override (the Stage 4 escape hatch): the emitted type is renamed to the derived name plus a per-language suffix (Go `ContactGo`, TS `ContactTs`, Python `ContactPy`, Java `ContactJava`) at its declaration and at every `$ref`, while the wire `$ref` name stays `Contact`. + /// + [GeneratedCode("nex-gen", null)] + public class Contact : IJsonOnDeserialized + { + [JsonIgnore] + public string? Email + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "email"); + init + { + JsonRuntime.RejectNull("email", value); + AdditionalProperties["email"] = value; + } + } + [JsonIgnore] + public string? ShippingStreet + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "shippingStreet"); + init + { + JsonRuntime.RejectNull("shippingStreet", value); + AdditionalProperties["shippingStreet"] = value; + } + } + [JsonIgnore] + public string? ShippingZip + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "shippingZip"); + init + { + JsonRuntime.RejectNull("shippingZip", value); + AdditionalProperties["shippingZip"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("email", out var emailValue)) + { + JsonRuntime.RejectNull("email", emailValue); + if (emailValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"email"}: expected string"); + } + else if (emailValue is not JsonElement && emailValue is not string) + { + throw new JsonException($"{"email"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("shippingStreet", out var shippingStreetValue)) + { + JsonRuntime.RejectNull("shippingStreet", shippingStreetValue); + if (shippingStreetValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"shippingStreet"}: expected string"); + } + else if (shippingStreetValue is not JsonElement && shippingStreetValue is not string) + { + throw new JsonException($"{"shippingStreet"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("shippingZip", out var shippingZipValue)) + { + JsonRuntime.RejectNull("shippingZip", shippingZipValue); + if (shippingZipValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"shippingZip"}: expected string"); + } + else if (shippingZipValue is not JsonElement && shippingZipValue is not string) + { + throw new JsonException($"{"shippingZip"}: expected string"); + } + } + } + } + + + /// + /// Arbitrary string key/value labels (typed map). + /// + [GeneratedCode("nex-gen", null)] + public class Labels : IJsonOnDeserialized + { + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.Count > 50) + { + throw new JsonException("maxProperties: at most 50 entries"); + } + foreach (var entry in AdditionalProperties) + { + if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{entry.Key}: expected string"); + } + else if (entry.Value is not JsonElement && entry.Value is not string) + { + throw new JsonException($"{entry.Key}: expected string"); + } + } + } + } + + + /// + /// A closed object; unknown members are rejected. + /// + [GeneratedCode("nex-gen", null)] + public class Settings : IJsonOnDeserialized + { + [JsonIgnore] + public string? Theme + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "theme"); + init + { + JsonRuntime.RejectNull("theme", value); + AdditionalProperties["theme"] = value; + } + } + [JsonIgnore] + public long? FontSize + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "fontSize"); + init + { + JsonRuntime.RejectNull("fontSize", value); + AdditionalProperties["fontSize"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "theme" && key != "fontSize") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("theme", out var themeValue)) + { + JsonRuntime.RejectNull("theme", themeValue); + if (themeValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"theme"}: expected string"); + } + else if (themeValue is not JsonElement && themeValue is not string) + { + throw new JsonException($"{"theme"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("fontSize", out var fontSizeValue)) + { + JsonRuntime.RejectNull("fontSize", fontSizeValue); + _ = JsonRuntime.ReadJsonValue(fontSizeValue); + } + } + } + + + /// + /// A closed sum type (discriminated union) of Circle | Square, tagged by the shared required `kind` const. Selection reads `kind` and routes to the matching branch; an unknown tag is a Violation. + /// + [GeneratedCode("nex-gen", null)] + public class Shape + { + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + } + + + /// + /// Root object exercising the supported JSON-Schema feature subset: required and optional fields of every scalar type, optional+nullable and required+nullable members, arrays, a nested object via $ref, a typed-map, a closed object, an open (catch-all) object, a string const, a scalar default, and member docs. + /// + [GeneratedCode("nex-gen", null)] + public class Showcase : IJsonOnDeserialized + { + public Showcase(string status, long tier, double scale, string name, long count, bool active, string? category) + { + Status = status; + Tier = tier; + Scale = scale; + Name = name; + Count = count; + Active = active; + Category = category; + } + + /// + /// Discriminator; always "showcase". + /// + private string kindValue = "showcase"; + + [JsonPropertyName("kind")] + [JsonRequired] + public string Kind + { + get => kindValue; + init + { + if (value != "showcase") + { + throw new JsonException("kind must equal \"showcase\""); + } + kindValue = value; + } + } + /// + /// Integer const; always 1. Also exercises the single-`const` value override: `x-go-const-name`/`x-java-const-name` rename the emitted constant to the derived name plus a per-language suffix (Go `RevisionGo`, Java `REVISION_JAVA`) while the wire value stays `1`. TS/Python are inert here (no const override keyword — the value is emitted as a plain literal type). + /// + private long revisionValue = 1; + + [JsonPropertyName("revision")] + [JsonRequired] + public long Revision + { + get => revisionValue; + init + { + if (value != 1) + { + throw new JsonException("revision must equal 1"); + } + revisionValue = value; + } + } + /// + /// Boolean const; always true. + /// + private bool enabledValue = true; + + [JsonPropertyName("enabled")] + [JsonRequired] + public bool Enabled + { + get => enabledValue; + init + { + if (value != true) + { + throw new JsonException("enabled must equal true"); + } + enabledValue = value; + } + } + /// + /// Closed string value set. Also exercises the enum value-constant override: `x-go-enum-names`/`x-java-enum-names` rename the `active` value's emitted constant to the value name plus a per-language suffix (Go `ActiveGo`, Java `ACTIVE_JAVA`) while the wire value stays `active`. TS/Python are inert here (no enum override keyword). + /// + [JsonPropertyName("status")] + [JsonRequired] + public string Status { get; init; } + /// + /// Closed integer value set. + /// + [JsonPropertyName("tier")] + [JsonRequired] + public long Tier { get; init; } + /// + /// Closed number value set (exercises the Python float exception: emitted as plain float, validated by membership). + /// + [JsonPropertyName("scale")] + [JsonRequired] + public double Scale { get; init; } + /// + /// Required human-readable name, 1 to 64 code points. + /// + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; init; } + /// + /// Required integer scalar. + /// + [JsonPropertyName("count")] + [JsonRequired] + public long Count { get; init; } + /// + /// Required boolean scalar. + /// + [JsonPropertyName("active")] + [JsonRequired] + public bool Active { get; init; } + /// + /// Optional short name, at most 12 code points. + /// + [JsonIgnore] + public string? Nickname + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "nickname"); + init + { + JsonRuntime.RejectNull("nickname", value); + AdditionalProperties["nickname"] = value; + } + } + /// + /// Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. + /// + [JsonIgnore] + public string? Code + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "code"); + init + { + JsonRuntime.RejectNull("code", value); + AdditionalProperties["code"] = value; + } + } + /// + /// Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. + /// + [JsonIgnore] + public string? Sku + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "sku"); + init + { + JsonRuntime.RejectNull("sku", value); + AdditionalProperties["sku"] = value; + } + } + /// + /// Optional two-word phrase separated by whitespace (`^\S+\s\S+$`). Exercises the loader's `\s`/`\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\Z` / Java `\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. + /// + [JsonIgnore] + public string? Phrase + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "phrase"); + init + { + JsonRuntime.RejectNull("phrase", value); + AdditionalProperties["phrase"] = value; + } + } + /// + /// Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. + /// + [JsonIgnore] + public string? RequestId + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "requestId"); + init + { + JsonRuntime.RejectNull("requestId", value); + AdditionalProperties["requestId"] = value; + } + } + /// + /// Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). + /// + [JsonIgnore] + public string? ContactEmail + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "contactEmail"); + init + { + JsonRuntime.RejectNull("contactEmail", value); + AdditionalProperties["contactEmail"] = value; + } + } + /// + /// Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). + /// + [JsonIgnore] + public string? Host + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "host"); + init + { + JsonRuntime.RejectNull("host", value); + AdditionalProperties["host"] = value; + } + } + /// + /// Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). + /// + [JsonIgnore] + public string? Homepage + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "homepage"); + init + { + JsonRuntime.RejectNull("homepage", value); + AdditionalProperties["homepage"] = value; + } + } + /// + /// Optional gateway address; asserted dotted-quad IPv4 via format ipv4. + /// + [JsonIgnore] + public string? Gateway + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "gateway"); + init + { + JsonRuntime.RejectNull("gateway", value); + AdditionalProperties["gateway"] = value; + } + } + /// + /// Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. + /// + [JsonIgnore] + public string? Blob + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "blob"); + init + { + JsonRuntime.RejectNull("blob", value); + AdditionalProperties["blob"] = value; + } + } + /// + /// Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). + /// + [JsonIgnore] + public string? UrlBlob + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "urlBlob"); + init + { + JsonRuntime.RejectNull("urlBlob", value); + AdditionalProperties["urlBlob"] = value; + } + } + /// + /// Optional integer with a schema default. + /// + [JsonIgnore] + public long? Retries + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "retries", 3); + init + { + JsonRuntime.RejectNull("retries", value); + AdditionalProperties["retries"] = value; + } + } + [JsonIgnore] + public bool? Verbose + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "verbose"); + init + { + JsonRuntime.RejectNull("verbose", value); + AdditionalProperties["verbose"] = value; + } + } + /// + /// Optional string with a schema default, surfaced on read. + /// + [JsonIgnore] + public string? Greeting + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "greeting", "hello"); + init + { + JsonRuntime.RejectNull("greeting", value); + AdditionalProperties["greeting"] = value; + } + } + /// + /// Optional boolean with a schema default. + /// + [JsonIgnore] + public bool? Debug + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "debug", false); + init + { + JsonRuntime.RejectNull("debug", value); + AdditionalProperties["debug"] = value; + } + } + /// + /// Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x-<lang>-name` override (the Stage 4 escape hatch): the emitted member identifier is renamed to the derived name plus a per-language suffix (Go `LegacyIdGo`, TS `legacyIdTs`, Python `legacy_id_py`, Java `legacyIdJava`) while the wire name stays `legacyId` (json tag / alias / @JsonProperty). + /// + [JsonIgnore] + public string? LegacyId + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "legacyId"); + init + { + JsonRuntime.RejectNull("legacyId", value); + AdditionalProperties["legacyId"] = value; + } + } + /// + /// Optional and nullable; may be absent or explicitly null. + /// + [JsonIgnore] + public string? MiddleName + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "middleName"); + init + { + AdditionalProperties["middleName"] = value; + } + } + /// + /// Required but nullable; may be explicitly cleared to null. + /// + [JsonPropertyName("category")] + [JsonRequired] + public string? Category { get; init; } + /// + /// Optional integer bounded to the inclusive range [1, 10]. + /// + [JsonIgnore] + public long? Priority + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "priority"); + init + { + JsonRuntime.RejectNull("priority", value); + AdditionalProperties["priority"] = value; + } + } + /// + /// Optional integer that must be strictly greater than 0. + /// + [JsonIgnore] + public long? Level + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "level"); + init + { + JsonRuntime.RejectNull("level", value); + AdditionalProperties["level"] = value; + } + } + /// + /// Optional number that must be a non-negative multiple of 5. + /// + [JsonIgnore] + public double? Ratio + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "ratio"); + init + { + JsonRuntime.RejectNull("ratio", value); + AdditionalProperties["ratio"] = value; + } + } + /// + /// Optional integer that must be a multiple of 3. + /// + [JsonIgnore] + public long? Step + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "step"); + init + { + JsonRuntime.RejectNull("step", value); + AdditionalProperties["step"] = value; + } + } + /// + /// Ordered list of free-form tags; 1 to 5 entries. + /// + [JsonIgnore] + public IReadOnlyList? Tags + { + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "tags"); + init + { + JsonRuntime.RejectNull("tags", value); + AdditionalProperties["tags"] = value; + } + } + /// + /// Alternate names; each must be distinct. + /// + [JsonIgnore] + public IReadOnlyList? Aliases + { + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "aliases"); + init + { + JsonRuntime.RejectNull("aliases", value); + AdditionalProperties["aliases"] = value; + } + } + /// + /// Access roles; must contain between one and two "admin" entries. + /// + [JsonIgnore] + public IReadOnlyList? Roles + { + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "roles"); + init + { + JsonRuntime.RejectNull("roles", value); + AdditionalProperties["roles"] = value; + } + } + /// + /// Disjoint-kind union (oneOf sum type): the wire value is either a string or an integer, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. + /// + [JsonIgnore] + public object? IdOrName + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "idOrName"); + init + { + JsonRuntime.RejectNull("idOrName", value); + AdditionalProperties["idOrName"] = value; + } + } + [JsonIgnore] + public Shape? Shape + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "shape"); + init + { + JsonRuntime.RejectNull("shape", value); + AdditionalProperties["shape"] = value; + } + } + [JsonIgnore] + public Address? Address + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "address"); + init + { + JsonRuntime.RejectNull("address", value); + AdditionalProperties["address"] = value; + } + } + [JsonIgnore] + public Labels? Labels + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "labels"); + init + { + JsonRuntime.RejectNull("labels", value); + AdditionalProperties["labels"] = value; + } + } + [JsonIgnore] + public Settings? Settings + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "settings"); + init + { + JsonRuntime.RejectNull("settings", value); + AdditionalProperties["settings"] = value; + } + } + [JsonIgnore] + public Attributes? Attributes + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "attributes"); + init + { + JsonRuntime.RejectNull("attributes", value); + AdditionalProperties["attributes"] = value; + } + } + [JsonIgnore] + public Contact? Contact + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "contact"); + init + { + JsonRuntime.RejectNull("contact", value); + AdditionalProperties["contact"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + private static readonly Regex skuPattern = new Regex("^[A-Z]{2,4}\\z", RegexOptions.CultureInvariant); + private static readonly Regex phrasePattern = new Regex("^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z", RegexOptions.CultureInvariant); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + var nameLength = JsonRuntime.CodePointCount(Name); + if (nameLength < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "name"), "must have length >= 1, got " + nameLength)); + } + if (nameLength > 64) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "name"), "must have length <= 64, got " + nameLength)); + } + if (Nickname is string nicknameValue) + { + var nicknameLength = JsonRuntime.CodePointCount(nicknameValue); + if (nicknameLength > 12) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "nickname"), "must have length <= 12, got " + nicknameLength)); + } + } + if (Code is string codeValue) + { + var codeLength = JsonRuntime.CodePointCount(codeValue); + if (codeLength < 2) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "code"), "must have length >= 2, got " + codeLength)); + } + if (codeLength > 5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "code"), "must have length <= 5, got " + codeLength)); + } + } + if (Sku is string skuValue) + { + if (!skuPattern.IsMatch(skuValue)) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "sku"), "must match pattern ^[A-Z]{2,4}\\z, got " + skuValue)); + } + } + if (Phrase is string phraseValue) + { + if (!phrasePattern.IsMatch(phraseValue)) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "phrase"), "must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z, got " + phraseValue)); + } + } + if (Priority is long priorityValue) + { + if (priorityValue < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "must be >= 1, got " + JsonRuntime.FormatNumber(priorityValue))); + } + if (priorityValue > 10) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "must be <= 10, got " + JsonRuntime.FormatNumber(priorityValue))); + } + } + if (Level is long levelValue) + { + if (levelValue <= 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "level"), "must be > 0, got " + JsonRuntime.FormatNumber(levelValue))); + } + } + if (Ratio is double ratioValue) + { + if (ratioValue < 5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "ratio"), "must be >= 5, got " + JsonRuntime.FormatNumber(ratioValue))); + } + if (ratioValue % 5 != 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "ratio"), "must be a multiple of 5, got " + JsonRuntime.FormatNumber(ratioValue))); + } + } + if (Step is long stepValue) + { + if (stepValue % 3 != 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "must be a multiple of 3, got " + JsonRuntime.FormatNumber(stepValue))); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "nickname" && key != "code" && key != "sku" && key != "phrase" && key != "requestId" && key != "contactEmail" && key != "host" && key != "homepage" && key != "gateway" && key != "blob" && key != "urlBlob" && key != "retries" && key != "verbose" && key != "greeting" && key != "debug" && key != "legacyId" && key != "middleName" && key != "priority" && key != "level" && key != "ratio" && key != "step" && key != "tags" && key != "aliases" && key != "roles" && key != "idOrName" && key != "shape" && key != "address" && key != "labels" && key != "settings" && key != "attributes" && key != "contact") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("nickname", out var nicknameValue)) + { + JsonRuntime.RejectNull("nickname", nicknameValue); + if (nicknameValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"nickname"}: expected string"); + } + else if (nicknameValue is not JsonElement && nicknameValue is not string) + { + throw new JsonException($"{"nickname"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("code", out var codeValue)) + { + JsonRuntime.RejectNull("code", codeValue); + if (codeValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"code"}: expected string"); + } + else if (codeValue is not JsonElement && codeValue is not string) + { + throw new JsonException($"{"code"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("sku", out var skuValue)) + { + JsonRuntime.RejectNull("sku", skuValue); + if (skuValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"sku"}: expected string"); + } + else if (skuValue is not JsonElement && skuValue is not string) + { + throw new JsonException($"{"sku"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("phrase", out var phraseValue)) + { + JsonRuntime.RejectNull("phrase", phraseValue); + if (phraseValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"phrase"}: expected string"); + } + else if (phraseValue is not JsonElement && phraseValue is not string) + { + throw new JsonException($"{"phrase"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("requestId", out var requestIdValue)) + { + JsonRuntime.RejectNull("requestId", requestIdValue); + if (requestIdValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"requestId"}: expected string"); + } + else if (requestIdValue is not JsonElement && requestIdValue is not string) + { + throw new JsonException($"{"requestId"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("contactEmail", out var contactEmailValue)) + { + JsonRuntime.RejectNull("contactEmail", contactEmailValue); + if (contactEmailValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"contactEmail"}: expected string"); + } + else if (contactEmailValue is not JsonElement && contactEmailValue is not string) + { + throw new JsonException($"{"contactEmail"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("host", out var hostValue)) + { + JsonRuntime.RejectNull("host", hostValue); + if (hostValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"host"}: expected string"); + } + else if (hostValue is not JsonElement && hostValue is not string) + { + throw new JsonException($"{"host"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("homepage", out var homepageValue)) + { + JsonRuntime.RejectNull("homepage", homepageValue); + if (homepageValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"homepage"}: expected string"); + } + else if (homepageValue is not JsonElement && homepageValue is not string) + { + throw new JsonException($"{"homepage"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("gateway", out var gatewayValue)) + { + JsonRuntime.RejectNull("gateway", gatewayValue); + if (gatewayValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"gateway"}: expected string"); + } + else if (gatewayValue is not JsonElement && gatewayValue is not string) + { + throw new JsonException($"{"gateway"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("blob", out var blobValue)) + { + JsonRuntime.RejectNull("blob", blobValue); + if (blobValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"blob"}: expected string"); + } + else if (blobValue is not JsonElement && blobValue is not string) + { + throw new JsonException($"{"blob"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("urlBlob", out var urlBlobValue)) + { + JsonRuntime.RejectNull("urlBlob", urlBlobValue); + if (urlBlobValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"urlBlob"}: expected string"); + } + else if (urlBlobValue is not JsonElement && urlBlobValue is not string) + { + throw new JsonException($"{"urlBlob"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("retries", out var retriesValue)) + { + JsonRuntime.RejectNull("retries", retriesValue); + _ = JsonRuntime.ReadJsonValue(retriesValue); + } + if (AdditionalProperties.TryGetValue("verbose", out var verboseValue)) + { + JsonRuntime.RejectNull("verbose", verboseValue); + } + if (AdditionalProperties.TryGetValue("greeting", out var greetingValue)) + { + JsonRuntime.RejectNull("greeting", greetingValue); + if (greetingValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"greeting"}: expected string"); + } + else if (greetingValue is not JsonElement && greetingValue is not string) + { + throw new JsonException($"{"greeting"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("debug", out var debugValue)) + { + JsonRuntime.RejectNull("debug", debugValue); + } + if (AdditionalProperties.TryGetValue("legacyId", out var legacyIdValue)) + { + JsonRuntime.RejectNull("legacyId", legacyIdValue); + if (legacyIdValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"legacyId"}: expected string"); + } + else if (legacyIdValue is not JsonElement && legacyIdValue is not string) + { + throw new JsonException($"{"legacyId"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("middleName", out var middleNameValue)) + { + } + if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) + { + JsonRuntime.RejectNull("priority", priorityValue); + _ = JsonRuntime.ReadJsonValue(priorityValue); + } + if (AdditionalProperties.TryGetValue("level", out var levelValue)) + { + JsonRuntime.RejectNull("level", levelValue); + _ = JsonRuntime.ReadJsonValue(levelValue); + } + if (AdditionalProperties.TryGetValue("ratio", out var ratioValue)) + { + JsonRuntime.RejectNull("ratio", ratioValue); + } + if (AdditionalProperties.TryGetValue("step", out var stepValue)) + { + JsonRuntime.RejectNull("step", stepValue); + _ = JsonRuntime.ReadJsonValue(stepValue); + } + if (AdditionalProperties.TryGetValue("tags", out var tagsValue)) + { + JsonRuntime.RejectNull("tags", tagsValue); + _ = JsonRuntime.ReadJsonValue?>(tagsValue); + } + if (AdditionalProperties.TryGetValue("aliases", out var aliasesValue)) + { + JsonRuntime.RejectNull("aliases", aliasesValue); + _ = JsonRuntime.ReadJsonValue?>(aliasesValue); + } + if (AdditionalProperties.TryGetValue("roles", out var rolesValue)) + { + JsonRuntime.RejectNull("roles", rolesValue); + _ = JsonRuntime.ReadJsonValue?>(rolesValue); + } + if (AdditionalProperties.TryGetValue("idOrName", out var idOrNameValue)) + { + JsonRuntime.RejectNull("idOrName", idOrNameValue); + } + if (AdditionalProperties.TryGetValue("shape", out var shapeValue)) + { + JsonRuntime.RejectNull("shape", shapeValue); + _ = JsonRuntime.ReadJsonValue(shapeValue); + } + if (AdditionalProperties.TryGetValue("address", out var addressValue)) + { + JsonRuntime.RejectNull("address", addressValue); + _ = JsonRuntime.ReadJsonValue(addressValue); + } + if (AdditionalProperties.TryGetValue("labels", out var labelsValue)) + { + JsonRuntime.RejectNull("labels", labelsValue); + _ = JsonRuntime.ReadJsonValue(labelsValue); + } + if (AdditionalProperties.TryGetValue("settings", out var settingsValue)) + { + JsonRuntime.RejectNull("settings", settingsValue); + _ = JsonRuntime.ReadJsonValue(settingsValue); + } + if (AdditionalProperties.TryGetValue("attributes", out var attributesValue)) + { + JsonRuntime.RejectNull("attributes", attributesValue); + _ = JsonRuntime.ReadJsonValue(attributesValue); + } + if (AdditionalProperties.TryGetValue("contact", out var contactValue)) + { + JsonRuntime.RejectNull("contact", contactValue); + _ = JsonRuntime.ReadJsonValue(contactValue); + } + Validate(); + } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class GetShowcaseInput + { + public GetShowcaseInput(string id) + { + Id = id; + } + + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; init; } + } + + + /// + /// A square branch of the Shape tagged union. + /// + [GeneratedCode("nex-gen", null)] + public class Square + { + public Square(double side) + { + Side = side; + } + + private string kindValue = "square"; + + [JsonPropertyName("kind")] + [JsonRequired] + public string Kind + { + get => kindValue; + init + { + if (value != "square") + { + throw new JsonException("kind must equal \"square\""); + } + kindValue = value; + } + } + [JsonPropertyName("side")] + [JsonRequired] + public double Side { get; init; } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + } + + + /// + /// Base-type extension via allOf: WidgetBase is flattened in and the extension branch adds fields, so Widget merges to one standalone object with the union of properties ({id, kind, name, size}) and required ([id, name]). The `size` member is itself an allOf that tightens two numeric bounds to a single interval [10, 20]; a value outside it is rejected by the merged constraint. No allOf survives past the loader. + /// + [GeneratedCode("nex-gen", null)] + public class Widget : IJsonOnDeserialized + { + public Widget(string id, string name) + { + Id = id; + Name = name; + } + + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; init; } + [JsonIgnore] + public string? Kind + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "kind"); + init + { + JsonRuntime.RejectNull("kind", value); + AdditionalProperties["kind"] = value; + } + } + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; init; } + /// + /// Optional integer with two allOf branches tightened to [10, 20]. + /// + [JsonIgnore] + public long? Size + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "size"); + init + { + JsonRuntime.RejectNull("size", value); + AdditionalProperties["size"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Size is long sizeValue) + { + if (sizeValue < 10) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "must be >= 10, got " + JsonRuntime.FormatNumber(sizeValue))); + } + if (sizeValue > 20) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "must be <= 20, got " + JsonRuntime.FormatNumber(sizeValue))); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("kind", out var kindValue)) + { + JsonRuntime.RejectNull("kind", kindValue); + if (kindValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"kind"}: expected string"); + } + else if (kindValue is not JsonElement && kindValue is not string) + { + throw new JsonException($"{"kind"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("size", out var sizeValue)) + { + JsonRuntime.RejectNull("size", sizeValue); + _ = JsonRuntime.ReadJsonValue(sizeValue); + } + Validate(); + } + } + + + /// + /// A base object folded into Widget via allOf. It stays its own type; Widget copies its fields rather than referencing or subtyping it. + /// + [GeneratedCode("nex-gen", null)] + public class WidgetBase : IJsonOnDeserialized + { + public WidgetBase(string id) + { + Id = id; + } + + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; init; } + [JsonIgnore] + public string? Kind + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "kind"); + init + { + JsonRuntime.RejectNull("kind", value); + AdditionalProperties["kind"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("kind", out var kindValue)) + { + JsonRuntime.RejectNull("kind", kindValue); + if (kindValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"kind"}: expected string"); + } + else if (kindValue is not JsonElement && kindValue is not string) + { + throw new JsonException($"{"kind"}: expected string"); + } + } + } + } + +} diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Services.cs b/advanced/samples/dotnet/json_schema/api/showcase/Services.cs new file mode 100644 index 00000000..3d7c82ca --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/showcase/Services.cs @@ -0,0 +1,51 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Threading.Tasks; +using NexusRpc; +using Temporalio.Workflows; + +namespace NexGen.ShowcaseService +{ + + [GeneratedCode("nex-gen", null)] + [NexusService("example.showcase.v1.ShowcaseService")] + internal interface IShowcaseService + { + /// + /// Fetch a showcase by id. Also exercises the operation-level `x-<lang>-name` override: the emitted operation code identifier is renamed to the derived name plus a per-language suffix (Go `GetShowcaseGo`, TS `getShowcaseTs`, Python `get_showcase_py`, Java `getShowcaseJava`) while the wire operation name stays `GetShowcase` and the synthesized I/O type stays `GetShowcaseInput` (derived from the operation key, not the override). + /// + [GeneratedCode("nex-gen", null)] + [NexusOperation("GetShowcase")] + Showcase GetShowcase(GetShowcaseInput request); + + } + + [GeneratedCode("nex-gen", null)] + public class ShowcaseServiceClient + { + private readonly NexusWorkflowClient _client; + + public ShowcaseServiceClient(string endpoint) + { + _client = Workflow.CreateNexusWorkflowClient(endpoint); + } + + /// + /// Fetch a showcase by id. Also exercises the operation-level `x-<lang>-name` override: the emitted operation code identifier is renamed to the derived name plus a per-language suffix (Go `GetShowcaseGo`, TS `getShowcaseTs`, Python `get_showcase_py`, Java `getShowcaseJava`) while the wire operation name stays `GetShowcase` and the synthesized I/O type stays `GetShowcaseInput` (derived from the operation key, not the override). + /// + [GeneratedCode("nex-gen", null)] + public async Task GetShowcaseAsync(GetShowcaseInput request) + { + var result = await _client.ExecuteNexusOperationAsync(svc => svc.GetShowcase(request)).ConfigureAwait(true); + return result; + } + + } + +} diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 245e7e40..1a49dd3b 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -4,7 +4,7 @@ Generated C# for the JSON-Schema inputs in [`../schemas/`](../schemas/), in **definitions** mode — plain data models plus the `NexusRpc` service interface, without the native-api operation/client scaffolding. -- `chat/`, `kb/` — generated models, one directory per schema. +- `chat/`, `kb/`, `showcase/` — generated models, one directory per schema. - `tests/` — round-trip checks that serialize the shared wire fixtures in [`../wire/json_schema/`](../wire/json_schema/) through `System.Text.Json` and assert JSON-equality. @@ -43,7 +43,7 @@ affected members, so nothing is dropped silently. | Aggregated `ValidationError` over `Violation[]` | ✅ shared `definitions` runtime | ✅ `Definitions.cs` | | `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` / `multipleOf` | ✅ | ✅ | | `maxProperties` | ✅ | ✅ | -| `minLength` / `maxLength` / `pattern` | ✅ | ❌ dropped | +| `minLength` / `maxLength` / `pattern` | ✅ | ✅ | | `minItems` / `maxItems` / `uniqueItems` / `contains` | ✅ | ❌ dropped | | `minProperties` / `dependentRequired` / `propertyNames` | ✅ | ❌ dropped | | `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | @@ -51,10 +51,28 @@ affected members, so nothing is dropped silently. | `format` temporal materialization | ✅ native types | ❌ left as `string` | | `contentEncoding: base64` | ✅ native bytes | ❌ left as `string` | -One known diagnostic divergence within the covered set: Go reports an -out-of-range integer as an aggregated `Violation` reading -`exceeds ±(2^53-1) integer cap`, while .NET throws a non-aggregated -`JsonException("expected integer")` with no member path. +Two known diagnostic divergences within the covered set: + +- Go reports an out-of-range integer as an aggregated `Violation` reading + `exceeds ±(2^53-1) integer cap`, while .NET throws a non-aggregated + `JsonException("expected integer")` with no member path. +- `pattern` reasons quote the target's own rewritten expression, and Go and Java + already differ here (Go `%q`-quotes and keeps `$`; Java concatenates and uses + `\z`). .NET follows Java, since it shares the `\z` rewrite. + +### .NET-specific lowering + +Two places where the obvious C# spelling would be wrong, both covered by +`tests/StringConstraintChecks.cs`: + +- **`pattern` end anchors are rewritten `$` → `\z`.** .NET's `Regex` treats `$` as + "end of input, *or* immediately before a final newline", so `^[A-Z]{2,4}$` would + match `"ABCD\n"` — a value the contract forbids. Python (`\Z`) and Java (`\z`) + need the same rewrite; Go and JS do not. +- **`minLength`/`maxLength` count code points, not `string.Length`.** C#'s + `string.Length` counts UTF-16 code units, so 12 astral characters would score 24 + and be rejected against `maxLength: 12`. `JsonRuntime.CodePointCount` matches + Go's `utf8.RuneCountInString` and Java's `codePointCount`. The wire fixtures in `../wire/json_schema/` hold only valid payloads, so they exercise serialization but never rejection; `tests/ConstraintValidationChecks.cs` @@ -71,6 +89,8 @@ cargo run --features advanced -- dotnet samples/schemas/chat.nexusrpc.yaml \ --output samples/dotnet/chat cargo run --features advanced -- dotnet samples/schemas/kb \ --output samples/dotnet/kb +cargo run --features advanced -- dotnet samples/schemas/showcase.nexusrpc.yaml \ + --output samples/dotnet/showcase ``` ## Building and testing diff --git a/samples/dotnet/showcase/Definitions.cs b/samples/dotnet/showcase/Definitions.cs new file mode 100644 index 00000000..eb7a54e3 --- /dev/null +++ b/samples/dotnet/showcase/Definitions.cs @@ -0,0 +1,223 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; + +namespace NexGen.ShowcaseService +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1) — the + /// same cap every other target enforces. + /// + private const double MaxSafeInteger = 9007199254740991d; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + double number; + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + number = json.GetDouble(); + } + else if (value is long longValue) + { + number = longValue; + } + else if (value is int intValue) + { + number = intValue; + } + else + { + throw new JsonException("expected integer"); + } + if (double.IsNaN(number) + || double.IsInfinity(number) + || Math.Truncate(number) != number + || Math.Abs(number) > MaxSafeInteger) + { + throw new JsonException("expected integer"); + } + return (long)number; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + +} diff --git a/samples/dotnet/showcase/Models.cs b/samples/dotnet/showcase/Models.cs new file mode 100644 index 00000000..e5edd0e0 --- /dev/null +++ b/samples/dotnet/showcase/Models.cs @@ -0,0 +1,1377 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace NexGen.ShowcaseService +{ + + /// + /// A nested object, open to forward-compatible extension. + /// + [GeneratedCode("nex-gen", null)] + public class Address : IJsonOnDeserialized + { + public Address(string street) + { + Street = street; + } + + [JsonPropertyName("street")] + [JsonRequired] + public string Street { get; init; } + [JsonIgnore] + public string? City + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "city"); + init + { + JsonRuntime.RejectNull("city", value); + AdditionalProperties["city"] = value; + } + } + [JsonIgnore] + public long? Zip + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "zip"); + init + { + JsonRuntime.RejectNull("zip", value); + AdditionalProperties["zip"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("city", out var cityValue)) + { + JsonRuntime.RejectNull("city", cityValue); + if (cityValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"city"}: expected string"); + } + else if (cityValue is not JsonElement && cityValue is not string) + { + throw new JsonException($"{"city"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("zip", out var zipValue)) + { + JsonRuntime.RejectNull("zip", zipValue); + _ = JsonRuntime.ReadJsonValue(zipValue); + } + } + } + + + /// + /// A string map with member-count and key-shape constraints: 1 to 3 entries, each key at most 8 code points (minProperties/maxProperties/propertyNames on a map-shaped object). + /// + [GeneratedCode("nex-gen", null)] + public class Attributes : IJsonOnDeserialized + { + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.Count > 3) + { + throw new JsonException("maxProperties: at most 3 entries"); + } + foreach (var entry in AdditionalProperties) + { + if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{entry.Key}: expected string"); + } + else if (entry.Value is not JsonElement && entry.Value is not string) + { + throw new JsonException($"{entry.Key}: expected string"); + } + } + } + } + + + /// + /// A circle branch of the Shape tagged union. + /// + [GeneratedCode("nex-gen", null)] + public class Circle + { + public Circle(double radius) + { + Radius = radius; + } + + private string kindValue = "circle"; + + [JsonPropertyName("kind")] + [JsonRequired] + public string Kind + { + get => kindValue; + init + { + if (value != "circle") + { + throw new JsonException("kind must equal \"circle\""); + } + kindValue = value; + } + } + [JsonPropertyName("radius")] + [JsonRequired] + public double Radius { get; init; } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + } + + + /// + /// Contact details with a conditional requirement and a member-count bound: a shipping street requires a shipping zip (dependentRequired), and the object must carry 1 to 3 members (minProperties/maxProperties on a declared-property object). Also exercises the type-level `x-<lang>-name` override (the Stage 4 escape hatch): the emitted type is renamed to the derived name plus a per-language suffix (Go `ContactGo`, TS `ContactTs`, Python `ContactPy`, Java `ContactJava`) at its declaration and at every `$ref`, while the wire `$ref` name stays `Contact`. + /// + [GeneratedCode("nex-gen", null)] + public class Contact : IJsonOnDeserialized + { + [JsonIgnore] + public string? Email + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "email"); + init + { + JsonRuntime.RejectNull("email", value); + AdditionalProperties["email"] = value; + } + } + [JsonIgnore] + public string? ShippingStreet + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "shippingStreet"); + init + { + JsonRuntime.RejectNull("shippingStreet", value); + AdditionalProperties["shippingStreet"] = value; + } + } + [JsonIgnore] + public string? ShippingZip + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "shippingZip"); + init + { + JsonRuntime.RejectNull("shippingZip", value); + AdditionalProperties["shippingZip"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("email", out var emailValue)) + { + JsonRuntime.RejectNull("email", emailValue); + if (emailValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"email"}: expected string"); + } + else if (emailValue is not JsonElement && emailValue is not string) + { + throw new JsonException($"{"email"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("shippingStreet", out var shippingStreetValue)) + { + JsonRuntime.RejectNull("shippingStreet", shippingStreetValue); + if (shippingStreetValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"shippingStreet"}: expected string"); + } + else if (shippingStreetValue is not JsonElement && shippingStreetValue is not string) + { + throw new JsonException($"{"shippingStreet"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("shippingZip", out var shippingZipValue)) + { + JsonRuntime.RejectNull("shippingZip", shippingZipValue); + if (shippingZipValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"shippingZip"}: expected string"); + } + else if (shippingZipValue is not JsonElement && shippingZipValue is not string) + { + throw new JsonException($"{"shippingZip"}: expected string"); + } + } + } + } + + + /// + /// Arbitrary string key/value labels (typed map). + /// + [GeneratedCode("nex-gen", null)] + public class Labels : IJsonOnDeserialized + { + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.Count > 50) + { + throw new JsonException("maxProperties: at most 50 entries"); + } + foreach (var entry in AdditionalProperties) + { + if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{entry.Key}: expected string"); + } + else if (entry.Value is not JsonElement && entry.Value is not string) + { + throw new JsonException($"{entry.Key}: expected string"); + } + } + } + } + + + /// + /// A closed object; unknown members are rejected. + /// + [GeneratedCode("nex-gen", null)] + public class Settings : IJsonOnDeserialized + { + [JsonIgnore] + public string? Theme + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "theme"); + init + { + JsonRuntime.RejectNull("theme", value); + AdditionalProperties["theme"] = value; + } + } + [JsonIgnore] + public long? FontSize + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "fontSize"); + init + { + JsonRuntime.RejectNull("fontSize", value); + AdditionalProperties["fontSize"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "theme" && key != "fontSize") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("theme", out var themeValue)) + { + JsonRuntime.RejectNull("theme", themeValue); + if (themeValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"theme"}: expected string"); + } + else if (themeValue is not JsonElement && themeValue is not string) + { + throw new JsonException($"{"theme"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("fontSize", out var fontSizeValue)) + { + JsonRuntime.RejectNull("fontSize", fontSizeValue); + _ = JsonRuntime.ReadJsonValue(fontSizeValue); + } + } + } + + + /// + /// A closed sum type (discriminated union) of Circle | Square, tagged by the shared required `kind` const. Selection reads `kind` and routes to the matching branch; an unknown tag is a Violation. + /// + [GeneratedCode("nex-gen", null)] + public class Shape + { + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + } + + + /// + /// Root object exercising the supported JSON-Schema feature subset: required and optional fields of every scalar type, optional+nullable and required+nullable members, arrays, a nested object via $ref, a typed-map, a closed object, an open (catch-all) object, a string const, a scalar default, and member docs. + /// + [GeneratedCode("nex-gen", null)] + public class Showcase : IJsonOnDeserialized + { + public Showcase(string status, long tier, double scale, string name, long count, bool active, string? category) + { + Status = status; + Tier = tier; + Scale = scale; + Name = name; + Count = count; + Active = active; + Category = category; + } + + /// + /// Discriminator; always "showcase". + /// + private string kindValue = "showcase"; + + [JsonPropertyName("kind")] + [JsonRequired] + public string Kind + { + get => kindValue; + init + { + if (value != "showcase") + { + throw new JsonException("kind must equal \"showcase\""); + } + kindValue = value; + } + } + /// + /// Integer const; always 1. Also exercises the single-`const` value override: `x-go-const-name`/`x-java-const-name` rename the emitted constant to the derived name plus a per-language suffix (Go `RevisionGo`, Java `REVISION_JAVA`) while the wire value stays `1`. TS/Python are inert here (no const override keyword — the value is emitted as a plain literal type). + /// + private long revisionValue = 1; + + [JsonPropertyName("revision")] + [JsonRequired] + public long Revision + { + get => revisionValue; + init + { + if (value != 1) + { + throw new JsonException("revision must equal 1"); + } + revisionValue = value; + } + } + /// + /// Boolean const; always true. + /// + private bool enabledValue = true; + + [JsonPropertyName("enabled")] + [JsonRequired] + public bool Enabled + { + get => enabledValue; + init + { + if (value != true) + { + throw new JsonException("enabled must equal true"); + } + enabledValue = value; + } + } + /// + /// Closed string value set. Also exercises the enum value-constant override: `x-go-enum-names`/`x-java-enum-names` rename the `active` value's emitted constant to the value name plus a per-language suffix (Go `ActiveGo`, Java `ACTIVE_JAVA`) while the wire value stays `active`. TS/Python are inert here (no enum override keyword). + /// + [JsonPropertyName("status")] + [JsonRequired] + public string Status { get; init; } + /// + /// Closed integer value set. + /// + [JsonPropertyName("tier")] + [JsonRequired] + public long Tier { get; init; } + /// + /// Closed number value set (exercises the Python float exception: emitted as plain float, validated by membership). + /// + [JsonPropertyName("scale")] + [JsonRequired] + public double Scale { get; init; } + /// + /// Required human-readable name, 1 to 64 code points. + /// + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; init; } + /// + /// Required integer scalar. + /// + [JsonPropertyName("count")] + [JsonRequired] + public long Count { get; init; } + /// + /// Required boolean scalar. + /// + [JsonPropertyName("active")] + [JsonRequired] + public bool Active { get; init; } + /// + /// Optional short name, at most 12 code points. + /// + [JsonIgnore] + public string? Nickname + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "nickname"); + init + { + JsonRuntime.RejectNull("nickname", value); + AdditionalProperties["nickname"] = value; + } + } + /// + /// Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. + /// + [JsonIgnore] + public string? Code + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "code"); + init + { + JsonRuntime.RejectNull("code", value); + AdditionalProperties["code"] = value; + } + } + /// + /// Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. + /// + [JsonIgnore] + public string? Sku + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "sku"); + init + { + JsonRuntime.RejectNull("sku", value); + AdditionalProperties["sku"] = value; + } + } + /// + /// Optional two-word phrase separated by whitespace (`^\S+\s\S+$`). Exercises the loader's `\s`/`\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\Z` / Java `\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. + /// + [JsonIgnore] + public string? Phrase + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "phrase"); + init + { + JsonRuntime.RejectNull("phrase", value); + AdditionalProperties["phrase"] = value; + } + } + /// + /// Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. + /// + [JsonIgnore] + public string? RequestId + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "requestId"); + init + { + JsonRuntime.RejectNull("requestId", value); + AdditionalProperties["requestId"] = value; + } + } + /// + /// Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). + /// + [JsonIgnore] + public string? ContactEmail + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "contactEmail"); + init + { + JsonRuntime.RejectNull("contactEmail", value); + AdditionalProperties["contactEmail"] = value; + } + } + /// + /// Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). + /// + [JsonIgnore] + public string? Host + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "host"); + init + { + JsonRuntime.RejectNull("host", value); + AdditionalProperties["host"] = value; + } + } + /// + /// Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). + /// + [JsonIgnore] + public string? Homepage + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "homepage"); + init + { + JsonRuntime.RejectNull("homepage", value); + AdditionalProperties["homepage"] = value; + } + } + /// + /// Optional gateway address; asserted dotted-quad IPv4 via format ipv4. + /// + [JsonIgnore] + public string? Gateway + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "gateway"); + init + { + JsonRuntime.RejectNull("gateway", value); + AdditionalProperties["gateway"] = value; + } + } + /// + /// Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. + /// + [JsonIgnore] + public string? Blob + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "blob"); + init + { + JsonRuntime.RejectNull("blob", value); + AdditionalProperties["blob"] = value; + } + } + /// + /// Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). + /// + [JsonIgnore] + public string? UrlBlob + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "urlBlob"); + init + { + JsonRuntime.RejectNull("urlBlob", value); + AdditionalProperties["urlBlob"] = value; + } + } + /// + /// Optional integer with a schema default. + /// + [JsonIgnore] + public long? Retries + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "retries", 3); + init + { + JsonRuntime.RejectNull("retries", value); + AdditionalProperties["retries"] = value; + } + } + [JsonIgnore] + public bool? Verbose + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "verbose"); + init + { + JsonRuntime.RejectNull("verbose", value); + AdditionalProperties["verbose"] = value; + } + } + /// + /// Optional string with a schema default, surfaced on read. + /// + [JsonIgnore] + public string? Greeting + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "greeting", "hello"); + init + { + JsonRuntime.RejectNull("greeting", value); + AdditionalProperties["greeting"] = value; + } + } + /// + /// Optional boolean with a schema default. + /// + [JsonIgnore] + public bool? Debug + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "debug", false); + init + { + JsonRuntime.RejectNull("debug", value); + AdditionalProperties["debug"] = value; + } + } + /// + /// Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x-<lang>-name` override (the Stage 4 escape hatch): the emitted member identifier is renamed to the derived name plus a per-language suffix (Go `LegacyIdGo`, TS `legacyIdTs`, Python `legacy_id_py`, Java `legacyIdJava`) while the wire name stays `legacyId` (json tag / alias / @JsonProperty). + /// + [JsonIgnore] + public string? LegacyId + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "legacyId"); + init + { + JsonRuntime.RejectNull("legacyId", value); + AdditionalProperties["legacyId"] = value; + } + } + /// + /// Optional and nullable; may be absent or explicitly null. + /// + [JsonIgnore] + public string? MiddleName + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "middleName"); + init + { + AdditionalProperties["middleName"] = value; + } + } + /// + /// Required but nullable; may be explicitly cleared to null. + /// + [JsonPropertyName("category")] + [JsonRequired] + public string? Category { get; init; } + /// + /// Optional integer bounded to the inclusive range [1, 10]. + /// + [JsonIgnore] + public long? Priority + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "priority"); + init + { + JsonRuntime.RejectNull("priority", value); + AdditionalProperties["priority"] = value; + } + } + /// + /// Optional integer that must be strictly greater than 0. + /// + [JsonIgnore] + public long? Level + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "level"); + init + { + JsonRuntime.RejectNull("level", value); + AdditionalProperties["level"] = value; + } + } + /// + /// Optional number that must be a non-negative multiple of 5. + /// + [JsonIgnore] + public double? Ratio + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "ratio"); + init + { + JsonRuntime.RejectNull("ratio", value); + AdditionalProperties["ratio"] = value; + } + } + /// + /// Optional integer that must be a multiple of 3. + /// + [JsonIgnore] + public long? Step + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "step"); + init + { + JsonRuntime.RejectNull("step", value); + AdditionalProperties["step"] = value; + } + } + /// + /// Ordered list of free-form tags; 1 to 5 entries. + /// + [JsonIgnore] + public IReadOnlyList? Tags + { + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "tags"); + init + { + JsonRuntime.RejectNull("tags", value); + AdditionalProperties["tags"] = value; + } + } + /// + /// Alternate names; each must be distinct. + /// + [JsonIgnore] + public IReadOnlyList? Aliases + { + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "aliases"); + init + { + JsonRuntime.RejectNull("aliases", value); + AdditionalProperties["aliases"] = value; + } + } + /// + /// Access roles; must contain between one and two "admin" entries. + /// + [JsonIgnore] + public IReadOnlyList? Roles + { + get => JsonRuntime.ReadOptionalValue?>(AdditionalProperties, "roles"); + init + { + JsonRuntime.RejectNull("roles", value); + AdditionalProperties["roles"] = value; + } + } + /// + /// Disjoint-kind union (oneOf sum type): the wire value is either a string or an integer, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. + /// + [JsonIgnore] + public object? IdOrName + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "idOrName"); + init + { + JsonRuntime.RejectNull("idOrName", value); + AdditionalProperties["idOrName"] = value; + } + } + [JsonIgnore] + public Shape? Shape + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "shape"); + init + { + JsonRuntime.RejectNull("shape", value); + AdditionalProperties["shape"] = value; + } + } + [JsonIgnore] + public Address? Address + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "address"); + init + { + JsonRuntime.RejectNull("address", value); + AdditionalProperties["address"] = value; + } + } + [JsonIgnore] + public Labels? Labels + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "labels"); + init + { + JsonRuntime.RejectNull("labels", value); + AdditionalProperties["labels"] = value; + } + } + [JsonIgnore] + public Settings? Settings + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "settings"); + init + { + JsonRuntime.RejectNull("settings", value); + AdditionalProperties["settings"] = value; + } + } + [JsonIgnore] + public Attributes? Attributes + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "attributes"); + init + { + JsonRuntime.RejectNull("attributes", value); + AdditionalProperties["attributes"] = value; + } + } + [JsonIgnore] + public Contact? Contact + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "contact"); + init + { + JsonRuntime.RejectNull("contact", value); + AdditionalProperties["contact"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + private static readonly Regex skuPattern = new Regex("^[A-Z]{2,4}\\z", RegexOptions.CultureInvariant); + private static readonly Regex phrasePattern = new Regex("^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z", RegexOptions.CultureInvariant); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + var nameLength = JsonRuntime.CodePointCount(Name); + if (nameLength < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "name"), "must have length >= 1, got " + nameLength)); + } + if (nameLength > 64) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "name"), "must have length <= 64, got " + nameLength)); + } + if (Nickname is string nicknameValue) + { + var nicknameLength = JsonRuntime.CodePointCount(nicknameValue); + if (nicknameLength > 12) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "nickname"), "must have length <= 12, got " + nicknameLength)); + } + } + if (Code is string codeValue) + { + var codeLength = JsonRuntime.CodePointCount(codeValue); + if (codeLength < 2) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "code"), "must have length >= 2, got " + codeLength)); + } + if (codeLength > 5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "code"), "must have length <= 5, got " + codeLength)); + } + } + if (Sku is string skuValue) + { + if (!skuPattern.IsMatch(skuValue)) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "sku"), "must match pattern ^[A-Z]{2,4}\\z, got " + skuValue)); + } + } + if (Phrase is string phraseValue) + { + if (!phrasePattern.IsMatch(phraseValue)) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "phrase"), "must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z, got " + phraseValue)); + } + } + if (Priority is long priorityValue) + { + if (priorityValue < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "must be >= 1, got " + JsonRuntime.FormatNumber(priorityValue))); + } + if (priorityValue > 10) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "must be <= 10, got " + JsonRuntime.FormatNumber(priorityValue))); + } + } + if (Level is long levelValue) + { + if (levelValue <= 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "level"), "must be > 0, got " + JsonRuntime.FormatNumber(levelValue))); + } + } + if (Ratio is double ratioValue) + { + if (ratioValue < 5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "ratio"), "must be >= 5, got " + JsonRuntime.FormatNumber(ratioValue))); + } + if (ratioValue % 5 != 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "ratio"), "must be a multiple of 5, got " + JsonRuntime.FormatNumber(ratioValue))); + } + } + if (Step is long stepValue) + { + if (stepValue % 3 != 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "must be a multiple of 3, got " + JsonRuntime.FormatNumber(stepValue))); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "nickname" && key != "code" && key != "sku" && key != "phrase" && key != "requestId" && key != "contactEmail" && key != "host" && key != "homepage" && key != "gateway" && key != "blob" && key != "urlBlob" && key != "retries" && key != "verbose" && key != "greeting" && key != "debug" && key != "legacyId" && key != "middleName" && key != "priority" && key != "level" && key != "ratio" && key != "step" && key != "tags" && key != "aliases" && key != "roles" && key != "idOrName" && key != "shape" && key != "address" && key != "labels" && key != "settings" && key != "attributes" && key != "contact") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("nickname", out var nicknameValue)) + { + JsonRuntime.RejectNull("nickname", nicknameValue); + if (nicknameValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"nickname"}: expected string"); + } + else if (nicknameValue is not JsonElement && nicknameValue is not string) + { + throw new JsonException($"{"nickname"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("code", out var codeValue)) + { + JsonRuntime.RejectNull("code", codeValue); + if (codeValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"code"}: expected string"); + } + else if (codeValue is not JsonElement && codeValue is not string) + { + throw new JsonException($"{"code"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("sku", out var skuValue)) + { + JsonRuntime.RejectNull("sku", skuValue); + if (skuValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"sku"}: expected string"); + } + else if (skuValue is not JsonElement && skuValue is not string) + { + throw new JsonException($"{"sku"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("phrase", out var phraseValue)) + { + JsonRuntime.RejectNull("phrase", phraseValue); + if (phraseValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"phrase"}: expected string"); + } + else if (phraseValue is not JsonElement && phraseValue is not string) + { + throw new JsonException($"{"phrase"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("requestId", out var requestIdValue)) + { + JsonRuntime.RejectNull("requestId", requestIdValue); + if (requestIdValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"requestId"}: expected string"); + } + else if (requestIdValue is not JsonElement && requestIdValue is not string) + { + throw new JsonException($"{"requestId"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("contactEmail", out var contactEmailValue)) + { + JsonRuntime.RejectNull("contactEmail", contactEmailValue); + if (contactEmailValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"contactEmail"}: expected string"); + } + else if (contactEmailValue is not JsonElement && contactEmailValue is not string) + { + throw new JsonException($"{"contactEmail"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("host", out var hostValue)) + { + JsonRuntime.RejectNull("host", hostValue); + if (hostValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"host"}: expected string"); + } + else if (hostValue is not JsonElement && hostValue is not string) + { + throw new JsonException($"{"host"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("homepage", out var homepageValue)) + { + JsonRuntime.RejectNull("homepage", homepageValue); + if (homepageValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"homepage"}: expected string"); + } + else if (homepageValue is not JsonElement && homepageValue is not string) + { + throw new JsonException($"{"homepage"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("gateway", out var gatewayValue)) + { + JsonRuntime.RejectNull("gateway", gatewayValue); + if (gatewayValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"gateway"}: expected string"); + } + else if (gatewayValue is not JsonElement && gatewayValue is not string) + { + throw new JsonException($"{"gateway"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("blob", out var blobValue)) + { + JsonRuntime.RejectNull("blob", blobValue); + if (blobValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"blob"}: expected string"); + } + else if (blobValue is not JsonElement && blobValue is not string) + { + throw new JsonException($"{"blob"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("urlBlob", out var urlBlobValue)) + { + JsonRuntime.RejectNull("urlBlob", urlBlobValue); + if (urlBlobValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"urlBlob"}: expected string"); + } + else if (urlBlobValue is not JsonElement && urlBlobValue is not string) + { + throw new JsonException($"{"urlBlob"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("retries", out var retriesValue)) + { + JsonRuntime.RejectNull("retries", retriesValue); + _ = JsonRuntime.ReadJsonValue(retriesValue); + } + if (AdditionalProperties.TryGetValue("verbose", out var verboseValue)) + { + JsonRuntime.RejectNull("verbose", verboseValue); + } + if (AdditionalProperties.TryGetValue("greeting", out var greetingValue)) + { + JsonRuntime.RejectNull("greeting", greetingValue); + if (greetingValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"greeting"}: expected string"); + } + else if (greetingValue is not JsonElement && greetingValue is not string) + { + throw new JsonException($"{"greeting"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("debug", out var debugValue)) + { + JsonRuntime.RejectNull("debug", debugValue); + } + if (AdditionalProperties.TryGetValue("legacyId", out var legacyIdValue)) + { + JsonRuntime.RejectNull("legacyId", legacyIdValue); + if (legacyIdValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"legacyId"}: expected string"); + } + else if (legacyIdValue is not JsonElement && legacyIdValue is not string) + { + throw new JsonException($"{"legacyId"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("middleName", out var middleNameValue)) + { + } + if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) + { + JsonRuntime.RejectNull("priority", priorityValue); + _ = JsonRuntime.ReadJsonValue(priorityValue); + } + if (AdditionalProperties.TryGetValue("level", out var levelValue)) + { + JsonRuntime.RejectNull("level", levelValue); + _ = JsonRuntime.ReadJsonValue(levelValue); + } + if (AdditionalProperties.TryGetValue("ratio", out var ratioValue)) + { + JsonRuntime.RejectNull("ratio", ratioValue); + } + if (AdditionalProperties.TryGetValue("step", out var stepValue)) + { + JsonRuntime.RejectNull("step", stepValue); + _ = JsonRuntime.ReadJsonValue(stepValue); + } + if (AdditionalProperties.TryGetValue("tags", out var tagsValue)) + { + JsonRuntime.RejectNull("tags", tagsValue); + _ = JsonRuntime.ReadJsonValue?>(tagsValue); + } + if (AdditionalProperties.TryGetValue("aliases", out var aliasesValue)) + { + JsonRuntime.RejectNull("aliases", aliasesValue); + _ = JsonRuntime.ReadJsonValue?>(aliasesValue); + } + if (AdditionalProperties.TryGetValue("roles", out var rolesValue)) + { + JsonRuntime.RejectNull("roles", rolesValue); + _ = JsonRuntime.ReadJsonValue?>(rolesValue); + } + if (AdditionalProperties.TryGetValue("idOrName", out var idOrNameValue)) + { + JsonRuntime.RejectNull("idOrName", idOrNameValue); + } + if (AdditionalProperties.TryGetValue("shape", out var shapeValue)) + { + JsonRuntime.RejectNull("shape", shapeValue); + _ = JsonRuntime.ReadJsonValue(shapeValue); + } + if (AdditionalProperties.TryGetValue("address", out var addressValue)) + { + JsonRuntime.RejectNull("address", addressValue); + _ = JsonRuntime.ReadJsonValue(addressValue); + } + if (AdditionalProperties.TryGetValue("labels", out var labelsValue)) + { + JsonRuntime.RejectNull("labels", labelsValue); + _ = JsonRuntime.ReadJsonValue(labelsValue); + } + if (AdditionalProperties.TryGetValue("settings", out var settingsValue)) + { + JsonRuntime.RejectNull("settings", settingsValue); + _ = JsonRuntime.ReadJsonValue(settingsValue); + } + if (AdditionalProperties.TryGetValue("attributes", out var attributesValue)) + { + JsonRuntime.RejectNull("attributes", attributesValue); + _ = JsonRuntime.ReadJsonValue(attributesValue); + } + if (AdditionalProperties.TryGetValue("contact", out var contactValue)) + { + JsonRuntime.RejectNull("contact", contactValue); + _ = JsonRuntime.ReadJsonValue(contactValue); + } + Validate(); + } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class GetShowcaseInput + { + public GetShowcaseInput(string id) + { + Id = id; + } + + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; init; } + } + + + /// + /// A square branch of the Shape tagged union. + /// + [GeneratedCode("nex-gen", null)] + public class Square + { + public Square(double side) + { + Side = side; + } + + private string kindValue = "square"; + + [JsonPropertyName("kind")] + [JsonRequired] + public string Kind + { + get => kindValue; + init + { + if (value != "square") + { + throw new JsonException("kind must equal \"square\""); + } + kindValue = value; + } + } + [JsonPropertyName("side")] + [JsonRequired] + public double Side { get; init; } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + } + + + /// + /// Base-type extension via allOf: WidgetBase is flattened in and the extension branch adds fields, so Widget merges to one standalone object with the union of properties ({id, kind, name, size}) and required ([id, name]). The `size` member is itself an allOf that tightens two numeric bounds to a single interval [10, 20]; a value outside it is rejected by the merged constraint. No allOf survives past the loader. + /// + [GeneratedCode("nex-gen", null)] + public class Widget : IJsonOnDeserialized + { + public Widget(string id, string name) + { + Id = id; + Name = name; + } + + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; init; } + [JsonIgnore] + public string? Kind + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "kind"); + init + { + JsonRuntime.RejectNull("kind", value); + AdditionalProperties["kind"] = value; + } + } + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; init; } + /// + /// Optional integer with two allOf branches tightened to [10, 20]. + /// + [JsonIgnore] + public long? Size + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "size"); + init + { + JsonRuntime.RejectNull("size", value); + AdditionalProperties["size"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Size is long sizeValue) + { + if (sizeValue < 10) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "must be >= 10, got " + JsonRuntime.FormatNumber(sizeValue))); + } + if (sizeValue > 20) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "must be <= 20, got " + JsonRuntime.FormatNumber(sizeValue))); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("kind", out var kindValue)) + { + JsonRuntime.RejectNull("kind", kindValue); + if (kindValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"kind"}: expected string"); + } + else if (kindValue is not JsonElement && kindValue is not string) + { + throw new JsonException($"{"kind"}: expected string"); + } + } + if (AdditionalProperties.TryGetValue("size", out var sizeValue)) + { + JsonRuntime.RejectNull("size", sizeValue); + _ = JsonRuntime.ReadJsonValue(sizeValue); + } + Validate(); + } + } + + + /// + /// A base object folded into Widget via allOf. It stays its own type; Widget copies its fields rather than referencing or subtyping it. + /// + [GeneratedCode("nex-gen", null)] + public class WidgetBase : IJsonOnDeserialized + { + public WidgetBase(string id) + { + Id = id; + } + + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; init; } + [JsonIgnore] + public string? Kind + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "kind"); + init + { + JsonRuntime.RejectNull("kind", value); + AdditionalProperties["kind"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + if (AdditionalProperties.TryGetValue("kind", out var kindValue)) + { + JsonRuntime.RejectNull("kind", kindValue); + if (kindValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"kind"}: expected string"); + } + else if (kindValue is not JsonElement && kindValue is not string) + { + throw new JsonException($"{"kind"}: expected string"); + } + } + } + } + +} diff --git a/samples/dotnet/showcase/Services.cs b/samples/dotnet/showcase/Services.cs new file mode 100644 index 00000000..ff95f64a --- /dev/null +++ b/samples/dotnet/showcase/Services.cs @@ -0,0 +1,28 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Threading.Tasks; +using NexusRpc; + +namespace NexGen.ShowcaseService +{ + + [GeneratedCode("nex-gen", null)] + [NexusService("example.showcase.v1.ShowcaseService")] + internal interface IShowcaseService + { + /// + /// Fetch a showcase by id. Also exercises the operation-level `x-<lang>-name` override: the emitted operation code identifier is renamed to the derived name plus a per-language suffix (Go `GetShowcaseGo`, TS `getShowcaseTs`, Python `get_showcase_py`, Java `getShowcaseJava`) while the wire operation name stays `GetShowcase` and the synthesized I/O type stays `GetShowcaseInput` (derived from the operation key, not the override). + /// + [GeneratedCode("nex-gen", null)] + [NexusOperation("GetShowcase")] + Showcase GetShowcase(GetShowcaseInput request); + + } + +} diff --git a/samples/dotnet/tests/StringConstraintChecks.cs b/samples/dotnet/tests/StringConstraintChecks.cs new file mode 100644 index 00000000..fa706d09 --- /dev/null +++ b/samples/dotnet/tests/StringConstraintChecks.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using Xunit; +using Showcase = NexGen.ShowcaseService; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers minLength, maxLength and pattern on the + /// showcase schema, which is the only input exercising them. + /// + /// Two of these guard hazards specific to .NET: Regex's $ anchor + /// also matches before a trailing newline, and string.Length counts + /// UTF-16 units rather than code points. + /// + public class StringConstraintChecks + { + private static readonly JsonSerializerOptions Options = new(); + + /// + /// Showcase's required members, all conforming — the same shape as the + /// checked-in showcase-minimal.json fixture. Cases below add or + /// override members on top of it. + /// + private const string RequiredMembers = + @"""kind"":""showcase"",""revision"":1,""enabled"":true," + + @"""status"":""active"",""tier"":1,""scale"":1.5," + + @"""count"":3,""active"":true,""category"":""tools"""; + + private static string Payload(string extraMembers = "", string name = "Widget") => + "{" + RequiredMembers + @",""name"":""" + name + @"""" + + (extraMembers.Length == 0 ? "" : "," + extraMembers) + + "}"; + + private static Showcase.Showcase Deserialize(string json) => + JsonSerializer.Deserialize(json, Options)!; + + private static Showcase.ValidationException Rejects(string json) => + Assert.Throws(() => Deserialize(json)); + + [Fact] + public void ConformingPayloadIsAccepted() + { + var value = Deserialize(Payload()); + + Assert.Equal("Widget", value.Name); + } + + [Fact] + public void StringShorterThanMinLengthIsRejected() + { + var exception = Rejects(Payload(name: "")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("name", violation.Path); + Assert.Equal("must have length >= 1, got 0", violation.Reason); + } + + [Fact] + public void StringLongerThanMaxLengthIsRejected() + { + var exception = Rejects(Payload(@"""nickname"":""0123456789abc""")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("nickname", violation.Path); + Assert.Equal("must have length <= 12, got 13", violation.Reason); + } + + /// + /// maxLength counts code points. An astral character is one code + /// point but two UTF-16 units, so a naive string.Length check would + /// reject 12 emoji against maxLength: 12. Go counts runes and Java + /// counts code points, so accepting this is what keeps .NET in step. + /// + [Fact] + public void AstralCharactersCountAsOneCodePointEach() + { + // 12 U+1F600 characters: 12 code points, 24 UTF-16 units. + var nickname = string.Concat(System.Linq.Enumerable.Repeat("\U0001F600", 12)); + Assert.Equal(24, nickname.Length); + + var value = Deserialize(Payload(@"""nickname"":""" + nickname + @"""")); + + Assert.Equal(nickname, value.Nickname); + } + + [Fact] + public void ThirteenAstralCharactersStillExceedMaxLength() + { + var nickname = string.Concat(System.Linq.Enumerable.Repeat("\U0001F600", 13)); + + var exception = Rejects(Payload(@"""nickname"":""" + nickname + @"""")); + + Assert.Equal("must have length <= 12, got 13", Assert.Single(exception.Violations).Reason); + } + + [Fact] + public void ValueMatchingPatternIsAccepted() + { + var value = Deserialize(Payload(@"""sku"":""ABCD""")); + + Assert.Equal("ABCD", value.Sku); + } + + [Fact] + public void ValueViolatingPatternIsRejected() + { + var exception = Rejects(Payload(@"""sku"":""abcd""")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("sku", violation.Path); + Assert.Equal(@"must match pattern ^[A-Z]{2,4}\z, got abcd", violation.Reason); + } + + /// + /// The reason the emitted pattern ends in \z rather than $. + /// + /// .NET's Regex treats $ as "end of input, or immediately + /// before a final newline", so ^[A-Z]{2,4}$ would happily match + /// "ABCD\n" — a value the contract forbids and that Go rejects. + /// + [Fact] + public void TrailingNewlineDoesNotSatisfyTheEndAnchor() + { + var exception = Rejects(Payload(@"""sku"":""ABCD\n""")); + + Assert.Equal("sku", Assert.Single(exception.Violations).Path); + } + + /// + /// The loader normalizes Perl \s/\S to explicit ASCII + /// classes, so a non-ASCII space such as U+00A0 must not satisfy the + /// separator in showcase's two-word phrase pattern. + /// + [Fact] + public void NonAsciiSpaceDoesNotSatisfyNormalizedWhitespaceClass() + { + // Written as an escape rather than a literal so the character + // cannot be silently normalized to an ASCII space by an editor. + var phrase = "one\u00A0two"; + var exception = Rejects(Payload(@"""phrase"":""" + phrase + @"""")); + + Assert.Equal("phrase", Assert.Single(exception.Violations).Path); + } + + [Fact] + public void AsciiSpaceSatisfiesNormalizedWhitespaceClass() + { + var value = Deserialize(Payload(@"""phrase"":""one two""")); + + Assert.Equal("one two", value.Phrase); + } + + /// + /// P11 across constraint families: a length failure and a pattern failure + /// in one payload must both be reported. + /// + [Fact] + public void LengthAndPatternViolationsAreReportedTogether() + { + var exception = Rejects(Payload(@"""sku"":""abcd""", name: "")); + + Assert.Equal(2, exception.Violations.Count); + Assert.Contains("name: must have length >= 1, got 0", exception.Message); + Assert.Contains(@"sku: must match pattern ^[A-Z]{2,4}\z, got abcd", exception.Message); + Assert.StartsWith("2 validation error(s): ", exception.Message); + } + } +} diff --git a/src/generator/dotnet.rs b/src/generator/dotnet.rs index e6b126c4..7ac43a9b 100644 --- a/src/generator/dotnet.rs +++ b/src/generator/dotnet.rs @@ -88,6 +88,9 @@ impl<'a> ApiPlanner<'a> { imports.push("System.Text.Json"); imports.push("System.Text.Json.Serialization"); } + if self.external_model_fragments.needs_regex() { + imports.push("System.Text.RegularExpressions"); + } // The shared runtime resolves implicitly when it sits in this namespace or // an enclosing one; an explicit import covers the case where divergent // `@nexus.namespace` overrides leave it somewhere unrelated. diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index ee9ed3b5..f939d5c8 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -46,6 +46,11 @@ struct Schema { exclusive_maximum: Option, #[serde(rename = "multipleOf")] multiple_of: Option, + #[serde(rename = "minLength")] + min_length: Option, + #[serde(rename = "maxLength")] + max_length: Option, + pattern: Option, } impl Schema { @@ -69,6 +74,28 @@ impl Schema { .filter_map(|(kind, bound)| bound.map(|bound| NumericBound { kind, bound })) .collect() } + + /// The string-length bounds declared on this schema, `minLength` first to + /// match the order Go and Java emit them in. + fn length_bounds(&self) -> Vec { + [(true, self.min_length), (false, self.max_length)] + .into_iter() + .filter_map(|(at_least, bound)| bound.map(|bound| LengthBound { at_least, bound })) + .collect() + } + + /// This schema's `pattern` with its `$` end anchor rewritten to `\z`. + /// + /// .NET's `Regex` treats a bare `$` as "end of string, or before a single + /// trailing newline" — the same exception Python and Java have — so a value + /// ending in `\n` would pass a `$`-anchored pattern that the contract intends + /// to reject. `\z` is the unconditional end-of-input anchor. Go and JS keep + /// `$` because their engines have no such exception. + fn dotnet_pattern(&self) -> Option { + self.pattern + .as_deref() + .map(|pattern| crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\z")) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -131,6 +158,12 @@ impl RenderedModelFragments { pub(in crate::generator) fn has_models(&self) -> bool { !self.body.is_empty() } + + /// True when any emitted model compiles a `pattern`, so the models file needs + /// `System.Text.RegularExpressions`. + pub(in crate::generator) fn needs_regex(&self) -> bool { + self.body.contains("new Regex(") + } } impl ExternalModelBackend for ModelBackend { @@ -235,6 +268,8 @@ fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<( return Ok(()); } + render_pattern_fields(output, &constrained); + output.push('\n'); output.push_str(" /// \n"); output.push_str( @@ -267,13 +302,52 @@ fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<( /// value is reached in C#. struct ConstrainedMember<'a> { json_name: &'a str, - /// The C# expression for the member's value inside `CollectViolations`. + /// The C# property name holding the member's value. accessor: String, - /// True when the member is optional or nullable, so the check has to be + /// True when the member is optional or nullable, so the checks have to be /// guarded against the absent case. needs_null_guard: bool, - numeric_type: &'static str, - bounds: Vec>, + /// The CLR type the value binds to once unwrapped from its nullable form. + clr_type: &'static str, + numeric_bounds: Vec>, + length_bounds: Vec, + /// The loader-normalized pattern, already end-anchor rewritten for .NET. + pattern: Option, +} + +impl ConstrainedMember<'_> { + fn has_constraints(&self) -> bool { + !self.numeric_bounds.is_empty() || !self.length_bounds.is_empty() || self.pattern.is_some() + } + + /// The private static `Regex` field backing this member's `pattern`. Named + /// camelCase like the other generated private fields, which also keeps it + /// from colliding with any PascalCase property. + fn pattern_field(&self) -> String { + csharp_parameter_name(&format!("{}-pattern", self.json_name)) + } +} + +#[derive(Debug, Clone, Copy)] +struct LengthBound { + at_least: bool, + bound: usize, +} + +impl LengthBound { + /// The reason wording Go and Java both use, over a **code point** count. + fn reason_prefix(&self) -> String { + let comparison = if self.at_least { ">=" } else { "<=" }; + format!("must have length {comparison} {}, got ", self.bound) + } + + fn violation_condition(&self, length_expr: &str) -> String { + if self.at_least { + format!("{length_expr} < {}", self.bound) + } else { + format!("{length_expr} > {}", self.bound) + } + } } fn constrained_members(schema: &Schema) -> Vec> { @@ -284,40 +358,75 @@ fn constrained_members(schema: &Schema) -> Vec> { properties .iter() .filter_map(|(json_name, property)| { - let bounds = property.numeric_bounds(); - if bounds.is_empty() { - return None; - } let is_required = required.contains(json_name.as_str()); - Some(ConstrainedMember { + let member = ConstrainedMember { json_name, accessor: csharp_type_name(json_name), needs_null_guard: !is_required || allows_null(property), - numeric_type: numeric_clr_type(property), - bounds, - }) + clr_type: constraint_clr_type(property), + numeric_bounds: property.numeric_bounds(), + length_bounds: property.length_bounds(), + pattern: property.dotnet_pattern(), + }; + member.has_constraints().then_some(member) }) .collect() } -/// The CLR type a numeric member's value binds to when unwrapped from its -/// nullable form — `long` for `type: integer`, `double` otherwise. -fn numeric_clr_type(schema: &Schema) -> &'static str { +/// The CLR type a constrained member's value binds to when unwrapped from its +/// nullable form. +fn constraint_clr_type(schema: &Schema) -> &'static str { match schema.ty.as_ref().and_then(Value::as_str) { Some("integer") => "long", - _ => "double", + Some("number") => "double", + Some("string") => "string", + // Nullable spellings carry the concrete type on the non-null branch. + _ => schema + .one_of + .as_ref() + .and_then(|branches| { + branches + .iter() + .find(|branch| !schema_type_includes(branch, "null")) + .map(constraint_clr_type) + }) + .unwrap_or("object"), + } +} + +/// Emits the `private static readonly Regex` field for every member with a +/// `pattern`, so the expression is compiled once per type rather than per call. +fn render_pattern_fields(output: &mut String, members: &[ConstrainedMember<'_>]) { + let patterned = members + .iter() + .filter(|member| member.pattern.is_some()) + .collect::>(); + if patterned.is_empty() { + return; + } + output.push('\n'); + for member in patterned { + let pattern = member.pattern.as_deref().expect("pattern presence checked"); + output.push_str(" private static readonly Regex "); + output.push_str(&member.pattern_field()); + output.push_str(" = new Regex("); + output.push_str(&csharp_string_literal(pattern)); + // CultureInvariant so character classes never depend on the ambient + // locale. Backtracking safety comes from the loader's RE2 gate, which + // rejects lookaround and backreferences outright. + output.push_str(", RegexOptions.CultureInvariant);\n"); } } fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_>) { - // An optional member is `long?`/`double?`; bind it once so each bound reads - // the unwrapped value, and skip every check when it is absent. + // An optional member arrives as `long?`/`double?`/`string?`; bind it once so + // every check reads the unwrapped value, and skip them all when it is absent. let (indent, value_expr) = if member.needs_null_guard { let local = csharp_parameter_name(&format!("{}-value", member.json_name)); output.push_str(" if ("); output.push_str(&member.accessor); output.push_str(" is "); - output.push_str(member.numeric_type); + output.push_str(member.clr_type); output.push(' '); output.push_str(&local); output.push_str(")\n {\n"); @@ -326,21 +435,83 @@ fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_> (" ", member.accessor.clone()) }; - for bound in &member.bounds { + let add_violation = |output: &mut String, reason_expr: &str| { + output.push_str(indent); + output.push_str(" violations.Add(new Violation(JsonRuntime.JoinPath(path, "); + output.push_str(&csharp_string_literal(member.json_name)); + output.push_str("), "); + output.push_str(reason_expr); + output.push_str("));\n"); + }; + + for bound in &member.numeric_bounds { output.push_str(indent); output.push_str("if ("); output.push_str(&bound.violation_condition(&value_expr)); output.push_str(")\n"); output.push_str(indent); output.push_str("{\n"); + add_violation( + output, + &format!( + "{} + JsonRuntime.FormatNumber({value_expr})", + csharp_string_literal(&bound.reason_format()) + ), + ); output.push_str(indent); - output.push_str(" violations.Add(new Violation(JsonRuntime.JoinPath(path, "); - output.push_str(&csharp_string_literal(member.json_name)); - output.push_str("), "); - output.push_str(&csharp_string_literal(&bound.reason_format())); - output.push_str(" + JsonRuntime.FormatNumber("); + output.push_str("}\n"); + } + + // Length is a **code point** count, matching Go's utf8.RuneCountInString and + // Java's codePointCount. C#'s `string.Length` counts UTF-16 units, which would + // over-count every astral character. + if !member.length_bounds.is_empty() { + let length_local = csharp_parameter_name(&format!("{}-length", member.json_name)); + output.push_str(indent); + output.push_str("var "); + output.push_str(&length_local); + output.push_str(" = JsonRuntime.CodePointCount("); output.push_str(&value_expr); - output.push_str(")));\n"); + output.push_str(");\n"); + for bound in &member.length_bounds { + output.push_str(indent); + output.push_str("if ("); + output.push_str(&bound.violation_condition(&length_local)); + output.push_str(")\n"); + output.push_str(indent); + output.push_str("{\n"); + add_violation( + output, + &format!( + "{} + {length_local}", + csharp_string_literal(&bound.reason_prefix()) + ), + ); + output.push_str(indent); + output.push_str("}\n"); + } + } + + if let Some(pattern) = &member.pattern { + output.push_str(indent); + output.push_str("if (!"); + output.push_str(&member.pattern_field()); + output.push_str(".IsMatch("); + output.push_str(&value_expr); + output.push_str("))\n"); + output.push_str(indent); + output.push_str("{\n"); + // Wording follows Java, which like .NET rewrites the `$` end anchor to + // `\z` and so reports the rewritten pattern. Go quotes via `%q` and keeps + // `$`, so the two already differ; matching Java is the closest parity + // available. + add_violation( + output, + &format!( + "{} + {value_expr}", + csharp_string_literal(&format!("must match pattern {pattern}, got ")) + ), + ); output.push_str(indent); output.push_str("}\n"); } diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs index c1f03a9f..37f7c159 100644 --- a/src/generator/json_schema/dotnet_coverage.rs +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -31,10 +31,7 @@ use crate::workspace::{ApiSpecNode, ApiSpecTree}; /// because the loader merges it away before any backend sees it. const UNENFORCED_KEYWORDS: &[&str] = &[ // Numeric bounds are enforced; see `render_constraint_validator`. - // String assertions. - "minLength", - "maxLength", - "pattern", + // String assertions. minLength/maxLength/pattern are enforced. "format", "contentEncoding", "contentMediaType", @@ -199,6 +196,11 @@ mod tests { use super::*; + // These cases deliberately use `contentMediaType` / `dependentSchemas` — real + // entries in UNENFORCED_KEYWORDS that no planned phase implements — so the + // classifier's behavior can be asserted without the case needing an edit every + // time a keyword gains enforcement. Coverage of the *current* gap set lives in + // `tests/generate_dotnet.rs`, which is meant to churn. fn findings_for(schema: serde_json::Value) -> Vec { let mut findings = BTreeMap::new(); collect_schema(&schema, "Model", &mut findings); @@ -212,22 +214,24 @@ mod tests { fn reports_an_unenforced_constraint_on_a_member() { let warnings = findings_for(json!({ "type": "object", - "properties": { "name": { "type": "string", "minLength": 2 } }, + "properties": { "name": { "type": "string", "contentMediaType": "text/plain" } }, })); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("`minLength`"), "{:?}", warnings); + assert!(warnings[0].contains("`contentMediaType`"), "{:?}", warnings); assert!(warnings[0].contains("Model.name"), "{:?}", warnings); } #[test] - fn does_not_report_enforced_numeric_bounds() { + fn does_not_report_enforced_constraints() { let warnings = findings_for(json!({ "type": "object", "properties": { "order": { "type": "integer", "minimum": 0, "maximum": 10 }, "level": { "type": "integer", "exclusiveMinimum": 0 }, "ratio": { "type": "number", "multipleOf": 5 }, + "name": { "type": "string", "minLength": 1, "maxLength": 8 }, + "code": { "type": "string", "pattern": "^[A-Z]+$" }, }, })); @@ -282,10 +286,13 @@ mod tests { let warnings = findings_for(json!({ "type": "object", "properties": { - "tags": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "tags": { + "type": "array", + "items": { "type": "string", "contentMediaType": "text/plain" }, + }, "nested": { "type": "object", - "properties": { "code": { "type": "string", "pattern": "^[A-Z]+$" } }, + "dependentSchemas": { "a": { "type": "object" } }, }, }, })); @@ -294,12 +301,12 @@ mod tests { assert!( warnings .iter() - .any(|w| w.contains("`minLength`") && w.contains("Model.tags")) + .any(|w| w.contains("`contentMediaType`") && w.contains("Model.tags")) ); assert!( warnings .iter() - .any(|w| w.contains("`pattern`") && w.contains("Model.nested.code")) + .any(|w| w.contains("`dependentSchemas`") && w.contains("Model.nested")) ); } @@ -308,12 +315,12 @@ mod tests { let warnings = findings_for(json!({ "type": "object", "properties": { - "a": { "type": "string", "minLength": 1 }, - "b": { "type": "string", "minLength": 1 }, - "c": { "type": "string", "minLength": 1 }, - "d": { "type": "string", "minLength": 1 }, - "e": { "type": "string", "minLength": 1 }, - "f": { "type": "string", "minLength": 1 }, + "a": { "type": "string", "contentMediaType": "text/plain" }, + "b": { "type": "string", "contentMediaType": "text/plain" }, + "c": { "type": "string", "contentMediaType": "text/plain" }, + "d": { "type": "string", "contentMediaType": "text/plain" }, + "e": { "type": "string", "contentMediaType": "text/plain" }, + "f": { "type": "string", "contentMediaType": "text/plain" }, }, })); diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs index c99cd7fb..38f4a10f 100644 --- a/src/generator/json_schema/dotnet_definitions.rs +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -270,6 +270,31 @@ namespace {namespace} return (long)number; }} + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + {{ + var count = 0; + for (var index = 0; index < value.Length; index++) + {{ + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + {{ + index++; + }} + }} + return count; + }} + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index 6a3c9381..3cd444a9 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -613,13 +613,10 @@ fn dotnet_json_coverage_warnings_match_known_gaps() { "format", "maxContains", "maxItems", - "maxLength", "minContains", "minItems", - "minLength", "minProperties", "oneOf", - "pattern", "propertyNames", "uniqueItems", ]; From 2102e698129773b3d492415658de1fcde08e7b00 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 16:34:52 -0700 Subject: [PATCH 09/16] Enforce JSON-Schema array assertions in the .NET backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds minItems, maxItems, uniqueItems and the contains/minContains/maxContains occurrence window. Coverage warnings drop from 13 keywords to 7. Semantics follow Go element-for-element: - `uniqueItems` reports one violation per *later* occurrence, each against the index where the value was first seen — so ["a","a","a"] yields two violations (1→0 and 2→0), not one per pair. A test pins that. - `contains` counts only matching elements, so minContains/maxContains bound the match count rather than the array length. - `contains` with no `minContains` means "at least one", per the spec default. `contains` is lowered only for a bare `const` branch. Matching an arbitrary subschema per element would require the validator to be reentrant over element values, which is a larger change than this slice; `const` is what the corpus uses and all Go emits. Rather than let the unsupported shape pass silently, the coverage classifier is now shape-aware: a non-`const` `contains` still warns, and minContains/maxContains warn with it, reported as "enforced only for a `const` branch, and this one is not". Same treatment as the `oneOf` nullable-wrapper check. `constraint_clr_type` now returns String and resolves an array member to its `IReadOnlyList` shape so the null-guard pattern match binds against the stored value. Also worth noting for anyone reproducing the sample regeneration: `set -- $a` inside a `for` loop does not word-split in zsh, so a loop of that shape silently passes an empty `--output`. The regeneration commands in the README are individual invocations for that reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../json_schema/api/chat/Definitions.cs | 72 +++++++ .../dotnet/json_schema/api/kb/Definitions.cs | 72 +++++++ .../json_schema/api/showcase/Definitions.cs | 47 +++++ .../dotnet/json_schema/api/showcase/Models.cs | 27 +++ samples/dotnet/README.md | 3 +- samples/dotnet/chat/Definitions.cs | 72 +++++++ samples/dotnet/kb/Definitions.cs | 72 +++++++ samples/dotnet/showcase/Definitions.cs | 47 +++++ samples/dotnet/showcase/Models.cs | 27 +++ samples/dotnet/tests/ArrayConstraintChecks.cs | 155 ++++++++++++++ src/generator/json_schema/dotnet.rs | 192 +++++++++++++++++- src/generator/json_schema/dotnet_coverage.rs | 31 ++- .../json_schema/dotnet_definitions.rs | 47 +++++ tests/generate_dotnet.rs | 6 - 14 files changed, 848 insertions(+), 22 deletions(-) create mode 100644 samples/dotnet/tests/ArrayConstraintChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs index 68595e8f..51e35f17 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs @@ -163,6 +163,78 @@ internal static class JsonRuntime return (long)number; } + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs index f908c38e..678c5547 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs @@ -163,6 +163,78 @@ internal static class JsonRuntime return (long)number; } + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs index eb7a54e3..0ee7cbfb 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs @@ -163,6 +163,53 @@ internal static class JsonRuntime return (long)number; } + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + /// /// Counts Unicode code points, which is the unit JSON Schema's /// minLength/maxLength measure. diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs index e5edd0e0..b0359331 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -923,6 +923,33 @@ internal void CollectViolations(List violations, string path) violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "must be a multiple of 3, got " + JsonRuntime.FormatNumber(stepValue))); } } + if (Tags is IReadOnlyList tagsValue) + { + if (tagsValue.Count < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "tags"), "must have at least 1 items, got " + tagsValue.Count)); + } + if (tagsValue.Count > 5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "tags"), "must have at most 5 items, got " + tagsValue.Count)); + } + } + if (Aliases is IReadOnlyList aliasesValue) + { + JsonRuntime.CollectDuplicateItems(aliasesValue, JsonRuntime.JoinPath(path, "aliases"), violations); + } + if (Roles is IReadOnlyList rolesValue) + { + var rolesMatchCount = JsonRuntime.CountMatchingItems(rolesValue, "admin"); + if (rolesMatchCount < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "roles"), "too few matching items: at least 1, got " + rolesMatchCount)); + } + if (rolesMatchCount > 2) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "roles"), "too many matching items: at most 2, got " + rolesMatchCount)); + } + } } void IJsonOnDeserialized.OnDeserialized() diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 1a49dd3b..3ac16bcf 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -44,7 +44,8 @@ affected members, so nothing is dropped silently. | `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` / `multipleOf` | ✅ | ✅ | | `maxProperties` | ✅ | ✅ | | `minLength` / `maxLength` / `pattern` | ✅ | ✅ | -| `minItems` / `maxItems` / `uniqueItems` / `contains` | ✅ | ❌ dropped | +| `minItems` / `maxItems` / `uniqueItems` | ✅ | ✅ | +| `contains` / `minContains` / `maxContains` | ✅ | ⚠️ `const` branch only | | `minProperties` / `dependentRequired` / `propertyNames` | ✅ | ❌ dropped | | `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | | `oneOf` discriminated unions | ✅ | ❌ emitted as an empty class — branches lost | diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs index 68595e8f..51e35f17 100644 --- a/samples/dotnet/chat/Definitions.cs +++ b/samples/dotnet/chat/Definitions.cs @@ -163,6 +163,78 @@ internal static class JsonRuntime return (long)number; } + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/samples/dotnet/kb/Definitions.cs b/samples/dotnet/kb/Definitions.cs index f908c38e..678c5547 100644 --- a/samples/dotnet/kb/Definitions.cs +++ b/samples/dotnet/kb/Definitions.cs @@ -163,6 +163,78 @@ internal static class JsonRuntime return (long)number; } + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/samples/dotnet/showcase/Definitions.cs b/samples/dotnet/showcase/Definitions.cs index eb7a54e3..0ee7cbfb 100644 --- a/samples/dotnet/showcase/Definitions.cs +++ b/samples/dotnet/showcase/Definitions.cs @@ -163,6 +163,53 @@ internal static class JsonRuntime return (long)number; } + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + /// /// Counts Unicode code points, which is the unit JSON Schema's /// minLength/maxLength measure. diff --git a/samples/dotnet/showcase/Models.cs b/samples/dotnet/showcase/Models.cs index e5edd0e0..b0359331 100644 --- a/samples/dotnet/showcase/Models.cs +++ b/samples/dotnet/showcase/Models.cs @@ -923,6 +923,33 @@ internal void CollectViolations(List violations, string path) violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "must be a multiple of 3, got " + JsonRuntime.FormatNumber(stepValue))); } } + if (Tags is IReadOnlyList tagsValue) + { + if (tagsValue.Count < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "tags"), "must have at least 1 items, got " + tagsValue.Count)); + } + if (tagsValue.Count > 5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "tags"), "must have at most 5 items, got " + tagsValue.Count)); + } + } + if (Aliases is IReadOnlyList aliasesValue) + { + JsonRuntime.CollectDuplicateItems(aliasesValue, JsonRuntime.JoinPath(path, "aliases"), violations); + } + if (Roles is IReadOnlyList rolesValue) + { + var rolesMatchCount = JsonRuntime.CountMatchingItems(rolesValue, "admin"); + if (rolesMatchCount < 1) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "roles"), "too few matching items: at least 1, got " + rolesMatchCount)); + } + if (rolesMatchCount > 2) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "roles"), "too many matching items: at most 2, got " + rolesMatchCount)); + } + } } void IJsonOnDeserialized.OnDeserialized() diff --git a/samples/dotnet/tests/ArrayConstraintChecks.cs b/samples/dotnet/tests/ArrayConstraintChecks.cs new file mode 100644 index 00000000..497d6169 --- /dev/null +++ b/samples/dotnet/tests/ArrayConstraintChecks.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Xunit; +using Showcase = NexGen.ShowcaseService; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers minItems, maxItems, uniqueItems and the + /// contains/minContains/maxContains occurrence window, + /// using showcase's tags, aliases and roles members. + /// + public class ArrayConstraintChecks + { + private static readonly JsonSerializerOptions Options = new(); + + private const string RequiredMembers = + @"""kind"":""showcase"",""revision"":1,""enabled"":true," + + @"""status"":""active"",""tier"":1,""scale"":1.5,""name"":""Widget""," + + @"""count"":3,""active"":true,""category"":""tools"""; + + private static string Payload(string extraMembers) => + "{" + RequiredMembers + "," + extraMembers + "}"; + + private static Showcase.Showcase Deserialize(string json) => + JsonSerializer.Deserialize(json, Options)!; + + private static Showcase.ValidationException Rejects(string json) => + Assert.Throws(() => Deserialize(json)); + + [Fact] + public void ArrayWithinBoundsIsAccepted() + { + var value = Deserialize(Payload(@"""tags"":[""a"",""b""]")); + + Assert.Equal(2, value.Tags!.Count); + } + + [Fact] + public void EmptyArrayBelowMinItemsIsRejected() + { + var exception = Rejects(Payload(@"""tags"":[]")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("tags", violation.Path); + Assert.Equal("must have at least 1 items, got 0", violation.Reason); + } + + [Fact] + public void ArrayAboveMaxItemsIsRejected() + { + var exception = Rejects(Payload(@"""tags"":[""a"",""b"",""c"",""d"",""e"",""f""]")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("tags", violation.Path); + Assert.Equal("must have at most 5 items, got 6", violation.Reason); + } + + [Fact] + public void DistinctItemsSatisfyUniqueItems() + { + var value = Deserialize(Payload(@"""aliases"":[""a"",""b"",""c""]")); + + Assert.Equal(3, value.Aliases!.Count); + } + + [Fact] + public void DuplicateItemReportsBothIndexes() + { + var exception = Rejects(Payload(@"""aliases"":[""a"",""b"",""a""]")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("aliases", violation.Path); + Assert.Equal("duplicate items: element at index 2 equals index 0", violation.Reason); + } + + /// + /// A value repeated three times yields one violation per later occurrence, + /// each against the first sighting — not one per pair. Matches Go. + /// + [Fact] + public void EveryDuplicateOccurrenceIsReportedAgainstTheFirstSighting() + { + var exception = Rejects(Payload(@"""aliases"":[""a"",""a"",""a""]")); + + Assert.Equal(2, exception.Violations.Count); + Assert.Equal("duplicate items: element at index 1 equals index 0", exception.Violations[0].Reason); + Assert.Equal("duplicate items: element at index 2 equals index 0", exception.Violations[1].Reason); + } + + [Fact] + public void ContainsWindowIsSatisfiedByOneMatch() + { + var value = Deserialize(Payload(@"""roles"":[""admin"",""user""]")); + + Assert.Equal(2, value.Roles!.Count); + } + + [Fact] + public void NoMatchingItemViolatesMinContains() + { + var exception = Rejects(Payload(@"""roles"":[""user"",""guest""]")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("roles", violation.Path); + Assert.Equal("too few matching items: at least 1, got 0", violation.Reason); + } + + [Fact] + public void TooManyMatchingItemsViolatesMaxContains() + { + var exception = Rejects(Payload(@"""roles"":[""admin"",""admin"",""admin""]")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("roles", violation.Path); + Assert.Equal("too many matching items: at most 2, got 3", violation.Reason); + } + + /// + /// maxContains counts only matching elements, so a long array with two + /// matches is fine. + /// + [Fact] + public void NonMatchingItemsDoNotCountTowardTheWindow() + { + var value = Deserialize(Payload(@"""roles"":[""admin"",""a"",""b"",""admin""]")); + + Assert.Equal(4, value.Roles!.Count); + } + + /// + /// An absent optional array is not a violation, even though `tags` declares + /// `minItems: 1` — the bound applies to a present value. + /// + [Fact] + public void AbsentOptionalArrayIsNotAViolation() + { + var value = Deserialize("{" + RequiredMembers + "}"); + + Assert.Null(value.Tags); + } + + [Fact] + public void ViolationsAcrossSeveralArraysAreReportedTogether() + { + var exception = Rejects(Payload( + @"""tags"":[],""aliases"":[""a"",""a""],""roles"":[""user""]")); + + Assert.Equal(3, exception.Violations.Count); + Assert.Contains("tags: must have at least 1 items, got 0", exception.Message); + Assert.Contains("aliases: duplicate items: element at index 1 equals index 0", exception.Message); + Assert.Contains("roles: too few matching items: at least 1, got 0", exception.Message); + } + } +} diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index f939d5c8..067fbb09 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -51,6 +51,17 @@ struct Schema { #[serde(rename = "maxLength")] max_length: Option, pattern: Option, + #[serde(rename = "minItems")] + min_items: Option, + #[serde(rename = "maxItems")] + max_items: Option, + #[serde(rename = "uniqueItems")] + unique_items: Option, + contains: Option>, + #[serde(rename = "minContains")] + min_contains: Option, + #[serde(rename = "maxContains")] + max_contains: Option, } impl Schema { @@ -84,6 +95,34 @@ impl Schema { .collect() } + /// The array-length bounds declared on this schema, `minItems` first. + fn item_count_bounds(&self) -> Vec { + [(true, self.min_items), (false, self.max_items)] + .into_iter() + .filter_map(|(at_least, bound)| bound.map(|bound| ItemCountBound { at_least, bound })) + .collect() + } + + /// The `contains` check, when it is a shape .NET can lower. + /// + /// Only a bare `const` branch is supported, which is what the corpus uses and + /// all Go emits — matching an arbitrary subschema per element would need the + /// whole validator to be reentrant over element values. Anything else stays a + /// reported gap; see [`contains_is_supported`]. + fn contains_check(&self) -> Option { + let contains = self.contains.as_deref()?; + let literal = contains + .const_value + .as_ref() + .and_then(csharp_value_literal)?; + Some(ContainsCheck { + literal, + // `contains` without `minContains` means "at least one" per the spec. + min: self.min_contains.unwrap_or(1), + max: self.max_contains, + }) + } + /// This schema's `pattern` with its `$` end anchor rewritten to `\z`. /// /// .NET's `Regex` treats a bare `$` as "end of string, or before a single @@ -308,16 +347,25 @@ struct ConstrainedMember<'a> { /// guarded against the absent case. needs_null_guard: bool, /// The CLR type the value binds to once unwrapped from its nullable form. - clr_type: &'static str, + clr_type: String, numeric_bounds: Vec>, length_bounds: Vec, + item_count_bounds: Vec, + /// True when `uniqueItems` demands every element be distinct. + unique_items: bool, + contains: Option, /// The loader-normalized pattern, already end-anchor rewritten for .NET. pattern: Option, } impl ConstrainedMember<'_> { fn has_constraints(&self) -> bool { - !self.numeric_bounds.is_empty() || !self.length_bounds.is_empty() || self.pattern.is_some() + !self.numeric_bounds.is_empty() + || !self.length_bounds.is_empty() + || self.pattern.is_some() + || !self.item_count_bounds.is_empty() + || self.unique_items + || self.contains.is_some() } /// The private static `Regex` field backing this member's `pattern`. Named @@ -334,6 +382,38 @@ struct LengthBound { bound: usize, } +/// A `minItems`/`maxItems` bound over an array's element count. +#[derive(Debug, Clone, Copy)] +struct ItemCountBound { + at_least: bool, + bound: usize, +} + +impl ItemCountBound { + fn reason_prefix(&self) -> String { + let quantifier = if self.at_least { "at least" } else { "at most" }; + format!("must have {quantifier} {} items, got ", self.bound) + } + + fn violation_condition(&self, count_expr: &str) -> String { + if self.at_least { + format!("{count_expr} < {}", self.bound) + } else { + format!("{count_expr} > {}", self.bound) + } + } +} + +/// A `contains` check over a `const` element, with its `minContains`/`maxContains` +/// occurrence window. +#[derive(Debug)] +struct ContainsCheck { + /// The C# literal every element is compared against. + literal: String, + min: usize, + max: Option, +} + impl LengthBound { /// The reason wording Go and Java both use, over a **code point** count. fn reason_prefix(&self) -> String { @@ -367,6 +447,9 @@ fn constrained_members(schema: &Schema) -> Vec> { numeric_bounds: property.numeric_bounds(), length_bounds: property.length_bounds(), pattern: property.dotnet_pattern(), + item_count_bounds: property.item_count_bounds(), + unique_items: property.unique_items.unwrap_or(false), + contains: property.contains_check(), }; member.has_constraints().then_some(member) }) @@ -375,11 +458,21 @@ fn constrained_members(schema: &Schema) -> Vec> { /// The CLR type a constrained member's value binds to when unwrapped from its /// nullable form. -fn constraint_clr_type(schema: &Schema) -> &'static str { +fn constraint_clr_type(schema: &Schema) -> String { match schema.ty.as_ref().and_then(Value::as_str) { - Some("integer") => "long", - Some("number") => "double", - Some("string") => "string", + Some("integer") => "long".to_string(), + Some("number") => "double".to_string(), + Some("string") => "string".to_string(), + // An array binds to the same read-only list shape the property exposes, so + // the pattern match in the null guard succeeds against the stored value. + Some("array") => { + let item = schema + .items + .as_deref() + .map(constraint_clr_type) + .unwrap_or_else(|| "object".to_string()); + format!("IReadOnlyList<{item}>") + } // Nullable spellings carry the concrete type on the non-null branch. _ => schema .one_of @@ -390,7 +483,7 @@ fn constraint_clr_type(schema: &Schema) -> &'static str { .find(|branch| !schema_type_includes(branch, "null")) .map(constraint_clr_type) }) - .unwrap_or("object"), + .unwrap_or_else(|| "object".to_string()), } } @@ -426,7 +519,7 @@ fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_> output.push_str(" if ("); output.push_str(&member.accessor); output.push_str(" is "); - output.push_str(member.clr_type); + output.push_str(&member.clr_type); output.push(' '); output.push_str(&local); output.push_str(")\n {\n"); @@ -492,6 +585,89 @@ fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_> } } + if !member.item_count_bounds.is_empty() { + let count_expr = format!("{value_expr}.Count"); + for bound in &member.item_count_bounds { + output.push_str(indent); + output.push_str("if ("); + output.push_str(&bound.violation_condition(&count_expr)); + output.push_str(")\n"); + output.push_str(indent); + output.push_str("{\n"); + add_violation( + output, + &format!( + "{} + {count_expr}", + csharp_string_literal(&bound.reason_prefix()) + ), + ); + output.push_str(indent); + output.push_str("}\n"); + } + } + + // Reports every duplicate occurrence against the index of its first sighting, + // matching Go element-for-element rather than stopping at the first pair. + if member.unique_items { + output.push_str(indent); + output.push_str("JsonRuntime.CollectDuplicateItems("); + output.push_str(&value_expr); + output.push_str(", JsonRuntime.JoinPath(path, "); + output.push_str(&csharp_string_literal(member.json_name)); + output.push_str("), violations);\n"); + } + + if let Some(contains) = &member.contains { + let match_count = csharp_parameter_name(&format!("{}-match-count", member.json_name)); + output.push_str(indent); + output.push_str("var "); + output.push_str(&match_count); + output.push_str(" = JsonRuntime.CountMatchingItems("); + output.push_str(&value_expr); + output.push_str(", "); + output.push_str(&contains.literal); + output.push_str(");\n"); + output.push_str(indent); + output.push_str("if ("); + output.push_str(&match_count); + output.push_str(" < "); + output.push_str(&contains.min.to_string()); + output.push_str(")\n"); + output.push_str(indent); + output.push_str("{\n"); + add_violation( + output, + &format!( + "{} + {match_count}", + csharp_string_literal(&format!( + "too few matching items: at least {}, got ", + contains.min + )) + ), + ); + output.push_str(indent); + output.push_str("}\n"); + if let Some(max) = contains.max { + output.push_str(indent); + output.push_str("if ("); + output.push_str(&match_count); + output.push_str(" > "); + output.push_str(&max.to_string()); + output.push_str(")\n"); + output.push_str(indent); + output.push_str("{\n"); + add_violation( + output, + &format!( + "{} + {match_count}", + csharp_string_literal(&format!("too many matching items: at most {max}, got ")) + ), + ); + output.push_str(indent); + output.push_str("}\n"); + } + } + if let Some(pattern) = &member.pattern { output.push_str(indent); output.push_str("if (!"); diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs index 37f7c159..33f117a4 100644 --- a/src/generator/json_schema/dotnet_coverage.rs +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -36,13 +36,8 @@ const UNENFORCED_KEYWORDS: &[&str] = &[ "contentEncoding", "contentMediaType", "contentSchema", - // Array assertions. - "minItems", - "maxItems", - "uniqueItems", - "contains", - "minContains", - "maxContains", + // Array assertions. minItems/maxItems/uniqueItems are enforced, and so is + // `contains` in its `const` form — see the shape check below. "prefixItems", // Object assertions. "minProperties", @@ -117,6 +112,20 @@ fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static s findings.entry("oneOf").or_default().push(path.to_string()); } + // `contains` is lowered only for a bare `const` branch; matching an arbitrary + // subschema per element would need the validator to be reentrant over element + // values. `minContains`/`maxContains` ride along with it, so they are reported + // only when the `contains` they qualify is itself unsupported. + if let Some(contains) = members.get("contains") + && !contains_is_supported(contains) + { + for keyword in ["contains", "minContains", "maxContains"] { + if keyword == "contains" || members.contains_key(keyword) { + findings.entry(keyword).or_default().push(path.to_string()); + } + } + } + for (key, value) in members { match key.as_str() { // Child schemas keyed by member name. @@ -159,6 +168,11 @@ fn is_nullable_wrapper(branches: &[Value]) -> bool { null_branches > 0 && branches.len() - null_branches == 1 } +/// True when a `contains` subschema is the `const` form the backend lowers. +fn contains_is_supported(contains: &Value) -> bool { + contains.get("const").is_some() +} + fn is_null_schema(schema: &Value) -> bool { schema .get("type") @@ -172,6 +186,9 @@ fn format_warning(keyword: &str, paths: &[String]) -> String { "enum" => "is emitted as a bare scalar with no closed value set", "format" => "is left as `string` — neither asserted nor materialized", "contentEncoding" => "is left as `string` — not decoded to bytes", + "contains" | "minContains" | "maxContains" => { + "is enforced only for a `const` branch, and this one is not" + } _ => "is not enforced — .NET constraint validation is unimplemented", }; diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs index 38f4a10f..30015394 100644 --- a/src/generator/json_schema/dotnet_definitions.rs +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -270,6 +270,53 @@ namespace {namespace} return (long)number; }} + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + {{ + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + {{ + if (seen.TryGetValue(items[index], out var first)) + {{ + violations.Add(new Violation( + path, + $"duplicate items: element at index {{index}} equals index {{first}}")); + }} + else + {{ + seen[items[index]] = index; + }} + }} + }} + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + {{ + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + {{ + if (comparer.Equals(item, expected)) + {{ + count++; + }} + }} + return count; + }} + /// /// Counts Unicode code points, which is the unit JSON Schema's /// minLength/maxLength measure. diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index 3cd444a9..a913c701 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -606,19 +606,13 @@ fn dotnet_json_coverage_warnings_match_known_gaps() { warned_keywords.dedup(); let expected = [ - "contains", "contentEncoding", "dependentRequired", "enum", "format", - "maxContains", - "maxItems", - "minContains", - "minItems", "minProperties", "oneOf", "propertyNames", - "uniqueItems", ]; assert_eq!( From 3bc77093a690eaf1b9df0a863e14c17e65d1be8b Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 16:43:17 -0700 Subject: [PATCH 10/16] Enforce JSON-Schema object assertions in the .NET backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds minProperties, dependentRequired and propertyNames, and moves maxProperties onto the same footing. Coverage warnings drop from 7 keywords to 4 — only `oneOf`, `enum`, `format` and `contentEncoding` remain. These differ from every constraint so far by being checked against the **wire member set** rather than a single member's value, so they render at the top level of CollectViolations rather than inside a member guard, and their violations carry the containing path with no member segment appended (matching Go, which reports them with an empty path). Recovering "how many members did the payload carry" needed care. Go reads it off the raw map at unmarshal time and consequently leaves ContactGo.Validate() empty — the count is not recoverable from its parsed struct. The .NET shape does recover it exactly: `[JsonRequired]` guarantees every required property was present, and every optional or unknown member lands in the extension bag, so the count is ` + AdditionalProperties.Count`. That makes minProperties and maxProperties hold on the serialize side too, which Go's arrangement cannot do. maxProperties previously threw a bare `JsonException("maxProperties: at most 50 entries")` from OnDeserialized. It now aggregates with everything else and reads `must have at most 50 properties, got 51`, matching Go. Behavior change covered by a test. Two keywords are lowered only for the shapes the corpus exercises, with the coverage classifier made shape-aware rather than letting the rest pass silently: - `propertyNames` applies to map-shaped objects, whose bag holds every member. On an object with declared properties the keyword also governs those declared names, which the bag does not carry. - Its reason interpolates the offending key — `invalid property name "x": must have length <= 8, got 12` — duplicating what the path already says, because that is what keeps the text identical to Go's `%q` form. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotnet/json_schema/api/chat/Models.cs | 27 +- .../dotnet/json_schema/api/showcase/Models.cs | 99 +++++++- samples/dotnet/README.md | 4 +- samples/dotnet/chat/Models.cs | 27 +- samples/dotnet/showcase/Models.cs | 99 +++++++- .../dotnet/tests/ObjectConstraintChecks.cs | 179 ++++++++++++++ src/generator/json_schema/dotnet.rs | 233 +++++++++++++++++- src/generator/json_schema/dotnet_coverage.rs | 25 +- tests/generate_dotnet.rs | 10 +- 9 files changed, 658 insertions(+), 45 deletions(-) create mode 100644 samples/dotnet/tests/ObjectConstraintChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/chat/Models.cs b/advanced/samples/dotnet/json_schema/api/chat/Models.cs index 03d34498..acd245d1 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Models.cs @@ -36,12 +36,32 @@ public class Labels : IJsonOnDeserialized [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) { - if (AdditionalProperties.Count > 50) + var propertyCount = AdditionalProperties.Count; + if (propertyCount > 50) { - throw new JsonException("maxProperties: at most 50 entries"); + violations.Add(new Violation(path, "must have at most 50 properties, got " + propertyCount)); } + } + + void IJsonOnDeserialized.OnDeserialized() + { foreach (var entry in AdditionalProperties) { if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) @@ -53,6 +73,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{entry.Key}: expected string"); } } + Validate(); } } diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs index b0359331..a7657af6 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -83,12 +83,44 @@ public class Attributes : IJsonOnDeserialized [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() { - if (AdditionalProperties.Count > 3) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + var propertyCount = AdditionalProperties.Count; + if (propertyCount < 1) + { + violations.Add(new Violation(path, "must have at least 1 properties, got " + propertyCount)); + } + if (propertyCount > 3) { - throw new JsonException("maxProperties: at most 3 entries"); + violations.Add(new Violation(path, "must have at most 3 properties, got " + propertyCount)); } + foreach (var propertyName in AdditionalProperties.Keys) + { + var nameLength = JsonRuntime.CodePointCount(propertyName); + if (nameLength > 8) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, propertyName), $"invalid property name \"{propertyName}\": must have length <= 8, got {nameLength}")); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { foreach (var entry in AdditionalProperties) { if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) @@ -100,6 +132,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{entry.Key}: expected string"); } } + Validate(); } } @@ -180,6 +213,38 @@ public string? ShippingZip [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + var propertyCount = AdditionalProperties.Count; + if (propertyCount < 1) + { + violations.Add(new Violation(path, "must have at least 1 properties, got " + propertyCount)); + } + if (propertyCount > 3) + { + violations.Add(new Violation(path, "must have at most 3 properties, got " + propertyCount)); + } + if (AdditionalProperties.ContainsKey("shippingStreet") && !AdditionalProperties.ContainsKey("shippingZip")) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "shippingZip"), "property \"shippingZip\" is required when \"shippingStreet\" is present")); + } + } + void IJsonOnDeserialized.OnDeserialized() { if (AdditionalProperties.TryGetValue("email", out var emailValue)) @@ -218,6 +283,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{"shippingZip"}: expected string"); } } + Validate(); } } @@ -231,12 +297,32 @@ public class Labels : IJsonOnDeserialized [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() { - if (AdditionalProperties.Count > 50) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - throw new JsonException("maxProperties: at most 50 entries"); + throw new ValidationException(violations); } + } + + internal void CollectViolations(List violations, string path) + { + var propertyCount = AdditionalProperties.Count; + if (propertyCount > 50) + { + violations.Add(new Violation(path, "must have at most 50 properties, got " + propertyCount)); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { foreach (var entry in AdditionalProperties) { if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) @@ -248,6 +334,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{entry.Key}: expected string"); } } + Validate(); } } diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 3ac16bcf..cb888a03 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -42,11 +42,11 @@ affected members, so nothing is dropped silently. |---|---|---| | Aggregated `ValidationError` over `Violation[]` | ✅ shared `definitions` runtime | ✅ `Definitions.cs` | | `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` / `multipleOf` | ✅ | ✅ | -| `maxProperties` | ✅ | ✅ | | `minLength` / `maxLength` / `pattern` | ✅ | ✅ | | `minItems` / `maxItems` / `uniqueItems` | ✅ | ✅ | | `contains` / `minContains` / `maxContains` | ✅ | ⚠️ `const` branch only | -| `minProperties` / `dependentRequired` / `propertyNames` | ✅ | ❌ dropped | +| `minProperties` / `maxProperties` / `dependentRequired` | ✅ | ✅ | +| `propertyNames` | ✅ | ⚠️ map-shaped objects only | | `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | | `oneOf` discriminated unions | ✅ | ❌ emitted as an empty class — branches lost | | `format` temporal materialization | ✅ native types | ❌ left as `string` | diff --git a/samples/dotnet/chat/Models.cs b/samples/dotnet/chat/Models.cs index 03d34498..acd245d1 100644 --- a/samples/dotnet/chat/Models.cs +++ b/samples/dotnet/chat/Models.cs @@ -36,12 +36,32 @@ public class Labels : IJsonOnDeserialized [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) { - if (AdditionalProperties.Count > 50) + var propertyCount = AdditionalProperties.Count; + if (propertyCount > 50) { - throw new JsonException("maxProperties: at most 50 entries"); + violations.Add(new Violation(path, "must have at most 50 properties, got " + propertyCount)); } + } + + void IJsonOnDeserialized.OnDeserialized() + { foreach (var entry in AdditionalProperties) { if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) @@ -53,6 +73,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{entry.Key}: expected string"); } } + Validate(); } } diff --git a/samples/dotnet/showcase/Models.cs b/samples/dotnet/showcase/Models.cs index b0359331..a7657af6 100644 --- a/samples/dotnet/showcase/Models.cs +++ b/samples/dotnet/showcase/Models.cs @@ -83,12 +83,44 @@ public class Attributes : IJsonOnDeserialized [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() { - if (AdditionalProperties.Count > 3) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + var propertyCount = AdditionalProperties.Count; + if (propertyCount < 1) + { + violations.Add(new Violation(path, "must have at least 1 properties, got " + propertyCount)); + } + if (propertyCount > 3) { - throw new JsonException("maxProperties: at most 3 entries"); + violations.Add(new Violation(path, "must have at most 3 properties, got " + propertyCount)); } + foreach (var propertyName in AdditionalProperties.Keys) + { + var nameLength = JsonRuntime.CodePointCount(propertyName); + if (nameLength > 8) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, propertyName), $"invalid property name \"{propertyName}\": must have length <= 8, got {nameLength}")); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { foreach (var entry in AdditionalProperties) { if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) @@ -100,6 +132,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{entry.Key}: expected string"); } } + Validate(); } } @@ -180,6 +213,38 @@ public string? ShippingZip [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + var propertyCount = AdditionalProperties.Count; + if (propertyCount < 1) + { + violations.Add(new Violation(path, "must have at least 1 properties, got " + propertyCount)); + } + if (propertyCount > 3) + { + violations.Add(new Violation(path, "must have at most 3 properties, got " + propertyCount)); + } + if (AdditionalProperties.ContainsKey("shippingStreet") && !AdditionalProperties.ContainsKey("shippingZip")) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "shippingZip"), "property \"shippingZip\" is required when \"shippingStreet\" is present")); + } + } + void IJsonOnDeserialized.OnDeserialized() { if (AdditionalProperties.TryGetValue("email", out var emailValue)) @@ -218,6 +283,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{"shippingZip"}: expected string"); } } + Validate(); } } @@ -231,12 +297,32 @@ public class Labels : IJsonOnDeserialized [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() { - if (AdditionalProperties.Count > 50) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - throw new JsonException("maxProperties: at most 50 entries"); + throw new ValidationException(violations); } + } + + internal void CollectViolations(List violations, string path) + { + var propertyCount = AdditionalProperties.Count; + if (propertyCount > 50) + { + violations.Add(new Violation(path, "must have at most 50 properties, got " + propertyCount)); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { foreach (var entry in AdditionalProperties) { if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) @@ -248,6 +334,7 @@ void IJsonOnDeserialized.OnDeserialized() throw new JsonException($"{entry.Key}: expected string"); } } + Validate(); } } diff --git a/samples/dotnet/tests/ObjectConstraintChecks.cs b/samples/dotnet/tests/ObjectConstraintChecks.cs new file mode 100644 index 00000000..85d8fa61 --- /dev/null +++ b/samples/dotnet/tests/ObjectConstraintChecks.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using Xunit; +using Chat = NexGen.ChatService; +using Showcase = NexGen.ShowcaseService; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers the object-level assertions — minProperties, + /// maxProperties, propertyNames and dependentRequired — + /// which are checked against the wire member set rather than any one member's + /// value. + /// + /// Two object shapes matter here and are covered separately: showcase's + /// Attributes is map-shaped, so its extension bag holds every member, + /// while Contact declares properties. + /// + public class ObjectConstraintChecks + { + private static readonly JsonSerializerOptions Options = new(); + + private static T Deserialize(string json) => + JsonSerializer.Deserialize(json, Options)!; + + private static Showcase.ValidationException RejectsShowcase(string json) => + Assert.Throws(() => Deserialize(json)); + + [Fact] + public void MapWithinPropertyCountBoundsIsAccepted() + { + var value = Deserialize(@"{""a"":""1"",""b"":""2""}"); + + Assert.Equal(2, value.AdditionalProperties.Count); + } + + /// + /// Object-level violations carry the containing path with no member segment + /// appended — the failure belongs to the object, not a member. Matches Go, + /// which reports these with an empty path. + /// + [Fact] + public void EmptyMapViolatesMinProperties() + { + var exception = RejectsShowcase("{}"); + + var violation = Assert.Single(exception.Violations); + Assert.Equal(string.Empty, violation.Path); + Assert.Equal("must have at least 1 properties, got 0", violation.Reason); + } + + [Fact] + public void OversizedMapViolatesMaxProperties() + { + var exception = RejectsShowcase( + @"{""a"":""1"",""b"":""2"",""c"":""3"",""d"":""4""}"); + + var violation = Assert.Single(exception.Violations); + Assert.Equal(string.Empty, violation.Path); + Assert.Equal("must have at most 3 properties, got 4", violation.Reason); + } + + /// + /// propertyNames length is a code-point count, and the reason names + /// the offending key even though the path carries it too — that duplication + /// is what keeps the text identical to Go's `invalid property name %q`. + /// + [Fact] + public void OverlongPropertyNameIsRejected() + { + var exception = RejectsShowcase(@"{""aVeryLongKey"":""1""}"); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("aVeryLongKey", violation.Path); + Assert.Equal( + @"invalid property name ""aVeryLongKey"": must have length <= 8, got 12", + violation.Reason); + } + + [Fact] + public void PropertyNameAtTheLengthBoundIsAccepted() + { + var value = Deserialize(@"{""12345678"":""1""}"); + + Assert.Contains("12345678", value.AdditionalProperties.Keys); + } + + [Fact] + public void DeclaredPropertyObjectWithinBoundsIsAccepted() + { + var value = Deserialize(@"{""email"":""a@b.c""}"); + + Assert.Equal("a@b.c", value.Email); + } + + [Fact] + public void EmptyDeclaredPropertyObjectViolatesMinProperties() + { + var exception = RejectsShowcase("{}"); + + Assert.Equal("must have at least 1 properties, got 0", Assert.Single(exception.Violations).Reason); + } + + /// + /// The whole point of dependentRequired: a shipping street obliges a + /// shipping zip. + /// + [Fact] + public void DependentRequiredIsViolatedWhenTheTriggerIsPresentAlone() + { + var exception = RejectsShowcase(@"{""shippingStreet"":""1 Main St""}"); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("shippingZip", violation.Path); + Assert.Equal( + @"property ""shippingZip"" is required when ""shippingStreet"" is present", + violation.Reason); + } + + [Fact] + public void DependentRequiredIsSatisfiedWhenBothArePresent() + { + var value = Deserialize( + @"{""shippingStreet"":""1 Main St"",""shippingZip"":""12345""}"); + + Assert.Equal("12345", value.ShippingZip); + } + + /// + /// Absent trigger, absent dependent — not a violation. + /// + [Fact] + public void DependentRequiredDoesNotFireWhenTheTriggerIsAbsent() + { + var value = Deserialize(@"{""shippingZip"":""12345""}"); + + Assert.Null(value.ShippingStreet); + } + + /// + /// chat's Labels declares maxProperties: 50. This used to throw a + /// bare JsonException("maxProperties: at most 50 entries"); it now + /// aggregates like every other constraint and reads the same as Go. + /// + /// Note the exception type is Chat.ValidationException, not + /// showcase's: the shared runtime is emitted per package, so each generated + /// package carries its own. Go, Python and Java all work the same way. + /// + [Fact] + public void MaxPropertiesOnATypedMapNowAggregates() + { + var members = new string[51]; + for (var index = 0; index < members.Length; index++) + { + members[index] = $@"""k{index}"":""v"""; + } + var json = "{" + string.Join(",", members) + "}"; + + var exception = Assert.Throws( + () => Deserialize(json)); + + var violation = Assert.Single(exception.Violations); + Assert.Equal(string.Empty, violation.Path); + Assert.Equal("must have at most 50 properties, got 51", violation.Reason); + } + + [Fact] + public void ObjectAndMemberViolationsAreReportedTogether() + { + // Four members (over maxProperties: 3) and one key too long. + var exception = RejectsShowcase( + @"{""a"":""1"",""b"":""2"",""c"":""3"",""aVeryLongKey"":""4""}"); + + Assert.Equal(2, exception.Violations.Count); + Assert.Contains("must have at most 3 properties, got 4", exception.Message); + Assert.Contains(@"invalid property name ""aVeryLongKey""", exception.Message); + } + } +} diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index 067fbb09..24b7a77a 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -62,6 +62,12 @@ struct Schema { min_contains: Option, #[serde(rename = "maxContains")] max_contains: Option, + #[serde(rename = "minProperties")] + min_properties: Option, + #[serde(rename = "propertyNames")] + property_names: Option>, + #[serde(rename = "dependentRequired")] + dependent_required: Option>>, } impl Schema { @@ -123,6 +129,51 @@ impl Schema { }) } + /// The object-level constraints declared on this schema. + /// + /// These are checked against the **wire member set** rather than any single + /// member's value, which is why they render at the top level of + /// `CollectViolations` rather than inside a member guard. + fn object_constraints(&self) -> Result { + // `propertyNames` is lowered only for a map-shaped object, whose extension + // bag holds every wire member. On an object with declared properties the + // keyword also governs the declared names, which the bag does not carry. + let property_names = match (&self.property_names, self.has_declared_properties()) { + (Some(names), false) => names.length_bounds(), + _ => Vec::new(), + }; + Ok(ObjectConstraints { + count_bounds: [(true, self.min_properties), (false, self.max_properties)] + .into_iter() + .filter_map(|(at_least, bound)| { + bound.map(|bound| PropertyCountBound { at_least, bound }) + }) + .collect(), + property_name_lengths: property_names, + dependent_required: self + .dependent_required + .as_ref() + .map(|dependencies| { + dependencies + .iter() + .flat_map(|(trigger, dependents)| { + dependents.iter().map(move |dependent| DependentRequired { + trigger: trigger.clone(), + dependent: dependent.clone(), + }) + }) + .collect() + }) + .unwrap_or_default(), + }) + } + + fn has_declared_properties(&self) -> bool { + self.properties + .as_ref() + .is_some_and(|properties| !properties.is_empty()) + } + /// This schema's `pattern` with its `$` end anchor rewritten to `\z`. /// /// .NET's `Regex` treats a bare `$` as "end of string, or before a single @@ -303,7 +354,8 @@ fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { /// a bare `order`. fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<()> { let constrained = constrained_members(schema); - if constrained.is_empty() { + let object_constraints = schema.object_constraints()?; + if constrained.is_empty() && object_constraints.is_empty() { return Ok(()); } @@ -333,10 +385,127 @@ fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<( for member in &constrained { render_member_constraints(output, member); } + // Object-level checks come after the per-member ones, matching the order Go + // aggregates them so a multi-violation message reads the same. + render_object_constraints(output, schema, &object_constraints)?; output.push_str(" }\n"); Ok(()) } +/// Emits the object-level checks: `minProperties`/`maxProperties` over the wire +/// member count, `propertyNames` over each member name, and `dependentRequired`. +fn render_object_constraints( + output: &mut String, + schema: &Schema, + constraints: &ObjectConstraints, +) -> Result<()> { + if constraints.is_empty() { + return Ok(()); + } + + if !constraints.count_bounds.is_empty() { + let count_expr = wire_property_count_expression(schema)?; + output.push_str(" var propertyCount = "); + output.push_str(&count_expr); + output.push_str(";\n"); + for bound in &constraints.count_bounds { + output.push_str(" if ("); + output.push_str(&bound.violation_condition("propertyCount")); + output.push_str(")\n {\n"); + // Object-level violations carry the containing path, with no member + // segment appended — the failure is the object's, not a member's. + output.push_str(" violations.Add(new Violation(path, "); + output.push_str(&csharp_string_literal(&bound.reason_prefix())); + output.push_str(" + propertyCount));\n"); + output.push_str(" }\n"); + } + } + + if !constraints.property_name_lengths.is_empty() { + output.push_str(" foreach (var propertyName in AdditionalProperties.Keys)\n"); + output.push_str(" {\n"); + output.push_str(" var nameLength = JsonRuntime.CodePointCount(propertyName);\n"); + for bound in &constraints.property_name_lengths { + output.push_str(" if ("); + output.push_str(&bound.violation_condition("nameLength")); + output.push_str(")\n {\n"); + output.push_str( + " violations.Add(new Violation(JsonRuntime.JoinPath(path, propertyName), ", + ); + // Interpolated so the reason names the offending key, matching Go's + // `invalid property name %q: ...`. The path carries the key too, but + // the duplication is what keeps the diagnostic text identical. + output.push_str(&format!( + "$\"invalid property name \\\"{{propertyName}}\\\": {}{{nameLength}}\"", + bound.reason_prefix() + )); + output.push_str("));\n"); + output.push_str(" }\n"); + } + output.push_str(" }\n"); + } + + for dependency in &constraints.dependent_required { + let trigger = member_presence_expression(schema, &dependency.trigger); + let dependent = member_presence_expression(schema, &dependency.dependent); + output.push_str(" if ("); + output.push_str(&trigger); + output.push_str(" && !"); + output.push_str(&dependent); + output.push_str(")\n {\n"); + output.push_str(" violations.Add(new Violation(JsonRuntime.JoinPath(path, "); + output.push_str(&csharp_string_literal(&dependency.dependent)); + output.push_str("), "); + output.push_str(&csharp_string_literal(&format!( + "property {:?} is required when {:?} is present", + dependency.dependent, dependency.trigger + ))); + output.push_str("));\n"); + output.push_str(" }\n"); + } + Ok(()) +} + +/// The C# expression for how many members the payload carried. +/// +/// A required property is always present, so it contributes a constant; every +/// optional and unknown member lands in the extension bag. Together those cover +/// the whole wire member set exactly. +fn wire_property_count_expression(schema: &Schema) -> Result { + let required_count = schema + .properties + .as_ref() + .map(|properties| { + let required = required_fields(schema); + properties + .keys() + .filter(|name| required.contains(name.as_str())) + .count() + }) + .unwrap_or(0); + if !model_needs_extension_data(schema)? { + // No bag: the member set is exactly the required properties. + return Ok(required_count.to_string()); + } + Ok(if required_count == 0 { + "AdditionalProperties.Count".to_string() + } else { + format!("{required_count} + AdditionalProperties.Count") + }) +} + +/// The C# expression that is true when `json_name` was present on the wire. +fn member_presence_expression(schema: &Schema, json_name: &str) -> String { + if required_fields(schema).contains(json_name) { + // `[JsonRequired]` already guarantees presence. + return "true".to_string(); + } + format!( + "AdditionalProperties.ContainsKey({})", + csharp_string_literal(json_name) + ) +} + /// A member carrying at least one enforceable constraint, paired with how its /// value is reached in C#. struct ConstrainedMember<'a> { @@ -404,6 +573,52 @@ impl ItemCountBound { } } +/// The object-level assertions, checked against the wire member set. +#[derive(Debug, Default)] +struct ObjectConstraints { + count_bounds: Vec, + /// Length bounds applied to every member name (map-shaped objects only). + property_name_lengths: Vec, + dependent_required: Vec, +} + +impl ObjectConstraints { + fn is_empty(&self) -> bool { + self.count_bounds.is_empty() + && self.property_name_lengths.is_empty() + && self.dependent_required.is_empty() + } +} + +/// A `minProperties`/`maxProperties` bound over the wire member count. +#[derive(Debug, Clone, Copy)] +struct PropertyCountBound { + at_least: bool, + bound: usize, +} + +impl PropertyCountBound { + fn reason_prefix(&self) -> String { + let quantifier = if self.at_least { "at least" } else { "at most" }; + format!("must have {quantifier} {} properties, got ", self.bound) + } + + fn violation_condition(&self, count_expr: &str) -> String { + if self.at_least { + format!("{count_expr} < {}", self.bound) + } else { + format!("{count_expr} > {}", self.bound) + } + } +} + +/// One `dependentRequired` edge: presence of `trigger` requires `dependent`. +#[derive(Debug)] +struct DependentRequired { + trigger: String, + dependent: String, +} + /// A `contains` check over a `const` element, with its `minContains`/`maxContains` /// occurrence window. #[derive(Debug)] @@ -882,19 +1097,12 @@ fn render_model_validation(output: &mut String, schema: &Schema) -> Result<()> { output.push('\n'); output.push_str(" void IJsonOnDeserialized.OnDeserialized()\n {\n"); if let Some(value_schema) = typed_map_value_schema(schema)? { - if let Some(max_properties) = schema.max_properties { - output.push_str(" if (AdditionalProperties.Count > "); - output.push_str(&max_properties.to_string()); - output.push_str(")\n {\n"); - output.push_str(" throw new JsonException("); - output.push_str(&csharp_string_literal(&format!( - "maxProperties: at most {max_properties} entries" - ))); - output.push_str(");\n }\n"); - } output.push_str(" foreach (var entry in AdditionalProperties)\n {\n"); render_extension_value_validation(output, "entry.Key", "entry.Value", &value_schema, 3)?; output.push_str(" }\n"); + if !schema.object_constraints()?.is_empty() { + output.push_str(" Validate();\n"); + } output.push_str(" }\n"); return Ok(()); } @@ -945,7 +1153,7 @@ fn render_model_validation(output: &mut String, schema: &Schema) -> Result<()> { // Structural checks above reject a malformed payload outright; the contract // constraints then run so an inbound value cannot enter the process in a shape // the contract forbids. - if !constrained_members(schema).is_empty() { + if !constrained_members(schema).is_empty() || !schema.object_constraints()?.is_empty() { output.push_str(" Validate();\n"); } output.push_str(" }\n"); @@ -1069,6 +1277,7 @@ fn model_needs_on_deserialized(schema: &Schema) -> Result { // A model whose only validation is constraint checking still needs the hook, // so an inbound payload is validated on deserialize. Ok(!constrained_members(schema).is_empty() + || !schema.object_constraints()?.is_empty() || typed_map_value_schema(schema)?.is_some() || (!optional_fields(schema).is_empty() && (!is_open_object(schema) diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs index 33f117a4..4c9a6b33 100644 --- a/src/generator/json_schema/dotnet_coverage.rs +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -39,11 +39,10 @@ const UNENFORCED_KEYWORDS: &[&str] = &[ // Array assertions. minItems/maxItems/uniqueItems are enforced, and so is // `contains` in its `const` form — see the shape check below. "prefixItems", - // Object assertions. - "minProperties", - "dependentRequired", + // Object assertions. minProperties/maxProperties/dependentRequired are + // enforced, and so is `propertyNames` on a map-shaped object — see the shape + // check below. "dependentSchemas", - "propertyNames", "patternProperties", // Closed value sets. "enum", @@ -112,6 +111,21 @@ fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static s findings.entry("oneOf").or_default().push(path.to_string()); } + // `propertyNames` is lowered only for a map-shaped object, whose extension bag + // holds every wire member. On an object with declared properties the keyword + // also governs those declared names, which the bag does not carry. + if members.contains_key("propertyNames") + && members + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| !properties.is_empty()) + { + findings + .entry("propertyNames") + .or_default() + .push(path.to_string()); + } + // `contains` is lowered only for a bare `const` branch; matching an arbitrary // subschema per element would need the validator to be reentrant over element // values. `minContains`/`maxContains` ride along with it, so they are reported @@ -189,6 +203,9 @@ fn format_warning(keyword: &str, paths: &[String]) -> String { "contains" | "minContains" | "maxContains" => { "is enforced only for a `const` branch, and this one is not" } + "propertyNames" => { + "is enforced only on a map-shaped object, and this one declares properties" + } _ => "is not enforced — .NET constraint validation is unimplemented", }; diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index a913c701..680c2d20 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -605,15 +605,7 @@ fn dotnet_json_coverage_warnings_match_known_gaps() { warned_keywords.sort(); warned_keywords.dedup(); - let expected = [ - "contentEncoding", - "dependentRequired", - "enum", - "format", - "minProperties", - "oneOf", - "propertyNames", - ]; + let expected = ["contentEncoding", "enum", "format", "oneOf"]; assert_eq!( warned_keywords, expected, From 4ddb9e5ad400d36b09f18b43f20f983fe284b739 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 17:29:22 -0700 Subject: [PATCH 11/16] Report the 2^53-1 integer cap as an aggregated violation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last diagnostic divergence in the covered constraint set. Go reports an out-of-range integer as `Violation{"priority", "exceeds ±(2^53-1) integer cap"}`; .NET threw a bare `JsonException("expected integer")` from the read path, with no member path and no aggregation. The cap is a contract constraint, not a parse error, so it moves into the validator. `ReadJsonInteger` no longer rejects values past 2^53-1 — it reads them exactly via TryGetInt64 and lets CollectViolations report them. Reading through double, as before, would have rounded the value away before the validator saw it. Two subtleties the samples caught: - `1.0` is a valid integer per JSON Schema, and TryGetInt64 rejects the decimal-point spelling. A guarded double fallback restores it, bounded to the range where the conversion is exact. The pre-existing IntegerFieldsFollowJsonSchemaNumberSemantics test covers this. - A non-integral or non-numeric value stays a plain JsonException. It is a type error, not a constraint violation. The cap now keys off the resolved CLR type rather than a second, independently computed type predicate. Two predicates that could disagree had emitted `if (IdOrName is string idOrNameValue) { if (idOrNameValue < -IntegerCap ...` for showcase's `oneOf: [string, integer]` member — comparing a string to a long, which would not have compiled. A genuine sum type is no longer capped at the container level; Go puts that cap on the integer branch type, which arrives with oneOf support. Members with a closed value set (`const`, `enum`) carry no cap check, since membership already bounds them — the same members Go skips. The cap-checked set is now exactly Go's eight: count, fontSize, level, priority, retries, size, step, zip. Co-Authored-By: Claude Opus 5 (1M context) --- .../json_schema/api/chat/Definitions.cs | 55 ++++---- .../dotnet/json_schema/api/chat/Models.cs | 27 ++++ .../dotnet/json_schema/api/kb/Definitions.cs | 55 ++++---- .../api/kb/content/block/Models.cs | 8 ++ .../json_schema/api/kb/content/page/Models.cs | 27 ++++ .../dotnet/json_schema/api/kb/kb/Models.cs | 30 ++++- .../json_schema/api/showcase/Definitions.cs | 55 ++++---- .../dotnet/json_schema/api/showcase/Models.cs | 81 ++++++++++++ samples/dotnet/README.md | 6 +- samples/dotnet/chat/Definitions.cs | 55 ++++---- samples/dotnet/chat/Models.cs | 27 ++++ samples/dotnet/kb/Definitions.cs | 55 ++++---- samples/dotnet/kb/content/block/Models.cs | 8 ++ samples/dotnet/kb/content/page/Models.cs | 27 ++++ samples/dotnet/kb/kb/Models.cs | 30 ++++- samples/dotnet/showcase/Definitions.cs | 55 ++++---- samples/dotnet/showcase/Models.cs | 81 ++++++++++++ samples/dotnet/tests/SpecIntegerChecks.cs | 121 ++++++++++++++++++ src/generator/json_schema/dotnet.rs | 45 ++++++- .../json_schema/dotnet_definitions.rs | 55 ++++---- 20 files changed, 741 insertions(+), 162 deletions(-) create mode 100644 samples/dotnet/tests/SpecIntegerChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs index 51e35f17..f3559080 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs @@ -3,7 +3,6 @@ #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -76,10 +75,13 @@ private static string FormatMessage(IReadOnlyList violations) internal static class JsonRuntime { /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -128,7 +130,6 @@ internal static class JsonRuntime { return default; } - double number; if (value is JsonElement json) { if (json.ValueKind == JsonValueKind.Null) @@ -139,28 +140,38 @@ internal static class JsonRuntime { throw new JsonException("expected integer"); } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); } - else + if (value is long longValue) { - throw new JsonException("expected integer"); + return longValue; } - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) { - throw new JsonException("expected integer"); + return intValue; } - return (long)number; + throw new JsonException("expected integer"); } /// diff --git a/advanced/samples/dotnet/json_schema/api/chat/Models.cs b/advanced/samples/dotnet/json_schema/api/chat/Models.cs index acd245d1..519bd1aa 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Models.cs @@ -140,6 +140,32 @@ public long? Priority [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Priority is long priorityValue) + { + if (priorityValue < -JsonRuntime.IntegerCap || priorityValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -157,6 +183,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("priority", priorityValue); _ = JsonRuntime.ReadJsonValue(priorityValue); } + Validate(); } } diff --git a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs index 678c5547..f5328606 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs @@ -3,7 +3,6 @@ #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -76,10 +75,13 @@ private static string FormatMessage(IReadOnlyList violations) internal static class JsonRuntime { /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -128,7 +130,6 @@ internal static class JsonRuntime { return default; } - double number; if (value is JsonElement json) { if (json.ValueKind == JsonValueKind.Null) @@ -139,28 +140,38 @@ internal static class JsonRuntime { throw new JsonException("expected integer"); } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); } - else + if (value is long longValue) { - throw new JsonException("expected integer"); + return longValue; } - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) { - throw new JsonException("expected integer"); + return intValue; } - return (long)number; + throw new JsonException("expected integer"); } /// diff --git a/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs index 4b655d3d..6769ddc8 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/content/block/Models.cs @@ -87,6 +87,10 @@ public void Validate() internal void CollectViolations(List violations, string path) { + if (Order < -JsonRuntime.IntegerCap || Order > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "order"), "exceeds ±(2^53-1) integer cap")); + } if (Order < 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "order"), "must be >= 0, got " + JsonRuntime.FormatNumber(Order))); @@ -176,6 +180,10 @@ internal void CollectViolations(List violations, string path) { if (Indent is long indentValue) { + if (indentValue < -JsonRuntime.IntegerCap || indentValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "indent"), "exceeds ±(2^53-1) integer cap")); + } if (indentValue < 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "indent"), "must be >= 0, got " + JsonRuntime.FormatNumber(indentValue))); diff --git a/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs index e8dbfc92..8b51b659 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/content/page/Models.cs @@ -98,6 +98,32 @@ public long? WordCount [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (WordCount is long wordCountValue) + { + if (wordCountValue < -JsonRuntime.IntegerCap || wordCountValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "wordCount"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -112,6 +138,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("wordCount", wordCountValue); _ = JsonRuntime.ReadJsonValue(wordCountValue); } + Validate(); } } diff --git a/advanced/samples/dotnet/json_schema/api/kb/kb/Models.cs b/advanced/samples/dotnet/json_schema/api/kb/kb/Models.cs index e8703570..8fa214bf 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/kb/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/kb/Models.cs @@ -47,7 +47,7 @@ public GetPageInput(string pageId) [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [GeneratedCode("nex-gen", null)] - public class PutBlockOutput + public class PutBlockOutput : IJsonOnDeserialized { public PutBlockOutput(string blockId, long revision) { @@ -61,6 +61,34 @@ public PutBlockOutput(string blockId, long revision) [JsonPropertyName("revision")] [JsonRequired] public long Revision { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Revision < -JsonRuntime.IntegerCap || Revision > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "revision"), "exceeds ±(2^53-1) integer cap")); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } } } diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs index 0ee7cbfb..53696dd7 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs @@ -3,7 +3,6 @@ #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -76,10 +75,13 @@ private static string FormatMessage(IReadOnlyList violations) internal static class JsonRuntime { /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -128,7 +130,6 @@ internal static class JsonRuntime { return default; } - double number; if (value is JsonElement json) { if (json.ValueKind == JsonValueKind.Null) @@ -139,28 +140,38 @@ internal static class JsonRuntime { throw new JsonException("expected integer"); } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); } - else + if (value is long longValue) { - throw new JsonException("expected integer"); + return longValue; } - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) { - throw new JsonException("expected integer"); + return intValue; } - return (long)number; + throw new JsonException("expected integer"); } /// diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs index a7657af6..680896ae 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -51,6 +51,32 @@ public long? Zip [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Zip is long zipValue) + { + if (zipValue < -JsonRuntime.IntegerCap || zipValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "zip"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { if (AdditionalProperties.TryGetValue("city", out var cityValue)) @@ -70,6 +96,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("zip", zipValue); _ = JsonRuntime.ReadJsonValue(zipValue); } + Validate(); } } @@ -369,6 +396,32 @@ public long? FontSize [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (FontSize is long fontSizeValue) + { + if (fontSizeValue < -JsonRuntime.IntegerCap || fontSizeValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "fontSize"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -395,6 +448,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("fontSize", fontSizeValue); _ = JsonRuntime.ReadJsonValue(fontSizeValue); } + Validate(); } } @@ -940,6 +994,10 @@ internal void CollectViolations(List violations, string path) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "name"), "must have length <= 64, got " + nameLength)); } + if (Count < -JsonRuntime.IntegerCap || Count > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "count"), "exceeds ±(2^53-1) integer cap")); + } if (Nickname is string nicknameValue) { var nicknameLength = JsonRuntime.CodePointCount(nicknameValue); @@ -974,8 +1032,19 @@ internal void CollectViolations(List violations, string path) violations.Add(new Violation(JsonRuntime.JoinPath(path, "phrase"), "must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z, got " + phraseValue)); } } + if (Retries is long retriesValue) + { + if (retriesValue < -JsonRuntime.IntegerCap || retriesValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "retries"), "exceeds ±(2^53-1) integer cap")); + } + } if (Priority is long priorityValue) { + if (priorityValue < -JsonRuntime.IntegerCap || priorityValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "exceeds ±(2^53-1) integer cap")); + } if (priorityValue < 1) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "must be >= 1, got " + JsonRuntime.FormatNumber(priorityValue))); @@ -987,6 +1056,10 @@ internal void CollectViolations(List violations, string path) } if (Level is long levelValue) { + if (levelValue < -JsonRuntime.IntegerCap || levelValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "level"), "exceeds ±(2^53-1) integer cap")); + } if (levelValue <= 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "level"), "must be > 0, got " + JsonRuntime.FormatNumber(levelValue))); @@ -1005,6 +1078,10 @@ internal void CollectViolations(List violations, string path) } if (Step is long stepValue) { + if (stepValue < -JsonRuntime.IntegerCap || stepValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "exceeds ±(2^53-1) integer cap")); + } if (stepValue % 3 != 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "must be a multiple of 3, got " + JsonRuntime.FormatNumber(stepValue))); @@ -1408,6 +1485,10 @@ internal void CollectViolations(List violations, string path) { if (Size is long sizeValue) { + if (sizeValue < -JsonRuntime.IntegerCap || sizeValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "exceeds ±(2^53-1) integer cap")); + } if (sizeValue < 10) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "must be >= 10, got " + JsonRuntime.FormatNumber(sizeValue))); diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index cb888a03..811f89df 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -45,6 +45,7 @@ affected members, so nothing is dropped silently. | `minLength` / `maxLength` / `pattern` | ✅ | ✅ | | `minItems` / `maxItems` / `uniqueItems` | ✅ | ✅ | | `contains` / `minContains` / `maxContains` | ✅ | ⚠️ `const` branch only | +| 2^53-1 spec integer cap | ✅ | ✅ | | `minProperties` / `maxProperties` / `dependentRequired` | ✅ | ✅ | | `propertyNames` | ✅ | ⚠️ map-shaped objects only | | `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | @@ -52,11 +53,8 @@ affected members, so nothing is dropped silently. | `format` temporal materialization | ✅ native types | ❌ left as `string` | | `contentEncoding: base64` | ✅ native bytes | ❌ left as `string` | -Two known diagnostic divergences within the covered set: +One known diagnostic divergence within the covered set: -- Go reports an out-of-range integer as an aggregated `Violation` reading - `exceeds ±(2^53-1) integer cap`, while .NET throws a non-aggregated - `JsonException("expected integer")` with no member path. - `pattern` reasons quote the target's own rewritten expression, and Go and Java already differ here (Go `%q`-quotes and keeps `$`; Java concatenates and uses `\z`). .NET follows Java, since it shares the `\z` rewrite. diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs index 51e35f17..f3559080 100644 --- a/samples/dotnet/chat/Definitions.cs +++ b/samples/dotnet/chat/Definitions.cs @@ -3,7 +3,6 @@ #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -76,10 +75,13 @@ private static string FormatMessage(IReadOnlyList violations) internal static class JsonRuntime { /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -128,7 +130,6 @@ internal static class JsonRuntime { return default; } - double number; if (value is JsonElement json) { if (json.ValueKind == JsonValueKind.Null) @@ -139,28 +140,38 @@ internal static class JsonRuntime { throw new JsonException("expected integer"); } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); } - else + if (value is long longValue) { - throw new JsonException("expected integer"); + return longValue; } - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) { - throw new JsonException("expected integer"); + return intValue; } - return (long)number; + throw new JsonException("expected integer"); } /// diff --git a/samples/dotnet/chat/Models.cs b/samples/dotnet/chat/Models.cs index acd245d1..519bd1aa 100644 --- a/samples/dotnet/chat/Models.cs +++ b/samples/dotnet/chat/Models.cs @@ -140,6 +140,32 @@ public long? Priority [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Priority is long priorityValue) + { + if (priorityValue < -JsonRuntime.IntegerCap || priorityValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -157,6 +183,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("priority", priorityValue); _ = JsonRuntime.ReadJsonValue(priorityValue); } + Validate(); } } diff --git a/samples/dotnet/kb/Definitions.cs b/samples/dotnet/kb/Definitions.cs index 678c5547..f5328606 100644 --- a/samples/dotnet/kb/Definitions.cs +++ b/samples/dotnet/kb/Definitions.cs @@ -3,7 +3,6 @@ #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -76,10 +75,13 @@ private static string FormatMessage(IReadOnlyList violations) internal static class JsonRuntime { /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -128,7 +130,6 @@ internal static class JsonRuntime { return default; } - double number; if (value is JsonElement json) { if (json.ValueKind == JsonValueKind.Null) @@ -139,28 +140,38 @@ internal static class JsonRuntime { throw new JsonException("expected integer"); } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); } - else + if (value is long longValue) { - throw new JsonException("expected integer"); + return longValue; } - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) { - throw new JsonException("expected integer"); + return intValue; } - return (long)number; + throw new JsonException("expected integer"); } /// diff --git a/samples/dotnet/kb/content/block/Models.cs b/samples/dotnet/kb/content/block/Models.cs index 4b655d3d..6769ddc8 100644 --- a/samples/dotnet/kb/content/block/Models.cs +++ b/samples/dotnet/kb/content/block/Models.cs @@ -87,6 +87,10 @@ public void Validate() internal void CollectViolations(List violations, string path) { + if (Order < -JsonRuntime.IntegerCap || Order > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "order"), "exceeds ±(2^53-1) integer cap")); + } if (Order < 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "order"), "must be >= 0, got " + JsonRuntime.FormatNumber(Order))); @@ -176,6 +180,10 @@ internal void CollectViolations(List violations, string path) { if (Indent is long indentValue) { + if (indentValue < -JsonRuntime.IntegerCap || indentValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "indent"), "exceeds ±(2^53-1) integer cap")); + } if (indentValue < 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "indent"), "must be >= 0, got " + JsonRuntime.FormatNumber(indentValue))); diff --git a/samples/dotnet/kb/content/page/Models.cs b/samples/dotnet/kb/content/page/Models.cs index e8dbfc92..8b51b659 100644 --- a/samples/dotnet/kb/content/page/Models.cs +++ b/samples/dotnet/kb/content/page/Models.cs @@ -98,6 +98,32 @@ public long? WordCount [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (WordCount is long wordCountValue) + { + if (wordCountValue < -JsonRuntime.IntegerCap || wordCountValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "wordCount"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -112,6 +138,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("wordCount", wordCountValue); _ = JsonRuntime.ReadJsonValue(wordCountValue); } + Validate(); } } diff --git a/samples/dotnet/kb/kb/Models.cs b/samples/dotnet/kb/kb/Models.cs index e8703570..8fa214bf 100644 --- a/samples/dotnet/kb/kb/Models.cs +++ b/samples/dotnet/kb/kb/Models.cs @@ -47,7 +47,7 @@ public GetPageInput(string pageId) [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] [GeneratedCode("nex-gen", null)] - public class PutBlockOutput + public class PutBlockOutput : IJsonOnDeserialized { public PutBlockOutput(string blockId, long revision) { @@ -61,6 +61,34 @@ public PutBlockOutput(string blockId, long revision) [JsonPropertyName("revision")] [JsonRequired] public long Revision { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Revision < -JsonRuntime.IntegerCap || Revision > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "revision"), "exceeds ±(2^53-1) integer cap")); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } } } diff --git a/samples/dotnet/showcase/Definitions.cs b/samples/dotnet/showcase/Definitions.cs index 0ee7cbfb..53696dd7 100644 --- a/samples/dotnet/showcase/Definitions.cs +++ b/samples/dotnet/showcase/Definitions.cs @@ -3,7 +3,6 @@ #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -76,10 +75,13 @@ private static string FormatMessage(IReadOnlyList violations) internal static class JsonRuntime { /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -128,7 +130,6 @@ internal static class JsonRuntime { return default; } - double number; if (value is JsonElement json) { if (json.ValueKind == JsonValueKind.Null) @@ -139,28 +140,38 @@ internal static class JsonRuntime { throw new JsonException("expected integer"); } - number = json.GetDouble(); - } - else if (value is long longValue) - { - number = longValue; - } - else if (value is int intValue) - { - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); } - else + if (value is long longValue) { - throw new JsonException("expected integer"); + return longValue; } - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) { - throw new JsonException("expected integer"); + return intValue; } - return (long)number; + throw new JsonException("expected integer"); } /// diff --git a/samples/dotnet/showcase/Models.cs b/samples/dotnet/showcase/Models.cs index a7657af6..680896ae 100644 --- a/samples/dotnet/showcase/Models.cs +++ b/samples/dotnet/showcase/Models.cs @@ -51,6 +51,32 @@ public long? Zip [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Zip is long zipValue) + { + if (zipValue < -JsonRuntime.IntegerCap || zipValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "zip"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { if (AdditionalProperties.TryGetValue("city", out var cityValue)) @@ -70,6 +96,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("zip", zipValue); _ = JsonRuntime.ReadJsonValue(zipValue); } + Validate(); } } @@ -369,6 +396,32 @@ public long? FontSize [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (FontSize is long fontSizeValue) + { + if (fontSizeValue < -JsonRuntime.IntegerCap || fontSizeValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "fontSize"), "exceeds ±(2^53-1) integer cap")); + } + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -395,6 +448,7 @@ void IJsonOnDeserialized.OnDeserialized() JsonRuntime.RejectNull("fontSize", fontSizeValue); _ = JsonRuntime.ReadJsonValue(fontSizeValue); } + Validate(); } } @@ -940,6 +994,10 @@ internal void CollectViolations(List violations, string path) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "name"), "must have length <= 64, got " + nameLength)); } + if (Count < -JsonRuntime.IntegerCap || Count > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "count"), "exceeds ±(2^53-1) integer cap")); + } if (Nickname is string nicknameValue) { var nicknameLength = JsonRuntime.CodePointCount(nicknameValue); @@ -974,8 +1032,19 @@ internal void CollectViolations(List violations, string path) violations.Add(new Violation(JsonRuntime.JoinPath(path, "phrase"), "must match pattern ^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\z, got " + phraseValue)); } } + if (Retries is long retriesValue) + { + if (retriesValue < -JsonRuntime.IntegerCap || retriesValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "retries"), "exceeds ±(2^53-1) integer cap")); + } + } if (Priority is long priorityValue) { + if (priorityValue < -JsonRuntime.IntegerCap || priorityValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "exceeds ±(2^53-1) integer cap")); + } if (priorityValue < 1) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "must be >= 1, got " + JsonRuntime.FormatNumber(priorityValue))); @@ -987,6 +1056,10 @@ internal void CollectViolations(List violations, string path) } if (Level is long levelValue) { + if (levelValue < -JsonRuntime.IntegerCap || levelValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "level"), "exceeds ±(2^53-1) integer cap")); + } if (levelValue <= 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "level"), "must be > 0, got " + JsonRuntime.FormatNumber(levelValue))); @@ -1005,6 +1078,10 @@ internal void CollectViolations(List violations, string path) } if (Step is long stepValue) { + if (stepValue < -JsonRuntime.IntegerCap || stepValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "exceeds ±(2^53-1) integer cap")); + } if (stepValue % 3 != 0) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "step"), "must be a multiple of 3, got " + JsonRuntime.FormatNumber(stepValue))); @@ -1408,6 +1485,10 @@ internal void CollectViolations(List violations, string path) { if (Size is long sizeValue) { + if (sizeValue < -JsonRuntime.IntegerCap || sizeValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "exceeds ±(2^53-1) integer cap")); + } if (sizeValue < 10) { violations.Add(new Violation(JsonRuntime.JoinPath(path, "size"), "must be >= 10, got " + JsonRuntime.FormatNumber(sizeValue))); diff --git a/samples/dotnet/tests/SpecIntegerChecks.cs b/samples/dotnet/tests/SpecIntegerChecks.cs new file mode 100644 index 00000000..1875bf40 --- /dev/null +++ b/samples/dotnet/tests/SpecIntegerChecks.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Xunit; +using Showcase = NexGen.ShowcaseService; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers the 2^53-1 spec integer cap. + /// + /// Exceeding it is a contract violation, not a parse failure: it aggregates + /// with every other violation and names the offending member, matching Go's + /// `exceeds ±(2^53-1) integer cap`. It previously surfaced as a bare + /// JsonException("expected integer") with no path. + /// + public class SpecIntegerChecks + { + private static readonly JsonSerializerOptions Options = new(); + + private const string RequiredMembers = + @"""kind"":""showcase"",""revision"":1,""enabled"":true," + + @"""status"":""active"",""tier"":1,""scale"":1.5,""name"":""Widget""," + + @"""active"":true,""category"":""tools"""; + + private static string PayloadWithCount(string count) => + "{" + RequiredMembers + @",""count"":" + count + "}"; + + private static Showcase.Showcase Deserialize(string json) => + JsonSerializer.Deserialize(json, Options)!; + + [Fact] + public void ValueAtTheCapIsAccepted() + { + var value = Deserialize(PayloadWithCount("9007199254740991")); + + Assert.Equal(9007199254740991L, value.Count); + } + + [Fact] + public void ValueAboveTheCapIsReportedWithItsPath() + { + var exception = Assert.Throws( + () => Deserialize(PayloadWithCount("9007199254740992"))); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("count", violation.Path); + Assert.Equal("exceeds ±(2^53-1) integer cap", violation.Reason); + } + + [Fact] + public void ValueBelowTheNegativeCapIsReportedWithItsPath() + { + var exception = Assert.Throws( + () => Deserialize(PayloadWithCount("-9007199254740992"))); + + Assert.Equal("exceeds ±(2^53-1) integer cap", Assert.Single(exception.Violations).Reason); + } + + /// + /// A non-integral number is a *type* error, not a constraint violation, so it + /// still surfaces as a plain . + /// + [Fact] + public void NonIntegralNumberRemainsATypeError() + { + var exception = Record.Exception(() => Deserialize(PayloadWithCount("1.5"))); + + Assert.IsAssignableFrom(exception); + Assert.IsNotType(exception); + } + + /// + /// The cap aggregates with ordinary bound violations rather than + /// short-circuiting them, which is the whole point of routing it through the + /// validator instead of the read path. + /// + [Fact] + public void CapAggregatesWithOtherViolations() + { + var json = "{" + RequiredMembers + + @",""count"":9007199254740992,""priority"":99}"; + + var exception = Assert.Throws(() => Deserialize(json)); + + Assert.Equal(2, exception.Violations.Count); + Assert.Contains("count: exceeds ±(2^53-1) integer cap", exception.Message); + Assert.Contains("priority: must be <= 10, got 99", exception.Message); + } + + /// + /// tier is `enum: [1,2,3]` and revision is `const: 1`. A closed + /// value set already bounds the value, so no cap check is emitted for them — + /// the same members Go skips. + /// + [Fact] + public void ClosedValueSetMembersCarryNoCapCheck() + { + var models = System.IO.File.ReadAllText( + System.IO.Path.Combine(RepositoryRoot(), "samples/dotnet/showcase/Models.cs")); + + // The cap-checked set is exactly Go's: count, fontSize, level, priority, + // retries, size, step, zip. + Assert.Equal(8, System.Text.RegularExpressions.Regex.Matches( + models, @"exceeds ±\(2\^53-1\) integer cap").Count); + Assert.DoesNotContain("Tier < -JsonRuntime.IntegerCap", models); + Assert.DoesNotContain("Revision < -JsonRuntime.IntegerCap", models); + } + + private static string RepositoryRoot() + { + var directory = System.AppContext.BaseDirectory; + while (directory is not null + && !System.IO.Directory.Exists(System.IO.Path.Combine(directory, "samples"))) + { + directory = System.IO.Path.GetDirectoryName(directory); + } + Assert.NotNull(directory); + return directory!; + } + } +} diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index 24b7a77a..1cc986dd 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -68,6 +68,8 @@ struct Schema { property_names: Option>, #[serde(rename = "dependentRequired")] dependent_required: Option>>, + #[serde(rename = "enum")] + enum_values: Option>, } impl Schema { @@ -168,6 +170,17 @@ impl Schema { }) } + /// True when the member declares a closed value set, which makes the spec + /// integer cap redundant — membership already bounds the value. Go skips the + /// cap on these for the same reason. + fn has_closed_value_set(&self) -> bool { + self.const_value.is_some() + || self + .enum_values + .as_ref() + .is_some_and(|values| !values.is_empty()) + } + fn has_declared_properties(&self) -> bool { self.properties .as_ref() @@ -517,6 +530,9 @@ struct ConstrainedMember<'a> { needs_null_guard: bool, /// The CLR type the value binds to once unwrapped from its nullable form. clr_type: String, + /// True for a `type: integer` member, which carries the 2^53-1 spec cap + /// whether or not the schema declares any explicit bound. + integer_cap: bool, numeric_bounds: Vec>, length_bounds: Vec, item_count_bounds: Vec, @@ -529,7 +545,8 @@ struct ConstrainedMember<'a> { impl ConstrainedMember<'_> { fn has_constraints(&self) -> bool { - !self.numeric_bounds.is_empty() + self.integer_cap + || !self.numeric_bounds.is_empty() || !self.length_bounds.is_empty() || self.pattern.is_some() || !self.item_count_bounds.is_empty() @@ -654,11 +671,18 @@ fn constrained_members(schema: &Schema) -> Vec> { .iter() .filter_map(|(json_name, property)| { let is_required = required.contains(json_name.as_str()); + let clr_type = constraint_clr_type(property); let member = ConstrainedMember { json_name, accessor: csharp_type_name(json_name), needs_null_guard: !is_required || allows_null(property), - clr_type: constraint_clr_type(property), + clr_type: clr_type.clone(), + // Keyed off the resolved CLR type so the emitted comparison can + // never be against a value of another type. A genuine sum type such + // as `oneOf: [string, integer]` resolves to its first non-null + // branch and so is not capped here; Go puts that cap on the + // integer *branch* type, which arrives with `oneOf` support. + integer_cap: clr_type == "long" && !property.has_closed_value_set(), numeric_bounds: property.numeric_bounds(), length_bounds: property.length_bounds(), pattern: property.dotnet_pattern(), @@ -752,6 +776,23 @@ fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_> output.push_str("));\n"); }; + if member.integer_cap { + output.push_str(indent); + output.push_str("if ("); + output.push_str(&value_expr); + output.push_str(" < -JsonRuntime.IntegerCap || "); + output.push_str(&value_expr); + output.push_str(" > JsonRuntime.IntegerCap)\n"); + output.push_str(indent); + output.push_str("{\n"); + add_violation( + output, + &csharp_string_literal("exceeds ±(2^53-1) integer cap"), + ); + output.push_str(indent); + output.push_str("}\n"); + } + for bound in &member.numeric_bounds { output.push_str(indent); output.push_str("if ("); diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs index 30015394..22ee2148 100644 --- a/src/generator/json_schema/dotnet_definitions.rs +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -110,7 +110,6 @@ fn render(namespace: &str) -> String { #nullable enable #pragma warning disable CS1591 -using System; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; @@ -183,10 +182,13 @@ namespace {namespace} internal static class JsonRuntime {{ /// - /// The largest integer a JSON number carries losslessly (2^53-1) — the - /// same cap every other target enforces. + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. /// - private const double MaxSafeInteger = 9007199254740991d; + internal const long IntegerCap = 9007199254740991L; /// /// Reads an optional member out of the extension-data bag, falling back to @@ -235,7 +237,6 @@ namespace {namespace} {{ return default; }} - double number; if (value is JsonElement json) {{ if (json.ValueKind == JsonValueKind.Null) @@ -246,28 +247,38 @@ namespace {namespace} {{ throw new JsonException("expected integer"); }} - number = json.GetDouble(); - }} - else if (value is long longValue) - {{ - number = longValue; - }} - else if (value is int intValue) - {{ - number = intValue; + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + {{ + return exact; + }} + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + {{ + return (long)number; + }} + throw new JsonException("expected integer"); }} - else + if (value is long longValue) {{ - throw new JsonException("expected integer"); + return longValue; }} - if (double.IsNaN(number) - || double.IsInfinity(number) - || Math.Truncate(number) != number - || Math.Abs(number) > MaxSafeInteger) + if (value is int intValue) {{ - throw new JsonException("expected integer"); + return intValue; }} - return (long)number; + throw new JsonException("expected integer"); }} /// From 81c2b31c71171831787dc389bd35e5413d7c08b3 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 17:33:16 -0700 Subject: [PATCH 12/16] Validate JSON-Schema enum value sets in the .NET backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `enum` membership checking over the three scalar shapes showcase declares: string (`status`), integer (`tier`) and number (`scale`). Coverage warnings drop to 3 — only `oneOf`, `format` and `contentEncoding` remain. Membership is validated rather than modeled as a C# `enum` type. The member keeps its wire type and a value outside the set becomes a ValidationException. A real C# enum would read better at the call site, but it does not survive contact with the corpus: `scale` is `enum: [1.5, 2.5]`, and C# enums cannot have floating-point members. Wire values are also not constrained to be valid C# identifiers, and .NET has no `x-dotnet-enum-names` escape hatch because it is not part of the P15 identifier subset. Noted in the README as an ergonomics follow-up rather than smuggled in here. Reason text matches Go exactly, including the quoting split: a string value is quoted like Go's `%q` (`got "retired"`) while numbers are bare like `%v` (`got 4`). The admitted set renders as Go prints it — quoted strings, bare numbers, comma-separated with no spaces. Co-Authored-By: Claude Opus 5 (1M context) --- .../json_schema/api/chat/Definitions.cs | 7 + .../dotnet/json_schema/api/kb/Definitions.cs | 7 + .../json_schema/api/showcase/Definitions.cs | 7 + .../dotnet/json_schema/api/showcase/Models.cs | 12 ++ samples/dotnet/README.md | 2 +- samples/dotnet/chat/Definitions.cs | 7 + samples/dotnet/kb/Definitions.cs | 7 + samples/dotnet/showcase/Definitions.cs | 7 + samples/dotnet/showcase/Models.cs | 12 ++ samples/dotnet/tests/EnumConstraintChecks.cs | 126 ++++++++++++++++++ src/generator/json_schema/dotnet.rs | 71 ++++++++++ src/generator/json_schema/dotnet_coverage.rs | 3 +- .../json_schema/dotnet_definitions.rs | 7 + tests/generate_dotnet.rs | 2 +- 14 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 samples/dotnet/tests/EnumConstraintChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs index f3559080..4c3ec031 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Definitions.cs @@ -246,6 +246,13 @@ internal static int CodePointCount(string value) return count; } + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs index f5328606..6688a8ae 100644 --- a/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/kb/Definitions.cs @@ -246,6 +246,13 @@ internal static int CodePointCount(string value) return count; } + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs index 53696dd7..c190d2dd 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Definitions.cs @@ -246,6 +246,13 @@ internal static int CodePointCount(string value) return count; } + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs index 680896ae..b82bd3d1 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -985,6 +985,18 @@ public void Validate() internal void CollectViolations(List violations, string path) { + if (Status != "active" && Status != "inactive" && Status != "pending") + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "status"), "must be one of [\"active\",\"inactive\",\"pending\"], got " + JsonRuntime.Quote(Status))); + } + if (Tier != 1 && Tier != 2 && Tier != 3) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "tier"), "must be one of [1,2,3], got " + JsonRuntime.FormatNumber(Tier))); + } + if (Scale != 1.5 && Scale != 2.5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "scale"), "must be one of [1.5,2.5], got " + JsonRuntime.FormatNumber(Scale))); + } var nameLength = JsonRuntime.CodePointCount(Name); if (nameLength < 1) { diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 811f89df..1fd1ef2e 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -48,7 +48,7 @@ affected members, so nothing is dropped silently. | 2^53-1 spec integer cap | ✅ | ✅ | | `minProperties` / `maxProperties` / `dependentRequired` | ✅ | ✅ | | `propertyNames` | ✅ | ⚠️ map-shaped objects only | -| `enum` closed value sets | ✅ | ❌ emitted as bare `string` / `long` | +| `enum` closed value sets | ✅ | ✅ validated (not a C# `enum` type) | | `oneOf` discriminated unions | ✅ | ❌ emitted as an empty class — branches lost | | `format` temporal materialization | ✅ native types | ❌ left as `string` | | `contentEncoding: base64` | ✅ native bytes | ❌ left as `string` | diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs index f3559080..4c3ec031 100644 --- a/samples/dotnet/chat/Definitions.cs +++ b/samples/dotnet/chat/Definitions.cs @@ -246,6 +246,13 @@ internal static int CodePointCount(string value) return count; } + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/samples/dotnet/kb/Definitions.cs b/samples/dotnet/kb/Definitions.cs index f5328606..6688a8ae 100644 --- a/samples/dotnet/kb/Definitions.cs +++ b/samples/dotnet/kb/Definitions.cs @@ -246,6 +246,13 @@ internal static int CodePointCount(string value) return count; } + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/samples/dotnet/showcase/Definitions.cs b/samples/dotnet/showcase/Definitions.cs index 53696dd7..c190d2dd 100644 --- a/samples/dotnet/showcase/Definitions.cs +++ b/samples/dotnet/showcase/Definitions.cs @@ -246,6 +246,13 @@ internal static int CodePointCount(string value) return count; } + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/samples/dotnet/showcase/Models.cs b/samples/dotnet/showcase/Models.cs index 680896ae..b82bd3d1 100644 --- a/samples/dotnet/showcase/Models.cs +++ b/samples/dotnet/showcase/Models.cs @@ -985,6 +985,18 @@ public void Validate() internal void CollectViolations(List violations, string path) { + if (Status != "active" && Status != "inactive" && Status != "pending") + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "status"), "must be one of [\"active\",\"inactive\",\"pending\"], got " + JsonRuntime.Quote(Status))); + } + if (Tier != 1 && Tier != 2 && Tier != 3) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "tier"), "must be one of [1,2,3], got " + JsonRuntime.FormatNumber(Tier))); + } + if (Scale != 1.5 && Scale != 2.5) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "scale"), "must be one of [1.5,2.5], got " + JsonRuntime.FormatNumber(Scale))); + } var nameLength = JsonRuntime.CodePointCount(Name); if (nameLength < 1) { diff --git a/samples/dotnet/tests/EnumConstraintChecks.cs b/samples/dotnet/tests/EnumConstraintChecks.cs new file mode 100644 index 00000000..a3e8bf51 --- /dev/null +++ b/samples/dotnet/tests/EnumConstraintChecks.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using Xunit; +using Showcase = NexGen.ShowcaseService; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers enum closed value sets over the three scalar shapes showcase + /// declares: string (status), integer (tier) and number + /// (scale). + /// + /// Membership is validated rather than modeled as a C# enum type. The + /// wire value stays the member's type, so a value outside the set is a + /// rather than a parse failure, and + /// the reason names the admitted set exactly as Go does. + /// + public class EnumConstraintChecks + { + private static readonly JsonSerializerOptions Options = new(); + + private static string Payload( + string status = "active", string tier = "1", string scale = "1.5") => + @"{""kind"":""showcase"",""revision"":1,""enabled"":true," + + @"""status"":""" + status + @""",""tier"":" + tier + + @",""scale"":" + scale + + @",""name"":""Widget"",""count"":3,""active"":true,""category"":""tools""}"; + + private static Showcase.Showcase Deserialize(string json) => + JsonSerializer.Deserialize(json, Options)!; + + private static Showcase.ValidationException Rejects(string json) => + Assert.Throws(() => Deserialize(json)); + + [Theory] + [InlineData("active")] + [InlineData("inactive")] + [InlineData("pending")] + public void EveryAdmittedStringValueIsAccepted(string status) + { + var value = Deserialize(Payload(status: status)); + + Assert.Equal(status, value.Status); + } + + [Fact] + public void StringOutsideTheSetIsRejected() + { + var exception = Rejects(Payload(status: "retired")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("status", violation.Path); + Assert.Equal( + @"must be one of [""active"",""inactive"",""pending""], got ""retired""", + violation.Reason); + } + + /// + /// Membership is case-sensitive — the wire value is compared verbatim. + /// + [Fact] + public void StringMembershipIsCaseSensitive() + { + var exception = Rejects(Payload(status: "Active")); + + Assert.Equal("status", Assert.Single(exception.Violations).Path); + } + + [Theory] + [InlineData("1")] + [InlineData("2")] + [InlineData("3")] + public void EveryAdmittedIntegerValueIsAccepted(string tier) + { + var value = Deserialize(Payload(tier: tier)); + + Assert.Equal(long.Parse(tier), value.Tier); + } + + [Fact] + public void IntegerOutsideTheSetIsRejected() + { + var exception = Rejects(Payload(tier: "4")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("tier", violation.Path); + // Numbers are unquoted in the reason, matching Go's `%v`. + Assert.Equal("must be one of [1,2,3], got 4", violation.Reason); + } + + [Fact] + public void NumberOutsideTheSetIsRejected() + { + var exception = Rejects(Payload(scale: "3.5")); + + var violation = Assert.Single(exception.Violations); + Assert.Equal("scale", violation.Path); + Assert.Equal("must be one of [1.5,2.5], got 3.5", violation.Reason); + } + + [Fact] + public void AdmittedNumberValueIsAccepted() + { + var value = Deserialize(Payload(scale: "2.5")); + + Assert.Equal(2.5, value.Scale); + } + + /// + /// A closed value set already bounds an integer member, so no spec integer + /// cap check is emitted alongside it — see . + /// Three enum failures at once still aggregate. + /// + [Fact] + public void EnumViolationsAcrossMembersAggregate() + { + var exception = Rejects(Payload(status: "retired", tier: "9", scale: "9.5")); + + Assert.Equal(3, exception.Violations.Count); + Assert.StartsWith("3 validation error(s): ", exception.Message); + Assert.Contains(@"status: must be one of [""active"",""inactive"",""pending""], got ""retired""", exception.Message); + Assert.Contains("tier: must be one of [1,2,3], got 9", exception.Message); + Assert.Contains("scale: must be one of [1.5,2.5], got 9.5", exception.Message); + } + } +} diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index 1cc986dd..31f07d36 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -170,6 +170,34 @@ impl Schema { }) } + /// The closed value set this schema admits, as the C# literals to compare + /// against plus the bracketed list Go names in its violation reason. + fn enum_membership(&self) -> Option { + let values = self + .enum_values + .as_ref() + .filter(|values| !values.is_empty())?; + let literals = values + .iter() + .map(csharp_value_literal) + .collect::>>()?; + // Rendered the way Go's `%v` over the decoded values does: strings quoted, + // numbers bare, comma-separated with no spaces. + let rendered = values + .iter() + .map(|value| match value { + Value::String(text) => format!("{text:?}"), + other => other.to_string(), + }) + .collect::>() + .join(","); + Some(EnumMembership { + literals, + rendered, + quotes_value: values.iter().any(|value| value.is_string()), + }) + } + /// True when the member declares a closed value set, which makes the spec /// integer cap redundant — membership already bounds the value. Go skips the /// cap on these for the same reason. @@ -533,6 +561,7 @@ struct ConstrainedMember<'a> { /// True for a `type: integer` member, which carries the 2^53-1 spec cap /// whether or not the schema declares any explicit bound. integer_cap: bool, + enum_membership: Option, numeric_bounds: Vec>, length_bounds: Vec, item_count_bounds: Vec, @@ -546,6 +575,7 @@ struct ConstrainedMember<'a> { impl ConstrainedMember<'_> { fn has_constraints(&self) -> bool { self.integer_cap + || self.enum_membership.is_some() || !self.numeric_bounds.is_empty() || !self.length_bounds.is_empty() || self.pattern.is_some() @@ -590,6 +620,17 @@ impl ItemCountBound { } } +/// An `enum` closed value set. +#[derive(Debug)] +struct EnumMembership { + /// C# literals for each admitted value. + literals: Vec, + /// The bracket-list body Go names in the reason, e.g. `"a","b"` or `1,2,3`. + rendered: String, + /// Whether the offending value is quoted in the reason (Go's `%q` vs `%v`). + quotes_value: bool, +} + /// The object-level assertions, checked against the wire member set. #[derive(Debug, Default)] struct ObjectConstraints { @@ -683,6 +724,7 @@ fn constrained_members(schema: &Schema) -> Vec> { // branch and so is not capped here; Go puts that cap on the // integer *branch* type, which arrives with `oneOf` support. integer_cap: clr_type == "long" && !property.has_closed_value_set(), + enum_membership: property.enum_membership(), numeric_bounds: property.numeric_bounds(), length_bounds: property.length_bounds(), pattern: property.dotnet_pattern(), @@ -776,6 +818,35 @@ fn render_member_constraints(output: &mut String, member: &ConstrainedMember<'_> output.push_str("));\n"); }; + if let Some(membership) = &member.enum_membership { + output.push_str(indent); + output.push_str("if ("); + for (index, literal) in membership.literals.iter().enumerate() { + if index > 0 { + output.push_str(" && "); + } + output.push_str(&value_expr); + output.push_str(" != "); + output.push_str(literal); + } + output.push_str(")\n"); + output.push_str(indent); + output.push_str("{\n"); + let reason = format!("must be one of [{}], got ", membership.rendered); + let value_text = if membership.quotes_value { + // Go uses `%q` for a string value, so the offending value is quoted. + format!("JsonRuntime.Quote({value_expr})") + } else { + format!("JsonRuntime.FormatNumber({value_expr})") + }; + add_violation( + output, + &format!("{} + {value_text}", csharp_string_literal(&reason)), + ); + output.push_str(indent); + output.push_str("}\n"); + } + if member.integer_cap { output.push_str(indent); output.push_str("if ("); diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs index 4c9a6b33..80d42c9a 100644 --- a/src/generator/json_schema/dotnet_coverage.rs +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -44,8 +44,7 @@ const UNENFORCED_KEYWORDS: &[&str] = &[ // check below. "dependentSchemas", "patternProperties", - // Closed value sets. - "enum", + // Closed value sets (`enum`) are enforced. ]; /// How many member paths to name before eliding the rest, so a large schema diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs index 22ee2148..411c568c 100644 --- a/src/generator/json_schema/dotnet_definitions.rs +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -353,6 +353,13 @@ namespace {namespace} return count; }} + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + /// /// Joins a violation path prefix to a member name, so a nested model /// reports page.blocks.order rather than a bare order. diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index 680c2d20..3e649df1 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -605,7 +605,7 @@ fn dotnet_json_coverage_warnings_match_known_gaps() { warned_keywords.sort(); warned_keywords.dedup(); - let expected = ["contentEncoding", "enum", "format", "oneOf"]; + let expected = ["contentEncoding", "format", "oneOf"]; assert_eq!( warned_keywords, expected, From 0f45b012ee0eb12f414010e792f9d1e2634c826f Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 19:27:51 -0700 Subject: [PATCH 13/16] Lower JSON-Schema tagged unions to a C# class hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showcase's `Shape` — a closed `Circle | Square` sum type tagged by a shared required `kind` const — generated as a class with **no members at all**. Both branches were dropped and any payload round-tripped as an empty object. This was the worst of the silently-wrong output the .NET backend produced. It now lowers to an abstract base class with a private-protected constructor (closing the hierarchy the way `oneOf` means it), the branch classes deriving from it, and a JsonConverter that reads the tag and routes. Validate() and CollectViolations are abstract on the base and overridden by each branch — emitted even for a branch with no constraints of its own — so a caller holding a `Shape` can validate it without knowing which branch it is. The converter is hand-rolled rather than `[JsonPolymorphic]`. System.Text.Json's built-in discriminator is a metadata property distinct from the model's own members; here the tag *is* a declared member, and the two mechanisms collide. All four of Go's diagnostics are reproduced verbatim, including that the expected tag list uses a comma-space separator here while the `enum` list uses none. A branch shared between two unions cannot be modeled with single inheritance, so that case leaves every union unlowered rather than emitting code that will not compile. The coverage classifier now resolves `$ref` branches instead of pattern-matching on shape. That turned out to be necessary, not fastidious: the loader rewrites showcase's inline `oneOf: [string, integer]` into `$ref`s to synthesized types, so it reaches the classifier looking structurally identical to `Circle | Square`. An "all branches are $ref" test silently stopped reporting it — under-reporting a real gap, which is the exact failure this module exists to prevent. Following the refs separates the union that is lowered from the two that are not (scalar branches, or objects with no shared discriminator); both are pinned by tests. Disjoint-kind scalar unions still degrade to `object` and remain a reported gap. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotnet/json_schema/api/showcase/Models.cs | 100 +++++- samples/dotnet/README.md | 3 +- samples/dotnet/showcase/Models.cs | 100 +++++- samples/dotnet/tests/TaggedUnionChecks.cs | 138 ++++++++ src/generator/json_schema/dotnet.rs | 302 +++++++++++++++++- src/generator/json_schema/dotnet_coverage.rs | 206 ++++++++++-- tests/generate_dotnet.rs | 2 + 7 files changed, 810 insertions(+), 41 deletions(-) create mode 100644 samples/dotnet/tests/TaggedUnionChecks.cs diff --git a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs index b82bd3d1..96471f14 100644 --- a/advanced/samples/dotnet/json_schema/api/showcase/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -168,7 +168,7 @@ void IJsonOnDeserialized.OnDeserialized() /// A circle branch of the Shape tagged union. /// [GeneratedCode("nex-gen", null)] - public class Circle + public class Circle : Shape { public Circle(double radius) { @@ -197,6 +197,25 @@ public string Kind [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public override void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal override void CollectViolations(List violations, string path) + { + } } @@ -456,11 +475,63 @@ void IJsonOnDeserialized.OnDeserialized() /// /// A closed sum type (discriminated union) of Circle | Square, tagged by the shared required `kind` const. Selection reads `kind` and routes to the matching branch; an unknown tag is a Violation. /// + [JsonConverter(typeof(ShapeJsonConverter))] [GeneratedCode("nex-gen", null)] - public class Shape + public abstract class Shape { - [JsonExtensionData] - public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + private protected Shape() + { + } + + /// + /// Validates the selected branch, throwing a single + /// carrying every violation. + /// + public abstract void Validate(); + + internal abstract void CollectViolations(List violations, string path); + } + + [GeneratedCode("nex-gen", null)] + internal sealed class ShapeJsonConverter : JsonConverter + { + public override Shape Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new ValidationException(new List + { + new Violation(string.Empty, "expected one of: Circle, Square"), + }); + } + if (!root.TryGetProperty("kind", out var tag)) + { + throw new ValidationException(new List + { + new Violation(string.Empty, "discriminator \"kind\" is required"), + }); + } + var raw = root.GetRawText(); + switch (tag.ValueKind == JsonValueKind.String ? tag.GetString() : null) + { + case "circle": + return JsonSerializer.Deserialize(raw, options)!; + case "square": + return JsonSerializer.Deserialize(raw, options)!; + default: + throw new ValidationException(new List + { + new Violation(string.Empty, $"unknown discriminator kind {tag.GetRawText()}: expected one of [\"circle\", \"square\"]"), + }); + } + } + + public override void Write(Utf8JsonWriter writer, Shape value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } } @@ -1401,7 +1472,7 @@ public GetShowcaseInput(string id) /// A square branch of the Shape tagged union. /// [GeneratedCode("nex-gen", null)] - public class Square + public class Square : Shape { public Square(double side) { @@ -1430,6 +1501,25 @@ public string Kind [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public override void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal override void CollectViolations(List violations, string path) + { + } } diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 1fd1ef2e..49abce24 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -49,7 +49,8 @@ affected members, so nothing is dropped silently. | `minProperties` / `maxProperties` / `dependentRequired` | ✅ | ✅ | | `propertyNames` | ✅ | ⚠️ map-shaped objects only | | `enum` closed value sets | ✅ | ✅ validated (not a C# `enum` type) | -| `oneOf` discriminated unions | ✅ | ❌ emitted as an empty class — branches lost | +| `oneOf` discriminated unions (`$ref` branches + `const` tag) | ✅ | ✅ abstract base + routing converter | +| `oneOf` disjoint-kind scalar unions | ✅ | ❌ degrades to `object` | | `format` temporal materialization | ✅ native types | ❌ left as `string` | | `contentEncoding: base64` | ✅ native bytes | ❌ left as `string` | diff --git a/samples/dotnet/showcase/Models.cs b/samples/dotnet/showcase/Models.cs index b82bd3d1..96471f14 100644 --- a/samples/dotnet/showcase/Models.cs +++ b/samples/dotnet/showcase/Models.cs @@ -168,7 +168,7 @@ void IJsonOnDeserialized.OnDeserialized() /// A circle branch of the Shape tagged union. /// [GeneratedCode("nex-gen", null)] - public class Circle + public class Circle : Shape { public Circle(double radius) { @@ -197,6 +197,25 @@ public string Kind [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public override void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal override void CollectViolations(List violations, string path) + { + } } @@ -456,11 +475,63 @@ void IJsonOnDeserialized.OnDeserialized() /// /// A closed sum type (discriminated union) of Circle | Square, tagged by the shared required `kind` const. Selection reads `kind` and routes to the matching branch; an unknown tag is a Violation. /// + [JsonConverter(typeof(ShapeJsonConverter))] [GeneratedCode("nex-gen", null)] - public class Shape + public abstract class Shape { - [JsonExtensionData] - public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + private protected Shape() + { + } + + /// + /// Validates the selected branch, throwing a single + /// carrying every violation. + /// + public abstract void Validate(); + + internal abstract void CollectViolations(List violations, string path); + } + + [GeneratedCode("nex-gen", null)] + internal sealed class ShapeJsonConverter : JsonConverter + { + public override Shape Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new ValidationException(new List + { + new Violation(string.Empty, "expected one of: Circle, Square"), + }); + } + if (!root.TryGetProperty("kind", out var tag)) + { + throw new ValidationException(new List + { + new Violation(string.Empty, "discriminator \"kind\" is required"), + }); + } + var raw = root.GetRawText(); + switch (tag.ValueKind == JsonValueKind.String ? tag.GetString() : null) + { + case "circle": + return JsonSerializer.Deserialize(raw, options)!; + case "square": + return JsonSerializer.Deserialize(raw, options)!; + default: + throw new ValidationException(new List + { + new Violation(string.Empty, $"unknown discriminator kind {tag.GetRawText()}: expected one of [\"circle\", \"square\"]"), + }); + } + } + + public override void Write(Utf8JsonWriter writer, Shape value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } } @@ -1401,7 +1472,7 @@ public GetShowcaseInput(string id) /// A square branch of the Shape tagged union. /// [GeneratedCode("nex-gen", null)] - public class Square + public class Square : Shape { public Square(double side) { @@ -1430,6 +1501,25 @@ public string Kind [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public override void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal override void CollectViolations(List violations, string path) + { + } } diff --git a/samples/dotnet/tests/TaggedUnionChecks.cs b/samples/dotnet/tests/TaggedUnionChecks.cs new file mode 100644 index 00000000..fb1ad834 --- /dev/null +++ b/samples/dotnet/tests/TaggedUnionChecks.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using Xunit; +using Showcase = NexGen.ShowcaseService; + +namespace NexGen.DotNetExamples.Tests +{ + + /// + /// Covers the Shape tagged union — Circle | Square, selected by the + /// shared required kind const. + /// + /// This is the construct that previously generated a class with no members at + /// all: both branches were dropped and any payload round-tripped as an empty + /// object. + /// + public class TaggedUnionChecks + { + private static readonly JsonSerializerOptions Options = new(); + + private static Showcase.Shape Deserialize(string json) => + JsonSerializer.Deserialize(json, Options)!; + + private static Showcase.ValidationException Rejects(string json) => + Assert.Throws(() => Deserialize(json)); + + [Fact] + public void CircleTagSelectsTheCircleBranch() + { + var shape = Deserialize(@"{""kind"":""circle"",""radius"":2.5}"); + + var circle = Assert.IsType(shape); + Assert.Equal("circle", circle.Kind); + Assert.Equal(2.5, circle.Radius); + } + + [Fact] + public void SquareTagSelectsTheSquareBranch() + { + var shape = Deserialize(@"{""kind"":""square"",""side"":4}"); + + var square = Assert.IsType(shape); + Assert.Equal(4, square.Side); + } + + /// + /// The branches are reachable through the base type, which is the whole + /// point — a member typed Shape can hold either. + /// + [Fact] + public void BranchesShareTheAbstractBaseType() + { + Assert.IsAssignableFrom(Deserialize(@"{""kind"":""circle"",""radius"":1}")); + Assert.IsAssignableFrom(Deserialize(@"{""kind"":""square"",""side"":1}")); + } + + [Fact] + public void UnknownTagIsRejected() + { + var exception = Rejects(@"{""kind"":""triangle"",""side"":1}"); + + var violation = Assert.Single(exception.Violations); + Assert.Equal( + @"unknown discriminator kind ""triangle"": expected one of [""circle"", ""square""]", + violation.Reason); + } + + [Fact] + public void MissingDiscriminatorIsRejected() + { + var exception = Rejects(@"{""radius"":2.5}"); + + Assert.Equal(@"discriminator ""kind"" is required", Assert.Single(exception.Violations).Reason); + } + + [Fact] + public void NonObjectIsRejected() + { + var exception = Rejects(@"""circle"""); + + Assert.Equal("expected one of: Circle, Square", Assert.Single(exception.Violations).Reason); + } + + /// + /// Round-tripping through the base type writes the branch's own shape, not + /// an empty object — the converter dispatches on the runtime type. + /// + [Fact] + public void SerializingThroughTheBaseTypeWritesTheBranch() + { + Showcase.Shape shape = new Showcase.Circle(2.5); + + var json = JsonSerializer.Serialize(shape, Options); + + using var document = JsonDocument.Parse(json); + Assert.Equal("circle", document.RootElement.GetProperty("kind").GetString()); + Assert.Equal(2.5, document.RootElement.GetProperty("radius").GetDouble()); + } + + [Fact] + public void RoundTripThroughTheBaseTypePreservesTheBranch() + { + var original = @"{""kind"":""square"",""side"":3}"; + + var reserialized = JsonSerializer.Serialize(Deserialize(original), Options); + + Assert.IsType(Deserialize(reserialized)); + } + + /// + /// Validate() is declared on the base, so a caller holding a Shape can check + /// it without knowing the branch. + /// + [Fact] + public void ValidateIsCallableThroughTheBaseType() + { + Showcase.Shape shape = new Showcase.Square(3); + + shape.Validate(); + } + + /// + /// A branch used as a member of a containing model still selects correctly. + /// + [Fact] + public void UnionNestedInAContainingModelSelectsTheBranch() + { + var json = @"{""kind"":""showcase"",""revision"":1,""enabled"":true," + + @"""status"":""active"",""tier"":1,""scale"":1.5,""name"":""Widget""," + + @"""count"":3,""active"":true,""category"":""tools""," + + @"""shape"":{""kind"":""circle"",""radius"":7}}"; + + var value = JsonSerializer.Deserialize(json, Options)!; + + var circle = Assert.IsType(value.Shape); + Assert.Equal(7, circle.Radius); + } + } +} diff --git a/src/generator/json_schema/dotnet.rs b/src/generator/json_schema/dotnet.rs index 31f07d36..1bbafcf6 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use heck::ToUpperCamelCase; @@ -341,23 +341,284 @@ fn model_type_ref(json_type: &PlannedJsonType) -> String { csharp_type_name(&json_type.model_name) } +/// A closed sum type: `oneOf` over `$ref` branches that all share a required +/// `const` discriminator member. +/// +/// Lowered to an abstract base class plus a `JsonConverter` that reads the tag and +/// routes to the branch. Before this, the union rendered as a class with no +/// members at all and both branches were simply lost. +#[derive(Debug)] +struct TaggedUnion { + /// The JSON member carrying the tag, e.g. `kind`. + discriminator: String, + /// `(tag value, branch type name)` in declaration order. + branches: Vec<(String, String)>, +} + +impl TaggedUnion { + /// The bracketed tag list Go names when no branch matches. Note the + /// comma-**space** separator, which differs from the `enum` reason's list. + fn expected_tags(&self) -> String { + self.branches + .iter() + .map(|(tag, _)| format!("{tag:?}")) + .collect::>() + .join(", ") + } + + fn converter_name(base: &str) -> String { + format!("{base}JsonConverter") + } +} + +/// Every tagged union in one emitted package, plus the reverse branch index. +#[derive(Debug, Default)] +struct TaggedUnions { + definitions: BTreeMap, + /// Branch type name to its base class. + branch_bases: BTreeMap, +} + +impl TaggedUnions { + fn resolve(json_models: &[PlannedJsonType]) -> Result { + // Branch schemas are looked up by emitted type name, which is how a `$ref` + // resolves once the name manifest has been applied. + let mut schemas = BTreeMap::new(); + for model in json_models { + schemas.insert(model_type_ref(model), decode_schema(model)?); + } + + let mut resolved = Self::default(); + for model in json_models { + let type_name = model_type_ref(model); + let schema = &schemas[&type_name]; + let Some(union) = tagged_union_for(schema, &schemas) else { + continue; + }; + for (_, branch) in &union.branches { + // C# has single inheritance, so a branch shared between two unions + // cannot be modeled this way. Leave every union unlowered rather + // than emit something that will not compile; `dotnet_coverage` then + // reports them all as gaps. + if resolved.branch_bases.contains_key(branch) { + return Ok(Self::default()); + } + resolved + .branch_bases + .insert(branch.clone(), type_name.clone()); + } + resolved.definitions.insert(type_name, union); + } + Ok(resolved) + } + + fn definition(&self, type_name: &str) -> Option<&TaggedUnion> { + self.definitions.get(type_name) + } + + fn base_of(&self, type_name: &str) -> Option<&str> { + self.branch_bases.get(type_name).map(String::as_str) + } + + /// True when this model is a union branch, which forces its validator to be + /// emitted (as an override) even when it has no constraints of its own. + fn is_branch(&self, type_name: &str) -> bool { + self.branch_bases.contains_key(type_name) + } +} + +/// Emits a tagged union: the abstract base class and the `JsonConverter` that +/// selects a branch from the discriminator. +/// +/// The converter is hand-rolled rather than using `[JsonPolymorphic]`, because +/// System.Text.Json's built-in discriminator is a metadata property distinct from +/// the model's own members. Here the tag *is* a declared member — each branch has +/// `kind` as a required `const` — and the two mechanisms collide. +fn render_tagged_union( + output: &mut String, + model: &PlannedJsonType, + union: &TaggedUnion, +) -> Result<()> { + let schema = decode_schema(model)?; + let base = model_type_ref(model); + let converter = TaggedUnion::converter_name(&base); + + render_xml_summary(output, "", schema.description.as_deref()); + output.push_str("[JsonConverter(typeof("); + output.push_str(&converter); + output.push_str("))]\n"); + output.push_str(GENERATED_CODE_ATTRIBUTE); + output.push('\n'); + output.push_str("public abstract class "); + output.push_str(&base); + output.push_str("\n{\n"); + // A private-protected constructor closes the hierarchy: only the generated + // branches in this assembly can derive from it, which is what `oneOf` means. + output.push_str(" private protected "); + output.push_str(&base); + output.push_str("()\n {\n }\n\n"); + output.push_str(" /// \n"); + output.push_str(" /// Validates the selected branch, throwing a single\n"); + output.push_str(" /// carrying every violation.\n"); + output.push_str(" /// \n"); + output.push_str(" public abstract void Validate();\n\n"); + output.push_str( + " internal abstract void CollectViolations(List violations, string path);\n", + ); + output.push_str("}\n\n"); + + output.push_str(GENERATED_CODE_ATTRIBUTE); + output.push('\n'); + output.push_str("internal sealed class "); + output.push_str(&converter); + output.push_str(" : JsonConverter<"); + output.push_str(&base); + output.push_str(">\n{\n"); + output.push_str(" public override "); + output.push_str(&base); + output.push_str( + " Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n {\n", + ); + output.push_str(" using var document = JsonDocument.ParseValue(ref reader);\n"); + output.push_str(" var root = document.RootElement;\n"); + output.push_str(" if (root.ValueKind != JsonValueKind.Object)\n {\n"); + output + .push_str(" throw new ValidationException(new List\n {\n"); + output.push_str(" new Violation(string.Empty, "); + output.push_str(&csharp_string_literal(&format!( + "expected one of: {}", + union + .branches + .iter() + .map(|(_, branch)| branch.as_str()) + .collect::>() + .join(", ") + ))); + output.push_str("),\n });\n }\n"); + output.push_str(" if (!root.TryGetProperty("); + output.push_str(&csharp_string_literal(&union.discriminator)); + output.push_str(", out var tag))\n {\n"); + output + .push_str(" throw new ValidationException(new List\n {\n"); + output.push_str(" new Violation(string.Empty, "); + output.push_str(&csharp_string_literal(&format!( + "discriminator {:?} is required", + union.discriminator + ))); + output.push_str("),\n });\n }\n"); + output.push_str(" var raw = root.GetRawText();\n"); + output.push_str(" switch (tag.ValueKind == JsonValueKind.String ? tag.GetString() : null)\n {\n"); + for (tag, branch) in &union.branches { + output.push_str(" case "); + output.push_str(&csharp_string_literal(tag)); + output.push_str(":\n return JsonSerializer.Deserialize<"); + output.push_str(branch); + output.push_str(">(raw, options)!;\n"); + } + output.push_str(" default:\n"); + output.push_str( + " throw new ValidationException(new List\n {\n", + ); + output.push_str(" new Violation(string.Empty, $"); + output.push_str(&csharp_string_literal(&format!( + "unknown discriminator {} {{tag.GetRawText()}}: expected one of [{}]", + union.discriminator, + union.expected_tags() + ))); + output.push_str("),\n });\n }\n }\n\n"); + output.push_str(" public override void Write(Utf8JsonWriter writer, "); + output.push_str(&base); + output.push_str(" value, JsonSerializerOptions options)\n {\n"); + // Dispatching on the runtime type serializes the branch through its own + // converter rather than re-entering this one. + output.push_str(" JsonSerializer.Serialize(writer, value, value.GetType(), options);\n"); + output.push_str(" }\n}\n\n"); + Ok(()) +} + +/// Recognizes the tagged-union shape: two or more `$ref` branches, each an object +/// declaring the same required member with a distinct string `const`. +fn tagged_union_for(schema: &Schema, schemas: &BTreeMap) -> Option { + let one_of = schema.one_of.as_ref()?; + if one_of.len() < 2 || schema.properties.is_some() { + return None; + } + + let branch_names = one_of + .iter() + .map(|branch| branch.reference.as_deref().map(reference_type_name)) + .collect::>>()?; + + // The discriminator is a member every branch declares as required with a + // string `const`. + let first = schemas.get(branch_names.first()?)?; + let candidates = first + .properties + .as_ref()? + .iter() + .filter(|(name, property)| { + property.const_value.as_ref().is_some_and(Value::is_string) + && required_fields(first).contains(name.as_str()) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + + for discriminator in candidates { + let mut branches = Vec::new(); + for branch_name in &branch_names { + let Some(branch) = schemas.get(branch_name) else { + break; + }; + if !required_fields(branch).contains(discriminator.as_str()) { + break; + } + let Some(tag) = branch + .properties + .as_ref() + .and_then(|properties| properties.get(&discriminator)) + .and_then(|property| property.const_value.as_ref()) + .and_then(Value::as_str) + else { + break; + }; + branches.push((tag.to_string(), branch_name.clone())); + } + if branches.len() == branch_names.len() { + return Some(TaggedUnion { + discriminator, + branches, + }); + } + } + None +} + fn render_external_models(json_models: &[PlannedJsonType]) -> Result { if json_models.is_empty() { return Ok(RenderedModelFragments::default()); } + let unions = TaggedUnions::resolve(json_models)?; let mut output = String::new(); for (index, model) in json_models.iter().enumerate() { if index > 0 { output.push('\n'); } - render_model(&mut output, model)?; + if let Some(union) = unions.definition(&model_type_ref(model)) { + render_tagged_union(&mut output, model, union)?; + } else { + render_model(&mut output, model, &unions)?; + } } Ok(RenderedModelFragments { body: output }) } -fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { +fn render_model(output: &mut String, model: &PlannedJsonType, unions: &TaggedUnions) -> Result<()> { let schema = decode_schema(model)?; + let type_name = model_type_ref(model); + // A branch of a tagged union derives from that union's base class, so its + // validator overrides the base's abstract members. + let base_class = unions.base_of(&type_name); render_xml_summary(output, "", schema.description.as_deref()); if !model_needs_extension_data(&schema)? && !is_open_object(&schema) { output.push_str("[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]\n"); @@ -365,9 +626,17 @@ fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { output.push_str(GENERATED_CODE_ATTRIBUTE); output.push('\n'); output.push_str("public class "); - output.push_str(&model_type_ref(model)); + output.push_str(&type_name); + let mut bases = Vec::new(); + if let Some(base_class) = base_class { + bases.push(base_class.to_string()); + } if model_needs_on_deserialized(&schema)? { - output.push_str(" : IJsonOnDeserialized"); + bases.push("IJsonOnDeserialized".to_string()); + } + if !bases.is_empty() { + output.push_str(" : "); + output.push_str(&bases.join(", ")); } output.push_str("\n{\n"); @@ -376,7 +645,7 @@ fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { render_model_properties(output, &schema)?; } render_extension_data_property(output, &schema)?; - render_constraint_validator(output, &schema)?; + render_constraint_validator(output, &schema, unions.is_branch(&type_name))?; render_model_validation(output, &schema)?; output.push_str("}\n\n"); @@ -393,12 +662,19 @@ fn render_model(output: &mut String, model: &PlannedJsonType) -> Result<()> { /// binding can call it before serializing an outbound value. `CollectViolations` /// takes a path prefix so a nested model reports `page.blocks.order` rather than /// a bare `order`. -fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<()> { +fn render_constraint_validator( + output: &mut String, + schema: &Schema, + is_union_branch: bool, +) -> Result<()> { let constrained = constrained_members(schema); let object_constraints = schema.object_constraints()?; - if constrained.is_empty() && object_constraints.is_empty() { + // A union branch always emits the pair, even with nothing to check, because + // the base class declares them abstract. + if !is_union_branch && constrained.is_empty() && object_constraints.is_empty() { return Ok(()); } + let modifier = if is_union_branch { "override " } else { "" }; render_pattern_fields(output, &constrained); @@ -412,7 +688,9 @@ fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<( ); output.push_str(" /// than stopping at the first.\n"); output.push_str(" /// \n"); - output.push_str(" public void Validate()\n {\n"); + output.push_str(" public "); + output.push_str(modifier); + output.push_str("void Validate()\n {\n"); output.push_str(" var violations = new List();\n"); output.push_str(" CollectViolations(violations, string.Empty);\n"); output.push_str(" if (violations.Count > 0)\n {\n"); @@ -420,9 +698,9 @@ fn render_constraint_validator(output: &mut String, schema: &Schema) -> Result<( output.push_str(" }\n"); output.push_str(" }\n\n"); - output.push_str( - " internal void CollectViolations(List violations, string path)\n {\n", - ); + output.push_str(" internal "); + output.push_str(modifier); + output.push_str("void CollectViolations(List violations, string path)\n {\n"); for member in &constrained { render_member_constraints(output, member); } diff --git a/src/generator/json_schema/dotnet_coverage.rs b/src/generator/json_schema/dotnet_coverage.rs index 80d42c9a..44c2dc6c 100644 --- a/src/generator/json_schema/dotnet_coverage.rs +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -73,11 +73,19 @@ fn collect_node( ) { match node { ApiSpecNode::Leaf(leaf) => { + // Deciding whether a `$ref` union is lowered needs the branch schemas, + // which are sibling models, so index them before walking. + let mut siblings = BTreeMap::new(); + for (_, binding) in leaf.spec.external_types() { + if let ExternalTypeSpec::Json(json) = &binding.external_type { + siblings.insert(reference_tail(&json.full_name), &json.schema); + } + } for (_, binding) in leaf.spec.external_types() { let ExternalTypeSpec::Json(json) = &binding.external_type else { continue; }; - collect_schema(&json.schema, &json.model_name, findings); + collect_schema(&json.schema, &json.model_name, &siblings, findings); } } ApiSpecNode::Branch(branch) => { @@ -88,9 +96,24 @@ fn collect_node( } } +/// The trailing name of a `$ref` or qualified model identity — the segment after +/// the last `/` or `#`. +fn reference_tail(reference: &str) -> String { + reference + .rsplit(['#', '/']) + .next() + .unwrap_or(reference) + .to_string() +} + /// Walks a schema recursively, recording each unenforced keyword against the /// dotted member path that carries it. -fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static str, Vec>) { +fn collect_schema( + schema: &Value, + path: &str, + siblings: &BTreeMap, + findings: &mut BTreeMap<&'static str, Vec>, +) { let Value::Object(members) = schema else { return; }; @@ -101,11 +124,14 @@ fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static s } } - // `oneOf` is only reported when it is a real sum type. The - // `[, {"type": "null"}]` spelling is how the loader expresses an - // optional-nullable member, and the backend does lower that correctly. + // `oneOf` is reported only for the spellings the backend does not lower. + // Two are lowered: the `[, {"type": "null"}]` nullable wrapper, and a + // tagged union over `$ref` branches (abstract base plus a routing converter). + // What remains — a disjoint-kind scalar union such as + // `oneOf: [{type: string}, {type: integer}]` — still degrades to `object`. if let Some(Value::Array(branches)) = members.get("oneOf") && !is_nullable_wrapper(branches) + && !is_tagged_union(branches, siblings) { findings.entry("oneOf").or_default().push(path.to_string()); } @@ -145,7 +171,7 @@ fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static s "properties" | "$defs" | "patternProperties" => { if let Value::Object(children) = value { for (name, child) in children { - collect_schema(child, &format!("{path}.{name}"), findings); + collect_schema(child, &format!("{path}.{name}"), siblings, findings); } } } @@ -153,7 +179,7 @@ fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static s "oneOf" | "anyOf" | "allOf" | "prefixItems" => { if let Value::Array(children) = value { for child in children { - collect_schema(child, path, findings); + collect_schema(child, path, siblings, findings); } } } @@ -164,7 +190,7 @@ fn collect_schema(schema: &Value, path: &str, findings: &mut BTreeMap<&'static s | "not" | "propertyNames" | "contentSchema" => { - collect_schema(value, path, findings); + collect_schema(value, path, siblings, findings); } _ => {} } @@ -181,6 +207,71 @@ fn is_nullable_wrapper(branches: &[Value]) -> bool { null_branches > 0 && branches.len() - null_branches == 1 } +/// True when the branches form the tagged union the backend lowers: two or more +/// `$ref`s that all resolve to objects sharing a required `const` discriminator. +/// +/// Resolving the branches matters. The loader rewrites an inline union into +/// `$ref`s to synthesized types, so showcase's disjoint-kind +/// `oneOf: [{type: string}, {type: integer}]` reaches this pass as two `$ref` +/// branches and is indistinguishable from `Circle | Square` by shape alone. Only +/// following the refs separates the union that is lowered from the one that still +/// degrades to `object`. +fn is_tagged_union(branches: &[Value], siblings: &BTreeMap) -> bool { + let resolved = branches + .iter() + .filter(|branch| !is_null_schema(branch)) + .map(|branch| { + branch + .get("$ref") + .and_then(Value::as_str) + .map(reference_tail) + .and_then(|name| siblings.get(&name).copied()) + }) + .collect::>>(); + let Some(resolved) = resolved else { + return false; + }; + if resolved.len() < 2 { + return false; + } + discriminator_names(resolved[0]) + .into_iter() + .any(|candidate| { + resolved + .iter() + .all(|branch| discriminator_names(branch).contains(&candidate)) + }) +} + +/// The member names a schema declares as required with a string `const` — the +/// candidates for a union discriminator. +fn discriminator_names(schema: &Value) -> Vec { + let required = schema + .get("required") + .and_then(Value::as_array) + .map(|names| { + names + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + schema + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .filter(|(name, property)| { + required.contains(name) && property.get("const").is_some_and(Value::is_string) + }) + .map(|(name, _)| name.clone()) + .collect() + }) + .unwrap_or_default() +} + /// True when a `contains` subschema is the `const` form the backend lowers. fn contains_is_supported(contains: &Value) -> bool { contains.get("const").is_some() @@ -195,7 +286,10 @@ fn is_null_schema(schema: &Value) -> bool { fn format_warning(keyword: &str, paths: &[String]) -> String { let detail = match keyword { - "oneOf" => "branches are dropped — the emitted class has no members", + "oneOf" => { + "is a disjoint-kind scalar union, which degrades to `object` — only \ + `$ref` tagged unions are lowered" + } "enum" => "is emitted as a bare scalar with no closed value set", "format" => "is left as `string` — neither asserted nor materialized", "contentEncoding" => "is left as `string` — not decoded to bytes", @@ -235,8 +329,21 @@ mod tests { // time a keyword gains enforcement. Coverage of the *current* gap set lives in // `tests/generate_dotnet.rs`, which is meant to churn. fn findings_for(schema: serde_json::Value) -> Vec { + findings_with_siblings(schema, &[]) + } + + /// `siblings` supplies the `$ref` targets a union's branches resolve to, + /// keyed by their trailing name. + fn findings_with_siblings( + schema: serde_json::Value, + siblings: &[(&str, serde_json::Value)], + ) -> Vec { + let resolved = siblings + .iter() + .map(|(name, value)| (name.to_string(), value)) + .collect::>(); let mut findings = BTreeMap::new(); - collect_schema(&schema, "Model", &mut findings); + collect_schema(&schema, "Model", &resolved, &mut findings); findings .into_iter() .map(|(keyword, paths)| format_warning(keyword, &paths)) @@ -300,18 +407,81 @@ mod tests { } #[test] - fn reports_real_sum_type_one_of() { - let warnings = findings_for(json!({ - "oneOf": [{ "$ref": "#/$defs/Circle" }, { "$ref": "#/$defs/Square" }], - })); + fn does_not_report_a_ref_union_lowered_to_a_tagged_union() { + let warnings = findings_with_siblings( + json!({ + "oneOf": [{ "$ref": "#/$defs/Circle" }, { "$ref": "#/$defs/Square" }], + }), + &[ + ( + "Circle", + json!({ + "type": "object", + "required": ["kind"], + "properties": { "kind": { "type": "string", "const": "circle" } }, + }), + ), + ( + "Square", + json!({ + "type": "object", + "required": ["kind"], + "properties": { "kind": { "type": "string", "const": "square" } }, + }), + ), + ], + ); + + assert!(warnings.is_empty(), "{:?}", warnings); + } + + /// The remaining `oneOf` gap: a disjoint-kind scalar union, which still + /// degrades to `object`. + /// + /// The loader rewrites an inline scalar union into `$ref`s to synthesized + /// types, so this arrives looking structurally like the tagged union above. + /// Only resolving the branches tells them apart. + #[test] + fn reports_a_ref_union_whose_branches_are_scalars() { + let warnings = findings_with_siblings( + json!({ + "type": "object", + "properties": { + "idOrName": { + "oneOf": [ + { "$ref": "#/$defs/IdOrNameString" }, + { "$ref": "#/$defs/IdOrNameInteger" }, + ], + }, + }, + }), + &[ + ("IdOrNameString", json!({ "type": "string" })), + ("IdOrNameInteger", json!({ "type": "integer" })), + ], + ); assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("`oneOf`"), "{:?}", warnings); - assert!( - warnings[0].contains("branches are dropped"), - "{:?}", - warnings + assert!(warnings[0].contains("Model.idOrName"), "{:?}", warnings); + } + + /// A `$ref` union whose branches are objects but share no `const` + /// discriminator is not a tagged union either, and must still be reported. + #[test] + fn reports_a_ref_union_with_no_shared_discriminator() { + let warnings = findings_with_siblings( + json!({ + "oneOf": [{ "$ref": "#/$defs/Left" }, { "$ref": "#/$defs/Right" }], + }), + &[ + ("Left", json!({ "type": "object" })), + ("Right", json!({ "type": "object" })), + ], ); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("`oneOf`"), "{:?}", warnings); } #[test] diff --git a/tests/generate_dotnet.rs b/tests/generate_dotnet.rs index 3e649df1..4b68c689 100644 --- a/tests/generate_dotnet.rs +++ b/tests/generate_dotnet.rs @@ -605,6 +605,8 @@ fn dotnet_json_coverage_warnings_match_known_gaps() { warned_keywords.sort(); warned_keywords.dedup(); + // `oneOf` remains only for showcase's disjoint-kind scalar union; the + // `Circle | Square` tagged union is lowered. let expected = ["contentEncoding", "format", "oneOf"]; assert_eq!( From 04bf3c6d6e0c6be392e5ee9cc88c3d15c5607b85 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Thu, 6 Aug 2026 13:25:19 -0700 Subject: [PATCH 14/16] Banking Service schema --- samples/schemas/banking-service.yaml | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 samples/schemas/banking-service.yaml diff --git a/samples/schemas/banking-service.yaml b/samples/schemas/banking-service.yaml new file mode 100644 index 00000000..2052fc15 --- /dev/null +++ b/samples/schemas/banking-service.yaml @@ -0,0 +1,80 @@ +nexusrpc: "1.0.0" +$schema: https://json-schema.org/draft/2020-12/schema +description: A banking service for sending a receiving money +services: + BankService: + fqn: org.example.BankService + description: Send and Receive Money into accounts + operations: + sendMoney: + input: { $ref: "#/$defs/TransferMoneyInput" } + output: { $ref: "#/$defs/TransferMoneyOutput" } + getBalance: + input: { $ref: "#/$defs/GetBalanceInput" } + output: { $ref: "#/$defs/GetBalanceOutput" } + createAccount: + input: { $ref: "#/$defs/CreateAccountInput" } + output: { $ref: "#/$defs/CreateAccountOutput" } + +$defs: + TransferMoneyInput: + description: Request to transfer money + type: object + additionalProperties: false + properties: + sourceAccountId: + description: The Account sending money + type: string + destinationAccountId: + description: The Account receiving money + type: string + amount: + type: integer + exclusiveMinimum: 0 + maximum: 100000 + required: [sourceAccountId, destinationAccountId, amount] + TransferMoneyOutput: + type: object + additionalProperties: false + properties: + success: + type: boolean + errorMessage: + description: Error if the transfer failed + type: string + required: [success] + GetBalanceInput: + type: object + additionalProperties: false + properties: + accountId: + type: string + required: [accountId] + GetBalanceOutput: + type: object + additionalProperties: false + properties: + accountId: + type: string + amount: + type: integer + required: [accountId, amount] + CreateAccountInput: + type: object + additionalProperties: false + properties: + accountId: { type: string } + amount: + type: integer + minimum: 0 + default: 500 + required: [ accountId ] + CreateAccountOutput: + type: object + additionalProperties: false + properties: + accountId: + type: string + amount: + type: integer + required: [accountId, amount] \ No newline at end of file From d4cacea4cacc137685c8092dea013956dad511ac Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Thu, 6 Aug 2026 13:37:41 -0700 Subject: [PATCH 15/16] Generated client library --- samples/dotnet-banking-svc/Definitions.cs | 288 ++++++++++++++++++++ samples/dotnet-banking-svc/Models.cs | 318 ++++++++++++++++++++++ samples/dotnet-banking-svc/Services.cs | 33 +++ 3 files changed, 639 insertions(+) create mode 100644 samples/dotnet-banking-svc/Definitions.cs create mode 100644 samples/dotnet-banking-svc/Models.cs create mode 100644 samples/dotnet-banking-svc/Services.cs diff --git a/samples/dotnet-banking-svc/Definitions.cs b/samples/dotnet-banking-svc/Definitions.cs new file mode 100644 index 00000000..8f220789 --- /dev/null +++ b/samples/dotnet-banking-svc/Definitions.cs @@ -0,0 +1,288 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; + +namespace NexGen.BankService +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. + /// + internal const long IntegerCap = 9007199254740991L; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); + } + if (value is long longValue) + { + return longValue; + } + if (value is int intValue) + { + return intValue; + } + throw new JsonException("expected integer"); + } + + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + +} diff --git a/samples/dotnet-banking-svc/Models.cs b/samples/dotnet-banking-svc/Models.cs new file mode 100644 index 00000000..02acde43 --- /dev/null +++ b/samples/dotnet-banking-svc/Models.cs @@ -0,0 +1,318 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NexGen.BankService +{ + + [GeneratedCode("nex-gen", null)] + public class CreateAccountInput : IJsonOnDeserialized + { + public CreateAccountInput(string accountId) + { + AccountId = accountId; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + [JsonIgnore] + public long? Amount + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "amount", 500); + init + { + JsonRuntime.RejectNull("amount", value); + AdditionalProperties["amount"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount is long amountValue) + { + if (amountValue < -JsonRuntime.IntegerCap || amountValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + if (amountValue < 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "must be >= 0, got " + JsonRuntime.FormatNumber(amountValue))); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "amount") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("amount", out var amountValue)) + { + JsonRuntime.RejectNull("amount", amountValue); + _ = JsonRuntime.ReadJsonValue(amountValue); + } + Validate(); + } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class CreateAccountOutput : IJsonOnDeserialized + { + public CreateAccountOutput(string accountId, long amount) + { + AccountId = accountId; + Amount = amount; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + [JsonPropertyName("amount")] + [JsonRequired] + public long Amount { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount < -JsonRuntime.IntegerCap || Amount > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class GetBalanceInput + { + public GetBalanceInput(string accountId) + { + AccountId = accountId; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class GetBalanceOutput : IJsonOnDeserialized + { + public GetBalanceOutput(string accountId, long amount) + { + AccountId = accountId; + Amount = amount; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + [JsonPropertyName("amount")] + [JsonRequired] + public long Amount { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount < -JsonRuntime.IntegerCap || Amount > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } + } + + + /// + /// Request to transfer money + /// + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class TransferMoneyInput : IJsonOnDeserialized + { + public TransferMoneyInput(string sourceAccountId, string destinationAccountId, long amount) + { + SourceAccountId = sourceAccountId; + DestinationAccountId = destinationAccountId; + Amount = amount; + } + + /// + /// The Account sending money + /// + [JsonPropertyName("sourceAccountId")] + [JsonRequired] + public string SourceAccountId { get; init; } + /// + /// The Account receiving money + /// + [JsonPropertyName("destinationAccountId")] + [JsonRequired] + public string DestinationAccountId { get; init; } + [JsonPropertyName("amount")] + [JsonRequired] + public long Amount { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount < -JsonRuntime.IntegerCap || Amount > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + if (Amount > 100000) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "must be <= 100000, got " + JsonRuntime.FormatNumber(Amount))); + } + if (Amount <= 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "must be > 0, got " + JsonRuntime.FormatNumber(Amount))); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } + } + + + [GeneratedCode("nex-gen", null)] + public class TransferMoneyOutput : IJsonOnDeserialized + { + public TransferMoneyOutput(bool success) + { + Success = success; + } + + [JsonPropertyName("success")] + [JsonRequired] + public bool Success { get; init; } + /// + /// Error if the transfer failed + /// + [JsonIgnore] + public string? ErrorMessage + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "errorMessage"); + init + { + JsonRuntime.RejectNull("errorMessage", value); + AdditionalProperties["errorMessage"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "errorMessage") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("errorMessage", out var errorMessageValue)) + { + JsonRuntime.RejectNull("errorMessage", errorMessageValue); + if (errorMessageValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"errorMessage"}: expected string"); + } + else if (errorMessageValue is not JsonElement && errorMessageValue is not string) + { + throw new JsonException($"{"errorMessage"}: expected string"); + } + } + } + } + +} diff --git a/samples/dotnet-banking-svc/Services.cs b/samples/dotnet-banking-svc/Services.cs new file mode 100644 index 00000000..1fce64dc --- /dev/null +++ b/samples/dotnet-banking-svc/Services.cs @@ -0,0 +1,33 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Threading.Tasks; +using NexusRpc; + +namespace NexGen.BankService +{ + + [GeneratedCode("nex-gen", null)] + [NexusService("org.example.BankService")] + internal interface IBankService + { + [GeneratedCode("nex-gen", null)] + [NexusOperation("SendMoney")] + TransferMoneyOutput SendMoney(TransferMoneyInput request); + + [GeneratedCode("nex-gen", null)] + [NexusOperation("GetBalance")] + GetBalanceOutput GetBalance(GetBalanceInput request); + + [GeneratedCode("nex-gen", null)] + [NexusOperation("CreateAccount")] + CreateAccountOutput CreateAccount(CreateAccountInput request); + + } + +} From be1a5bade948b16863e3aea6a3b8a2b62add38dd Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Thu, 6 Aug 2026 14:45:17 -0700 Subject: [PATCH 16/16] Bug bash sample working --- samples/dotnet-banking-svc_caller/.gitignore | 2 + .../Generated/Definitions.cs | 288 ++++++++++++++++ .../Generated/Models.cs | 318 ++++++++++++++++++ .../Generated/Services.cs | 33 ++ .../NexusEndpoints.cs | 40 +++ samples/dotnet-banking-svc_caller/Program.cs | 202 +++++++++++ samples/dotnet-banking-svc_caller/README.md | 119 +++++++ .../TemporalioSamples.BankingSvcCaller.csproj | 22 ++ 8 files changed, 1024 insertions(+) create mode 100644 samples/dotnet-banking-svc_caller/.gitignore create mode 100644 samples/dotnet-banking-svc_caller/Generated/Definitions.cs create mode 100644 samples/dotnet-banking-svc_caller/Generated/Models.cs create mode 100644 samples/dotnet-banking-svc_caller/Generated/Services.cs create mode 100644 samples/dotnet-banking-svc_caller/NexusEndpoints.cs create mode 100644 samples/dotnet-banking-svc_caller/Program.cs create mode 100644 samples/dotnet-banking-svc_caller/README.md create mode 100644 samples/dotnet-banking-svc_caller/TemporalioSamples.BankingSvcCaller.csproj diff --git a/samples/dotnet-banking-svc_caller/.gitignore b/samples/dotnet-banking-svc_caller/.gitignore new file mode 100644 index 00000000..cd42ee34 --- /dev/null +++ b/samples/dotnet-banking-svc_caller/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/dotnet-banking-svc_caller/Generated/Definitions.cs b/samples/dotnet-banking-svc_caller/Generated/Definitions.cs new file mode 100644 index 00000000..8f220789 --- /dev/null +++ b/samples/dotnet-banking-svc_caller/Generated/Definitions.cs @@ -0,0 +1,288 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; + +namespace NexGen.BankService +{ + + /// + /// A single constraint failure. is the JSON member path + /// (dotted for nested members); is a human-readable + /// message naming the bound and the offending value. + /// + [GeneratedCode("nex-gen", null)] + public sealed class Violation + { + public Violation(string path, string reason) + { + Path = path; + Reason = reason; + } + + public string Path { get; } + + public string Reason { get; } + + /// + /// Returns "Path: Reason", or just Reason when the path is + /// empty. + /// + public override string ToString() => + Path.Length == 0 ? Reason : Path + ": " + Reason; + } + + /// + /// Aggregates every found while (de)serializing a + /// value, surfacing them all in one error rather than stopping at the first. + /// + [GeneratedCode("nex-gen", null)] + public sealed class ValidationException : JsonException + { + public ValidationException(IReadOnlyList violations) + : base(FormatMessage(violations)) + { + Violations = violations; + } + + /// + /// Every violation found, never a partial first-failure. + /// + public IReadOnlyList Violations { get; } + + private static string FormatMessage(IReadOnlyList violations) + { + var parts = new string[violations.Count]; + for (var index = 0; index < violations.Count; index++) + { + parts[index] = violations[index].ToString(); + } + return $"{violations.Count} validation error(s): {string.Join("; ", parts)}"; + } + } + + /// + /// Read helpers shared by every generated model. Internal because they are an + /// implementation detail of the generated (de)serialization path rather than + /// part of the contract surface. + /// + [GeneratedCode("nex-gen", null)] + internal static class JsonRuntime + { + /// + /// The largest integer a JSON number carries losslessly (2^53-1). + /// + /// Exceeding it is a **contract violation**, reported through + /// with the offending member's path — not + /// a parse failure. Mirrors Go's `integerCap`. + /// + internal const long IntegerCap = 9007199254740991L; + + /// + /// Reads an optional member out of the extension-data bag, falling back to + /// when absent. + /// + internal static T? ReadOptionalValue( + IDictionary members, + string name, + T? defaultValue = default) + { + if (!members.TryGetValue(name, out var value)) + { + return defaultValue; + } + return ReadJsonValue(value); + } + + internal static T? ReadJsonValue(object? value) + { + if (value is null) + { + return default; + } + if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + { + return (T?)(object?)ReadJsonInteger(value); + } + if (value is JsonElement json) + { + return json.Deserialize(); + } + if (value is T typed) + { + return typed; + } + return (T)value; + } + + /// + /// Reads a JSON number as an integer, rejecting non-integral values and + /// anything beyond the lossless integer range. + /// + internal static long? ReadJsonInteger(object? value) + { + if (value is null) + { + return default; + } + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Null) + { + return default; + } + if (json.ValueKind != JsonValueKind.Number) + { + throw new JsonException("expected integer"); + } + // Exact across the whole Int64 range, and fails for a non-integral + // number. Deliberately does not enforce IntegerCap: a value past + // 2^53-1 is a constraint violation the validator reports with a + // path, not a parse error. Reading through double would round it + // away before the validator ever saw it. + if (json.TryGetInt64(out var exact)) + { + return exact; + } + // A number spelled with a decimal point but no fractional part — + // `1.0` — is a valid integer per JSON Schema, and TryGetInt64 + // rejects that spelling. Fall back to the double reading, bounded + // to the range where double to long is exact. `% 1 != 0` also + // rejects NaN and infinity, whose remainder is NaN. + if (json.TryGetDouble(out var number) + && number % 1 == 0 + && number >= -9007199254740992d + && number <= 9007199254740992d) + { + return (long)number; + } + throw new JsonException("expected integer"); + } + if (value is long longValue) + { + return longValue; + } + if (value is int intValue) + { + return intValue; + } + throw new JsonException("expected integer"); + } + + /// + /// Reports every uniqueItems duplicate, each against the index where + /// the value was first seen. + /// + /// A repeated value therefore yields one violation per later occurrence + /// rather than one per pair, which is what the other targets do. + /// + internal static void CollectDuplicateItems( + IReadOnlyList items, + string path, + List violations) + where T : notnull + { + var seen = new Dictionary(items.Count); + for (var index = 0; index < items.Count; index++) + { + if (seen.TryGetValue(items[index], out var first)) + { + violations.Add(new Violation( + path, + $"duplicate items: element at index {index} equals index {first}")); + } + else + { + seen[items[index]] = index; + } + } + } + + /// + /// Counts elements equal to a contains const value, feeding the + /// minContains/maxContains occurrence window. + /// + internal static int CountMatchingItems(IReadOnlyList items, T expected) + { + var comparer = EqualityComparer.Default; + var count = 0; + foreach (var item in items) + { + if (comparer.Equals(item, expected)) + { + count++; + } + } + return count; + } + + /// + /// Counts Unicode code points, which is the unit JSON Schema's + /// minLength/maxLength measure. + /// + /// string.Length counts UTF-16 code units, so it would score an + /// astral character such as U+1F600 as 2 and reject a value the contract + /// permits. This matches Go's utf8.RuneCountInString and Java's + /// codePointCount, including counting an unpaired surrogate as one. + /// + internal static int CodePointCount(string value) + { + var count = 0; + for (var index = 0; index < value.Length; index++) + { + count++; + if (char.IsHighSurrogate(value[index]) + && index + 1 < value.Length + && char.IsLowSurrogate(value[index + 1])) + { + index++; + } + } + return count; + } + + /// + /// Quotes a string for a violation reason, mirroring Go's %q for the + /// values a contract admits. Used by the enum reason, which names the + /// offending value alongside the admitted set. + /// + internal static string Quote(string value) => "\"" + value + "\""; + + /// + /// Joins a violation path prefix to a member name, so a nested model + /// reports page.blocks.order rather than a bare order. + /// + internal static string JoinPath(string prefix, string name) => + prefix.Length == 0 ? name : prefix + "." + name; + + /// + /// Renders a number for a violation reason using the invariant culture, so + /// the message never picks up a locale's decimal separator and stays + /// byte-identical to the other targets' diagnostics. + /// + internal static string FormatNumber(double value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + internal static string FormatNumber(long value) => + value.ToString(CultureInfo.InvariantCulture); + + /// + /// Rejects an explicit JSON null for a member the contract declares + /// non-nullable. + /// + internal static void RejectNull(string name, object? value) + { + if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + { + throw new JsonException($"{name}: explicit null not allowed"); + } + } + } + +} diff --git a/samples/dotnet-banking-svc_caller/Generated/Models.cs b/samples/dotnet-banking-svc_caller/Generated/Models.cs new file mode 100644 index 00000000..02acde43 --- /dev/null +++ b/samples/dotnet-banking-svc_caller/Generated/Models.cs @@ -0,0 +1,318 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NexGen.BankService +{ + + [GeneratedCode("nex-gen", null)] + public class CreateAccountInput : IJsonOnDeserialized + { + public CreateAccountInput(string accountId) + { + AccountId = accountId; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + [JsonIgnore] + public long? Amount + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "amount", 500); + init + { + JsonRuntime.RejectNull("amount", value); + AdditionalProperties["amount"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount is long amountValue) + { + if (amountValue < -JsonRuntime.IntegerCap || amountValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + if (amountValue < 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "must be >= 0, got " + JsonRuntime.FormatNumber(amountValue))); + } + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "amount") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("amount", out var amountValue)) + { + JsonRuntime.RejectNull("amount", amountValue); + _ = JsonRuntime.ReadJsonValue(amountValue); + } + Validate(); + } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class CreateAccountOutput : IJsonOnDeserialized + { + public CreateAccountOutput(string accountId, long amount) + { + AccountId = accountId; + Amount = amount; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + [JsonPropertyName("amount")] + [JsonRequired] + public long Amount { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount < -JsonRuntime.IntegerCap || Amount > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class GetBalanceInput + { + public GetBalanceInput(string accountId) + { + AccountId = accountId; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + } + + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class GetBalanceOutput : IJsonOnDeserialized + { + public GetBalanceOutput(string accountId, long amount) + { + AccountId = accountId; + Amount = amount; + } + + [JsonPropertyName("accountId")] + [JsonRequired] + public string AccountId { get; init; } + [JsonPropertyName("amount")] + [JsonRequired] + public long Amount { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount < -JsonRuntime.IntegerCap || Amount > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } + } + + + /// + /// Request to transfer money + /// + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + [GeneratedCode("nex-gen", null)] + public class TransferMoneyInput : IJsonOnDeserialized + { + public TransferMoneyInput(string sourceAccountId, string destinationAccountId, long amount) + { + SourceAccountId = sourceAccountId; + DestinationAccountId = destinationAccountId; + Amount = amount; + } + + /// + /// The Account sending money + /// + [JsonPropertyName("sourceAccountId")] + [JsonRequired] + public string SourceAccountId { get; init; } + /// + /// The Account receiving money + /// + [JsonPropertyName("destinationAccountId")] + [JsonRequired] + public string DestinationAccountId { get; init; } + [JsonPropertyName("amount")] + [JsonRequired] + public long Amount { get; init; } + + /// + /// Validates every constraint the contract declares on this type, throwing a + /// single carrying all violations rather + /// than stopping at the first. + /// + public void Validate() + { + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) + { + throw new ValidationException(violations); + } + } + + internal void CollectViolations(List violations, string path) + { + if (Amount < -JsonRuntime.IntegerCap || Amount > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "exceeds ±(2^53-1) integer cap")); + } + if (Amount > 100000) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "must be <= 100000, got " + JsonRuntime.FormatNumber(Amount))); + } + if (Amount <= 0) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "amount"), "must be > 0, got " + JsonRuntime.FormatNumber(Amount))); + } + } + + void IJsonOnDeserialized.OnDeserialized() + { + Validate(); + } + } + + + [GeneratedCode("nex-gen", null)] + public class TransferMoneyOutput : IJsonOnDeserialized + { + public TransferMoneyOutput(bool success) + { + Success = success; + } + + [JsonPropertyName("success")] + [JsonRequired] + public bool Success { get; init; } + /// + /// Error if the transfer failed + /// + [JsonIgnore] + public string? ErrorMessage + { + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "errorMessage"); + init + { + JsonRuntime.RejectNull("errorMessage", value); + AdditionalProperties["errorMessage"] = value; + } + } + + [JsonExtensionData] + public Dictionary AdditionalProperties { get; set; } = new Dictionary(); + + void IJsonOnDeserialized.OnDeserialized() + { + foreach (var key in AdditionalProperties.Keys) + { + if (key != "errorMessage") + { + throw new JsonException($"Unknown field `{key}`."); + } + } + if (AdditionalProperties.TryGetValue("errorMessage", out var errorMessageValue)) + { + JsonRuntime.RejectNull("errorMessage", errorMessageValue); + if (errorMessageValue is JsonElement json3 && json3.ValueKind != JsonValueKind.String) + { + throw new JsonException($"{"errorMessage"}: expected string"); + } + else if (errorMessageValue is not JsonElement && errorMessageValue is not string) + { + throw new JsonException($"{"errorMessage"}: expected string"); + } + } + } + } + +} diff --git a/samples/dotnet-banking-svc_caller/Generated/Services.cs b/samples/dotnet-banking-svc_caller/Generated/Services.cs new file mode 100644 index 00000000..1fce64dc --- /dev/null +++ b/samples/dotnet-banking-svc_caller/Generated/Services.cs @@ -0,0 +1,33 @@ +// +// Generated by nex-gen. DO NOT EDIT! +#nullable enable +#pragma warning disable CS1591 + +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Threading.Tasks; +using NexusRpc; + +namespace NexGen.BankService +{ + + [GeneratedCode("nex-gen", null)] + [NexusService("org.example.BankService")] + internal interface IBankService + { + [GeneratedCode("nex-gen", null)] + [NexusOperation("SendMoney")] + TransferMoneyOutput SendMoney(TransferMoneyInput request); + + [GeneratedCode("nex-gen", null)] + [NexusOperation("GetBalance")] + GetBalanceOutput GetBalance(GetBalanceInput request); + + [GeneratedCode("nex-gen", null)] + [NexusOperation("CreateAccount")] + CreateAccountOutput CreateAccount(CreateAccountInput request); + + } + +} diff --git a/samples/dotnet-banking-svc_caller/NexusEndpoints.cs b/samples/dotnet-banking-svc_caller/NexusEndpoints.cs new file mode 100644 index 00000000..23fb13cd --- /dev/null +++ b/samples/dotnet-banking-svc_caller/NexusEndpoints.cs @@ -0,0 +1,40 @@ +namespace TemporalioSamples.BankingSvcCaller; + +/// +/// Connection and routing settings. Defaults point at the Temporal Cloud +/// namespace this sample was written against; every value is overridable by +/// environment variable. +/// +public static class NexusEndpoints +{ + /// + /// Nexus endpoint name. Must match an endpoint that already exists on the + /// server, routed to whichever service implements the contract; see the + /// README. This sample is a caller only — it never serves the contract, so it + /// has no task queue of its own. + /// + public static string BankService => + Environment.GetEnvironmentVariable("NEXUS_ENDPOINT") ?? "josh-nex-gen-java"; + + public static string Address => + Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS") + ?? "chrsmith-namespace-of-doom.a2dd6.tmprl.cloud:7233"; + + /// Namespace the operations are called from. + public static string Namespace => + Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") + ?? "chrsmith-namespace-of-doom.a2dd6"; + + /// + /// Temporal Cloud API key. + /// + /// Deliberately not baked into the source: this is a live credential, and a + /// sample that carries one leaks it to everyone who clones the repo. Export + /// TEMPORAL_API_KEY before running. + /// + public static string ApiKey => + Environment.GetEnvironmentVariable("TEMPORAL_API_KEY") + ?? throw new InvalidOperationException( + "TEMPORAL_API_KEY is not set. Export your Temporal Cloud API key:\n" + + " export TEMPORAL_API_KEY=''"); +} diff --git a/samples/dotnet-banking-svc_caller/Program.cs b/samples/dotnet-banking-svc_caller/Program.cs new file mode 100644 index 00000000..bda38c8d --- /dev/null +++ b/samples/dotnet-banking-svc_caller/Program.cs @@ -0,0 +1,202 @@ +using NexGen.BankService; +using Temporalio.Client; +using TemporalioSamples.BankingSvcCaller; + +// Accounts are hard-coded and the contract has no delete, so they outlive a run. +// Every transfer below is matched by one back the other way, leaving the balances +// where they started — the walkthrough reads the same on the first run and the +// hundredth. +const string Chris = "chris-checking"; +const string Merchant = "acme-merchant"; +const string Missing = "no-such-account"; + +Console.WriteLine("Hello, World"); + +await RunWalkthroughAsync(); + +async Task RunWalkthroughAsync() +{ + var client = await ConnectAsync(); + + // IBankService and every input/output below are emitted by nex-gen from + // samples/schemas/banking-service.yaml. Nothing here is hand-written. + var bank = client.CreateNexusClient(NexusEndpoints.BankService); + + Section( + "Open the accounts", + "CreateAccount is idempotent here: on a repeat run the handler returns the", + "account's current balance rather than resetting it. `amount` is optional in", + "the contract with a default of 500, so the merchant leaves it off."); + await CreateAccountAsync(bank, Chris, 1000); + await CreateAccountAsync(bank, Merchant, null); + + Section( + "Read the starting balances", + "GetBalance on each account."); + await GetBalanceAsync(bank, Chris); + await GetBalanceAsync(bank, Merchant); + + Section( + "Move money", + "A transfer the handler can satisfy. The contract models the outcome as", + "data — `success` plus an optional `errorMessage` — not as an error."); + await SendMoneyAsync(bank, Chris, Merchant, 250); + await GetBalanceAsync(bank, Chris); + await GetBalanceAsync(bank, Merchant); + + Section( + "Now try to overdraw the account", + "Send more than the account holds. The amount is well within the contract's", + "bounds, so this is a business rule the handler enforces, not a contract", + "violation. Comes back as success=False with a reason."); + await SendMoneyAsync(bank, Chris, Merchant, 100000); + + Section( + "Send to an account that does not exist", + "Another business-rule rejection from the handler, reported the same way."); + await SendMoneyAsync(bank, Chris, Missing, 10); + + Section( + "Break the contract, caught before the wire", + "`amount` is declared `exclusiveMinimum: 0`, so 0 is invalid. Calling", + "Validate() on the generated model rejects it locally — the request is never", + "sent, and every violation is reported at once rather than the first only."); + ValidateLocally(new TransferMoneyInput(Chris, Merchant, 0)); + + Section( + "Break two rules at once", + "2^53 exceeds both the contract's `maximum: 100000` and the largest integer", + "JSON carries losslessly. Both violations arrive in a single exception —", + "the validator never stops at the first one."); + ValidateLocally(new TransferMoneyInput(Chris, Merchant, 9007199254740992)); + + Section( + "Skip the local check and let it reach the handler", + "Same out-of-bounds amount, but sent without calling Validate() first. The", + "handler does not accept it, and because the failure is retried the call", + "ends at its deadline rather than returning a crisp error. Validating", + "locally is both faster and far more specific — that is the point of the", + "generated validator."); + await SendMoneyAsync(bank, Chris, Merchant, 200000, TimeSpan.FromSeconds(5)); + + Section( + "Put the money back", + "Restores the opening balances so this sample can be run repeatedly."); + await SendMoneyAsync(bank, Merchant, Chris, 250); + await GetBalanceAsync(bank, Chris); + await GetBalanceAsync(bank, Merchant); +} + +// Temporal Cloud: always TLS, always an API key. Reading NexusEndpoints.ApiKey +// throws if TEMPORAL_API_KEY is unset, which is the intent — there is no +// unauthenticated fallback. +async Task ConnectAsync() => + await TemporalClient.ConnectAsync(new(NexusEndpoints.Address) + { + Namespace = NexusEndpoints.Namespace, + Tls = new(), + ApiKey = NexusEndpoints.ApiKey, + }); + +async Task CreateAccountAsync(NexusClient bank, string accountId, long? amount) +{ + // Built outside the lambda: StartNexusOperationAsync takes an expression tree, + // which cannot contain a conditional pattern match. + var input = new CreateAccountInput(accountId); + if (amount is not null) + { + input = new CreateAccountInput(accountId) { Amount = amount }; + } + + try + { + var handle = await bank.StartNexusOperationAsync( + svc => svc.CreateAccount(input), + NewOptions($"create-{accountId}")); + var created = await handle.GetResultAsync(); + Console.WriteLine( + $" CreateAccount({accountId}) -> amount={created.Amount}"); + } + catch (Exception ex) + { + // A handler that rejects an existing account is free to signal it however + // it likes, and that is not fatal here — the account exists either way. + Console.WriteLine($" CreateAccount({accountId}) -> skipped: {Describe(ex)}"); + } +} + +async Task GetBalanceAsync(NexusClient bank, string accountId) +{ + var input = new GetBalanceInput(accountId); + var handle = await bank.StartNexusOperationAsync( + svc => svc.GetBalance(input), + NewOptions($"balance-{accountId}")); + var balance = await handle.GetResultAsync(); + Console.WriteLine($" GetBalance({accountId}) -> amount={balance.Amount}"); +} + +async Task SendMoneyAsync( + NexusClient bank, + string from, + string to, + long amount, + TimeSpan? timeout = null) +{ + var input = new TransferMoneyInput(from, to, amount); + try + { + var handle = await bank.StartNexusOperationAsync( + svc => svc.SendMoney(input), + NewOptions("send-money", timeout)); + var transfer = await handle.GetResultAsync(); + Console.WriteLine( + $" SendMoney({from} -> {to}, {amount}) -> success={transfer.Success}" + + (transfer.ErrorMessage is null ? string.Empty : $", error=\"{transfer.ErrorMessage}\"")); + } + catch (Exception ex) + { + Console.WriteLine($" SendMoney({from} -> {to}, {amount}) -> rejected: {Describe(ex)}"); + } +} + +// Runs the generated validator without calling the service at all. +void ValidateLocally(TransferMoneyInput input) +{ + try + { + input.Validate(); + Console.WriteLine($" Validate(amount={input.Amount}) -> passed, would be sent"); + } + catch (ValidationException ex) + { + Console.WriteLine( + $" Validate(amount={input.Amount}) -> {ex.Violations.Count} violation(s), not sent"); + foreach (var violation in ex.Violations) + { + Console.WriteLine($" {violation}"); + } + } +} + +// Operation ids must be unique per call, so each gets a fresh suffix. Account ids +// stay stable; only the operation id varies. +NexusOperationOptions NewOptions(string label, TimeSpan? timeout = null) => + new($"banking-{label}-{Guid.NewGuid():N}") + { + ScheduleToCloseTimeout = timeout ?? TimeSpan.FromSeconds(30), + }; + +void Section(string title, params string[] description) +{ + Console.WriteLine(); + Console.WriteLine($"## {title}"); + foreach (var line in description) + { + Console.WriteLine(line); + } + Console.WriteLine(); +} + +// Nexus failures nest the useful text one or two levels down. +static string Describe(Exception ex) => + (ex.InnerException?.InnerException ?? ex.InnerException ?? ex).Message; diff --git a/samples/dotnet-banking-svc_caller/README.md b/samples/dotnet-banking-svc_caller/README.md new file mode 100644 index 00000000..6622b83c --- /dev/null +++ b/samples/dotnet-banking-svc_caller/README.md @@ -0,0 +1,119 @@ +# Banking service caller (.NET) + +A Temporal C# application that calls a Nexus service through the client library +**generated by `nex-gen`** — using +[standalone Nexus operations](https://docs.temporal.io/standalone-nexus-operation), +so the operations are issued straight from the client with no calling workflow. + +This is a **caller only**. It never serves the contract: the handler lives +elsewhere, behind the Nexus endpoint. The point is to exercise cross-language +Nexus calls against a contract both sides generate from the same schema. + +Modeled on +[`NexusStandaloneOperations`](https://github.com/temporalio/samples-dotnet/tree/main/src/NexusStandaloneOperations) +from `temporalio/samples-dotnet`. + +## What it does + +Prints `Hello, World`, then walks through a series of scenarios, narrating each +one before showing the calls it makes. Inputs are hard-coded throughout. + +| Scenario | Demonstrates | +|---|---| +| Open the accounts | `CreateAccount`; the contract's `default: 500` applied by the generated model when `amount` is omitted | +| Read the starting balances | `GetBalance` | +| Move money | A `SendMoney` the handler can satisfy | +| Now try to overdraw the account | A **business rule** the handler enforces — returned as `success=False` with a reason, since the contract models the outcome as data rather than an error | +| Send to an account that does not exist | The same rejection shape from the handler | +| Break the contract, caught before the wire | `amount` is `exclusiveMinimum: 0`; the generated `Validate()` rejects it **locally** and the call is never made | +| Break two rules at once | One exception carrying *both* violations — `maximum: 100000` and the 2^53-1 integer cap — rather than stopping at the first | +| Skip the local check and let it reach the handler | The same invalid payload sent unvalidated; the handler refuses it, and the call ends at its deadline | +| Put the money back | Restores the opening balances | + +Abridged output: + +``` +Hello, World + +## Now try to overdraw the account +Send more than the account holds. The amount is well within the contract's +bounds, so this is a business rule the handler enforces, not a contract +violation. Comes back as success=False with a reason. + + SendMoney(chris-checking -> acme-merchant, 100000) -> success=False, error="Insufficient funds" + +## Break two rules at once +2^53 exceeds both the contract's `maximum: 100000` and the largest integer +JSON carries losslessly. Both violations arrive in a single exception — +the validator never stops at the first one. + + Validate(amount=9007199254740992) -> 2 violation(s), not sent + amount: exceeds ±(2^53-1) integer cap + amount: must be <= 100000, got 9007199254740992 +``` + +> [!NOTE] +> Accounts are hard-coded and the contract has no delete, so they persist between +> runs. Every transfer is matched by one back the other way, so the balances end +> where they started and the walkthrough reads the same on every run. + +## Where the code comes from + +| Path | Origin | +|---|---| +| `Generated/Models.cs`, `Generated/Services.cs`, `Generated/Definitions.cs` | Copied verbatim from [`../dotnet-banking-svc/`](../dotnet-banking-svc/), which `nex-gen` produced from [`../schemas/banking-service.yaml`](../schemas/banking-service.yaml). Do not edit. | +| `Program.cs` | The caller. Hand-written. | +| `NexusEndpoints.cs` | Connection and routing settings. | + +`Generated/Services.cs` declares `IBankService` as `internal`, so it is only +visible inside the assembly that compiles it — hence copying the files in rather +than referencing the other directory as a project. + +## Running it + +The defaults point at a Temporal Cloud namespace. Only the API key is required, +and it is deliberately **not** committed — export it first: + +```bash +export TEMPORAL_API_KEY='' +dotnet run +``` + +### Configuration + +Every setting is overridable by environment variable: + +| Variable | Default | +|---|---| +| `TEMPORAL_API_KEY` | *(required — no default, and no unauthenticated fallback)* | +| `TEMPORAL_ADDRESS` | `chrsmith-namespace-of-doom.a2dd6.tmprl.cloud:7233` | +| `TEMPORAL_NAMESPACE` | `chrsmith-namespace-of-doom.a2dd6` | +| `NEXUS_ENDPOINT` | `josh-nex-gen-java` | + +The connection is always TLS with an API key. If `TEMPORAL_API_KEY` is unset the +app fails immediately rather than attempting a plaintext connection. + +### Prerequisites on the server + +1. **Standalone Nexus operations must be enabled** on the namespace. Without it, + the first call fails with `Standalone Nexus operation is disabled`. +2. **A Nexus endpoint must exist**, routed to a service that implements the + contract, and `NEXUS_ENDPOINT` must name it. Without it, the call fails with + `endpoint not found`. On Temporal Cloud these are managed through the Cloud UI + or `tcld` — `temporal operator nexus endpoint …` does not work against a + namespace-scoped Cloud address (it fails with a namespace header/body + mismatch). + +The default endpoint, `josh-nex-gen-java`, routes to a **Java** handler +implementing the same contract. The C# caller and the Java service share only the +schema in `../schemas/banking-service.yaml` — which is the point. + +## Notes + +- Targets `net8.0` to match the repo's other .NET samples, with + `RollForward=LatestMajor` so it also runs on a machine that only has a newer + runtime installed. +- `StartNexusOperationAsync` takes an **expression tree**, so the call lambda + cannot contain pattern matching or other unsupported constructs — build inputs + before the lambda and capture them. +- Nexus operation ids must be unique per call; account ids stay stable. diff --git a/samples/dotnet-banking-svc_caller/TemporalioSamples.BankingSvcCaller.csproj b/samples/dotnet-banking-svc_caller/TemporalioSamples.BankingSvcCaller.csproj new file mode 100644 index 00000000..28b7f362 --- /dev/null +++ b/samples/dotnet-banking-svc_caller/TemporalioSamples.BankingSvcCaller.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + TemporalioSamples.BankingSvcCaller + TemporalioSamples.BankingSvcCaller + + LatestMajor + + + + + + + + +