Skip to content

fix(hal): accept empty href as an RFC 3986 same-document reference - #123

Merged
brenpike merged 6 commits into
mainfrom
fix/120-empty-href-same-document-reference
Aug 23, 2026
Merged

brenpike merged 6 commits into
mainfrom
fix/120-empty-href-same-document-reference

Conversation

@brenpike

Copy link
Copy Markdown
Owner

Closes #120.

Problem

A Link Object whose href is the empty string was silently dropped on deserialization. LinkObjectConverter treated empty and whitespace-only hrefs alike as invalid and returned null, so the relation survived as an empty collection and every other property of the dropped object — title, name, templated — was discarded with it.

The empty string is a valid same-document URI reference under RFC 3986 §4.4, and HAL §5.1 defines href by reference to RFC 3986. Rejecting it was an acceptance deviation against the letter of the spec, and the silent, undocumented nature of the drop was the worst part of it.

Change

Read path. LinkObjectConverter.ReadFromNode now branches explicitly, in an order that matters: href is null returns null; a zero-length href is accepted and constructed via a new internal LinkObject.SameDocumentReference() factory; whitespace-only still returns null; anything else goes through the public constructor. The null check must precede href.Length, and the length check must precede any IsNullOrWhiteSpace call, since IsNullOrWhiteSpace is null-tolerant and .Length is not.

Construction seam. LinkObject gained a private parameterless constructor assigning Href = string.Empty, exposed only through internal static LinkObject SameDocumentReference(). The factory takes no parameter by design: it is structurally incapable of producing a null or whitespace-only href, so the "whitespace-only is still rejected" invariant is enforced by the type system rather than by a caller-side guard. No bool skipValidation overload was added, which would have reopened the invariant to every internal caller. The public LinkObject(string href) constructor and its ArgumentException guard are unchanged, so the fluent builder path still rejects empty and whitespace hrefs.

Sibling properties. Both accepted branches route through one shared PopulateOptionalAttributes helper covering all seven optional properties, so they cannot drift and an empty-href link keeps its title.

Write path. The if (!string.IsNullOrWhiteSpace(linkObject.Href)) guard was removed so href is emitted unconditionally. Because the public constructor rejects null-or-whitespace, Href is provably either "" or contains a non-whitespace character, making the old guard exactly equivalent to Href.Length != 0. Removing it changes only the "" case — precisely the one now worth emitting. Without this, relaxing the read alone would have produced a silently lossy round-trip.

Resulting href tolerance ladder

Input Behavior
"href": "" Accepted; round-trips losslessly as "href": ""
"href": " " Link Object silently dropped
"href": null or absent Link Object silently dropped
non-string href Throws JsonException

The bare JSON string shorthand ("self": "/path") still rejects an empty string. That asymmetry is deliberate: the shorthand is a library convenience the HAL spec does not define, so an empty shorthand is not a spec-valid Link Object, and relaxing it would additionally change LinkConverter.Read's documented tolerance contract. It is documented with its rationale in docs/serialization.md §5.2 rather than left implicit.

Versioning

Chatter.Rest.Hal 2.0.0 → 2.1.0 (minor). The public API surface is strictly unchanged — every new member is private or internal — and no input previously accepted is now rejected, so this is not a breaking change. But there is observable behavior change on both the read and write paths for a class of documents, which is more than a patch. Applied atomically across the csproj, the CLAUDE.md version table, and CHANGELOG.md with comparison links. No tag; CI creates hal/v2.1.0 after the post-merge deploy.

Tests

456 passing (453 baseline + 3 added by review), plus 51 CodeGenerators tests. New coverage pins every rung of the ladder above, the lossless round-trip in both the single-object and array link shapes, sibling-attribute preservation across both the string and boolean optional-property paths, and all three bare-string shorthand sites. The pre-existing guard asserting AddLinkObject(string.Empty) throws ArgumentException still passes, which is what proves the relaxation stayed read-path-only.

Incidental fixes found during review

  • docs/serialization.md claimed every read path applied the same tolerance ladder while §5.2 of the same document said the shorthand deliberately diverges — a self-contradiction, now scoped to the object form.
  • CHANGELOG.md had 2.1.0 dated 2026-08-23 above 2.0.0 dated 2026-08-24. The hal/v2.0.0 tag is dated 2026-08-23, so the pre-existing 2.0.0 date was wrong; corrected to tag ground truth.
  • Docs in two places claimed a whitespace-only href makes LinkConverter.Read return null. A code trace shows it returns the Link with an empty LinkObjects collection. Both corrected.
  • A new non-string-href test wrapped deserialization and a lazy Links access in a single asserted action, so it would have passed either way. Probing showed the throw is eager, not lazy; the assertion was narrowed and the inaccurate comment fixed.
  • docs/architecture.md sketch comments were audited as a set for wrong exception types. Link(string rel) claimed ArgumentNullException but throws ArgumentException; the other four exception mentions were verified correct and left alone.
  • docs/HAL_TEST_PLAN.md §3.1 cited a deleted test, and a second citation named the wrong class for a test that does exist in HalCuriesAndTemplatedTests. Both corrected rather than dropped.

Known follow-up

docs/architecture.md links to docs/uri-templates/architecture.md, which does not exist. Pre-existing and unrelated to this change, so deliberately left out of scope rather than widening a spec-conformance PR into doc repair.

@brenpike
brenpike merged commit a33c8ca into main Aug 23, 2026
6 checks passed
@brenpike
brenpike deleted the fix/120-empty-href-same-document-reference branch August 23, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Empty-string href (valid RFC 3986 same-document reference) is silently dropped on read

1 participant