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/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/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/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));
+ }
+}
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.
-->
-
+