-
Notifications
You must be signed in to change notification settings - Fork 5
cxp-846 return XML as a generic map: map targets and non-map roots #1061
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -217,11 +217,27 @@ func WithXMLResponse(response any) DoOption { | ||||||||||
| } | |||||||||||
|
|
|||||||||||
| // Ignore content type header and always try to parse the response as XML. | |||||||||||
| // | |||||||||||
| // A *map[string]any target is decoded through the generic xmlMap decoder, since | |||||||||||
| // encoding/xml cannot unmarshal into a map and would fail for every input. Any | |||||||||||
| // other target keeps going straight to xml.Unmarshal, so callers passing a typed | |||||||||||
| // struct are unaffected. | |||||||||||
| func WithAlwaysXMLResponse(response any) DoOption { | |||||||||||
| return func(resp *WrapperResponse) error { | |||||||||||
| if response == nil && len(resp.Body) == 0 { | |||||||||||
| return nil | |||||||||||
| } | |||||||||||
| if genericResponse, ok := response.(*map[string]any); ok { | |||||||||||
| // Scoped to the map target so the struct path below keeps its exact | |||||||||||
| // existing behavior, including for 204 and empty bodies. | |||||||||||
| if resp.StatusCode == http.StatusNoContent { | |||||||||||
| return nil | |||||||||||
| } | |||||||||||
| if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) == 0 { | |||||||||||
| return nil | |||||||||||
| } | |||||||||||
| return unmarshalXMLToMap(genericResponse, resp) | |||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this newly routes bodies that previously always errored into |
|||||||||||
| } | |||||||||||
| err := xml.Unmarshal(resp.Body, response) | |||||||||||
| if err != nil { | |||||||||||
| return fmt.Errorf("failed to unmarshal xml response: %w. status code: %d", err, resp.StatusCode) | |||||||||||
|
|
@@ -230,6 +246,47 @@ func WithAlwaysXMLResponse(response any) DoOption { | ||||||||||
| } | |||||||||||
| } | |||||||||||
|
|
|||||||||||
| // unmarshalXMLToMap decodes an XML body into a generic map via the xmlMap decoder. | |||||||||||
| // encoding/xml cannot unmarshal into a map directly, so any caller wanting an | |||||||||||
| // arbitrary XML document as a map has to route through xmlMap. | |||||||||||
| func unmarshalXMLToMap(response *map[string]any, resp *WrapperResponse) error { | |||||||||||
| // A typed-nil target, e.g. WithAlwaysXMLResponse((*map[string]any)(nil)), gets | |||||||||||
| // past an `any == nil` check because the interface still carries a type. Guard | |||||||||||
| // here rather than at each call site so assigning through it cannot panic. | |||||||||||
| // encoding/xml rejected this with "nil pointer passed to Unmarshal". | |||||||||||
| if response == nil { | |||||||||||
| return status.Error(codes.InvalidArgument, "response is nil") | |||||||||||
| } | |||||||||||
|
|
|||||||||||
| var xm xmlMap | |||||||||||
| if err := xml.Unmarshal(resp.Body, &xm); err != nil { | |||||||||||
| return fmt.Errorf("failed to unmarshal xml response: %w. status code: %d", err, resp.StatusCode) | |||||||||||
| } | |||||||||||
| vMap, ok := xm.data.(map[string]any) | |||||||||||
| if !ok { | |||||||||||
| // The root's content has no map representation in two cases, and both used | |||||||||||
| // to fail outright with "unsupported XML structure": | |||||||||||
| // | |||||||||||
| // - Its direct children repeat: <Users><User/><User/></Users> decodes to | |||||||||||
| // a []map[string]any. A root-level list is a common API shape, so this | |||||||||||
| // was a real gap rather than an edge case. | |||||||||||
| // - It holds only text: <Code>OK</Code> decodes to a string. | |||||||||||
| // | |||||||||||
| // Key it by the root element name, which is otherwise discarded, so the | |||||||||||
| // document is reachable by a path instead of being an error. | |||||||||||
| // | |||||||||||
| // Note the arity seam this leaves: a root holding a *single* <User> decodes | |||||||||||
| // to a map and keeps the root stripped, so its path is "User" while the | |||||||||||
| // repeated case is "Users". One config cannot serve both. Closing that | |||||||||||
| // needs the decoder to group repeated children under their shared name, | |||||||||||
| // which would also make the slice case here unreachable. | |||||||||||
| *response = map[string]any{xm.root: xm.data} | |||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Because |
|||||||||||
| return nil | |||||||||||
| } | |||||||||||
|
Comment on lines
+265
to
+285
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: the comment says the non-map case is "a document whose root holds only text", but
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch on both halves — the comment was wrong, and the slice case is the more important one. Fixed in 681b067. The comment. Corrected to name both ways the root's content can be a non-map, since I'd only documented the string case. The slice case. Handled as you suggested, keyed by the root element name — I did the text-only root the same way ( One caveat worth recording, since wrapping is reactive rather than a real fix. It inherits the decoder's arity asymmetry:
A single child means nothing repeats, so the content is a map, so the root name is discarded as usual — which means one config can't serve both arities for a root-level list. Measured, not assumed, and pinned by Still a clear win: the ≥2 case is the one every real tenant hits, and it went from an opaque SDK-internal error to something reachable by a path. What actually closes the seam is grouping repeated children under their shared name — which would also make this slice branch unreachable. That was in an earlier revision of this PR and is deferred (see the PR description) because it changes what existing CEL and template expressions read on paths that never touch items extraction. Your finding is a second argument for it, so it's parked for its own audit rather than dropped. Verified end-to-end against ConductorOne/baton-http#144 through a Go workspace: root-level lists now sync at both arities (with the path difference above), nested lists sync at both arities from a single path. |
|||||||||||
| *response = vMap | |||||||||||
| return nil | |||||||||||
| } | |||||||||||
|
|
|||||||||||
| type ErrorResponse interface { | |||||||||||
| Message() string | |||||||||||
| } | |||||||||||
|
|
@@ -374,17 +431,7 @@ func WithGenericResponse(response *map[string]any) DoOption { | ||||||||||
| } | |||||||||||
|
|
|||||||||||
| if IsXMLContentType(resp.Header.Get(ContentType)) { | |||||||||||
| var xm xmlMap | |||||||||||
| err = WithXMLResponse(&xm)(resp) | |||||||||||
| if err != nil { | |||||||||||
| return err | |||||||||||
| } | |||||||||||
| if vMap, ok := xm.data.(map[string]any); ok { | |||||||||||
| *response = vMap | |||||||||||
| } else { | |||||||||||
| return status.Errorf(codes.Internal, "unsupported XML structure: %T", xm.data) | |||||||||||
| } | |||||||||||
| return nil | |||||||||||
| return unmarshalXMLToMap(response, resp) | |||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: This is no longer a pure extraction — the shared helper's new root-keying changes |
|||||||||||
| } | |||||||||||
|
|
|||||||||||
| return status.Error(codes.Unknown, fmt.Sprintf("unsupported content type: %s", resp.Header.Get(ContentType))) | |||||||||||
|
|
|||||||||||
There was a problem hiding this comment.
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 theresponse == nilcheck above (interface holds a type), then this assertion succeeds with a nilgenericResponse, andunmarshalXMLToMapdoes*response = vMap→ nil-pointer panic on a non-empty body.WithGenericResponseguards this with an explicit nil check; consider mirroring it here. Low confidence — an unusual call pattern, but the map branch is new. (confidence: low)There was a problem hiding this comment.
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:
So routing map targets through
xmlMapturned that clean error into a panic. Reverting just the guard and running the new test reproduces it:Guarded inside
unmarshalXMLToMaprather than inWithAlwaysXMLResponse, soWithGenericResponseand any future caller are covered by the same check and it can't be reintroduced at a new call site. ReturnsInvalidArgumentto matchWithGenericResponse's existing nil handling. Test added:should error rather than panic on a typed-nil map target.