Skip to content
Open
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
22 changes: 22 additions & 0 deletions configuration-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,28 @@
"$ref": "#/$defs/format-mapping"
}
}
},
"json-encoding": {
"type": "object",
"additionalProperties": false,
"description": "JSONEncoding controls JSON encoding behavior (HTML escaping, indentation) for MarshalJSON methods and request body serialization in generated code.",
"properties": {
"escape-html": {
"type": "boolean",
"description": "Controls whether HTML special characters (<, >, &) are escaped to Unicode escape sequences in JSON output. When set to false, these characters are output as-is. Defaults to true, matching json.Marshal behavior.",
"default": true
},
"indent-prefix": {
"type": "string",
"description": "String prepended to each line per indentation level. Corresponds to the first argument of json.Encoder.SetIndent.",
"default": ""
},
"indent": {
"type": "string",
"description": "String appended per nesting level in JSON output. When non-empty, generated code uses an indented encoder instead of json.Marshal. Corresponds to the second argument of json.Encoder.SetIndent.",
"default": ""
}
}
}
}
},
Expand Down
9 changes: 9 additions & 0 deletions pkg/codegen/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) {

// This creates the golang templates text package
TemplateFunctions["opts"] = func() Configuration { return globalState.options }
TemplateFunctions["jsonNewEncoder"] = func(bufVarName string) string {
return jsonNewEncoderExpr(globalState.options.OutputOptions.JSONEncoding, bufVarName)
}
TemplateFunctions["jsonMarshalExpr"] = func(varName string) string {
return jsonMarshalExpr(globalState.options.OutputOptions.JSONEncoding, varName)
}
TemplateFunctions["jsonMarshalFieldExpr"] = func(varName string) string {
return jsonMarshalFieldExpr(globalState.options.OutputOptions.JSONEncoding, varName)
}
t := template.New("oapi-codegen").Funcs(TemplateFunctions)
// This parses all of our own template files into the template object
// above
Expand Down
173 changes: 173 additions & 0 deletions pkg/codegen/codegen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,5 +329,178 @@ paths:
assert.Contains(t, code, "roleName string")
}

func TestJSONEncodingOptions(t *testing.T) {
// Spec with a oneOf type (triggers union.tmpl MarshalJSON), a type with
// additionalProperties (triggers additional-properties.tmpl MarshalJSON),
// and a path with a JSON request body and response (exercises client.tmpl
// and strict-interface.tmpl).
const spec = `
openapi: "3.0.0"
info:
version: 1.0.0
title: JSON encoding test
components:
schemas:
Cat:
type: object
properties:
name:
type: string
Dog:
type: object
properties:
breed:
type: string
Pet:
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
Metadata:
type: object
properties:
name:
type: string
additionalProperties:
type: string
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'200':
description: created pet
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
`
loader := openapi3.NewLoader()
swagger, err := loader.LoadFromData([]byte(spec))
require.NoError(t, err)

boolPtr := func(b bool) *bool { return &b }

tests := []struct {
name string
generate GenerateOptions
encoding JSONEncodingOptions
wantContain []string
wantAbsent []string
}{
// --- models (union / additionalProperties MarshalJSON) ---
{
name: "models/default: uses json.Marshal",
generate: GenerateOptions{Models: true},
encoding: JSONEncodingOptions{},
wantContain: []string{"json.Marshal(v)"},
wantAbsent: []string{"SetEscapeHTML", "SetIndent"},
},
{
name: "models/escape-html false: uses encoder with SetEscapeHTML(false)",
generate: GenerateOptions{Models: true},
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(false)},
wantContain: []string{"json.NewEncoder", "SetEscapeHTML(false)"},
wantAbsent: []string{"json.Marshal(v)", "SetIndent"},
},
{
name: "models/indent set: uses encoder with SetIndent",
generate: GenerateOptions{Models: true},
encoding: JSONEncodingOptions{Indent: "\t"},
wantContain: []string{"json.NewEncoder", `SetIndent("", "\t")`},
wantAbsent: []string{"json.Marshal(v)", "SetEscapeHTML"},
},
{
name: "models/both options: encoder with SetEscapeHTML and SetIndent",
generate: GenerateOptions{Models: true},
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(false), Indent: " "},
wantContain: []string{"json.NewEncoder", "SetEscapeHTML(false)", `SetIndent("", " ")`},
wantAbsent: []string{"json.Marshal(v)"},
},
{
name: "models/escape-html true explicit: same as default",
generate: GenerateOptions{Models: true},
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(true)},
wantContain: []string{"json.Marshal(v)"},
wantAbsent: []string{"SetEscapeHTML", "SetIndent"},
},
// --- client (JSON request body path in client.tmpl) ---
{
name: "client/default: uses json.Marshal(body)",
generate: GenerateOptions{Client: true},
encoding: JSONEncodingOptions{},
wantContain: []string{"json.Marshal(body)"},
wantAbsent: []string{"func() ([]byte, error) {"},
},
{
name: "client/escape-html false: uses IIFE encoder for body",
generate: GenerateOptions{Client: true},
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(false)},
wantContain: []string{"func() ([]byte, error) {", "SetEscapeHTML(false)"},
wantAbsent: []string{"json.Marshal(body)"},
},
{
name: "client/indent set: uses IIFE encoder with SetIndent for body",
generate: GenerateOptions{Client: true},
encoding: JSONEncodingOptions{Indent: "\t"},
wantContain: []string{"func() ([]byte, error) {", `SetIndent("", "\t")`},
wantAbsent: []string{"json.Marshal(body)", "SetEscapeHTML"},
},
// --- strict server (JSON response path in strict-interface.tmpl) ---
{
name: "strict-server/default: uses json.NewEncoder(&buf) directly",
generate: GenerateOptions{StdHTTPServer: true, Strict: true, Models: true},
encoding: JSONEncodingOptions{},
wantContain: []string{"json.NewEncoder(&buf)"},
wantAbsent: []string{"func() *json.Encoder {"},
},
{
name: "strict-server/escape-html false: uses IIFE encoder",
generate: GenerateOptions{StdHTTPServer: true, Strict: true, Models: true},
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(false)},
wantContain: []string{"func() *json.Encoder {", "SetEscapeHTML(false)"},
wantAbsent: []string{},
},
{
name: "strict-server/indent set: uses IIFE encoder with SetIndent",
generate: GenerateOptions{StdHTTPServer: true, Strict: true, Models: true},
encoding: JSONEncodingOptions{Indent: " "},
wantContain: []string{"func() *json.Encoder {", `SetIndent("", " ")`},
wantAbsent: []string{"SetEscapeHTML"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := Configuration{
PackageName: "testpkg",
Generate: tt.generate,
OutputOptions: OutputOptions{
SkipPrune: true,
JSONEncoding: tt.encoding,
},
}
code, err := Generate(swagger, opts)
require.NoError(t, err)
assert.NotEmpty(t, code)

_, err = format.Source([]byte(code))
require.NoError(t, err, "generated code must be valid Go")

for _, want := range tt.wantContain {
assert.Contains(t, code, want)
}
for _, absent := range tt.wantAbsent {
assert.NotContains(t, code, absent)
}
})
}
}

//go:embed test_spec.yaml
var testOpenAPIDefinition string
Comment on lines 329 to 506
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.

P2 Test coverage gap for client and strict-server paths

TestJSONEncodingOptions only enables GenerateOptions{Models: true}, so the changes to client.tmpl (jsonMarshalExpr "body" on the request body path) and strict-interface.tmpl (jsonNewEncoder "buf" on the response encoding path) are never exercised with custom encoding settings. If an IIFE-based expression were syntactically invalid in either context, this test would not catch it. Adding sub-cases with Generate.Client: true and Generate.StrictServer: true (paired with a minimal spec that has a JSON request body / JSON response) would close the gap.

34 changes: 34 additions & 0 deletions pkg/codegen/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@ type OutputOptions struct {
// TypeMapping allows customizing OpenAPI type/format to Go type mappings.
// User-specified mappings are merged on top of the defaults.
TypeMapping *TypeMapping `yaml:"type-mapping,omitempty"`

// JSONEncoding controls JSON encoding behavior (e.g. HTML escaping, indentation)
// for MarshalJSON methods and request body serialization in generated code.
JSONEncoding JSONEncodingOptions `yaml:"json-encoding,omitempty"`
}

func (oo OutputOptions) Validate() map[string]string {
Expand Down Expand Up @@ -435,3 +439,33 @@ type OutputOptionsOverlay struct {
// Defaults to true.
Strict *bool `yaml:"strict,omitempty"`
}

// JSONEncodingOptions controls JSON encoding behavior for MarshalJSON methods
// and request body serialization in generated code.
type JSONEncodingOptions struct {
// EscapeHTML controls whether HTML special characters (<, >, &) are escaped
// to Unicode escape sequences in JSON output. When set to false, these
// characters are output as-is. Defaults to true, matching json.Marshal behavior.
EscapeHTML *bool `yaml:"escape-html,omitempty"`
// IndentPrefix is the string prepended to each line for each level of
// indentation. Corresponds to the first argument of json.Encoder.SetIndent.
IndentPrefix string `yaml:"indent-prefix,omitempty"`
// Indent is the string appended per nesting level. When non-empty, generated
// code uses an indented encoder instead of json.Marshal.
// Corresponds to the second argument of json.Encoder.SetIndent.
Indent string `yaml:"indent,omitempty"`
}

// EscapeHTMLValue returns the effective EscapeHTML setting, defaulting to true.
func (o JSONEncodingOptions) EscapeHTMLValue() bool {
if o.EscapeHTML == nil {
return true
}
return *o.EscapeHTML
}

// NeedsCustomEncoding reports whether the options differ from standard json.Marshal
// behavior and therefore require using json.Encoder.
func (o JSONEncodingOptions) NeedsCustomEncoding() bool {
return !o.EscapeHTMLValue() || o.Indent != "" || o.IndentPrefix != ""
}
60 changes: 60 additions & 0 deletions pkg/codegen/template_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,63 @@ var TemplateFunctions = template.FuncMap{
"genServerURLWithVariablesFunctionParams": genServerURLWithVariablesFunctionParams,
"httpMethodConstant": httpMethodConstant,
}

// jsonMarshalExpr returns a Go expression that marshals varName to JSON,
// respecting the JSONEncoding options. When no custom encoding is required the
// expression is a plain json.Marshal call; otherwise it is an immediately-
// invoked function that configures a json.Encoder appropriately.
func jsonMarshalExpr(enc JSONEncodingOptions, varName string) string {
if !enc.NeedsCustomEncoding() {
return "json.Marshal(" + varName + ")"
}
var sb strings.Builder
sb.WriteString("func() ([]byte, error) {\n")
sb.WriteString("var __buf bytes.Buffer\n")
sb.WriteString("__enc := json.NewEncoder(&__buf)\n")
if !enc.EscapeHTMLValue() {
sb.WriteString("__enc.SetEscapeHTML(false)\n")
}
if enc.Indent != "" || enc.IndentPrefix != "" {
fmt.Fprintf(&sb, "__enc.SetIndent(%q, %q)\n", enc.IndentPrefix, enc.Indent)
}
sb.WriteString("if __err := __enc.Encode(" + varName + "); __err != nil {\n")
sb.WriteString("return nil, __err\n")
sb.WriteString("}\n")
sb.WriteString("return bytes.TrimRight(__buf.Bytes(), \"\\n\"), nil\n")
sb.WriteString("}()")
return sb.String()
}

// jsonMarshalFieldExpr is like jsonMarshalExpr but never applies indentation.
// Use this when marshaling individual field values that will be stored as
// json.RawMessage in an intermediate map; the outer marshal via jsonMarshalExpr
// is the right place to apply indentation so that the full structure is
// indented consistently without double-indenting nested values.
func jsonMarshalFieldExpr(enc JSONEncodingOptions, varName string) string {
enc.Indent = ""
enc.IndentPrefix = ""
return jsonMarshalExpr(enc, varName)
}

// jsonNewEncoderExpr returns a Go expression that creates a *json.Encoder
// writing to &bufVarName, applying any JSONEncoding options. When no custom
// encoding is required the expression is a plain json.NewEncoder call;
// otherwise it is an immediately-invoked function that sets the options before
// returning the encoder.
func jsonNewEncoderExpr(enc JSONEncodingOptions, bufVarName string) string {
if !enc.NeedsCustomEncoding() {
return "json.NewEncoder(&" + bufVarName + ")"
}
var sb strings.Builder
sb.WriteString("func() *json.Encoder {\n")
sb.WriteString("__e := json.NewEncoder(&" + bufVarName + ")\n")
if !enc.EscapeHTMLValue() {
sb.WriteString("__e.SetEscapeHTML(false)\n")
}
if enc.Indent != "" || enc.IndentPrefix != "" {
fmt.Fprintf(&sb, "__e.SetIndent(%q, %q)\n", enc.IndentPrefix, enc.Indent)
}
sb.WriteString("return __e\n")
sb.WriteString("}()")
return sb.String()
}
6 changes: 3 additions & 3 deletions pkg/codegen/templates/additional-properties.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,19 @@ func (a {{.TypeName}}) MarshalJSON() ([]byte, error) {
object := make(map[string]json.RawMessage)
{{range .Schema.Properties}}
{{if .RequiresNilCheck}}if a.{{.GoFieldName}} != nil { {{end}}
object["{{.JsonFieldName}}"], err = json.Marshal(a.{{.GoFieldName}})
object["{{.JsonFieldName}}"], err = {{jsonMarshalFieldExpr (printf "a.%s" .GoFieldName)}}
if err != nil {
return nil, fmt.Errorf("error marshaling '{{.JsonFieldName}}': %w", err)
}
{{if .RequiresNilCheck}} }{{end}}
{{end}}
for fieldName, field := range a.AdditionalProperties {
object[fieldName], err = json.Marshal(field)
object[fieldName], err = {{jsonMarshalFieldExpr "field"}}
if err != nil {
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
}
}
return json.Marshal(object)
return {{jsonMarshalExpr "object"}}
}
Comment on lines 54 to 70
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.

P2 Double-encode with indent produces inconsistent indentation for complex field types

When indent is configured, jsonMarshalExpr is called once per property (each individual field value is encoded into a json.RawMessage with indentation applied), and then once more for the outer object map. Because json.RawMessage is embedded verbatim by encoding/json, any field whose type marshals to a multi-line JSON (a nested object or array) will be embedded as an already-indented blob inside the outer pretty-printed map. The resulting JSON is still valid, but the indentation of inner fields will not align with the outer structure's depth.

The same pattern applies in union-and-additional-properties.tmpl. For the common additionalProperties: type: string case this is invisible, but users adding indent for human-readable output over complex models will see misaligned nesting.

{{end}}
{{end}}
2 changes: 1 addition & 1 deletion pkg/codegen/templates/client.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (c *{{ $clientTypeName }}) {{$opid}}{{.Suffix}}(ctx context.Context{{genPar
func New{{$opid}}Request{{.Suffix}}(server string{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody) (*http.Request, error) {
var bodyReader io.Reader
{{if .IsJSON -}}
buf, err := json.Marshal(body)
buf, err := {{jsonMarshalExpr "body"}}
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/codegen/templates/strict/strict-interface.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
{{if .IsJSON -}}
{{$hasUnionElements := ne 0 (len .Schema.UnionElements) -}}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response{{if $hasBodyVar}}.Body{{end}}{{if and $hasUnionElements (not .Schema.IsExternalRef)}}.union{{end}}); err != nil {
if err := {{jsonNewEncoder "buf"}}.Encode(response{{if $hasBodyVar}}.Body{{end}}{{if and $hasUnionElements (not .Schema.IsExternalRef)}}.union{{end}}); err != nil {
return err
}
w.Header().Set("Content-Type", {{if .HasFixedContentType }}{{.ContentType | toGoString}}{{else}}response.ContentType{{end}})
Expand Down
6 changes: 3 additions & 3 deletions pkg/codegen/templates/union-and-additional-properties.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,18 @@ func (a {{.TypeName}}) MarshalJSON() ([]byte, error) {
}
{{range .Schema.Properties}}
{{if .RequiresNilCheck}}if a.{{.GoFieldName}} != nil { {{end}}
object["{{.JsonFieldName}}"], err = json.Marshal(a.{{.GoFieldName}})
object["{{.JsonFieldName}}"], err = {{jsonMarshalFieldExpr (printf "a.%s" .GoFieldName)}}
if err != nil {
return nil, fmt.Errorf("error marshaling '{{.JsonFieldName}}': %w", err)
}
{{if .RequiresNilCheck}} }{{end}}
{{end}}
for fieldName, field := range a.AdditionalProperties {
object[fieldName], err = json.Marshal(field)
object[fieldName], err = {{jsonMarshalFieldExpr "field"}}
if err != nil {
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
}
}
return json.Marshal(object)
return {{jsonMarshalExpr "object"}}
}
{{end}}
Loading