Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2124,6 +2124,12 @@ func (g *Generator) renderOptionalSupportFiles() error {
}
}

if g.Spec.AllResponsesXML() {
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)
Expand Down
32 changes: 30 additions & 2 deletions internal/generator/templates/client.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -1424,12 +1424,14 @@ func (c *Client) doInternal(ctx context.Context, method, path string, params map
// application/json; specs that need a different content type
// (vendor mediatypes, XML, HTML) declare it via RequiredHeaders or
// per-endpoint headerOverrides, both of which run before this
// if-empty default.
// if-empty default. XML-backed specs (response_format: xml) flip
// the default to application/xml so direct client calls negotiate
// the right representation instead of risking a 406 on XML-only APIs.
if req.Header.Get("Accept") == "" {
if binaryResponse {
req.Header.Set("Accept", "*/*")
} else {
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept", "application/{{if .AllResponsesXML}}xml{{else}}json{{end}}")
}
}
Comment thread
coopdogGGs marked this conversation as resolved.
{{- end}}
Expand Down Expand Up @@ -1469,6 +1471,16 @@ func (c *Client) doInternal(ctx context.Context, method, path string, params map
}
return env, resp.StatusCode, nil
}
{{- if .AllResponsesXML}}
// XML-only specs (every endpoint response_format: xml) normalize
// success bodies to a generic JSON document so --json, --select, and
// table output work unchanged. Non-XML content types (e.g. a JSON
// error envelope) pass through untouched; a decode failure returns
// the raw bytes rather than corrupting the body.
if isXMLResponseContentType(resp.Header.Get("Content-Type")) {
return cliutil.XMLToJSON(json.RawMessage(respBody)), resp.StatusCode, nil
}
{{- end}}
return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
}

Expand Down Expand Up @@ -2215,6 +2227,22 @@ func isBinaryResponseContentType(ct string) bool {
}
return true
}
{{- if .AllResponsesXML}}

// 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) {
Expand Down
127 changes: 127 additions & 0 deletions internal/generator/templates/cliutil_xml_parse.go.tmpl
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coopdogGGs marked this conversation as resolved.
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)
}
}
153 changes: 153 additions & 0 deletions internal/generator/xml_response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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")
// minimalSpec ships a JSON "items" resource; drop it so the spec is
// XML-only and AllResponsesXML() (which gates xml_parse.go emission) is true.
delete(apiSpec.Resources, "items")
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())

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, ` + "`" + `<items total="1"><item id="13"><name value="Catan"/></item></items>` + "`" + `)
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><item id="1"/><item id="2"/></items>` + "`" + `)
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, ` + "`" + `<root><message>hello</message><name type="primary">Catan</name></root>` + "`" + `)
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")
}

// TestGenerateMixedXMLJSONOmitsSpecWideXML pins that the spec-wide XML behavior
// (xml_parse.go, the XML→JSON normalization call site, and the application/xml
// Accept default) activates only when every endpoint is XML. A mixed spec — one
// XML endpoint plus minimalSpec's JSON "items" endpoint — must not emit the
// normalizer or flip the global Accept default, so its JSON endpoints are never
// forced to application/xml or run through XMLToJSON.
func TestGenerateMixedXMLJSONOmitsSpecWideXML(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")
require.False(t, apiSpec.AllResponsesXML(), "fixture is mixed, not XML-only")

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.True(t, os.IsNotExist(err), "mixed XML/JSON specs should not emit xml_parse.go")

client, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
require.NoError(t, err)
require.NotContains(t, string(client), "cliutil.XMLToJSON",
"mixed specs should not normalize responses spec-wide")
require.NotContains(t, string(client), `req.Header.Set("Accept", "application/xml")`,
"mixed specs should keep the application/json Accept default")
}
Loading
Loading