Skip to content

feat: add process-wide JSON codec - #183

Merged
mromaszewicz merged 3 commits into
oapi-codegen:mainfrom
CRaLFa:add-json-codec
Nov 10, 2026
Merged

mromaszewicz merged 3 commits into
oapi-codegen:mainfrom
CRaLFa:add-json-codec

Conversation

@CRaLFa

@CRaLFa CRaLFa commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Implements step 1 of the plan in oapi-codegen/oapi-codegen#2371 (comment): a single process-wide JSON codec that this package and generated code share.

What this adds

  • JSONCodec, StdJSONCodec, SetJSONCodec, JSONMarshal and JSONUnmarshal, with the signatures proposed in that comment.
  • The json.Marshal / json.Unmarshal calls in bindform.go, bindjson.go, bindparam.go and deepobject.go now go through the codec.
  • The MarshalJSON / UnmarshalJSON methods in types/ (Date, Duration, Email, File) now go through the codec.

Behaviour is unchanged unless SetJSONCodec is called.

Differences from the proposal

The codec lives in internal/jsoncodec.
runtime imports runtime/types, so types cannot call runtime.JSONMarshal without an import cycle.
The public API in runtime is as proposed; it delegates to the internal package, and types calls the internal package directly.

json.NewDecoder call sites are left on encoding/json.
The codec has only Marshal and Unmarshal, so these do not map onto it directly:

  • BindJSONBody (bindjson.go) decodes the request body with json.NewDecoder(r.Body).Decode(v). Switching it to io.ReadAll + JSONUnmarshal would change its documented error behaviour (io.EOF for an empty body) and how trailing data is handled.
  • MarshalDeepObject and StyleParamWithOptions decode into interface{} with UseNumber() to walk the value generically.

BindJSONBody is the main path for request bodies, so a custom codec does not apply there yet.
I left it alone to keep this PR free of behaviour changes, but I'm happy to switch it here or in a follow-up if you'd prefer. Let me know which.

jsonScalarFromString (deepobject.go) still uses json.Unmarshal.
It only tests whether a string is a JSON number, and does not encode or decode user values.

Testing

TestSetJSONCodec covers replacing the codec, restoring the default with nil, and that both runtime internals and runtime/types reach the replaced codec.

🤖 Generated with Claude Code

Add a JSONCodec that generated code and this package share, so the JSON implementation can be replaced in one place.

- Add JSONCodec, StdJSONCodec, SetJSONCodec, JSONMarshal and JSONUnmarshal.
- Route the json.Marshal and json.Unmarshal calls in bindform.go, bindjson.go, bindparam.go and deepobject.go through the codec.
- Route the MarshalJSON and UnmarshalJSON methods in types/ through the codec.
- Keep the codec in internal/jsoncodec, because runtime imports types and types cannot import runtime.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
@mromaszewicz

Copy link
Copy Markdown
Member

@CRaLFa thanks, this is a clean implementation and the internal/jsoncodec split is the right answer to the import cycle. I'm going to push a follow-up commit onto your branch rather than ask you for another round, since the changes below came out of a longer design discussion on our side. Here's what that commit will do and why, so the record is on the PR.

1. Re-export the internal package instead of duplicating it

The interface is declared twice (jsoncodec.Codec and runtime.JSONCodec) and the default is implemented twice (the nil branch in jsoncodec.Marshal/Unmarshal and runtime.StdJSONCodec). We'd like to avoid that kind of copy/paste where we can, so the default moves into the internal package and the top-level file becomes aliases plus one-line wrappers:

type JSONCodec = jsoncodec.Codec
type StdJSONCodec = jsoncodec.Std

func SetJSONCodec(c JSONCodec)                { jsoncodec.Set(c) }
func JSONMarshal(v any) ([]byte, error)      { return jsoncodec.Marshal(v) }
func JSONUnmarshal(data []byte, v any) error { return jsoncodec.Unmarshal(data, v) }

Godoc won't expand the method set under an alias to an internal type, so the doc comments on the aliases will spell the methods out. We're fine with that trade.

2. Add an optional streaming interface

Bodies are where large payloads live, and a byte-slice-only codec forecloses streaming for them forever. Adding methods to JSONCodec later would break every implementer, so this has to be decided now. Rather than widen JSONCodec, we'll add a second, optional interface:

// StreamingJSONCodec is optionally implemented by a JSONCodec. Body sites use it
// when present and otherwise buffer through Marshal and Unmarshal.
type StreamingJSONCodec interface {
	MarshalWrite(w io.Writer, v any) error
	UnmarshalRead(r io.Reader, v any) error
}

func JSONMarshalWrite(w io.Writer, v any) error   // uses MarshalWrite if implemented, else Marshal + w.Write
func JSONUnmarshalRead(r io.Reader, v any) error  // uses UnmarshalRead if implemented, else io.ReadAll + Unmarshal

Single SetJSONCodec registration, so a codec that implements only the byte pair behaves coherently everywhere, and one that also streams is picked up automatically. These are the same four method names as encoding/json/v2, so a v2 codec maps one-to-one.

One subtlety: the exported, embeddable StdJSONCodec will implement only the byte pair. If it also carried the stream methods, someone embedding it and overriding Marshal to disable HTML escaping would find strict server responses still escaping, because the promoted MarshalWrite would win. The runtime's actual default is an unexported type that embeds StdJSONCodec and adds the v1 Encoder/Decoder methods, so users who never call SetJSONCodec keep byte-identical behaviour. SetJSONCodec(nil) restores that default.

3. BindJSONBody switches to the codec

Our generated strict stdlib, chi and gorilla handlers call BindJSONBody for every JSON request body, so a registered codec should apply there. It will call JSONUnmarshalRead(r.Body, v). With the default codec that is exactly today's Decoder.Decode, so no behaviour change for anyone who hasn't opted in.

Two details:

  • The generated handlers detect an absent optional body via errors.Is(err, io.EOF). json.Unmarshal and v2 both return something else for empty input, so BindJSONBody will peek for the first non-whitespace byte and return io.EOF itself before the codec is involved. That keeps the sentinel codec-independent.
  • Whether trailing data after the body is rejected becomes codec-dependent: the default Decoder tolerates it, the buffered fall-through's Unmarshal rejects it, v2 has its own rule. Generated code relies on neither; the doc comment will say so.

The MarshalDeepObject / StyleParamWithOptions decoders and jsonScalarFromString stay on encoding/json as you have them; they decode intermediate values and don't touch the wire.

What stays as you wrote it

Everything in types/ going through the codec, the switched call sites in bindform.go, bindjson.go, bindparam.go and deepobject.go, and TestSetJSONCodec. I'll extend the test to cover the streaming fall-through and the io.EOF guard.

Once this is merged and released, your codegen follow-up can use runtime.JSONMarshalWrite for the strict response buffer and runtime.JSONMarshal / runtime.JSONUnmarshal everywhere else.

…ckage

Follow-up to the process-wide JSON codec, per the review on oapi-codegen#183.

Re-export rather than duplicate: the internal/jsoncodec package now also
holds the default implementation, and the top-level file is type aliases
and one-line wrappers, so neither the interface nor the default is
defined twice.

Add StreamingJSONCodec, optionally implemented by a JSONCodec, with
MarshalWrite(io.Writer, any) and UnmarshalRead(io.Reader, any), and the
helpers JSONMarshalWrite and JSONUnmarshalRead that use those methods
when present and otherwise buffer through Marshal and Unmarshal. One
SetJSONCodec registration covers both, so a codec that implements only
the byte pair behaves coherently everywhere, and one that also streams
is picked up automatically. The method names are encoding/json/v2's.

StdJSONCodec implements only the byte pair. A codec that embeds it and
overrides Marshal must not have encoding/json's Encoder promoted past
that override for bodies. The default codec, which SetJSONCodec(nil)
restores, is unexported and adds encoding/json's Encoder and Decoder,
so behaviour without SetJSONCodec is unchanged.

BindJSONBody decodes through JSONUnmarshalRead. Generated handlers take
io.EOF as an optional body left out, and neither json.Unmarshal nor v2
reports empty input that way, so BindJSONBody checks for an empty or
white-space-only body itself and returns io.EOF before the codec is
involved.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Review follow-ups to the streaming codec.

JSONUnmarshalRead, not only BindJSONBody, now returns io.EOF when its
input is nil, empty or only JSON white space, whichever codec is in use
and without involving it. The helper is exported for generated code, and
a handler that checks errors.Is(err, io.EOF) must not depend on how the
registered codec reports an empty input: encoding/json's Decoder says
io.EOF, json.Unmarshal says "unexpected end of JSON input", and v2 says
something else again. The bytes read to find the first non-white-space
byte are replayed in front of the reader, so the codec sees the input
unchanged and a syntax error's offset is what it was before.

SetJSONCodec(StdJSONCodec{}) restores the default, as nil does. StdJSONCodec
is the default's byte half, and passing it reads as "encoding/json", not
as a request for the buffered stream path with its missing trailing
newline and trailing-data rejection. The StdJSONCodec doc now spells out
those two differences for codecs that embed it.

Document the codec contract: it must be re-entrant, since the MarshalJSON
methods in runtime/types and in generated union and additionalProperties
types call back into it from inside its own Marshal; SetJSONCodec is for a
program's main package, being process-wide and unsynchronised; and the
codec doesn't reach JSON a web framework's binder or JSON method handles,
nor a validated body bound without decoding it again.

Fix two stale comments, and test a read error, the empty-input contract
under nil, byte-only and streaming codecs, and the default path with
leading white space.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@mromaszewicz
mromaszewicz enabled auto-merge (squash) November 10, 2026 18:49
@mromaszewicz
mromaszewicz merged commit 2de32b1 into oapi-codegen:main Nov 10, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants