From 3af00098a84b303b881e95fba7631574e605e955 Mon Sep 17 00:00:00 2001 From: brenpike Date: Sun, 23 Aug 2026 17:01:39 -0600 Subject: [PATCH 1/6] fix(hal): add same-document reference seam and document href tolerance --- CHANGELOG.md | 16 ++++++++++++- CLAUDE.md | 2 +- docs/architecture.md | 9 +++++-- docs/serialization.md | 25 +++++++++++++++++--- src/Chatter.Rest.Hal/Chatter.Rest.Hal.csproj | 2 +- src/Chatter.Rest.Hal/LinkObject.cs | 25 ++++++++++++++++++++ 6 files changed, 71 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 532e0a7..4c0b578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.0] - 2026-08-23 + +### Fixed + +- A Link Object whose `href` is the empty string is no longer silently dropped on + deserialization. The empty string is a valid RFC 3986 section 4.4 same-document reference, + which HAL section 5.1 permits by reference; it now materializes (preserving sibling + properties such as `title`) and re-serializes as `"href": ""`, making the round-trip + lossless. Whitespace-only, JSON-null, and absent `href` still drop the Link Object; a + non-string `href` still throws `JsonException`; the public `LinkObject(string href)` + constructor and the fluent builder path still reject null and whitespace hrefs. + ([#120](https://github.com/brenpike/Chatter.Rest.Hal/issues/120)) + ## [2.0.0] - 2026-08-24 A hardening and correctness release: a full-repository review produced 22 verified fixes across @@ -78,5 +91,6 @@ rejected. ([#120](https://github.com/brenpike/Chatter.Rest.Hal/issues/120), open: empty-string href tolerance). -[Unreleased]: https://github.com/brenpike/Chatter.Rest.Hal/compare/hal/v2.0.0...HEAD +[Unreleased]: https://github.com/brenpike/Chatter.Rest.Hal/compare/hal/v2.1.0...HEAD +[2.1.0]: https://github.com/brenpike/Chatter.Rest.Hal/compare/hal/v2.0.0...hal/v2.1.0 [2.0.0]: https://github.com/brenpike/Chatter.Rest.Hal/compare/hal/v1.1.0...hal/v2.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 4d01d79..2a0d0bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ Observed conventions: | Package | Version | |---|---| -| `Chatter.Rest.Hal` | `2.0.0` | +| `Chatter.Rest.Hal` | `2.1.0` | | `Chatter.Rest.Hal.CodeGenerators` | `0.4.0` | External dependency: `Chatter.Rest.UriTemplates` v0.1.0. diff --git a/docs/architecture.md b/docs/architecture.md index 0ee9080..30fd595 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,10 @@ public sealed record Link : IHalPart ```csharp public sealed record LinkObject : IHalPart { - public LinkObject(string href); // throws ArgumentNullException if null/whitespace + public LinkObject(string href); // throws ArgumentException if null/whitespace + private LinkObject(); // same-document reference; Href == string.Empty + + internal static LinkObject SameDocumentReference(); public string Href { get; } // REQUIRED public bool? Templated { get; set; } // OPTIONAL @@ -66,6 +69,8 @@ public sealed record LinkObject : IHalPart All properties except `Href` are optional per the HAL specification. +- `SameDocumentReference()` — internal seam backed by the private parameterless constructor, producing a `LinkObject` whose `Href` is `string.Empty` (an RFC 3986 same-document reference). It is reachable only from the deserialization path, and it takes no parameter precisely so it cannot produce a null or whitespace-only href. + ### `EmbeddedResource` ```csharp @@ -328,7 +333,7 @@ Expects a single-property JSON object where the key is the relation name. Return - Input is not a `JsonObject`. - The object does not have exactly one property. - The relation key is null or whitespace. -- The value is an object without a valid `href`. +- The value is an object without a valid `href` — absent, JSON null, or whitespace-only. An empty-string `href` is valid (RFC 3986 same-document reference) and is not rejected. **`EmbeddedResourceCollectionConverter.Read`** diff --git a/docs/serialization.md b/docs/serialization.md index 411d025..e594d36 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -226,6 +226,23 @@ There is no public builder API to set `ForceWriteAsCollection` independently. It ## 5. Deserialization Behavior +Every read path that produces a `LinkObject` applies the same tolerance ladder to the incoming `href` value: + +| Input | Behavior | +|---|---| +| `"href": "/orders/1"` | Accepted. `LinkObject.Href` is the string exactly as written. | +| `"href": ""` | **Accepted.** Round-trips losslessly. | +| `"href": " "` (whitespace-only) | The Link Object is **silently dropped**. No exception is thrown. | +| `"href": null` | The Link Object is **silently dropped**. No exception is thrown. | +| `href` absent from the Link Object | The Link Object is **silently dropped**. No exception is thrown. | +| `"href": 123` (any non-string JSON value) | Throws `JsonException`. | + +**Why the empty string is accepted.** HAL Section 5.1 defines `href` as a URI or URI Template, deferring to RFC 3986 for the former. RFC 3986 Section 4.4 defines the empty string as a *same-document reference* — a legal URI reference that resolves to the current document. An empty `href` is therefore spec-legal input, not malformed input, so it is preserved rather than dropped: it deserializes to `Href == string.Empty` and re-serializes as `"href": ""`, with no value lost on either leg of the round trip. + +**Why whitespace-only and `null` are not.** A whitespace-only string is not a URI reference under RFC 3986, and an absent or `null` `href` violates HAL Section 5.1's requirement that a Link Object have an `href`. Both are malformed and are dropped tolerantly, consistent with the rest of the read pipeline (see Section 5.3). + +**Why a non-string throws.** A non-string `href` is a JSON type violation rather than a malformed URI. Tolerance applies to values that are the right JSON type but the wrong content; a structurally wrong document is surfaced as a `JsonException` rather than silently discarded. + ### 5.1 `ResourceConverter.Read` Parses the entire JSON input into a `JsonNode` tree. Rather than immediately deserializing `_links` and `_embedded`, it captures three lazy `Func` delegates: @@ -244,9 +261,11 @@ Handles three input shapes for each link relation value: |---|---| | JSON object (`{ "href": "..." }`) | Deserializes as a single `LinkObject`; `Link.IsArray` remains `false` | | JSON array (`[{ "href": "..." }, ...]`) | Deserializes as `LinkObjectCollection`; sets `Link.IsArray = true` | -| JSON string (`"rel": "/path"`) | Creates a `LinkObject` with that string as `href` | +| JSON string (`"rel": "/path"`) | Creates a `LinkObject` with that string as `href`. An empty or whitespace-only string is **not** accepted and yields no `LinkObject` | | `null` | Creates a `Link` with no `LinkObjects` | +The object form and the string shorthand deliberately diverge on the empty string. The object form is a HAL Section 5 Link Object, so its `href` is governed by the spec and must accept every legal URI reference — including the empty same-document reference (see the tolerance ladder above). The bare-string shorthand is not defined by the HAL specification at all; it is a library convenience for the common single-`href` case. Rejecting an empty shorthand therefore narrows only this library's own extension, not the input space HAL requires an implementation to accept, and so is not a spec-acceptance deviation. + ### 5.3 `LinkConverter.Read` Expects a single-property JSON object where the property name is the link relation. Returns `null` (tolerant, does not throw) for any of these malformed inputs: @@ -254,8 +273,8 @@ Expects a single-property JSON object where the property name is the link relati - Not a JSON object - A JSON object with more than one property - A blank relation key -- A link object value missing `href` -- An array entry missing `href` +- A link object value whose `href` is absent, `null`, or whitespace-only (an empty-string `href` is accepted — see the tolerance ladder above) +- An array entry whose `href` is absent, `null`, or whitespace-only When the value is a JSON array, sets `Link.IsArray = true` on the resulting `Link`. diff --git a/src/Chatter.Rest.Hal/Chatter.Rest.Hal.csproj b/src/Chatter.Rest.Hal/Chatter.Rest.Hal.csproj index 9779684..be507e1 100644 --- a/src/Chatter.Rest.Hal/Chatter.Rest.Hal.csproj +++ b/src/Chatter.Rest.Hal/Chatter.Rest.Hal.csproj @@ -4,7 +4,7 @@ net8.0;netstandard2.0 10.0 enable - 2.0.0 + 2.1.0 Brennan Pike Brennan Pike A dotnet/c# implementation of the Hypertext Application Language (HAL+json) specification for RESTful Web Apis diff --git a/src/Chatter.Rest.Hal/LinkObject.cs b/src/Chatter.Rest.Hal/LinkObject.cs index 2e4ad18..cfb64bf 100644 --- a/src/Chatter.Rest.Hal/LinkObject.cs +++ b/src/Chatter.Rest.Hal/LinkObject.cs @@ -26,6 +26,27 @@ public LinkObject(string href) Href = href; } + /// + /// Initializes a new instance of the record whose href is the empty string. + /// + private LinkObject() + { + Href = string.Empty; + } + + /// + /// Creates a Link Object whose is the empty string, a valid same-document + /// URI reference as defined by RFC 3986 section 4.4. + /// + /// + /// https://datatracker.ietf.org/doc/html/rfc3986#section-4.4 + /// + /// Reachable only from the deserialization path; public construction continues to reject null + /// or whitespace-only href values. The factory takes no parameter, so it cannot produce an href + /// other than the empty string. + /// + internal static LinkObject SameDocumentReference() => new(); + /// /// The REQUIRED href property of the Link Object as defined by the HAL specification /// @@ -38,6 +59,10 @@ public LinkObject(string href) /// /// If the value is a URI Template then the Link Object SHOULD have a /// "templated" attribute whose value is true. + /// + /// The empty string is reachable only via deserialization (see ), + /// where it denotes an RFC 3986 section 4.4 same-document reference. Public construction rejects + /// null or whitespace-only values. /// public string Href { get; } From ca696d92ed2d8f76931580c5e524a6aac693e65f Mon Sep 17 00:00:00 2001 From: brenpike Date: Sun, 23 Aug 2026 17:05:17 -0600 Subject: [PATCH 2/6] fix(converters): accept empty href on read, emit it on write --- .../Converters/LinkObjectConverter.cs | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/Chatter.Rest.Hal/Converters/LinkObjectConverter.cs b/src/Chatter.Rest.Hal/Converters/LinkObjectConverter.cs index c53883b..e9711da 100644 --- a/src/Chatter.Rest.Hal/Converters/LinkObjectConverter.cs +++ b/src/Chatter.Rest.Hal/Converters/LinkObjectConverter.cs @@ -25,7 +25,10 @@ public sealed class LinkObjectConverter : JsonConverter /// The JSON reader. /// The type to convert. /// Serializer options. - /// The deserialized LinkObject, or null if href is missing or invalid. + /// + /// The deserialized LinkObject, or null when href is missing, null, or whitespace-only. An empty href is + /// accepted and yields a same-document reference (RFC 3986 section 4.4). + /// /// /// Thrown when the JSON is not a Link Object, or when its href property is present but not a string. /// @@ -38,7 +41,10 @@ public sealed class LinkObjectConverter : JsonConverter /// /// The already-parsed Link Object node. /// Serializer options. - /// The deserialized LinkObject, or null if href is missing or invalid. + /// + /// The deserialized LinkObject, or null when href is missing, null, or whitespace-only. An empty href is + /// accepted and yields a same-document reference (RFC 3986 section 4.4). + /// /// /// Thrown when the node is not a JSON object, or when its href property is present but not a string. /// @@ -69,21 +75,44 @@ public sealed class LinkObjectConverter : JsonConverter throw new JsonException("The 'href' property of a HAL Link Object must be a string."); } - if (string.IsNullOrWhiteSpace(href)) + if (href is null) { return null; } - return new LinkObject(href) + if (href.Length == 0) + { + // INVARIANT: the empty-string test must precede any IsNullOrWhiteSpace test so an empty href is + // kept as an RFC 3986 section 4.4 same-document reference instead of being dropped as blank. + return PopulateOptionalAttributes(LinkObject.SameDocumentReference(), linkObjectNode, options); + } + + if (string.IsNullOrWhiteSpace(href)) { - Templated = TryGetBooleanAsTrue(ConverterHelpers.GetProperty(linkObjectNode, "templated", options)), - Type = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "type", options)), - Deprecation = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "deprecation", options)), - Name = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "name", options)), - Title = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "title", options)), - Profile = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "profile", options)), - Hreflang = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "hreflang", options)) - }; + return null; + } + + return PopulateOptionalAttributes(new LinkObject(href), linkObjectNode, options); + } + + /// + /// Populates the optional Link Object attributes onto a link object, shared by every accepted href + /// form so the same-document-reference path and the normal path cannot drift apart. + /// + /// The link object to populate. + /// The already-parsed Link Object node. + /// Serializer options. + /// The same link object, with its optional attributes populated. + private static LinkObject PopulateOptionalAttributes(LinkObject linkObject, JsonObject linkObjectNode, JsonSerializerOptions options) + { + linkObject.Templated = TryGetBooleanAsTrue(ConverterHelpers.GetProperty(linkObjectNode, "templated", options)); + linkObject.Type = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "type", options)); + linkObject.Deprecation = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "deprecation", options)); + linkObject.Name = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "name", options)); + linkObject.Title = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "title", options)); + linkObject.Profile = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "profile", options)); + linkObject.Hreflang = TryGetString(ConverterHelpers.GetProperty(linkObjectNode, "hreflang", options)); + return linkObject; } /// @@ -123,11 +152,8 @@ public override void Write(Utf8JsonWriter writer, LinkObject linkObject, JsonSer { writer.WriteStartObject(); - if (!string.IsNullOrWhiteSpace(linkObject.Href)) - { - writer.WritePropertyName(HrefProperty); - writer.WriteStringValue(linkObject.Href); - } + writer.WritePropertyName(HrefProperty); + writer.WriteStringValue(linkObject.Href); if (linkObject.Templated.HasValue) { From 7d3075127abd816182bc8d59266f27758a403c2f Mon Sep 17 00:00:00 2001 From: brenpike Date: Sun, 23 Aug 2026 17:09:48 -0600 Subject: [PATCH 3/6] test(hal): cover empty href acceptance and lossless round-trip --- .../Converters/HalRoundTripShapeTests.cs | 42 ++++++++++ .../HalLinkAttributesValidationTests.cs | 76 ++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/test/Chatter.Rest.Hal.Tests/Converters/HalRoundTripShapeTests.cs b/test/Chatter.Rest.Hal.Tests/Converters/HalRoundTripShapeTests.cs index b702597..82fabae 100644 --- a/test/Chatter.Rest.Hal.Tests/Converters/HalRoundTripShapeTests.cs +++ b/test/Chatter.Rest.Hal.Tests/Converters/HalRoundTripShapeTests.cs @@ -183,6 +183,48 @@ public void A_Custom_ResourceCollection_Converter_Wins_Over_The_Built_In_Shape_R node["_embedded"]!["orders"]!.GetValue().Should().Be("stub"); } + [Fact] + public void Empty_Href_On_A_Link_Object_Round_Trips_As_An_Empty_String() + { + // Covers issue #120: the empty string is a valid RFC 3986 section 4.4 same-document reference and + // HAL section 5.1 defines href by reference to RFC 3986, so href must survive both directions + // instead of being dropped by the writer. + var json = "{\"_links\":{\"self\":{\"href\":\"\"}}}"; + + var resource = Resource.Parse(json); + var node = JsonNode.Parse(JsonSerializer.Serialize(resource))!.AsObject(); + + node["_links"]!["self"]!["href"]!.GetValue().Should().BeEmpty(); + } + + [Fact] + public void Empty_Href_Inside_A_Link_Array_Round_Trips_As_An_Empty_String() + { + // The array shape reaches the same read path through LinkObjectCollectionConverter, so it is pinned + // separately from the single Link Object case. + var json = "{\"_links\":{\"self\":[{\"href\":\"\"}]}}"; + + var resource = Resource.Parse(json); + var node = JsonNode.Parse(JsonSerializer.Serialize(resource))!.AsObject(); + + node["_links"]!["self"]!.AsArray().Should().HaveCount(1); + node["_links"]!["self"]![0]!["href"]!.GetValue().Should().BeEmpty(); + } + + [Fact] + public void Empty_Href_Round_Trips_Alongside_Its_Optional_Attributes() + { + // The same-document-reference branch and the normal branch share one optional-attribute helper, so + // an optional sibling must survive the empty-href branch too. + var json = "{\"_links\":{\"self\":{\"href\":\"\",\"title\":\"here\"}}}"; + + var resource = Resource.Parse(json); + var node = JsonNode.Parse(JsonSerializer.Serialize(resource))!.AsObject(); + + node["_links"]!["self"]!["href"]!.GetValue().Should().BeEmpty(); + node["_links"]!["self"]!["title"]!.GetValue().Should().Be("here"); + } + private sealed class StubResourceCollectionConverter : JsonConverter { public override ResourceCollection? Read(ref Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options) diff --git a/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs b/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs index 77bf284..a1269d7 100644 --- a/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs +++ b/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -24,8 +25,13 @@ public void NonBoolean_Templated_Value_Treated_As_False() lo.Templated.Should().NotBeTrue(); } + // HAL section 5.1 defines href by reference to RFC 3986, and RFC 3986 section 4.4 makes the empty + // string a valid same-document reference. The read path therefore accepts an empty href while every + // other blank or non-string form stays rejected. The tests below pin each rung of that ladder. + // https://datatracker.ietf.org/doc/html/rfc3986#section-4.4 + [Fact] - public void Href_Empty_String_Is_Invalid_On_Deserialization() + public void Href_Empty_String_Deserializes_As_Same_Document_Reference() { var json = "{ \"_links\": { \"self\": { \"href\": \"\" } } }"; var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); @@ -34,10 +40,76 @@ public void Href_Empty_String_Is_Invalid_On_Deserialization() resource.Should().NotBeNull(); var link = resource!.Links.Single(l => l.Rel == "self"); - // LinkObjectConverter treats an empty href as invalid and returns null, so the Link will have no LinkObjects + link.LinkObjects.Should().HaveCount(1); + link.LinkObjects.Single().Href.Should().BeEmpty(); + } + + [Fact] + public void Href_Empty_String_Preserves_Sibling_Attributes() + { + // Dropping the whole Link Object also discarded its other attributes, which is the defect + // reported in issue #120. Cover a boolean-valued and a string-valued optional so both + // branches of the shared optional-attribute population are exercised on this path. + var json = "{ \"_links\": { \"self\": { \"href\": \"\", \"title\": \"t\", \"name\": \"n\", \"templated\": true } } }"; + var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + resource.Should().NotBeNull(); + var link = resource!.Links.Single(l => l.Rel == "self"); + link.LinkObjects.Should().HaveCount(1); + var lo = link.LinkObjects.Single(); + + lo.Href.Should().BeEmpty(); + lo.Title.Should().Be("t"); + lo.Name.Should().Be("n"); + lo.Templated.Should().BeTrue(); + } + + [Fact] + public void Href_Whitespace_Only_Is_Invalid_On_Deserialization() + { + // Only the empty string is a same-document reference; a whitespace-only href stays invalid, + // so the converter returns null and the relation survives with no link objects. + var json = "{ \"_links\": { \"self\": { \"href\": \" \" } } }"; + var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + resource.Should().NotBeNull(); + var link = resource!.Links.Single(l => l.Rel == "self"); + link.LinkObjects.Should().BeEmpty(); } + [Fact] + public void Href_Null_Is_Invalid_On_Deserialization() + { + var json = "{ \"_links\": { \"self\": { \"href\": null } } }"; + var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + resource.Should().NotBeNull(); + var link = resource!.Links.Single(l => l.Rel == "self"); + + link.LinkObjects.Should().BeEmpty(); + } + + [Fact] + public void Href_NonString_Throws_JsonException_On_Deserialization() + { + // A non-string href is malformed rather than blank, so it keeps failing loudly instead of + // being tolerated. The link collection materializes lazily, so touching it forces the read. + var json = "{ \"_links\": { \"self\": { \"href\": 123 } } }"; + var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + + Action deserializing = () => + { + var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + _ = resource!.Links; + }; + + deserializing.Should().Throw(); + } + [Fact] public void NonString_Optional_Attributes_Are_Treated_As_Null() { From 42c31b799d64f230fff2befeb55ef7cbdbfedfa2 Mon Sep 17 00:00:00 2001 From: brenpike Date: Sun, 23 Aug 2026 17:12:43 -0600 Subject: [PATCH 4/6] docs: refresh href test citations and fix Link exception type --- docs/HAL_TEST_PLAN.md | 15 ++++++++++++--- docs/architecture.md | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/HAL_TEST_PLAN.md b/docs/HAL_TEST_PLAN.md index d316951..3cff99e 100644 --- a/docs/HAL_TEST_PLAN.md +++ b/docs/HAL_TEST_PLAN.md @@ -100,9 +100,18 @@ The root of every HAL document MUST be a Resource Object — a JSON object that A Link Object represents a hyperlink. It MUST have an `href`; all other properties are optional. ### 3.1 `href` is required +> HAL section 5.1 defines `href` by reference to [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-4.4), and RFC 3986 section 4.4 permits the empty string as a same-document reference. An empty `href` is therefore spec-conformant and is accepted on the object form; absent, JSON-null, and whitespace-only `href` values still drop the Link Object, and a non-string `href` still throws `JsonException`. The bare JSON string shorthand form continues to reject an empty string — see `docs/serialization.md`. + - ✅ `HalLinkObjectTests.LinkObject_Serializes_Required_Href` - ✅ `HalLinkObjectTests.LinkObject_Missing_Href_Produces_Null_On_Deserialization` -- ✅ `HalLinkAttributesValidationTests.Href_Empty_String_Is_Invalid_On_Deserialization` +- ✅ `HalLinkAttributesValidationTests.Href_Empty_String_Deserializes_As_Same_Document_Reference` — empty `href` materializes with `Href == string.Empty` +- ✅ `HalLinkAttributesValidationTests.Href_Empty_String_Preserves_Sibling_Attributes` — optional attributes survive the empty-`href` branch +- ✅ `HalLinkAttributesValidationTests.Href_Whitespace_Only_Is_Invalid_On_Deserialization` — whitespace-only `href` drops the Link Object +- ✅ `HalLinkAttributesValidationTests.Href_Null_Is_Invalid_On_Deserialization` — JSON-null `href` drops the Link Object +- ✅ `HalLinkAttributesValidationTests.Href_NonString_Throws_JsonException_On_Deserialization` — non-string `href` fails loudly +- ✅ `HalRoundTripShapeTests.Empty_Href_On_A_Link_Object_Round_Trips_As_An_Empty_String` — lossless round-trip of `"href": ""` +- ✅ `HalRoundTripShapeTests.Empty_Href_Inside_A_Link_Array_Round_Trips_As_An_Empty_String` — same guarantee through the array shape +- ✅ `HalRoundTripShapeTests.Empty_Href_Round_Trips_Alongside_Its_Optional_Attributes` — optional siblings survive the round-trip - ✅ `LinkConvertersTests.Deserialize_Single_Link_Object_Should_Parse` ### 3.2 `href` may be a URI Template (RFC 6570) @@ -111,7 +120,7 @@ A Link Object represents a hyperlink. It MUST have an `href`; all other properti - ✅ `HalCuriesAndTemplatedTests.Templated_Link_Has_Templated_True_If_Provided` - ✅ `HalCuriesAndTemplatedTests.Templated_Href_Does_Not_Automatically_Expand` - ✅ `HalLinkObjectTests.LinkObject_Reads_Templated_True_For_Template` -- ✅ `HalLinkAttributesValidationTests.Templated_Href_Without_Templated_Flag_Is_Handled_Gracefully` — verifies URI template href without `templated: true` is handled gracefully +- ✅ `HalCuriesAndTemplatedTests.Templated_Href_Without_Templated_Flag_Is_Handled_Gracefully` — verifies URI template href without `templated: true` is handled gracefully ### 3.3 `templated` is a boolean; non-boolean values default to false - ✅ `HalLinkAttributesValidationTests.NonBoolean_Templated_Value_Treated_As_False` @@ -327,7 +336,7 @@ All 20 gaps have been addressed. As of CodeGenerators 0.4.0 the two former out-o 1. ✅ **COMPLETED** — **[CURIE expansion]** Now tested via `HalCuriesAndTemplatedTests.Curie_Short_Form_Expands_To_Full_Uri` and `LinkCollectionExtensionsTests.ExpandCurieRelation_Should_Return_Full_Uri_When_Curie_Exists`. The `ExpandCurieRelation` extension method was also implemented. 2. ✅ **COMPLETED** — **[Root object validation]** Now tested via 5 new tests in `HalDeserializationRobustnessTests` covering array, string, number, boolean, and null roots. 3. ✅ **COMPLETED** — **[`self` link]** Now tested via 3 new tests in `HalSerializationRoundTripTests` validating serialization, extension access, and null-case handling. -4. ✅ **COMPLETED** — **[`href` + `templated` consistency]** Now tested via `HalLinkAttributesValidationTests.Templated_Href_Without_Templated_Flag_Is_Handled_Gracefully`. +4. ✅ **COMPLETED** — **[`href` + `templated` consistency]** Now tested via `HalCuriesAndTemplatedTests.Templated_Href_Without_Templated_Flag_Is_Handled_Gracefully`. 5. ✅ **COMPLETED** — **[CURIE round-trip]** Now tested via `HalCuriesAndTemplatedTests.Curie_Definition_Serializes_As_Array_Of_LinkObjects`. 6. ✅ **COMPLETED** — **[Undefined CURIE prefix]** Now tested via `HalCuriesAndTemplatedTests.Curie_Expansion_Returns_Original_When_No_Matching_Prefix`. 7. ✅ **COMPLETED** — **[Link Object tolerant reader]** Now tested via `HalLinkAttributesValidationTests.LinkObject_With_Unknown_Properties_Are_Ignored`. diff --git a/docs/architecture.md b/docs/architecture.md index 30fd595..46cfe3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ public sealed record Resource : IHalPart ```csharp public sealed record Link : IHalPart { - public Link(string rel); // throws ArgumentNullException if null/whitespace + public Link(string rel); // throws ArgumentException if null/whitespace public string Rel { get; } public LinkObjectCollection LinkObjects { get; set; } From b58761551d49a4aab1cd127128ad3df7465b4476 Mon Sep 17 00:00:00 2001 From: brenpike Date: Sun, 23 Aug 2026 17:22:51 -0600 Subject: [PATCH 5/6] docs(hal): scope href ladder to object form, pin shorthand tests --- docs/serialization.md | 4 +- .../HalLinkAttributesValidationTests.cs | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/serialization.md b/docs/serialization.md index e594d36..69112a5 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -226,7 +226,7 @@ There is no public builder API to set `ForceWriteAsCollection` independently. It ## 5. Deserialization Behavior -Every read path that produces a `LinkObject` applies the same tolerance ladder to the incoming `href` value: +Every read path that materializes a Link Object from its **object form** — `{ "href": ... }`, whether standalone, nested under a `_links` relation, or inside a Link Object array — applies the same tolerance ladder to the incoming `href` value: | Input | Behavior | |---|---| @@ -241,6 +241,8 @@ Every read path that produces a `LinkObject` applies the same tolerance ladder t **Why whitespace-only and `null` are not.** A whitespace-only string is not a URI reference under RFC 3986, and an absent or `null` `href` violates HAL Section 5.1's requirement that a Link Object have an `href`. Both are malformed and are dropped tolerantly, consistent with the rest of the read pipeline (see Section 5.3). +**The bare-string shorthand is not on this ladder.** The shorthand form (`"rel": "/orders/1"`) is a library convenience that the HAL specification does not define, and it deliberately diverges from the ladder on the empty string: an empty shorthand yields no `LinkObject` at all. See Section 5.2 for the rationale. + **Why a non-string throws.** A non-string `href` is a JSON type violation rather than a malformed URI. Tolerance applies to values that are the right JSON type but the wrong content; a structurally wrong document is surfaced as a `JsonException` rather than silently discarded. ### 5.1 `ResourceConverter.Read` diff --git a/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs b/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs index a1269d7..7080951 100644 --- a/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs +++ b/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs @@ -110,6 +110,55 @@ public void Href_NonString_Throws_JsonException_On_Deserialization() deserializing.Should().Throw(); } + // The bare-string shorthand ("rel": "/orders/1") is a library convenience the HAL specification does + // not define, so an empty shorthand is not a spec-valid Link Object and deliberately stays OFF the + // object-form tolerance ladder above (see docs/serialization.md section 5.2). The three tests below + // pin that divergence at every shorthand read path so it cannot drift silently now that the object + // form accepts an empty href. + + [Fact] + public void Empty_Href_Shorthand_Under_A_Links_Relation_Yields_No_LinkObject() + { + // LinkCollectionConverter shorthand path. + var json = "{ \"_links\": { \"self\": \"\" } }"; + var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + resource.Should().NotBeNull(); + var link = resource!.Links.Single(l => l.Rel == "self"); + + link.LinkObjects.Should().BeEmpty(); + } + + [Fact] + public void Empty_Href_Shorthand_Inside_A_Link_Object_Array_Yields_No_LinkObject() + { + // LinkObjectCollectionConverter shorthand path. + var json = "{ \"_links\": { \"self\": [ \"\" ] } }"; + var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + resource.Should().NotBeNull(); + var link = resource!.Links.Single(l => l.Rel == "self"); + + link.LinkObjects.Should().BeEmpty(); + } + + [Fact] + public void Empty_Href_Shorthand_On_A_Standalone_Link_Yields_Null() + { + // LinkConverter shorthand path. The non-empty case is asserted alongside it so the null result is + // pinned to the empty href specifically, not to the standalone-Link shape. + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + var empty = JsonSerializer.Deserialize("{ \"self\": \"\" }", options); + var populated = JsonSerializer.Deserialize("{ \"self\": \"/orders/1\" }", options); + + empty.Should().BeNull(); + populated.Should().NotBeNull(); + populated!.LinkObjects.Single().Href.Should().Be("/orders/1"); + } + [Fact] public void NonString_Optional_Attributes_Are_Treated_As_Null() { From aeb044fc6b664603650e8bf0b738f67a744111e0 Mon Sep 17 00:00:00 2001 From: brenpike Date: Sun, 23 Aug 2026 17:31:56 -0600 Subject: [PATCH 6/6] docs(hal): correct release date, Link docs, and href test timing --- CHANGELOG.md | 2 +- docs/architecture.md | 4 +++- docs/serialization.md | 6 ++++-- .../HalLinkAttributesValidationTests.cs | 11 +++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0b578..34b07e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 constructor and the fluent builder path still reject null and whitespace hrefs. ([#120](https://github.com/brenpike/Chatter.Rest.Hal/issues/120)) -## [2.0.0] - 2026-08-24 +## [2.0.0] - 2026-08-23 A hardening and correctness release: a full-repository review produced 22 verified fixes across converters, domain types, builders, and extensions, followed by a clause-by-clause conformance diff --git a/docs/architecture.md b/docs/architecture.md index 46cfe3f..e64491e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -333,7 +333,9 @@ Expects a single-property JSON object where the key is the relation name. Return - Input is not a `JsonObject`. - The object does not have exactly one property. - The relation key is null or whitespace. -- The value is an object without a valid `href` — absent, JSON null, or whitespace-only. An empty-string `href` is valid (RFC 3986 same-document reference) and is not rejected. +- The value is an object whose `href` is absent or JSON null. + +A whitespace-only `href` does **not** yield `null` here — the precheck passes and the `Link` is returned with an empty `LinkObjectCollection`. An empty-string `href` is valid (RFC 3986 same-document reference) and materializes a `LinkObject`. **`EmbeddedResourceCollectionConverter.Read`** diff --git a/docs/serialization.md b/docs/serialization.md index 69112a5..61102c0 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -275,8 +275,10 @@ Expects a single-property JSON object where the property name is the link relati - Not a JSON object - A JSON object with more than one property - A blank relation key -- A link object value whose `href` is absent, `null`, or whitespace-only (an empty-string `href` is accepted — see the tolerance ladder above) -- An array entry whose `href` is absent, `null`, or whitespace-only +- A link object value whose `href` is absent or JSON `null` +- An array entry that is not a JSON object, or whose `href` is absent or JSON `null` + +A whitespace-only `href` is **not** on that list. It passes the presence precheck, `LinkObjectConverter` then rejects it, and `LinkConverter` returns the `Link` carrying an **empty** `LinkObjects` collection rather than `null`. An empty-string `href` is accepted and materializes a same-document `LinkObject` (see the tolerance ladder above). When the value is a JSON array, sets `Link.IsArray = true` on the resulting `Link`. diff --git a/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs b/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs index 7080951..f25ad8e 100644 --- a/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs +++ b/test/Chatter.Rest.Hal.Tests/HalLinkAttributesValidationTests.cs @@ -97,15 +97,14 @@ public void Href_Null_Is_Invalid_On_Deserialization() public void Href_NonString_Throws_JsonException_On_Deserialization() { // A non-string href is malformed rather than blank, so it keeps failing loudly instead of - // being tolerated. The link collection materializes lazily, so touching it forces the read. + // being tolerated. The failure is EAGER: the Deserialize call itself throws, before any lazy + // Links access. Only the Deserialize call is inside the asserted action, which is what pins + // that timing - folding a resource.Links access into the same action would pass whether the + // throw were eager or lazy and would leave the accept/reject timing unpinned. var json = "{ \"_links\": { \"self\": { \"href\": 123 } } }"; var node = JsonNode.Parse(json, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); - Action deserializing = () => - { - var resource = node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - _ = resource!.Links; - }; + Action deserializing = () => node.Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); deserializing.Should().Throw(); }