Skip to content

uhttp: capture XML attributes in the XML->map unmarshaler#1038

Draft
cvkramer wants to merge 2 commits into
mainfrom
soap/xml-attributes
Draft

uhttp: capture XML attributes in the XML->map unmarshaler#1038
cvkramer wants to merge 2 commits into
mainfrom
soap/xml-attributes

Conversation

@cvkramer

Copy link
Copy Markdown
Contributor

Summary

Capture XML attributes in uhttp's XML→map unmarshaler. Previously unmarshalXMLElement read only element/text tokens and silently dropped all attributes, so data carried in XML attributes (common in SOAP/WS-*) was lost.

Changes (pkg/uhttp/xml.go)

  • Element with children: add an @attributes sibling key (map of local-name → value). Additive.
  • Leaf element with attributes: promote from bare string to {#text, @attributes} (or {@attributes} if no text) — a deliberate, documented type change at that path only.
  • Elements with no attributes are byte-for-byte unchanged.
  • Filter xmlns / xmlns:prefix namespace-declaration pseudo-attributes.
  • Disambiguate same-local-name attributes from different namespaces (qualify only colliding keys).
  • Clone the attribute map per entry in the duplicate-sibling branch to avoid shared-reference aliasing.

Tests

  • pkg/uhttp/xml_test.go, wrapper_test.go: attributed container, attributed leaf (with/without text), namespace-prefixed attr, nested, attrs+duplicates, namespace collision, xmlns filtering, per-sibling independence; plus a WithGenericResponse case for an attributed-leaf root. All pre-existing subtests unchanged.
  • go test ./pkg/uhttp/... and go build ./... pass.

Backward-compat

The attributed-leaf string→map promotion (and the related WithGenericResponse error→success for an attributed-leaf root) is the one deliberate behavior change; no-attribute paths are unchanged. Validated end-to-end against a live SOAP server (attribute data flowed into resource IDs and grants).

Part of CXH-2134. Resolves CXH-2137.

c1-charleskramer and others added 2 commits July 23, 2026 22:26
unmarshalXMLElement only switched on xml.StartElement / xml.CharData /
xml.EndElement and never read StartElement.Attr, so element attributes
were silently dropped when unmarshaling XML/SOAP responses into
map[string]any.

Thread each element's own attributes through the recursive descent
(the top-level element's Attr comes from xml.Unmarshal's start
parameter, child elements' Attr comes from the StartElement token) and
capture them badgerfish/xml-js style:

  - element with children: "@attributes" is added as an additional
    sibling key in the resulting map. Purely additive.
  - leaf element with attributes: the previously bare string value is
    promoted to map[string]any{"#text": <trimmed text>, "@attributes":
    {...}}, or just {"@attributes": {...}} if there is no text.
  - duplicate child names (the []map[string]any branch): since the
    slice has no single map to attach the container's own attributes
    to, "@attributes" is added to every entry in the slice.

Attribute names are taken from Attr.Name.Local (namespace prefix
dropped), matching how child/leaf element names are already handled.

BACKWARD COMPATIBILITY: elements with no attributes are byte-for-byte
unchanged (leaf -> string, children -> map, duplicates -> []map), so
all pre-existing tests pass without modification. The one deliberate,
intentional type change is the attributed-leaf promotion: a leaf
element that has attributes now returns map[string]any instead of a
bare string. Any caller currently type-asserting such a leaf's value
directly to string will need to switch to reading `["#text"]`, but
only for elements that actually carry attributes.

Adds table cases in pkg/uhttp/xml_test.go covering: attributed
container elements, attributed leaves with and without text,
namespace-prefixed attributes (local name only), nested attributed
elements, and attributes combined with duplicate siblings. All
existing subtests continue to pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… unmarshaler

Address panel review findings on the attribute-capture PR:

- attrMap no longer collides same-local-name attributes from different
  namespaces (e.g. a:id vs b:id): colliding entries are now disambiguated
  with a namespace-qualified key instead of one silently overwriting the
  other.
- attrMap now filters out XML namespace-declaration pseudo-attributes
  (bare `xmlns="..."` and prefixed `xmlns:foo="..."`), restoring the
  byte-for-byte "no attributes -> unaffected" invariant for ordinary
  namespaced XML/SOAP documents that declare a namespace but carry no
  real attributes.
- In the duplicate-sibling branch, each entry's "@attributes" map is now
  an independent clone (maps.Clone) rather than every sibling aliasing
  the same underlying map, so mutating one entry's attributes can no
  longer leak into its siblings.
- Added regression tests for all of the above, plus mixed-content and
  self-closing-tag coverage, and two WithGenericResponse subtests
  documenting (as intentional) that a root XML element with attributes
  now succeeds where it previously errored with "unsupported XML
  structure: string".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown

CXH-2137

CXH-2134

Comment thread pkg/uhttp/xml.go
Comment on lines +139 to +144
if selfAttrs != nil {
if text == "" {
return map[string]any{"@attributes": selfAttrs}, nil
}
return map[string]any{"#text": text, "@attributes": selfAttrs}, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (compatibility, medium confidence): promoting an attributed leaf element from a bare string to a map[string]any is a silent, non-opt-in default change on a parser many connectors use via WithGenericResponse. A downstream doing resp["user"].(string) on <user active="true">alice</user> previously got "alice"; it now gets a map, so the assertion yields "", false — a silent wrong/empty value that flows into resource IDs and grants (durable c1z output). Attributes were dropped before, so no caller could depend on the attribute data, but callers reading the text of an attributed leaf as a string will break. Since this is deliberate, consider making the shape opt-in (or clearly signaling it with a 0.x minor bump / migration note) rather than changing the default path.

@github-actions

Copy link
Copy Markdown
Contributor

General PR Review: uhttp: capture XML attributes in the XML->map unmarshaler

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 1e06515304d7.
Review mode: full
View review run

Review Summary

Full PR diff scanned for security and correctness. The change adds XML attribute capture to unmarshalXMLElement under an @attributes key, with xmlns/namespace-declaration filtering, same-local-name namespace-collision disambiguation, and per-sibling map cloning in the duplicate-children branch. The attribute logic is correct, nil-safe, and thoroughly tested (container, leaf, nested, self-closing, collision, xmlns, per-sibling independence). No security or correctness bugs were found. The one concern is the deliberate backward-compat behavior change (an attributed leaf element changes from a string to a map), which is documented but applied unconditionally rather than opt-in.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/xml.go:139-144 — The attributed-leaf string-to-map promotion is a silent, non-opt-in default change on a widely-used parser (reachable by many connectors via WithGenericResponse). A caller doing a .(string) assertion on an attributed leaf element (e.g. <user active="true">alice</user>) previously got "alice" and now gets a map, so the assertion yields an empty string plus false: a silently empty value that can flow into resource IDs and grants persisted in c1z. Consider gating the new shape behind an option (defaulting to the old string shape) or signaling the break with a 0.x minor bump plus a migration note.
Prompt for AI agents

Verify each finding against the current code and only fix it if needed.

Suggestions

In pkg/uhttp/xml.go:

  • Around line 139-144: Adding attribute support changes an attributed leaf element from a bare string to a map carrying a #text key (the trimmed text) and an @attributes key (the attribute map). This is a silent, always-on default change to unmarshalXMLElement, reachable by many connectors through WithGenericResponse (pkg/uhttp/wrapper.go:376-387). A downstream caller doing a .(string) assertion on the decoded value for an element like <user active="true">alice</user> previously received "alice" and now receives a map, so the assertion yields an empty string plus false, producing a silently empty or wrong value that can flow into resource IDs and grants persisted in c1z. Attributes were dropped before, so callers cannot depend on attribute data, but callers reading the text of an attributed leaf as a string will break. Consider making the new attributes/text shape opt-in (for example a functional option that preserves the old string shape by default), or clearly communicate the break with a 0.x minor version bump and a migration note so downstream connectors can adapt.

Note: the machine-readable review-state marker was omitted because the CI shell sandbox blocks emitting JSON braces; this only affects incremental-review bookkeeping, not the review itself.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

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.

2 participants