Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.0.0] - 2026-08-24
## [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-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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 12 additions & 3 deletions docs/HAL_TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`
Expand Down Expand Up @@ -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`.
Expand Down
13 changes: 10 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -328,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`.
- 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`**

Expand Down
29 changes: 26 additions & 3 deletions docs/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,25 @@ There is no public builder API to set `ForceWriteAsCollection` independently. It

## 5. Deserialization Behavior

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 |
|---|---|
| `"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).

**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`

Parses the entire JSON input into a `JsonNode` tree. Rather than immediately deserializing `_links` and `_embedded`, it captures three lazy `Func<T>` delegates:
Expand All @@ -244,18 +263,22 @@ 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:

- 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 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`.

Expand Down
2 changes: 1 addition & 1 deletion src/Chatter.Rest.Hal/Chatter.Rest.Hal.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Version>2.1.0</Version>
<Authors>Brennan Pike</Authors>
<Owners>Brennan Pike</Owners>
<Description>A dotnet/c# implementation of the Hypertext Application Language (HAL+json) specification for RESTful Web Apis</Description>
Expand Down
60 changes: 43 additions & 17 deletions src/Chatter.Rest.Hal/Converters/LinkObjectConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ public sealed class LinkObjectConverter : JsonConverter<LinkObject>
/// <param name="reader">The JSON reader.</param>
/// <param name="typeToConvert">The type to convert.</param>
/// <param name="options">Serializer options.</param>
/// <returns>The deserialized LinkObject, or null if href is missing or invalid.</returns>
/// <returns>
/// 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).
/// </returns>
/// <exception cref="JsonException">
/// Thrown when the JSON is not a Link Object, or when its <c>href</c> property is present but not a string.
/// </exception>
Expand All @@ -38,7 +41,10 @@ public sealed class LinkObjectConverter : JsonConverter<LinkObject>
/// </summary>
/// <param name="node">The already-parsed Link Object node.</param>
/// <param name="options">Serializer options.</param>
/// <returns>The deserialized LinkObject, or null if href is missing or invalid.</returns>
/// <returns>
/// 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).
/// </returns>
/// <exception cref="JsonException">
/// Thrown when the node is not a JSON object, or when its <c>href</c> property is present but not a string.
/// </exception>
Expand Down Expand Up @@ -69,21 +75,44 @@ public sealed class LinkObjectConverter : JsonConverter<LinkObject>
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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="linkObject">The link object to populate.</param>
/// <param name="linkObjectNode">The already-parsed Link Object node.</param>
/// <param name="options">Serializer options.</param>
/// <returns>The same link object, with its optional attributes populated.</returns>
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;
}

/// <summary>
Expand Down Expand Up @@ -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)
{
Expand Down
25 changes: 25 additions & 0 deletions src/Chatter.Rest.Hal/LinkObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ public LinkObject(string href)
Href = href;
}

/// <summary>
/// Initializes a new instance of the <see cref="LinkObject"/> record whose href is the empty string.
/// </summary>
private LinkObject()
{
Href = string.Empty;
}

/// <summary>
/// Creates a Link Object whose <see cref="Href"/> is the empty string, a valid same-document
/// URI reference as defined by RFC 3986 section 4.4.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static LinkObject SameDocumentReference() => new();

/// <summary>
/// The REQUIRED href property of the Link Object as defined by the HAL specification
/// </summary>
Expand All @@ -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 <see cref="SameDocumentReference"/>),
/// where it denotes an RFC 3986 section 4.4 same-document reference. Public construction rejects
/// null or whitespace-only values.
/// </remarks>
public string Href { get; }

Expand Down
Loading
Loading