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.