feat: add json-encoding output option for controlling JSON encoder settings#2371
feat: add json-encoding output option for controlling JSON encoder settings#2371CRaLFa wants to merge 5 commits into
Conversation
…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>
Greptile SummaryAdds a new opt-in
Confidence Score: 4/5Safe 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)
|
| 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
| 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 |
There was a problem hiding this comment.
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.
| 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"}} | ||
| } |
There was a problem hiding this comment.
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>
Summary
Add
output-options.json-encodingconfiguration block to control JSON encoding behavior in generated code.escape-html(bool, defaulttrue): maps tojson.Encoder.SetEscapeHTML. When set tofalse, HTML special characters (<,>,&) are output as-is instead of being escaped to Unicode sequences.indent-prefix(string, default""): maps to the first argument ofjson.Encoder.SetIndent.indent(string, default""): maps to the second argument ofjson.Encoder.SetIndent. When non-empty, indented output is produced.Example config
Affected generated code
MarshalJSON()in union typesjson.Marshal(v)→ encoder with configured settingsMarshalJSON()in types withadditionalPropertiesMarshalJSON()in union +additionalPropertiestypesjson.Marshal(body)→ encoder with configured settingsVisit*Response()in strict serverjson.NewEncoder(&buf)→ encoder with configured settingsWhen no options are set (default),
json.Marshal/json.NewEncoderare emitted as before — fully backward-compatible.Parameter serialization (path, query, header, cookie) is intentionally excluded: applying
SetIndentthere would embed whitespace into URL/header values and break requests.Test plan
TestJSONEncodingOptionscovers: default,escape-html: false,indent, both options combined, and explicitescape-html: truego/formatvalidation in all cases🤖 Generated with Claude Code