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 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..4c3ec031 --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/chat/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.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). + /// + /// 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/advanced/samples/dotnet/json_schema/api/chat/Models.cs b/advanced/samples/dotnet/json_schema/api/chat/Models.cs index b454f0b3..519bd1aa 100644 --- a/advanced/samples/dotnet/json_schema/api/chat/Models.cs +++ b/advanced/samples/dotnet/json_schema/api/chat/Models.cs @@ -36,100 +36,44 @@ 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"); - } - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - 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) + var propertyCount = AdditionalProperties.Count; + if (propertyCount > 50) { - return typed; + violations.Add(new Violation(path, "must have at most 50 properties, got " + propertyCount)); } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var entry in AdditionalProperties) { - if (json.ValueKind == JsonValueKind.Null) + if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) { - return default; + throw new JsonException($"{entry.Key}: expected string"); } - if (json.ValueKind != JsonValueKind.Number) + else if (entry.Value is not JsonElement && entry.Value is not string) { - throw new JsonException("expected integer"); + throw new JsonException($"{entry.Key}: expected string"); } - 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"); } + Validate(); } } @@ -173,7 +117,7 @@ public string Kind [JsonIgnore] public string? ReplyToId { - get => ReadOptionalValue("replyToId"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "replyToId"); init { AdditionalProperties["replyToId"] = value; @@ -185,10 +129,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; } } @@ -196,100 +140,50 @@ public long? Priority [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() - { - foreach (var key in AdditionalProperties.Keys) - { - if (key != "replyToId" && key != "priority") - { - throw new JsonException($"Unknown field `{key}`."); - } - } - if (AdditionalProperties.TryGetValue("replyToId", out var replyToIdValue)) - { - } - if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) - { - RejectNull("priority", priorityValue); - _ = ReadJsonValue(priorityValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - 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) + if (Priority is long priorityValue) { - return typed; + if (priorityValue < -JsonRuntime.IntegerCap || priorityValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "exceeds ±(2^53-1) integer cap")); + } } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var key in AdditionalProperties.Keys) { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) + if (key != "replyToId" && key != "priority") { - throw new JsonException("expected integer"); + throw new JsonException($"Unknown field `{key}`."); } - 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) + if (AdditionalProperties.TryGetValue("replyToId", out var replyToIdValue)) { - 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 }) + if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("priority", priorityValue); + _ = JsonRuntime.ReadJsonValue(priorityValue); } + Validate(); } } @@ -322,20 +216,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 +241,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..6688a8ae --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/kb/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.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). + /// + /// 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/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..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 @@ -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; @@ -70,6 +70,33 @@ 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 < -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))); + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -81,7 +108,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,89 +120,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"); - } + Validate(); } } @@ -189,20 +140,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; } } @@ -210,101 +161,55 @@ public long? Indent [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() - { - foreach (var key in AdditionalProperties.Keys) - { - if (key != "bold" && key != "indent") - { - throw new JsonException($"Unknown field `{key}`."); - } - } - if (AdditionalProperties.TryGetValue("bold", out var boldValue)) - { - RejectNull("bold", boldValue); - } - if (AdditionalProperties.TryGetValue("indent", out var indentValue)) - { - RejectNull("indent", indentValue); - _ = ReadJsonValue(indentValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - 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) + if (Indent is long indentValue) { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; + 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))); + } } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var key in AdditionalProperties.Keys) { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) + if (key != "bold" && key != "indent") { - throw new JsonException("expected integer"); + throw new JsonException($"Unknown field `{key}`."); } - 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) + if (AdditionalProperties.TryGetValue("bold", out var boldValue)) { - throw new JsonException("expected integer"); + JsonRuntime.RejectNull("bold", boldValue); } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + if (AdditionalProperties.TryGetValue("indent", out var indentValue)) { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("indent", indentValue); + _ = JsonRuntime.ReadJsonValue(indentValue); } + Validate(); } } 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..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 @@ -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; } } @@ -175,97 +98,47 @@ public long? WordCount [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() - { - foreach (var key in AdditionalProperties.Keys) - { - if (key != "wordCount") - { - throw new JsonException($"Unknown field `{key}`."); - } - } - if (AdditionalProperties.TryGetValue("wordCount", out var wordCountValue)) - { - RejectNull("wordCount", wordCountValue); - _ = ReadJsonValue(wordCountValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + if (WordCount is long wordCountValue) { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; + if (wordCountValue < -JsonRuntime.IntegerCap || wordCountValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "wordCount"), "exceeds ±(2^53-1) integer cap")); + } } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var key in AdditionalProperties.Keys) { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) + if (key != "wordCount") { - throw new JsonException("expected integer"); + throw new JsonException($"Unknown field `{key}`."); } - 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 }) + if (AdditionalProperties.TryGetValue("wordCount", out var wordCountValue)) { - throw new JsonException($"{name}: explicit null not allowed"); + 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/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/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..c190d2dd --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/showcase/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.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). + /// + /// 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/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..96471f14 --- /dev/null +++ b/advanced/samples/dotnet/json_schema/api/showcase/Models.cs @@ -0,0 +1,1674 @@ +// +// 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(); + + /// + /// 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)) + { + 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); + } + Validate(); + } + } + + + /// + /// 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(); + + /// + /// 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)); + } + 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) + { + 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"); + } + } + Validate(); + } + } + + + /// + /// A circle branch of the Shape tagged union. + /// + [GeneratedCode("nex-gen", null)] + public class Circle : Shape + { + 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(); + + /// + /// 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) + { + } + } + + + /// + /// 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(); + + /// + /// 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)) + { + 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"); + } + } + Validate(); + } + } + + + /// + /// Arbitrary string key/value labels (typed map). + /// + [GeneratedCode("nex-gen", null)] + public class Labels : IJsonOnDeserialized + { + [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 > 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) + { + 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"); + } + } + Validate(); + } + } + + + /// + /// 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(); + + /// + /// 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) + { + 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); + } + Validate(); + } + } + + + /// + /// 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 abstract class Shape + { + 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); + } + } + + + /// + /// 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) + { + 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) + { + 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 (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); + 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 (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))); + } + 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 < -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))); + } + } + 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 < -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))); + } + } + 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() + { + 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 : Shape + { + 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(); + + /// + /// 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) + { + } + } + + + /// + /// 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 < -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))); + } + 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/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); 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); + + } + +} 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 + + + + + + + + + diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 5d79c8df..49abce24 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -1,15 +1,99 @@ # .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. +- `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. -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, 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 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 | ✅ `Definitions.cs` | +| `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` / `multipleOf` | ✅ | ✅ | +| `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 | ✅ | ✅ validated (not a C# `enum` type) | +| `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` | + +One known diagnostic divergence within the covered set: + +- `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` +covers the rejection side. + +## 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 +cargo run --features advanced -- dotnet samples/schemas/showcase.nexusrpc.yaml \ + --output samples/dotnet/showcase +``` + +## Building and testing ```bash dotnet build NexusApiGen.DotNetExamples.csproj # compile the models diff --git a/samples/dotnet/chat/Definitions.cs b/samples/dotnet/chat/Definitions.cs new file mode 100644 index 00000000..4c3ec031 --- /dev/null +++ b/samples/dotnet/chat/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.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). + /// + /// 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/chat/Models.cs b/samples/dotnet/chat/Models.cs index b454f0b3..519bd1aa 100644 --- a/samples/dotnet/chat/Models.cs +++ b/samples/dotnet/chat/Models.cs @@ -36,100 +36,44 @@ 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"); - } - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - 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) + var propertyCount = AdditionalProperties.Count; + if (propertyCount > 50) { - return typed; + violations.Add(new Violation(path, "must have at most 50 properties, got " + propertyCount)); } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var entry in AdditionalProperties) { - if (json.ValueKind == JsonValueKind.Null) + if (entry.Value is JsonElement json3 && json3.ValueKind != JsonValueKind.String) { - return default; + throw new JsonException($"{entry.Key}: expected string"); } - if (json.ValueKind != JsonValueKind.Number) + else if (entry.Value is not JsonElement && entry.Value is not string) { - throw new JsonException("expected integer"); + throw new JsonException($"{entry.Key}: expected string"); } - 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"); } + Validate(); } } @@ -173,7 +117,7 @@ public string Kind [JsonIgnore] public string? ReplyToId { - get => ReadOptionalValue("replyToId"); + get => JsonRuntime.ReadOptionalValue(AdditionalProperties, "replyToId"); init { AdditionalProperties["replyToId"] = value; @@ -185,10 +129,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; } } @@ -196,100 +140,50 @@ public long? Priority [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() - { - foreach (var key in AdditionalProperties.Keys) - { - if (key != "replyToId" && key != "priority") - { - throw new JsonException($"Unknown field `{key}`."); - } - } - if (AdditionalProperties.TryGetValue("replyToId", out var replyToIdValue)) - { - } - if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) - { - RejectNull("priority", priorityValue); - _ = ReadJsonValue(priorityValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - 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) + if (Priority is long priorityValue) { - return typed; + if (priorityValue < -JsonRuntime.IntegerCap || priorityValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "priority"), "exceeds ±(2^53-1) integer cap")); + } } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var key in AdditionalProperties.Keys) { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) + if (key != "replyToId" && key != "priority") { - throw new JsonException("expected integer"); + throw new JsonException($"Unknown field `{key}`."); } - 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) + if (AdditionalProperties.TryGetValue("replyToId", out var replyToIdValue)) { - 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 }) + if (AdditionalProperties.TryGetValue("priority", out var priorityValue)) { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("priority", priorityValue); + _ = JsonRuntime.ReadJsonValue(priorityValue); } + Validate(); } } @@ -322,20 +216,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 +241,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 new file mode 100644 index 00000000..6688a8ae --- /dev/null +++ b/samples/dotnet/kb/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.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). + /// + /// 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/kb/content/block/Models.cs b/samples/dotnet/kb/content/block/Models.cs index 7833d1f2..6769ddc8 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; @@ -70,6 +70,33 @@ 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 < -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))); + } + } + void IJsonOnDeserialized.OnDeserialized() { foreach (var key in AdditionalProperties.Keys) @@ -81,7 +108,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,89 +120,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"); - } + Validate(); } } @@ -189,20 +140,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; } } @@ -210,101 +161,55 @@ public long? Indent [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() - { - foreach (var key in AdditionalProperties.Keys) - { - if (key != "bold" && key != "indent") - { - throw new JsonException($"Unknown field `{key}`."); - } - } - if (AdditionalProperties.TryGetValue("bold", out var boldValue)) - { - RejectNull("bold", boldValue); - } - if (AdditionalProperties.TryGetValue("indent", out var indentValue)) - { - RejectNull("indent", indentValue); - _ = ReadJsonValue(indentValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - 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) + if (Indent is long indentValue) { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; + 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))); + } } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var key in AdditionalProperties.Keys) { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) + if (key != "bold" && key != "indent") { - throw new JsonException("expected integer"); + throw new JsonException($"Unknown field `{key}`."); } - 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) + if (AdditionalProperties.TryGetValue("bold", out var boldValue)) { - throw new JsonException("expected integer"); + JsonRuntime.RejectNull("bold", boldValue); } - return (long)number; - } - - private static void RejectNull(string name, object? value) - { - if (value is null || value is JsonElement { ValueKind: JsonValueKind.Null }) + if (AdditionalProperties.TryGetValue("indent", out var indentValue)) { - throw new JsonException($"{name}: explicit null not allowed"); + JsonRuntime.RejectNull("indent", indentValue); + _ = JsonRuntime.ReadJsonValue(indentValue); } + Validate(); } } diff --git a/samples/dotnet/kb/content/page/Models.cs b/samples/dotnet/kb/content/page/Models.cs index 9e1798b4..8b51b659 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; } } @@ -175,97 +98,47 @@ public long? WordCount [JsonExtensionData] public Dictionary AdditionalProperties { get; set; } = new Dictionary(); - void IJsonOnDeserialized.OnDeserialized() - { - foreach (var key in AdditionalProperties.Keys) - { - if (key != "wordCount") - { - throw new JsonException($"Unknown field `{key}`."); - } - } - if (AdditionalProperties.TryGetValue("wordCount", out var wordCountValue)) - { - RejectNull("wordCount", wordCountValue); - _ = ReadJsonValue(wordCountValue); - } - } - - private T? ReadOptionalValue(string name, T? defaultValue = default) + /// + /// 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.TryGetValue(name, out var value)) + var violations = new List(); + CollectViolations(violations, string.Empty); + if (violations.Count > 0) { - return defaultValue; + throw new ValidationException(violations); } - return ReadJsonValue(value); } - private static T? ReadJsonValue(object? value) + internal void CollectViolations(List violations, string path) { - if (value is null) - { - return default; - } - if (typeof(T) == typeof(long?) || typeof(T) == typeof(long)) + if (WordCount is long wordCountValue) { - return (T?)(object?)ReadJsonInteger(value); - } - if (value is JsonElement json) - { - return json.Deserialize(); - } - if (value is T typed) - { - return typed; + if (wordCountValue < -JsonRuntime.IntegerCap || wordCountValue > JsonRuntime.IntegerCap) + { + violations.Add(new Violation(JsonRuntime.JoinPath(path, "wordCount"), "exceeds ±(2^53-1) integer cap")); + } } - return (T)value; } - private static long? ReadJsonInteger(object? value) + void IJsonOnDeserialized.OnDeserialized() { - const double maxSafeInteger = 9007199254740991d; - if (value is null) - { - return default; - } - double number; - if (value is JsonElement json) + foreach (var key in AdditionalProperties.Keys) { - if (json.ValueKind == JsonValueKind.Null) - { - return default; - } - if (json.ValueKind != JsonValueKind.Number) + if (key != "wordCount") { - throw new JsonException("expected integer"); + throw new JsonException($"Unknown field `{key}`."); } - 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 }) + if (AdditionalProperties.TryGetValue("wordCount", out var wordCountValue)) { - throw new JsonException($"{name}: explicit null not allowed"); + 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/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/samples/dotnet/showcase/Definitions.cs b/samples/dotnet/showcase/Definitions.cs new file mode 100644 index 00000000..c190d2dd --- /dev/null +++ b/samples/dotnet/showcase/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.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). + /// + /// 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/showcase/Models.cs b/samples/dotnet/showcase/Models.cs new file mode 100644 index 00000000..96471f14 --- /dev/null +++ b/samples/dotnet/showcase/Models.cs @@ -0,0 +1,1674 @@ +// +// 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(); + + /// + /// 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)) + { + 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); + } + Validate(); + } + } + + + /// + /// 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(); + + /// + /// 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)); + } + 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) + { + 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"); + } + } + Validate(); + } + } + + + /// + /// A circle branch of the Shape tagged union. + /// + [GeneratedCode("nex-gen", null)] + public class Circle : Shape + { + 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(); + + /// + /// 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) + { + } + } + + + /// + /// 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(); + + /// + /// 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)) + { + 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"); + } + } + Validate(); + } + } + + + /// + /// Arbitrary string key/value labels (typed map). + /// + [GeneratedCode("nex-gen", null)] + public class Labels : IJsonOnDeserialized + { + [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 > 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) + { + 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"); + } + } + Validate(); + } + } + + + /// + /// 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(); + + /// + /// 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) + { + 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); + } + Validate(); + } + } + + + /// + /// 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 abstract class Shape + { + 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); + } + } + + + /// + /// 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) + { + 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) + { + 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 (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); + 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 (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))); + } + 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 < -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))); + } + } + 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 < -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))); + } + } + 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() + { + 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 : Shape + { + 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(); + + /// + /// 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) + { + } + } + + + /// + /// 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 < -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))); + } + 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/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/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/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/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/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/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/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/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/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 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..7ac43a9b 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,18 @@ 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. + 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,22 +1900,41 @@ pub(crate) fn generate( support: &crate::SupportFiles, mode: GenerationMode, ) -> Result { - match &tree.root { + // 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), + }?; + if let Some((path, contents)) = definitions { + insert_generated_file(&mut generated.files, path, contents)?; } + Ok(generated) } 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, @@ -1944,11 +1983,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, @@ -1961,13 +2008,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 { @@ -1977,7 +2025,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(()) } @@ -3628,7 +3676,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.rs b/src/generator/json_schema/dotnet.rs index 3da911d5..1bbafcf6 100644 --- a/src/generator/json_schema/dotnet.rs +++ b/src/generator/json_schema/dotnet.rs @@ -1,10 +1,10 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; 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,244 @@ 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, + #[serde(rename = "minLength")] + min_length: Option, + #[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, + #[serde(rename = "minProperties")] + min_properties: Option, + #[serde(rename = "propertyNames")] + property_names: Option>, + #[serde(rename = "dependentRequired")] + dependent_required: Option>>, + #[serde(rename = "enum")] + enum_values: 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() + } + + /// 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() + } + + /// 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, + }) + } + + /// 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(), + }) + } + + /// 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. + 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() + .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 + /// 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)] +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)] @@ -51,6 +289,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 { @@ -97,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"); @@ -121,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"); @@ -132,13 +645,663 @@ 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, unions.is_branch(&type_name))?; render_model_validation(output, &schema)?; - render_optional_helpers(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, + is_union_branch: bool, +) -> Result<()> { + let constrained = constrained_members(schema); + let object_constraints = schema.object_constraints()?; + // 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); + + 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 "); + 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"); + output.push_str(" throw new ValidationException(violations);\n"); + output.push_str(" }\n"); + output.push_str(" }\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); + } + // 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> { + json_name: &'a str, + /// The C# property name holding the member's value. + accessor: String, + /// True when the member is optional or nullable, so the checks have to be + /// guarded against the absent case. + 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, + enum_membership: Option, + 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.integer_cap + || self.enum_membership.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 + /// 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, +} + +/// 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) + } + } +} + +/// 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 { + 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)] +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 { + 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> { + let required = required_fields(schema); + let Some(properties) = &schema.properties else { + return Vec::new(); + }; + properties + .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: 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(), + enum_membership: property.enum_membership(), + 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) + }) + .collect() +} + +/// The CLR type a constrained member's value binds to when unwrapped from its +/// nullable form. +fn constraint_clr_type(schema: &Schema) -> String { + match schema.ty.as_ref().and_then(Value::as_str) { + 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 + .as_ref() + .and_then(|branches| { + branches + .iter() + .find(|branch| !schema_type_includes(branch, "null")) + .map(constraint_clr_type) + }) + .unwrap_or_else(|| "object".to_string()), + } +} + +/// 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 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.clr_type); + output.push(' '); + output.push_str(&local); + output.push_str(")\n {\n"); + (" ", local) + } else { + (" ", member.accessor.clone()) + }; + + 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"); + }; + + 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 ("); + 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 ("); + 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("}\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"); + 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 !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 (!"); + 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"); + } + + if member.needs_null_guard { + output.push_str(" }\n"); + } +} + fn render_model_constructor( output: &mut String, model: &PlannedJsonType, @@ -220,9 +1383,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 +1394,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"); } @@ -324,19 +1487,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(()); } @@ -368,7 +1524,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); @@ -384,6 +1540,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() || !schema.object_constraints()?.is_empty() { + output.push_str(" Validate();\n"); + } output.push_str(" }\n"); Ok(()) } @@ -435,13 +1597,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 +1611,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 +1622,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(""), @@ -568,7 +1664,11 @@ 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() + || !schema.object_constraints()?.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 new file mode 100644 index 00000000..44c2dc6c --- /dev/null +++ b/src/generator/json_schema/dotnet_coverage.rs @@ -0,0 +1,533 @@ +//! Coverage diagnostics for the .NET JSON-Schema backend. +//! +//! `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 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 +//! 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 are enforced; see `render_constraint_validator`. + // String assertions. minLength/maxLength/pattern are enforced. + "format", + "contentEncoding", + "contentMediaType", + "contentSchema", + // Array assertions. minItems/maxItems/uniqueItems are enforced, and so is + // `contains` in its `const` form — see the shape check below. + "prefixItems", + // Object assertions. minProperties/maxProperties/dependentRequired are + // enforced, and so is `propertyNames` on a map-shaped object — see the shape + // check below. + "dependentSchemas", + "patternProperties", + // Closed value sets (`enum`) are enforced. +]; + +/// 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) => { + // 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, &siblings, findings); + } + } + ApiSpecNode::Branch(branch) => { + for child in branch.children.values() { + collect_node(child, findings); + } + } + } +} + +/// 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, + siblings: &BTreeMap, + 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 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()); + } + + // `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 + // 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. + "properties" | "$defs" | "patternProperties" => { + if let Value::Object(children) = value { + for (name, child) in children { + collect_schema(child, &format!("{path}.{name}"), siblings, 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, siblings, findings); + } + } + } + // Single child schema. + "items" + | "additionalProperties" + | "contains" + | "not" + | "propertyNames" + | "contentSchema" => { + collect_schema(value, path, siblings, 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 +} + +/// 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() +} + +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" => { + "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", + "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", + }; + + 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::*; + + // 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 { + 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", &resolved, &mut findings); + findings + .into_iter() + .map(|(keyword, paths)| format_warning(keyword, &paths)) + .collect() + } + + #[test] + fn reports_an_unenforced_constraint_on_a_member() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { "name": { "type": "string", "contentMediaType": "text/plain" } }, + })); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("`contentMediaType`"), "{:?}", warnings); + assert!(warnings[0].contains("Model.name"), "{:?}", warnings); + } + + #[test] + 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]+$" }, + }, + })); + + assert!(warnings.is_empty(), "{:?}", 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 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("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] + fn descends_into_nested_and_array_schemas() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string", "contentMediaType": "text/plain" }, + }, + "nested": { + "type": "object", + "dependentSchemas": { "a": { "type": "object" } }, + }, + }, + })); + + assert_eq!(warnings.len(), 2, "{:?}", warnings); + assert!( + warnings + .iter() + .any(|w| w.contains("`contentMediaType`") && w.contains("Model.tags")) + ); + assert!( + warnings + .iter() + .any(|w| w.contains("`dependentSchemas`") && w.contains("Model.nested")) + ); + } + + #[test] + fn elides_long_path_lists() { + let warnings = findings_for(json!({ + "type": "object", + "properties": { + "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" }, + }, + })); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("and 2 more"), "{:?}", warnings); + } +} diff --git a/src/generator/json_schema/dotnet_definitions.rs b/src/generator/json_schema/dotnet_definitions.rs new file mode 100644 index 00000000..411c568c --- /dev/null +++ b/src/generator/json_schema/dotnet_definitions.rs @@ -0,0 +1,463 @@ +//! 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.Globalization; +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)}}"; + }} + }} + + /// + /// 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"); + }} + }} + }} + +}} +"# + ) +} + +#[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 ae181d48..16093bbb 100644 --- a/src/generator/json_schema/mod.rs +++ b/src/generator/json_schema/mod.rs @@ -1,4 +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; 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..4b68c689 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,78 @@ 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(); + + // `oneOf` remains only for showcase's disjoint-kind scalar union; the + // `Circle | Square` tagged union is lowered. + let expected = ["contentEncoding", "format", "oneOf"]; + + 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(); +}