Skip to content

feat: add json-encoding output option for controlling JSON encoder settings#2371

Open
CRaLFa wants to merge 5 commits into
oapi-codegen:mainfrom
CRaLFa:add-json-encoding-option
Open

feat: add json-encoding output option for controlling JSON encoder settings#2371
CRaLFa wants to merge 5 commits into
oapi-codegen:mainfrom
CRaLFa:add-json-encoding-option

Conversation

@CRaLFa
Copy link
Copy Markdown

@CRaLFa CRaLFa commented May 12, 2026

Summary

Add output-options.json-encoding configuration block to control JSON encoding behavior in generated code.

  • escape-html (bool, default true): maps to json.Encoder.SetEscapeHTML. When set to false, HTML special characters (<, >, &) are output as-is instead of being escaped to Unicode sequences.
  • indent-prefix (string, default ""): maps to the first argument of json.Encoder.SetIndent.
  • indent (string, default ""): maps to the second argument of json.Encoder.SetIndent. When non-empty, indented output is produced.

Example config

output-options:
  json-encoding:
    escape-html: false
    indent: "  "

Affected generated code

Location Change
MarshalJSON() in union types json.Marshal(v) → encoder with configured settings
MarshalJSON() in types with additionalProperties same
MarshalJSON() in union + additionalProperties types same
JSON request body in client json.Marshal(body) → encoder with configured settings
Visit*Response() in strict server json.NewEncoder(&buf) → encoder with configured settings

When no options are set (default), json.Marshal / json.NewEncoder are emitted as before — fully backward-compatible.

Parameter serialization (path, query, header, cookie) is intentionally excluded: applying SetIndent there would embed whitespace into URL/header values and break requests.

Test plan

  • TestJSONEncodingOptions covers: default, escape-html: false, indent, both options combined, and explicit escape-html: true
  • All existing tests pass unchanged
  • Generated code passes go/format validation in all cases

🤖 Generated with Claude Code

CRaLFa and others added 4 commits May 12, 2026 20:59
…ttings

Add `output-options.json-encoding` configuration block with three fields:
- `escape-html` (bool, default true): controls SetEscapeHTML on the encoder
- `indent-prefix` (string): prefix per indentation level (SetIndent arg 1)
- `indent` (string): indent string per nesting level (SetIndent arg 2)

When any option differs from the json.Marshal defaults, generated MarshalJSON
methods (union, additional-properties, union-and-additional-properties) and
JSON request body serialization in the client use json.NewEncoder with the
configured settings instead of json.Marshal. Default behavior is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend the json-encoding output option to also cover the json.NewEncoder
used in Visit*Response methods generated from strict-interface.tmpl.

Add a jsonNewEncoder template function that returns either the plain
json.NewEncoder(&buf) expression (default, backward-compatible) or an
immediately-invoked function that creates the encoder and applies
SetEscapeHTML / SetIndent before returning it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that:
- default (no options) generates json.Marshal without encoder setup
- escape-html: false generates SetEscapeHTML(false)
- indent generates SetIndent with the configured string
- both options together produce both calls
- explicit escape-html: true behaves the same as the default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…template_helpers.go

Extract the code-generation logic from the closures in codegen.go into
named functions in template_helpers.go, where all other template helper
implementations live. codegen.go retains only the thin closure wrappers
that bind globalState, consistent with how the opts function is handled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@CRaLFa CRaLFa requested a review from a team as a code owner May 12, 2026 13:01
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 12, 2026

Greptile Summary

Adds a new opt-in output-options.json-encoding configuration block that lets users control HTML escaping (escape-html) and indentation (indent, indent-prefix) for JSON encoding in generated code. The default path is fully backward-compatible — all template changes fall through to the original json.Marshal / json.NewEncoder calls when no options are set.

  • JSONEncodingOptions struct and JSON schema entry are added together (rule compliance ✓); the NeedsCustomEncoding() helper gates a template-time IIFE that configures a json.Encoder and strips json.Encoder.Encode's trailing newline via bytes.TrimRight.
  • All five affected call sites (additional-properties.tmpl, union.tmpl, union-and-additional-properties.tmpl, client.tmpl, strict/strict-interface.tmpl) are covered; parameter serialization (path/query/header/cookie) is intentionally excluded.
  • No *.gen.go fixture changes are needed because the default code path is byte-for-byte identical to the previous templates.

Confidence Score: 4/5

Safe to merge; the default code path is unchanged and custom encoding is entirely opt-in.

The change is well-scoped and configuration/schema parity is maintained. The new test only exercises model generation, leaving the client and strict-server IIFE paths without syntax validation under custom encoding. Additionally, when indent is used with additionalProperties types, per-field values encoded to json.RawMessage are embedded verbatim, causing misaligned indentation for any field that renders as multi-line JSON.

pkg/codegen/codegen_test.go (missing client and strict-server coverage), pkg/codegen/templates/additional-properties.tmpl and union-and-additional-properties.tmpl (double-encode indentation behaviour with complex field types)

Important Files Changed

Filename Overview
configuration-schema.json JSON schema updated to include the new json-encoding block with all three properties (escape-html, indent-prefix, indent), matching the Go struct changes in configuration.go.
pkg/codegen/configuration.go Adds JSONEncodingOptions struct with EscapeHTML *bool, IndentPrefix string, and Indent string fields, plus helper methods EscapeHTMLValue() and NeedsCustomEncoding(); the struct is wired into OutputOptions.JSONEncoding.
pkg/codegen/template_helpers.go Adds jsonMarshalExpr and jsonNewEncoderExpr helpers that emit either a plain json.Marshal/json.NewEncoder call (default path) or an IIFE that configures the encoder; the bytes import is already in imports.tmpl so no import gap exists, but the double-encode under SetIndent for intermediate json.RawMessage values is a latent inconsistency.
pkg/codegen/codegen.go Registers jsonMarshalExpr and jsonNewEncoder as template functions via closures that capture globalState.options.OutputOptions.JSONEncoding at generation time — matches the existing pattern for opts.
pkg/codegen/codegen_test.go New TestJSONEncodingOptions covers default, escape-html: false, indent, combined, and explicit true cases for model generation only; client and strict-server code paths with custom encoding are not exercised.
pkg/codegen/templates/additional-properties.tmpl Replaces direct json.Marshal calls with {{jsonMarshalExpr}}, including for intermediate per-field marshaling to json.RawMessage; when indent is enabled, fields with complex types will be double-encoded and embedded verbatim, causing inconsistent indentation.
pkg/codegen/templates/client.tmpl Only the JSON request body marshal is replaced with {{jsonMarshalExpr}}; path/query/header/cookie parameter marshaling is intentionally left as-is, consistent with the PR rationale.
pkg/codegen/templates/strict/strict-interface.tmpl Single json.NewEncoder call replaced with {{jsonNewEncoder "buf"}}; no other strict templates contain JSON encoding calls (verified), so coverage is complete across the strict layer.
pkg/codegen/templates/union-and-additional-properties.tmpl Same per-field and final-object jsonMarshalExpr substitution as additional-properties.tmpl; same double-encode indentation caveat applies.
pkg/codegen/templates/union.tmpl Replaces json.Marshal(v) in From* and Merge* methods and json.Marshal(object) in MarshalJSON with {{jsonMarshalExpr}}; the bytes.TrimRight in the IIFE correctly strips the trailing newline added by json.Encoder.Encode.

Reviews (1): Last reviewed commit: "refactor: move jsonMarshalExpr/jsonNewEn..." | Re-trigger Greptile

Comment on lines 329 to 437
assert.Contains(t, code, "roleName string")
}

func TestJSONEncodingOptions(t *testing.T) {
// Spec with a oneOf type (triggers union.tmpl MarshalJSON) and a type
// with additionalProperties (triggers additional-properties.tmpl MarshalJSON).
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: {}
`
loader := openapi3.NewLoader()
swagger, err := loader.LoadFromData([]byte(spec))
require.NoError(t, err)

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

tests := []struct {
name string
encoding JSONEncodingOptions
wantContain []string
wantAbsent []string
}{
{
name: "default: uses json.Marshal",
encoding: JSONEncodingOptions{},
wantContain: []string{"json.Marshal(v)"},
wantAbsent: []string{"SetEscapeHTML", "SetIndent"},
},
{
name: "escape-html false: uses encoder with SetEscapeHTML(false)",
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(false)},
wantContain: []string{"json.NewEncoder", "SetEscapeHTML(false)"},
wantAbsent: []string{"json.Marshal(v)", "SetIndent"},
},
{
name: "indent set: uses encoder with SetIndent",
encoding: JSONEncodingOptions{Indent: "\t"},
wantContain: []string{"json.NewEncoder", `SetIndent("", "\t")`},
wantAbsent: []string{"json.Marshal(v)", "SetEscapeHTML"},
},
{
name: "both options: encoder with SetEscapeHTML and SetIndent",
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(false), Indent: " "},
wantContain: []string{"json.NewEncoder", "SetEscapeHTML(false)", `SetIndent("", " ")`},
wantAbsent: []string{"json.Marshal(v)"},
},
{
name: "escape-html true explicit: same as default",
encoding: JSONEncodingOptions{EscapeHTML: boolPtr(true)},
wantContain: []string{"json.Marshal(v)"},
wantAbsent: []string{"SetEscapeHTML", "SetIndent"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := Configuration{
PackageName: "testpkg",
Generate: GenerateOptions{Models: true},
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
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.

Comment on lines 54 to 70
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 = {{jsonMarshalExpr (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 = {{jsonMarshalExpr "field"}}
if err != nil {
return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err)
}
}
return json.Marshal(object)
return {{jsonMarshalExpr "object"}}
}
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.

- introduce jsonMarshalFieldExpr that strips indentation for intermediate
  per-field marshaling in additionalProperties / union templates, preventing
  double-indentation of nested values when indent is configured
- extend TestJSONEncodingOptions with client and strict-server sub-cases to
  cover the json-encoding branches in client.tmpl and strict-interface.tmpl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant