From 8412bde754aa1a9029dc353bd84f1c87b5f30112 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Wed, 21 Oct 2026 08:25:09 -0400 Subject: [PATCH 1/2] Bump MessagePack to 3.1.9 GHSA-qhrr-8q5h-9q3h covers 3.0.0 through 3.1.8, and NU1902 is an error under ContinuousIntegrationBuild, so the Release build failed at restore. Co-Authored-By: Claude Opus 5.5 Agent-Driver-Session: 2d7049bb-6fd8-4372-8d0f-661c0a5b9754 --- docs/guide/clients.md | 2 +- docs/guide/message-pack.md | 2 +- src/Directory.Packages.props | 2 +- .../templates/hardened-web/Directory.Packages.props | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guide/clients.md b/docs/guide/clients.md index b190fef0..a775da90 100644 --- a/docs/guide/clients.md +++ b/docs/guide/clients.md @@ -177,7 +177,7 @@ setting. With `--serializer message-pack-named` or `message-pack-keyed` and `--client refit`, the client project also holds two Liquid templates in `templates/`, `MessagePackContentSerializer.cs`, and a -reference to the `MessagePack` package, 3.1.8. `.refitter` then names the folder in +reference to the `MessagePack` package, 3.1.9. `.refitter` then names the folder in `customTemplateDirectory`. It also sets `generateDefaultAdditionalProperties` to `false`. The templates put these attributes on the generated models: diff --git a/docs/guide/message-pack.md b/docs/guide/message-pack.md index ffc0042f..2bcf21a4 100644 --- a/docs/guide/message-pack.md +++ b/docs/guide/message-pack.md @@ -113,7 +113,7 @@ Run this command in the solution directory: dotnet add src/Todos package Hardened.Requests.Serializers.MessagePack --version 0.0.0-HARDENED-VERSION ``` -The package depends on `MessagePack` 3.1.8, which brings MessagePack's source generator into the project. +The package depends on `MessagePack` 3.1.9, which brings MessagePack's source generator into the project. The template's solution uses central package management. There, the command adds a `PackageReference` to `src/Todos/Todos.csproj` and a `PackageVersion` to `Directory.Packages.props`. The `PackageVersion` holds the version as a literal. The template's other Hardened packages use `$(HardenedVersion)`. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index bcda1ed1..d8461b9b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -106,7 +106,7 @@ - + diff --git a/src/Templates/Hardened.Templates/templates/hardened-web/Directory.Packages.props b/src/Templates/Hardened.Templates/templates/hardened-web/Directory.Packages.props index 3bb3e356..22b6646b 100644 --- a/src/Templates/Hardened.Templates/templates/hardened-web/Directory.Packages.props +++ b/src/Templates/Hardened.Templates/templates/hardened-web/Directory.Packages.props @@ -75,7 +75,7 @@ [MessagePackObject] on every generated contract, and the attribute has to resolve here. It follows MessagePack's release line, like Refit's own pin above. --> - + From cb23aa3f80d960aac996537c0ae2af1f377a1d24 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Wed, 21 Oct 2026 08:25:10 -0400 Subject: [PATCH 2/2] Describe System.Text.Json DOM types as free-form JSON in the OpenAPI document JsonElement, JsonNode and JsonValue are written as {}, JsonObject as {"type":"object"} and JsonArray as {"type":"array","items":{}}, matched by their System.Text.Json and System.Text.Json.Nodes namespaces. The member walk skips indexers, so no schema has a this[] property. verify-templates.sh builds the Refit client of each code-first JSON Refit row over a probe handler that uses the five types. Closes #627 Co-Authored-By: Claude Opus 5.5 Agent-Driver-Session: 2d7049bb-6fd8-4372-8d0f-661c0a5b9754 --- scripts/probes/JsonDomController.cs | 41 +++ scripts/verify-templates.sh | 23 ++ .../OpenApiDocument/JsonSchemaWriter.cs | 41 +++ .../Hardened.Web.SourceGenerator.Tests.csproj | 8 + .../JsonDomDocumentTests.cs | 338 ++++++++++++++++++ 5 files changed, 451 insertions(+) create mode 100644 scripts/probes/JsonDomController.cs create mode 100644 src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/JsonDomDocumentTests.cs diff --git a/scripts/probes/JsonDomController.cs b/scripts/probes/JsonDomController.cs new file mode 100644 index 00000000..bf1a2c6b --- /dev/null +++ b/scripts/probes/JsonDomController.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Hardened.Requests.Abstract.Attributes; +using Hardened.Web.Runtime.Attributes; + +namespace Sample; + +/// +/// Copied into a code-first Refit scaffold by scripts/verify-templates.sh, so the client Refitter +/// generates from the document has to compile over the System.Text.Json DOM types. Their schemas +/// once carried a property named this[], which Refitter refused. +/// +public record JsonDomEnvelope( + JsonElement Element, + JsonNode Node, + JsonObject Object, + JsonArray Array, + JsonValue Value, + JsonObject? MaybeObject +); + +public class JsonDomController +{ + [Post("/dom/envelope")] + public JsonDomEnvelope Envelope(JsonDomEnvelope body) => body; + + [Post("/dom/element")] + public JsonElement Element([FromBody] JsonElement body) => body; + + [Post("/dom/node")] + public JsonNode Node([FromBody] JsonNode body) => body; + + [Post("/dom/object")] + public JsonObject Object([FromBody] JsonObject body) => body; + + [Post("/dom/array")] + public JsonArray Array([FromBody] JsonArray body) => body; + + [Post("/dom/value")] + public JsonValue Value([FromBody] JsonValue body) => body; +} diff --git a/scripts/verify-templates.sh b/scripts/verify-templates.sh index f0a210c1..de3547e0 100755 --- a/scripts/verify-templates.sh +++ b/scripts/verify-templates.sh @@ -676,6 +676,29 @@ for COMBO in "${COMBOS[@]}"; do run_tests "$OUT" GetTodo_ReadsTheMockedStore + # The System.Text.Json DOM types, on the rows where Refitter reads a document the server's + # code wrote. Their schemas once carried a property named this[], which Refitter refused, and + # the template's own handlers reach none of them. After the tests, because DocumentStatusTests + # fails on an operation it sends no request to, and removed again so the probes below serve + # the application as scaffolded. Not on the MessagePack rows, whose Liquid templates key every + # generated member and have no formatter for a free-form one. + if [ "$CONTRACT" = code ] && [ "$CLIENT" = refit ] && \ + { [ "$SERIALIZER" = default ] || [ "$SERIALIZER" = json ]; }; then + cp "$REPO/scripts/probes/JsonDomController.cs" "$OUT/src/Sample/JsonDomController.cs" + + if ! ( cd "$OUT" && dotnet build src/Sample.Client -v q --nologo ) > "$OUT/dom-probe.log" 2>&1; then + echo " FAILED: the Refit client does not compile over the System.Text.Json DOM types - see $OUT/dom-probe.log" + FAILED=1 + elif ! grep -rq '/dom/element"' "$OUT/src/Sample.Client/obj" --include='*.cs'; then + echo " FAILED: the System.Text.Json DOM probe did not reach the Refit client" + FAILED=1 + else + echo " the Refit client compiles over the System.Text.Json DOM types" + fi + + rm "$OUT/src/Sample/JsonDomController.cs" + fi + # The client rows: the document the library's build wrote is what the client generated from, # and it is the file a consumer commits - so it has to exist, and it has to be the served # document. The opt-out row: no property, no file, no client project. diff --git a/src/SourceGenerators/Hardened.SourceGenerator/OpenApiDocument/JsonSchemaWriter.cs b/src/SourceGenerators/Hardened.SourceGenerator/OpenApiDocument/JsonSchemaWriter.cs index 07b7f638..5b223bee 100644 --- a/src/SourceGenerators/Hardened.SourceGenerator/OpenApiDocument/JsonSchemaWriter.cs +++ b/src/SourceGenerators/Hardened.SourceGenerator/OpenApiDocument/JsonSchemaWriter.cs @@ -156,6 +156,16 @@ private static string SchemaFor( return BinaryPayload; } + // The System.Text.Json DOM holds whatever JSON arrived, so it is free-form on the wire. + // Reflected instead, JsonElement published its indexer as a property named this[] and + // JsonObject a required count, and Refitter refused the repeated this[] key. + var dom = JsonDom(type); + + if (dom != null) + { + return dom; + } + if (type is IArrayTypeSymbol array) { // byte[] is the payload itself, not a sequence of numbers. Every element type below @@ -274,6 +284,36 @@ private static bool IsProblemType(INamedTypeSymbol owner, IPropertySymbol proper && property.Type.SpecialType == SpecialType.System_String && Requests.ProblemBodies.Implement(owner); + /// + /// The schema for a System.Text.Json DOM type, or null for anything else. + /// + /// + /// Matched on the namespace as well as the name, so an application's own type named + /// JsonNode is still reflected. + /// + private static string? JsonDom(ITypeSymbol type) + { + var space = type.ContainingNamespace?.ToDisplayString(); + + if (space == "System.Text.Json") + { + return type.Name == "JsonElement" ? "{}" : null; + } + + if (space != "System.Text.Json.Nodes") + { + return null; + } + + return type.Name switch + { + "JsonObject" => "{\"type\":\"object\"}", + "JsonArray" => "{\"type\":\"array\",\"items\":{}}", + "JsonNode" or "JsonValue" => "{}", + _ => null, + }; + } + /// /// Whether a type is a System.IO.Stream, base types walked. /// @@ -515,6 +555,7 @@ private static string ObjectRef( if ( property.DeclaredAccessibility != Accessibility.Public || property.IsStatic + || property.IsIndexer || property.GetMethod == null ) { diff --git a/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/Hardened.Web.SourceGenerator.Tests.csproj b/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/Hardened.Web.SourceGenerator.Tests.csproj index 50150f91..616d0e97 100644 --- a/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/Hardened.Web.SourceGenerator.Tests.csproj +++ b/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/Hardened.Web.SourceGenerator.Tests.csproj @@ -140,5 +140,13 @@ Link="Routing/RouteConstraintFactsTests.cs"/> + + + diff --git a/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/JsonDomDocumentTests.cs b/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/JsonDomDocumentTests.cs new file mode 100644 index 00000000..f23eca10 --- /dev/null +++ b/src/SourceGenerators/Hardened.Web.SourceGenerator.Tests/JsonDomDocumentTests.cs @@ -0,0 +1,338 @@ +using System.Text.Json; +using Hardened.Requests.Abstract.Attributes; +using Hardened.SourceGeneration.Testing; +using Hardened.Web.Runtime.Attributes; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.YamlReader; +using Xunit; +using Tree = Hardened.Generation.Document; + +namespace Hardened.Web.SourceGenerator.Tests; + +/// +/// What the System.Text.Json DOM types publish: free-form JSON, in the served document, in +/// its YAML export and at each HardenedOpenApiOutputVersion. +/// +/// +/// They were reflected like models. JsonElement published its indexer as a property named +/// this[], JsonObject a required count, and JsonNode a required +/// root, while each of them holds whatever JSON arrived. Refitter refused the repeated +/// this[] key. +/// +/// +public class JsonDomDocumentTests +{ + private static readonly Type[] Anchors = [typeof(GetAttribute), typeof(FromBodyAttribute)]; + + private const string DomHandlers = """ + [Post("/dom/envelope")] + public Task Envelope(Envelope body) => Task.FromResult(body); + + [Post("/dom/element")] + public Task Element([FromBody] JsonElement body) => Task.FromResult(body); + + [Post("/dom/node")] + public Task Node([FromBody] JsonNode body) => Task.FromResult(body); + + [Post("/dom/object")] + public Task Object([FromBody] JsonObject body) => Task.FromResult(body); + + [Post("/dom/array")] + public Task Array([FromBody] JsonArray body) => Task.FromResult(body); + + [Post("/dom/value")] + public Task Value([FromBody] JsonValue body) => Task.FromResult(body); + """; + + private const string Models = """ + public record Envelope( + JsonElement Element, + JsonNode Node, + JsonObject Object, + JsonArray Array, + JsonValue Value, + JsonNode? MaybeNode, + JsonObject? MaybeObject, + JsonArray? MaybeArray + ); + """; + + private const string Free = "{}"; + private const string FreeObject = "{\"type\":\"object\"}"; + private const string FreeArray = "{\"type\":\"array\",\"items\":{}}"; + + private static readonly Dictionary Expected = new() + { + ["element"] = Free, + ["node"] = Free, + ["object"] = FreeObject, + ["array"] = FreeArray, + ["value"] = Free, + }; + + private static readonly Lazy DomDocument = new(() => Emit(DomHandlers, Models)); + + private static string Emit(string handlers, string models, string shapes = "") + { + var sources = new Dictionary + { + ["Application.cs"] = $$""" + using System.Text.Json; + using System.Text.Json.Nodes; + using System.Threading.Tasks; + using Hardened.Requests.Abstract.Attributes; + using Hardened.Shared.Runtime.Attributes; + using Hardened.Web.Runtime.Attributes; + + namespace TestApp; + + [HardenedModule] + {{GeneratedOpenApiDocument.EnableAttribute}} + public partial class TestApplication { } + + {{models}} + + public class DomController { + {{handlers}} + } + """, + ["Shapes.cs"] = "namespace TestApp.Shapes;\n\n" + shapes, + }; + + var result = GeneratorTestHarness.Run(sources, [new WebLibrarySourceGenerator()], Anchors); + + var source = result + .AssertNoErrors() + .GeneratedSources.First(pair => pair.Key.Contains("OpenApiDocument")) + .Value; + + return GeneratedOpenApiDocument.Extract(source); + } + + public static TheoryData Outputs() + { + var data = new TheoryData(); + + foreach (var format in new[] { "json", "yaml" }) + { + foreach (var version in new[] { "3.2.0", "3.1.0", "3.0.0" }) + { + data.Add(format, version); + } + } + + return data; + } + + /// + /// The document the export writes, the way WriteOpenApiDocument writes it: lowered when + /// the version is not the served one, then indented JSON or YAML. + /// + /// + /// A YAML export is read back by Microsoft.OpenApi and held to the same document as the + /// JSON one, so the assertions on the JSON speak for both. + /// + private static JsonElement Written(string compact, string format, string version) + { + var tree = (Tree.JsonObject)Tree.JsonTree.Parse(compact); + + if (version != "3.2.0") + { + Tree.OpenApiDocumentLowering.Lower(tree, version); + } + + var json = Tree.JsonTreeWriter.WriteIndented(tree); + + if (format == "yaml") + { + Assert.Equal( + Canonical(Read(json, "json")), + Canonical(Read(Tree.YamlTreeWriter.Write(tree), "yaml")) + ); + } + + var document = JsonDocument.Parse(json).RootElement; + + Assert.Equal(version, document.GetProperty("openapi").GetString()); + + return document; + } + + private static Microsoft.OpenApi.OpenApiDocument Read(string text, string format) + { + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + + var read = Microsoft.OpenApi.OpenApiDocument.Parse(text, format, settings); + + Assert.NotNull(read.Document); + Assert.Empty(read.Diagnostic!.Errors); + + return read.Document!; + } + + private static string Canonical(Microsoft.OpenApi.OpenApiDocument document) + { + using var text = new StringWriter(); + + document.SerializeAsV32(new OpenApiJsonWriter(text)); + + return text.ToString(); + } + + private static string Compact(JsonElement schema) => JsonSerializer.Serialize(schema); + + private static JsonElement Operation(JsonElement document, string kind) => + document.GetProperty("paths").GetProperty("/dom/" + kind).GetProperty("post"); + + private static JsonElement Schemas(JsonElement document) => + document.GetProperty("components").GetProperty("schemas"); + + [Theory] + [MemberData(nameof(Outputs))] + public void EachTypeIsFreeFormAsAProperty(string format, string version) + { + var envelope = Schemas(Written(DomDocument.Value, format, version)) + .GetProperty("Envelope") + .GetProperty("properties"); + + foreach (var (name, schema) in Expected) + { + Assert.Equal(schema, Compact(envelope.GetProperty(name))); + } + + // A nullable member adds null to a typed schema, and a schema that already admits null is + // left alone. + var lowered = version == "3.0.0"; + + Assert.Equal(Free, Compact(envelope.GetProperty("maybeNode"))); + Assert.Equal( + lowered + ? "{\"type\":\"object\",\"nullable\":true}" + : "{\"type\":[\"object\",\"null\"]}", + Compact(envelope.GetProperty("maybeObject")) + ); + Assert.Equal( + lowered + ? "{\"type\":\"array\",\"nullable\":true,\"items\":{}}" + : "{\"type\":[\"array\",\"null\"],\"items\":{}}", + Compact(envelope.GetProperty("maybeArray")) + ); + } + + [Theory] + [MemberData(nameof(Outputs))] + public void EachTypeIsFreeFormAsARequestBody(string format, string version) + { + var document = Written(DomDocument.Value, format, version); + + foreach (var (kind, schema) in Expected) + { + var body = Operation(document, kind) + .GetProperty("requestBody") + .GetProperty("content") + .GetProperty("application/json") + .GetProperty("schema"); + + Assert.Equal(schema, Compact(body)); + } + } + + [Theory] + [MemberData(nameof(Outputs))] + public void EachTypeIsFreeFormAsAResponse(string format, string version) + { + var document = Written(DomDocument.Value, format, version); + + foreach (var (kind, schema) in Expected) + { + var response = Operation(document, kind) + .GetProperty("responses") + .GetProperty("200") + .GetProperty("content") + .GetProperty("application/json") + .GetProperty("schema"); + + Assert.Equal(schema, Compact(response)); + } + } + + /// + /// No component for a DOM type, or for the two types only their members reach. + /// + [Theory] + [MemberData(nameof(Outputs))] + public void NoDomTypeIsAComponent(string format, string version) + { + var document = Written(DomDocument.Value, format, version); + var names = Schemas(document).EnumerateObject().Select(schema => schema.Name).ToList(); + + Assert.Contains("Envelope", names); + Assert.Empty( + names.Intersect([ + "JsonElement", + "JsonNode", + "JsonObject", + "JsonArray", + "JsonValue", + "JsonNodeOptions", + "JsonValueKind", + ]) + ); + Assert.DoesNotContain("this[]", document.GetRawText()); + } + + /// + /// An application's own type named JsonNode is a model like any other, because the DOM + /// types are recognised by their namespace. + /// + [Fact] + public void AnApplicationTypeNamedJsonNodeIsStillReflected() + { + var document = JsonDocument + .Parse( + Emit( + """ + [Get("/own")] + public Task Own() => + Task.FromResult(new TestApp.Shapes.JsonNode("a")); + """, + "", + "public record JsonNode(string Label);" + ) + ) + .RootElement; + + var properties = Schemas(document).GetProperty("JsonNode").GetProperty("properties"); + + Assert.Equal(["label"], properties.EnumerateObject().Select(property => property.Name)); + } + + /// An indexer is not a member on the wire, so a model's schema has no property for it. + [Fact] + public void AnIndexerIsNotAProperty() + { + var document = JsonDocument + .Parse( + Emit( + """ + [Get("/indexed")] + public Task Indexed() => Task.FromResult(new Indexed()); + """, + """ + public class Indexed { + public string Name { get; set; } = ""; + + public string this[int index] => Name; + } + """ + ) + ) + .RootElement; + + var properties = Schemas(document).GetProperty("Indexed").GetProperty("properties"); + + Assert.Equal(["name"], properties.EnumerateObject().Select(property => property.Name)); + } +}