diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index a55e54efa..c5c1a6ba9 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2496,6 +2496,12 @@ func (g *Generator) renderOptionalSupportFiles() error {
}
}
+ if g.Spec.HasXMLResponse() {
+ if err := g.renderTemplate("cliutil_xml_parse.go.tmpl", filepath.Join("internal", "cliutil", "xml_parse.go"), g.Spec); err != nil {
+ return fmt.Errorf("rendering cliutil xml parse: %w", err)
+ }
+ }
+
// Emit the cliutil proxypath helper only for proxy-envelope clients —
// the BuildPath function is the only caller of net/url.Values in the
// cliutil package, and there's no point shipping it (and its tests)
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index e181afaad..ab384e3c9 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -1631,6 +1631,14 @@ func (c *Client) doInternal(ctx context.Context, method, path string, params map
}
return env, resp.StatusCode, nil
}
+{{- if .HasXMLResponse}}
+ // XML endpoints carry an application/xml Accept override. Use that
+ // per-request signal so mixed XML/JSON specs normalize only the XML
+ // endpoint's success body. A decode failure returns the raw bytes.
+ if isXMLRequest(req.Header.Get("Accept")) && isXMLResponseContentType(resp.Header.Get("Content-Type")) {
+ return cliutil.XMLToJSON(json.RawMessage(respBody)), resp.StatusCode, nil
+ }
+{{- end}}
return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
}
@@ -2388,6 +2396,32 @@ func isBinaryResponseContentType(ct string) bool {
}
return true
}
+{{- if .HasXMLResponse}}
+
+func isXMLRequest(accept string) bool {
+ for value := range strings.SplitSeq(accept, ",") {
+ mt := strings.ToLower(strings.TrimSpace(strings.SplitN(value, ";", 2)[0]))
+ if mt == "application/xml" || mt == "text/xml" || (strings.HasSuffix(mt, "+xml") && mt != "application/xhtml+xml") {
+ return true
+ }
+ }
+ return false
+}
+
+// isXMLResponseContentType reports whether a success response should be
+// normalized from XML to JSON. application/xhtml+xml is excluded: it is
+// HTML-shaped and belongs to the html response path.
+func isXMLResponseContentType(ct string) bool {
+ mt := strings.ToLower(strings.TrimSpace(ct))
+ if i := strings.IndexByte(mt, ';'); i >= 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ if mt == "" || mt == "application/xhtml+xml" {
+ return false
+ }
+ return mt == "application/xml" || mt == "text/xml" || strings.HasSuffix(mt, "+xml")
+}
+{{- end}}
// wrapBinaryResponse marshals body into a self-describing base64 envelope.
func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
diff --git a/internal/generator/templates/cliutil_xml_parse.go.tmpl b/internal/generator/templates/cliutil_xml_parse.go.tmpl
new file mode 100644
index 000000000..9e4b54a91
--- /dev/null
+++ b/internal/generator/templates/cliutil_xml_parse.go.tmpl
@@ -0,0 +1,127 @@
+// Copyright {{currentYear}} {{copyrightHolder}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "bytes"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "io"
+ "strings"
+)
+
+// XMLToJSON normalizes an XML response body into a generic JSON document so
+// every downstream consumer (--json, --select, table output, MCP tools) treats
+// it like any JSON API. The mapping is BadgerFish-lite:
+//
+// - the root element name becomes the single top-level key
+// - attributes become "@name" string keys
+// - element text becomes the element's string value, or "#text" when the
+// element also carries attributes or child elements
+// - repeated sibling elements collapse into a JSON array
+//
+// On any decode failure the original bytes are returned unchanged, so a body
+// is never corrupted — callers can still inspect the raw XML.
+func XMLToJSON(raw json.RawMessage) json.RawMessage {
+ dec := xml.NewDecoder(bytes.NewReader(raw))
+ dec.Strict = false
+ dec.CharsetReader = xmlCharsetReader
+
+ for {
+ tok, err := dec.Token()
+ if err != nil {
+ return raw
+ }
+ start, ok := tok.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ value, err := decodeXMLElement(dec, start)
+ if err != nil {
+ return raw
+ }
+ out, err := json.Marshal(map[string]any{start.Name.Local: value})
+ if err != nil {
+ return raw
+ }
+ return json.RawMessage(out)
+ }
+}
+
+// decodeXMLElement consumes tokens through the end of start and returns its
+// value: a plain string for a text-only element, or a map for an element with
+// attributes and/or child elements.
+func decodeXMLElement(dec *xml.Decoder, start xml.StartElement) (any, error) {
+ attrs := make(map[string]any, len(start.Attr))
+ for _, attr := range start.Attr {
+ attrs["@"+attr.Name.Local] = attr.Value
+ }
+
+ children := map[string][]any{}
+ childOrder := []string{}
+ var text strings.Builder
+
+ for {
+ tok, err := dec.Token()
+ if err != nil {
+ return nil, err
+ }
+ switch t := tok.(type) {
+ case xml.StartElement:
+ child, err := decodeXMLElement(dec, t)
+ if err != nil {
+ return nil, err
+ }
+ key := t.Name.Local
+ if _, seen := children[key]; !seen {
+ childOrder = append(childOrder, key)
+ }
+ children[key] = append(children[key], child)
+ case xml.CharData:
+ text.Write(t)
+ case xml.EndElement:
+ return assembleXMLValue(attrs, children, childOrder, strings.TrimSpace(text.String())), nil
+ }
+ }
+}
+
+// assembleXMLValue collapses a decoded element into its final JSON value. A
+// bare text element returns its string; otherwise it returns a map merging
+// attributes, children (single child → scalar, repeated → array), and any
+// non-empty text under "#text".
+func assembleXMLValue(attrs map[string]any, children map[string][]any, childOrder []string, text string) any {
+ if len(attrs) == 0 && len(children) == 0 {
+ return text
+ }
+ obj := make(map[string]any, len(attrs)+len(children)+1)
+ for k, v := range attrs {
+ obj[k] = v
+ }
+ for _, key := range childOrder {
+ vals := children[key]
+ if len(vals) == 1 {
+ obj[key] = vals[0]
+ } else {
+ obj[key] = vals
+ }
+ }
+ if text != "" {
+ obj["#text"] = text
+ }
+ return obj
+}
+
+// xmlCharsetReader keeps the decoder dependency-free: UTF-8, ASCII, and an
+// absent charset are read as-is. Any other declared charset returns an error,
+// which XMLToJSON turns into a graceful raw-bytes pass-through rather than a
+// corrupted decode.
+func xmlCharsetReader(charset string, input io.Reader) (io.Reader, error) {
+ switch strings.ToLower(strings.TrimSpace(charset)) {
+ case "", "utf-8", "utf8", "us-ascii", "ascii":
+ return input, nil
+ default:
+ return nil, errors.New("unsupported XML charset: " + charset)
+ }
+}
diff --git a/internal/generator/xml_response_test.go b/internal/generator/xml_response_test.go
new file mode 100644
index 000000000..ee90ac10d
--- /dev/null
+++ b/internal/generator/xml_response_test.go
@@ -0,0 +1,144 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGenerateXMLResponseParseHelper(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("xml-response")
+ apiSpec.Resources["things"] = spec.Resource{
+ Description: "Things",
+ Endpoints: map[string]spec.Endpoint{
+ "get": {
+ Method: "GET",
+ Path: "/thing/{id}",
+ Description: "Get a thing",
+ ResponseFormat: spec.ResponseFormatXML,
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "xml-response-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+ requireGeneratedCompiles(t, outputDir)
+
+ helper, err := os.ReadFile(filepath.Join(outputDir, "internal", "cliutil", "xml_parse.go"))
+ require.NoError(t, err)
+ require.Contains(t, string(helper), `func XMLToJSON(raw json.RawMessage) json.RawMessage`)
+
+ testSrc := []byte(`package cliutil
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+)
+
+func decode(t *testing.T, in string) map[string]any {
+ t.Helper()
+ out := XMLToJSON(json.RawMessage(in))
+ var got map[string]any
+ if err := json.Unmarshal(out, &got); err != nil {
+ t.Fatalf("result is not valid JSON: %v (%s)", err, string(out))
+ }
+ return got
+}
+
+func TestXMLToJSONAttributesAndNesting(t *testing.T) {
+ got := decode(t, ` + "`" + ` ` + "`" + `)
+ want := map[string]any{"items": map[string]any{
+ "@total": "1",
+ "item": map[string]any{
+ "@id": "13",
+ "name": map[string]any{"@value": "Catan"},
+ },
+ }}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("attrs/nesting mismatch:\n got=%#v\nwant=%#v", got, want)
+ }
+}
+
+func TestXMLToJSONRepeatedSiblingsBecomeArray(t *testing.T) {
+ got := decode(t, ` + "`" + ` ` + "`" + `)
+ items, ok := got["items"].(map[string]any)
+ if !ok {
+ t.Fatalf("items not a map: %#v", got["items"])
+ }
+ arr, ok := items["item"].([]any)
+ if !ok || len(arr) != 2 {
+ t.Fatalf("item should be a 2-element array: %#v", items["item"])
+ }
+}
+
+func TestXMLToJSONTextAndMixedContent(t *testing.T) {
+ got := decode(t, ` + "`" + `helloCatan` + "`" + `)
+ root := got["root"].(map[string]any)
+ if root["message"] != "hello" {
+ t.Fatalf("text element should be a bare string: %#v", root["message"])
+ }
+ name := root["name"].(map[string]any)
+ if name["@type"] != "primary" || name["#text"] != "Catan" {
+ t.Fatalf("mixed attr+text mismatch: %#v", name)
+ }
+}
+
+func TestXMLToJSONMalformedPassesThrough(t *testing.T) {
+ in := "not xml at all"
+ out := XMLToJSON(json.RawMessage(in))
+ if string(out) != in {
+ t.Fatalf("malformed input should pass through unchanged, got %q", string(out))
+ }
+}
+`)
+ require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "cliutil", "xml_parse_extra_test.go"), testSrc, 0o600))
+ runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
+}
+
+func TestGenerateJSONOnlyOmitsXMLResponseParseHelper(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("json-only-xml")
+ outputDir := filepath.Join(t.TempDir(), "json-only-xml-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ _, err := os.Stat(filepath.Join(outputDir, "internal", "cliutil", "xml_parse.go"))
+ require.True(t, os.IsNotExist(err), "JSON-only CLIs should not emit xml_parse.go")
+}
+
+func TestGenerateMixedXMLJSONScopesNormalizationToXMLRequests(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("mixed-xml-json")
+ apiSpec.Resources["things"] = spec.Resource{
+ Description: "Things",
+ Endpoints: map[string]spec.Endpoint{
+ "get": {
+ Method: "GET",
+ Path: "/thing/{id}",
+ Description: "Get a thing",
+ ResponseFormat: spec.ResponseFormatXML,
+ },
+ },
+ }
+ require.True(t, apiSpec.HasXMLResponse(), "fixture should have an XML endpoint")
+
+ outputDir := filepath.Join(t.TempDir(), "mixed-xml-json-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ _, err := os.Stat(filepath.Join(outputDir, "internal", "cliutil", "xml_parse.go"))
+ require.NoError(t, err, "mixed XML/JSON specs need the XML normalizer")
+
+ client, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ require.Contains(t, string(client), `isXMLRequest(req.Header.Get("Accept")) && isXMLResponseContentType`,
+ "mixed specs should scope normalization to each XML request")
+ require.NotContains(t, string(client), `req.Header.Set("Accept", "application/xml")`,
+ "mixed specs should keep the application/json Accept default")
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index cbed8043f..b71be73b3 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -3558,6 +3558,13 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) error {
// templates do not re-walk schemas at generation time.
if responseUsesBinary(op) {
endpoint.ResponseFormat = spec.ResponseFormatBinary
+ } else if responseUsesXML(op) {
+ // XML-only success bodies are normalized to JSON by the
+ // generated client (response_format: xml). Pin Accept so the
+ // server returns XML instead of 406-ing the default
+ // application/json.
+ endpoint.ResponseFormat = spec.ResponseFormatXML
+ endpoint.HeaderOverrides = upsertHeaderOverride(endpoint.HeaderOverrides, "Accept", "application/xml")
}
if pathResourceIDOverride != "" {
endpoint.IDField = pathResourceIDOverride
@@ -5306,6 +5313,42 @@ func responseUsesBinary(op *openapi3.Operation) bool {
return false
}
+// responseUsesXML reports whether the operation's success response is
+// XML-only. It returns true when at least one declared success media type is
+// XML (application/xml, text/xml, or a *+xml suffix other than xhtml) and none
+// is JSON. A mixed JSON+XML response keeps the JSON default, so only genuinely
+// XML-only endpoints opt into the xml response_format normalization path.
+func responseUsesXML(op *openapi3.Operation) bool {
+ if op == nil || op.Responses == nil {
+ return false
+ }
+ success := selectSuccessResponse(op.Responses)
+ if success == nil || success.Value == nil || len(success.Value.Content) == 0 {
+ return false
+ }
+ sawXML := false
+ for ct := range success.Value.Content {
+ base := strings.ToLower(strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]))
+ switch {
+ case base == "application/json", base == "text/json", strings.HasSuffix(base, "+json"):
+ return false
+ case xmlResponseContentType(base):
+ sawXML = true
+ }
+ }
+ return sawXML
+}
+
+// xmlResponseContentType reports whether a media type is XML for response
+// normalization. application/xhtml+xml is intentionally excluded: it is
+// HTML-shaped and belongs to the html response path, not XML normalization.
+func xmlResponseContentType(base string) bool {
+ if base == "" || base == "application/xhtml+xml" {
+ return false
+ }
+ return base == "application/xml" || base == "text/xml" || strings.HasSuffix(base, "+xml")
+}
+
func binaryContentType(contentType string) bool {
base := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if base == "" {
diff --git a/internal/openapi/parser_binary_response_test.go b/internal/openapi/parser_binary_response_test.go
index 47ca2f94a..4d3834401 100644
--- a/internal/openapi/parser_binary_response_test.go
+++ b/internal/openapi/parser_binary_response_test.go
@@ -181,11 +181,13 @@ func TestParseBinaryOnlyResponseEmitsAcceptOverride(t *testing.T) {
assert.False(t, has, "text responses must not be forced into the binary response path")
})
- t.Run("XML response gets no Accept override", func(t *testing.T) {
+ t.Run("XML response gets an application/xml Accept override and xml format", func(t *testing.T) {
e, ok := endpointByPath(parsed, "/widgets/{id}/xml")
require.True(t, ok)
- _, has := acceptOverride(e)
- assert.False(t, has, "XML responses must not be forced into the binary response path")
+ v, has := acceptOverride(e)
+ require.True(t, has, "XML-only endpoints pin Accept so the server returns XML, not 406")
+ assert.Equal(t, "application/xml", v)
+ assert.Equal(t, spec.ResponseFormatXML, e.ResponseFormat)
})
}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 9613628eb..a0fae9a51 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -48,6 +48,7 @@ const (
ResponseFormatJSON = "json"
ResponseFormatCSV = "csv"
ResponseFormatHTML = "html"
+ ResponseFormatXML = "xml"
ResponseFormatBinary = "binary"
)
@@ -983,6 +984,35 @@ func resourceHasHTMLExtraction(resource Resource) bool {
return false
}
+// HasXMLResponse reports whether any endpoint in the spec declares
+// response_format: xml. The generated client gates its XML→JSON
+// normalization helper on this so JSON-only CLIs emit no XML code.
+func (s *APISpec) HasXMLResponse() bool {
+ if s == nil {
+ return false
+ }
+ for _, resource := range s.Resources {
+ if resourceHasXMLResponse(resource) {
+ return true
+ }
+ }
+ return false
+}
+
+func resourceHasXMLResponse(resource Resource) bool {
+ for _, endpoint := range resource.Endpoints {
+ if endpoint.UsesXMLResponse() {
+ return true
+ }
+ }
+ for _, sub := range resource.SubResources {
+ if resourceHasXMLResponse(sub) {
+ return true
+ }
+ }
+ return false
+}
+
// HasHTMLExtractMode reports whether any endpoint in the spec declares
// html_extract with the given effective mode. Used by the html_extract
// template to gate per-mode helpers: a CLI that uses only
@@ -2044,7 +2074,7 @@ type Endpoint struct {
BodyIsArray bool `yaml:"body_is_array,omitempty" json:"body_is_array,omitempty"`
RequestContentType string `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
Response ResponseDef `yaml:"response" json:"response"`
- ResponseFormat string `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default), csv, html, or binary
+ ResponseFormat string `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default), csv, html, xml, or binary
// DataSourceStrategy declares how this endpoint's generated read command
// should interpret --data-source. Empty inherits the resource strategy,
// then defaults to "auto".
@@ -2251,6 +2281,10 @@ func (e Endpoint) UsesCSVResponse() bool {
return e.EffectiveResponseFormat() == ResponseFormatCSV
}
+func (e Endpoint) UsesXMLResponse() bool {
+ return e.EffectiveResponseFormat() == ResponseFormatXML
+}
+
type HTMLExtract struct {
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // page (default), links, or embedded-json
LinkPrefixes []string `yaml:"link_prefixes,omitempty" json:"link_prefixes,omitempty"` // path-segment prefixes to keep when extracting links (mode: links)
@@ -4662,9 +4696,9 @@ func validateTierRoutingResource(s *APISpec, resourcePath string, resource Resou
func validateEndpointResponseFormat(e Endpoint) error {
switch e.ResponseFormat {
- case "", ResponseFormatJSON, ResponseFormatCSV, ResponseFormatHTML, ResponseFormatBinary:
+ case "", ResponseFormatJSON, ResponseFormatCSV, ResponseFormatHTML, ResponseFormatXML, ResponseFormatBinary:
default:
- return fmt.Errorf("response_format must be one of: json, csv, html, binary")
+ return fmt.Errorf("response_format must be one of: json, csv, html, xml, binary")
}
if !e.UsesHTMLResponse() {
return nil
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 9c058573a..6e31b5eaa 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -4314,9 +4314,26 @@ func TestHTMLResponseExtractionValidation(t *testing.T) {
require.NoError(t, csvSpec.Validate())
assert.True(t, csvSpec.Resources["posts"].Endpoints["list"].UsesCSVResponse())
+ xmlSpec := validHTMLSpec()
+ ep = xmlSpec.Resources["posts"].Endpoints["list"]
+ ep.ResponseFormat = ResponseFormatXML
+ ep.HTMLExtract = nil
+ xmlSpec.Resources["posts"].Endpoints["list"] = ep
+ require.NoError(t, xmlSpec.Validate())
+ assert.True(t, xmlSpec.Resources["posts"].Endpoints["list"].UsesXMLResponse())
+ assert.True(t, xmlSpec.HasXMLResponse())
+
+ mixedSpec := validHTMLSpec()
+ ep = mixedSpec.Resources["posts"].Endpoints["list"]
+ ep.ResponseFormat = ResponseFormatXML
+ ep.HTMLExtract = nil
+ mixedSpec.Resources["posts"].Endpoints["list"] = ep
+ mixedSpec.Resources["posts"].Endpoints["get"] = Endpoint{Method: "GET", Path: "/posts/{id}", Description: "Get a post (JSON)"}
+ assert.True(t, mixedSpec.HasXMLResponse())
+
badFormat := validHTMLSpec()
ep = badFormat.Resources["posts"].Endpoints["list"]
- ep.ResponseFormat = "xml"
+ ep.ResponseFormat = "yaml"
badFormat.Resources["posts"].Endpoints["list"] = ep
require.ErrorContains(t, badFormat.Validate(), "response_format must be one of")
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index f87e5d41c..966494c6a 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -3601,6 +3601,9 @@ RunE: func(cmd *cobra.Command, args []string) error {
}
// If the API returns CSV (`response_format: csv` in any spec endpoint),
// wrap raw client data with cliutil.ParseCSV(data) before embedding it in a JSON envelope.
+ // XML-only endpoints (`response_format: xml`) are auto-detected from the spec's
+ // response content type; the generated client already normalizes those bodies to JSON
+ // via cliutil.XMLToJSON, so novel code can treat `data` as ordinary JSON.
// Parse data into your feature's view. Use cliutil.CleanText for any
// text extracted from HTML or schema.org JSON-LD; re-implementing
// HTML-entity unescape inline is the ' bug class.