Skip to content

cxp-846 decode XML into a map target in WithAlwaysXMLResponse - #1061

Open
agustin-conductor wants to merge 1 commit into
mainfrom
bugfix/xml-parser
Open

cxp-846 decode XML into a map target in WithAlwaysXMLResponse#1061
agustin-conductor wants to merge 1 commit into
mainfrom
bugfix/xml-parser

Conversation

@agustin-conductor

@agustin-conductor agustin-conductor commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

WithAlwaysXMLResponse hands its target straight to encoding/xml, which cannot unmarshal into a map. A *map[string]any target therefore failed for every response with a body:

failed to unmarshal xml response: unknown type map[string]interface {}. status code: 200

Route that one target type through the xmlMap decoder WithGenericResponse already uses, sharing the code as unmarshalXMLToMap.

This scope was narrowed. It previously also reshaped the decoder so repeated siblings grouped under their shared key. That commit is dropped — see Deferred below. What remains changes no shapes at all.

Why

Callers wanting an arbitrary XML document as a map have no working option today. Concretely, baton-http maps parse_as: xml onto WithAlwaysXMLResponse(&map[string]any{}), so that config key has never functioned since it was added in 65f49692 — it hard-fails on every response with a body. This is the change that makes it work.

Compatibility

Two independent reasons this cannot affect a working caller.

1. Scoped by target type. The new branch fires only for *map[string]any; every other target falls through to the unchanged xml.Unmarshal call. All existing WithAlwaysXMLResponse call sites in the connector fleet pass typed structs or nil. WithXMLResponse — which is what panorama, litmos, and sage-intacct use — is not modified.

2. The branch it does reach always failed. For a map target the old code returned an error for every input, so there is no successful behavior to preserve.

Every divergence is error → something else, never success → something else:

input, map target before after
XML body error unknown type map[string]interface {} decoded map
204 / empty 2xx body error nil, map left empty
typed-nil (*map[string]any)(nil) error nil pointer passed to Unmarshal InvalidArgument: response is nil
root holds only text (<foo>bar</foo>) error Internal: unsupported XML structure: string

The map target now produces exactly what WithGenericResponse produces for the same document — a container with repeated children stays a []map[string]any of single-key maps, as today. A test asserts that shape explicitly so it is visible in review rather than implied.

The WithGenericResponse change is a pure extraction: its XML branch previously routed through WithXMLResponse(&xm), whose content-type and nil checks are both dead inside that branch (it is already guarded by IsXMLContentType, and &xm is never nil). Same decoder, same error wrapping, one code path.

The typed-nil guard addresses the review finding on the earlier revision: my map branch would have turned encoding/xml's clean "nil pointer passed to Unmarshal" into a nil-pointer panic. It lives inside unmarshalXMLToMap rather than at each call site, so assigning through the pointer cannot panic. A typed nil survives an any == nil check because the interface still carries a type.

Testing

go build ./..., go test ./pkg/uhttp/, and golangci-lint run ./pkg/uhttp/... (0 issues) all pass.

New cases on WithAlwaysXMLResponse: map target decoding despite a non-XML content type, the typed-struct path unchanged, root-text-only erroring, 204 and empty-200 leaving the map untouched, a typed-nil target erroring rather than panicking, and WithXMLResponse still rejecting map targets.

Deferred: the decoder shape change

The dropped commit made a container with 2+ same-named children decode to {"USER_LIST": {"USER": [...]}} instead of {"USER_LIST": [{"USER":…},{"USER":…}]}, so that jsonpath could walk it.

It is no longer needed. The consumer problem it targeted — baton-http's items_path failing on XML list responses — is fixed entirely in baton-http by ConductorOne/baton-http#144, which normalizes the existing shape at the extraction sites and needs no SDK release.

And it carried real risk that this PR does not. []map[string]any is only untraversable for jsonpath; CEL and Go templates walk it fine. In baton-http, responses on the provisioning, action, and pre-request paths never reach items extraction and are read solely by CEL — so cel:size(response.body.USER_LIST) returns N today and would return 1 after the reshape, a silent wrong answer in a working config. Reshaping is worth revisiting on its own merits, with that exposure audited first, rather than riding along with a fix that has none.

Part of CXP-846

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

CXP-846

Comment thread pkg/uhttp/wrapper.go
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) == 0 {
return nil
}
return unmarshalXMLToMap(genericResponse, resp)

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: A typed-nil (*map[string]any)(nil) passes the response == nil check above (interface holds a type), then this assertion succeeds with a nil genericResponse, and unmarshalXMLToMap does *response = vMap → nil-pointer panic on a non-empty body. WithGenericResponse guards this with an explicit nil check; consider mirroring it here. Low confidence — an unusual call pattern, but the map branch is new. (confidence: low)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — confirmed, and it's a regression rather than a latent edge case, so fixed in 0cf06e7.

Verified the premise and the prior behavior:

iface == nil?            false                            // typed nil carries a type, so it passes the guard
xml.Unmarshal(typedNil): nil pointer passed to Unmarshal  // old behavior: clean error

So routing map targets through xmlMap turned that clean error into a panic. Reverting just the guard and running the new test reproduces it:

panic: runtime error: invalid memory address or nil pointer dereference

Guarded inside unmarshalXMLToMap rather than in WithAlwaysXMLResponse, so WithGenericResponse and any future caller are covered by the same check and it can't be reintroduced at a new call site. Returns InvalidArgument to match WithGenericResponse's existing nil handling. Test added: should error rather than panic on a typed-nil map target.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

General PR Review: cxp-846 fix XML list decoding in the generic XML decoder

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

Review Summary

Scanned the full PR diff for security and correctness. This change reshapes xmlMap so repeated XML siblings group into a []any under their shared element name (replacing the old slice-of-single-key-maps shape that jsonpath could not walk), and lets WithAlwaysXMLResponse decode a map-pointer target through that decoder. The decode logic is correct — document order is preserved, unique siblings stay reachable alongside repeated ones, and the single-child asymmetry is documented — and the permutation-style test table (3+ duplicates, mixed siblings, nested depths, single-child, 204/empty-body, struct-path-unchanged) exercises the shape thoroughly. Triage: the failure mode is silent-empty (high silence) but the output is decode-time in-memory only, not durable serialized state, has no version-pair or scale dependence, and remediation is a connector redeploy — not a HIGH-risk contract change. This is a deliberate, well-documented default-shape change to a shared decoder; downstream consumers of the XML output should be aware, though the old shape was demonstrably unusable by config-driven callers. No blocking issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/wrapper.go:239 — a typed-nil map-pointer target bypasses the response == nil guard and would nil-panic at the map assignment in unmarshalXMLToMap; consider mirroring the explicit nil check WithGenericResponse uses. (low confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In pkg/uhttp/wrapper.go:
- Around line 230-239: The new map-pointer branch in WithAlwaysXMLResponse
  type-asserts response to a map pointer and passes it to unmarshalXMLToMap,
  which ends by assigning through the pointer. A caller passing a typed-nil map
  pointer is not caught by the earlier response == nil check (an interface holding
  a nil typed pointer is not equal to nil), so with a non-empty body the code
  dereferences a nil pointer and panics. Add an explicit guard for a nil map
  pointer (return nil, or return an InvalidArgument status), mirroring the
  response == nil guard already present in WithGenericResponse. Low-likelihood
  call pattern but the branch is new, so guarding it costs nothing.

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

agustin-conductor added a commit that referenced this pull request Aug 5, 2026
A typed-nil target such as (*map[string]any)(nil) gets past the
`response == nil` check in WithAlwaysXMLResponse, because the interface
still carries a type. The map branch was then reached with a nil pointer
and assigning through it panicked with a nil-pointer dereference.

encoding/xml rejected that input with "nil pointer passed to Unmarshal",
so routing map targets through xmlMap had turned a clean error into a
panic. Guard inside unmarshalXMLToMap rather than at each call site, so
neither this option nor WithGenericResponse nor any future caller can
assign through a nil pointer.

Reported by the PR review bot on #1061.

CXP-846

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

General PR Review: cxp-846 fix XML list decoding in the generic XML decoder

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

Review Summary

Scanned the full PR diff (4 files, all in pkg/uhttp) for security and correctness. The core change — grouping repeated XML siblings into a []any under their shared element name and routing *map[string]any targets through xmlMap — is correct; I traced document-order, single-child, mixed-sibling, and nested-depth cases against the new logic and they match the added tests. The typed-nil map-pointer panic noted in prior review (unmarshalXMLToMap) is now guarded and covered by a test, so that finding is addressed. No security issues and no blocking correctness issues. Risk triage: the decoded map is transient (not persisted to c1z/sync state) with no version-pair or scheduling dependence, so a defect here is redeploy-recoverable rather than a fleet migration — not a HIGH-risk change.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/sdk/version.go:3 — This PR changes the default decoded shape of the public WithGenericResponse/WithAlwaysXMLResponse XML path but does not bump Version (still v0.21.0). Per the repo-local criteria, default-behavior changes to public APIs should carry a 0.x minor bump as the compatibility signal, even though the PR argues (convincingly) that no working caller could depend on the old jsonpath-incompatible shape. (low confidence — may be handled at release/tag time.)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/sdk/version.go`:
- Around line 3: This PR changes the default decoded output shape of the public
  `WithGenericResponse` (XML path) and widens `WithAlwaysXMLResponse` to accept a
  `*map[string]any` target, but the `Version` constant stays at "v0.21.0". If this
  repo signals SDK compatibility via this constant per-change rather than only at
  release time, bump it to the next 0.x minor (e.g. v0.22.0) so downstream connectors
  have a version marker for the behavior change. If versioning is handled at tag/release
  time, no change is needed.

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

Comment thread pkg/uhttp/xml.go Outdated
// zero value of the assertion is a nil []any, which append handles,
// so the first occurrence creates the slice.
list, _ := result[e.key].([]any)
result[e.key] = append(list, e.value)

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.

Since this changes the structure of decoded XML, any connector that uses WithXMLResponse/WithGenericResponse will need to be updated, right? It looks like only a few connectors call WithXMLResponse directly: https://github.com/search?q=org%3AConductorOne+WithXMLResponse&type=code and only baton-http calls WithGenericResponse(), so that's acceptable.

Will an existing baton-http config break because of this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Based on what I've research with claude the baton connectors would not be affected "No updates needed for panorama, litmos, sage-intacct, or sap-grc. xmlMap is
unreachable from WithXMLResponse, all their targets are typed structs, and
they build and test identically against patched vs unpatched v0.22.0."

But baton-http is trickier and I'm not sure how to evaluate the impact, which would depend on how the config.yaml is set.
2 paths

mechanism: jsonpath
used by: items_path, item_path, entitlements_path, resources_path,
details/secondary EvaluateJSONPath
today: broken — error, or silently 0 items
after my change: fixed

mechanism: CEL / templates
used by: cel: and tmpl: expressions
today: works correctly
after my change: breaks — loud on indexing, silent N → 1 on size/len

the second one is a problem, silently losing pages.

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.

It looks like we're safe to make this change. There are no active http connectors in prod that use this part of the config.

@agustin-conductor
agustin-conductor marked this pull request as draft August 6, 2026 18:14
encoding/xml cannot unmarshal into a map, so WithAlwaysXMLResponse failed
for every response with a body when handed a *map[string]any, returning
"unknown type map[string]interface {}". Callers wanting an arbitrary XML
document as a map had no working option, which is why baton-http's
`parse_as: xml` has never functioned.

Route that one target type through the xmlMap decoder the generic path
already uses, and share the code as unmarshalXMLToMap. Any other target
still goes straight to xml.Unmarshal, so callers passing a typed struct
are untouched, and WithXMLResponse is not modified at all.

This changes no shapes: the map target now produces exactly what
WithGenericResponse already produces for the same document.

The behavior change is confined to a branch that previously always
failed:

  XML body, map target       error "unknown type map…"      -> decoded map
  204 / empty body           error                          -> nil, map empty
  typed-nil map target       error "nil pointer passed…"    -> InvalidArgument
  root holds only text       error                          -> Internal

Nothing that returns successfully today returns anything different. The
typed-nil guard lives inside unmarshalXMLToMap so assigning through the
pointer cannot panic; a typed nil survives an `any == nil` check because
the interface still carries a type.

Part of CXP-846.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@agustin-conductor agustin-conductor changed the title cxp-846 fix XML list decoding in the generic XML decoder cxp-846 decode XML into a map target in WithAlwaysXMLResponse Aug 6, 2026
@agustin-conductor
agustin-conductor marked this pull request as ready for review August 6, 2026 20:38
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.

5 participants