JSON
Muzak reads and writes JSON with encoding/json/v2. The defaults are strict, because a
request that is nearly right is a bug somewhere, and finding it in a 422 beats finding it
in a support ticket.
Decoding a request
A field with no location tag is a member of the JSON body.
type ItemCreateIn struct {
ID string `json:"id" doc:"The identifier to create the item under"`
Name string `json:"name" doc:"The item's display name"`
Async bool `json:"async,omitzero" doc:"Queue the work instead of doing it inline"`
}
What the decoder refuses:
| Refused | Why |
|---|---|
| An unknown member | A client's typo becomes an immediate 422 instead of a value that silently disappears |
| A duplicate member | Two values for one field means the client and the server disagree about which one won |
| Invalid UTF-8 | A string that is not text is not a string |
| A member of the wrong type | "age": "42" is not "age": 42. A value of the right kind that does not fit, 300 for an int8 or 1.5 for an int, is described as a parameter of the same type would be |
| A member naming a located field | A path, query, header or cookie field is never a body member, so naming one is an unknown member, 422 |
| An empty body, when the route binds one | Reported as {"field": "", "location": "body", "issue": "is required"} |
| A media type that is not JSON | 415, unless the type is application/json or ends in +json |
A body with no Content-Type | 415. Accepting an untyped body let any page drive a JSON route cross-site without a CORS preflight, by posting an untyped Blob. A request with no body and no Content-Type is unaffected |
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "nmae", "location": "body", "issue": "is not a field this endpoint accepts" },
{ "field": "age", "location": "body", "issue": "has the wrong type, a string is not accepted here" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
The issue follows the field's type, with the same wording and translation keys a parameter
uses: 300 for an int8 reads must be between -128 and 127, and a time that does not parse
reads is not in the expected format. Only a value of a kind the field cannot hold at all, a
string for a number, reads has the wrong type. The field is the member's full path,
addr.zip for a member of an object and items[1].zip for one inside an array, as a
validation failure names it. Every one of these messages, is not a field this endpoint accepts and is not valid JSON included, can be translated.
The Go type behind a failure is deliberately dropped from the message. Naming server-side types in a response tells a client more about the implementation than it needs to know.
A body type encoding/json/v2 cannot decode into, whatever a request sends, is a build error
naming the field or type at fault: an embedded struct tagged json:",omitzero", a format:
option, two fields claiming one name, a channel, function, complex or non-empty interface
field, or the string option on a field that is not a number. Such a type used to build, and
every request that reached it got a 422 blaming the client, with nothing logged. A failure of
the same kind that can only show up while a request is decoded, such as an embedded pointer to
an unexported struct, is a 500 with the reason logged. Fix the tag or the type the error
names, or tag the field json:"-".
A self-referential collection type, such as type Tree map[string]Tree, is accepted as a body
field and described once. Registering one used to hang or overflow the stack. As a query, header,
cookie or form parameter it is refused with an error naming the field.
Accepting unknown members
r.Post("/webhooks/stripe", handlers.StripeWebhook, muzak.AllowUnknownFields())
Reach for it where forward compatibility with a client that sends extra members matters more than catching typos, which is what a third-party webhook receiver looks like. It is a shared option, so a whole router can carry it. Duplicate members and invalid UTF-8 stay rejected either way.
A body next to a path parameter
type ItemRenameIn struct {
ID string `path:"item_id" doc:"The item to rename"`
Name string `json:"name" doc:"The item's new display name"`
}
The body can only ever reach Name. When an input has any located field, the body is decoded
into a struct of the body members alone, built when the route is registered, and those are
copied into the input. A request carrying {"ID": "somebody-elses-item"} cannot overwrite the
path parameter, and the ID member is an unknown member like any other: a 422, rather than a
value silently dropped, or ignored under AllowUnknownFields.
Encoding a response
The handler's return value is the body, serialized as it stands.
type ItemOut struct {
ID string `json:"id" doc:"The item's identifier"`
Name string `json:"name" doc:"The item's display name"`
Owner string `json:"owner,omitzero" doc:"The user the item belongs to"`
}
{"id":"foo","name":"Foo"}
Content-Type: application/json; charset=utf-8 and Content-Length are set for you. The
body is encoded fully into a pooled buffer before anything is written, so a failure part
way through encoding still produces a clean error response instead of a truncated body.
The tags that matter
| Tag | Effect |
|---|---|
json:"name" | The member name. Without a tag, the Go field name is used |
json:"name,omitzero" | Omit the member when the value is the zero value of its type |
json:"-" | Never encoded, and never a body member on the way in. On a field with a location tag it only keeps the field out of the body and the schema; its header or query value, default and required check still apply |
doc:"..." | The member's description in the generated schema |
default:"..." | The value used when a parameter, or a top-level member of the body, is absent, and its default in the generated schema, written as the JSON type of the member (10, not "10") |
omitzero is what keeps a response clean without a pointer:
Owner string `json:"owner,omitzero"`
if trimmed {
// The client asked for less, so the summary is left out. The field is
// omitzero, so it disappears from the response entirely.
entry.Summary = ""
}
It does not decide what a request must contain. The decoder accepts a body that leaves any
member out, so the generated schema lists a request member as required only when a
Required() rule refuses a body without it (and it has no default). The response side keeps
the shape: a member that is not a pointer and not omitzero or omitempty is always written.
See OpenAPI.
Types on the wire
| Go type | JSON |
|---|---|
time.Time | An RFC 3339 string, documented as "format": "date-time" |
uuid.UUID | A string, documented as "format": "uuid" |
time.Duration | a string such as "1500ms", both ways, documented as a string with a pattern |
a type implementing encoding.TextMarshaler and encoding.TextUnmarshaler | a string, both ways |
| a map | an object |
| a slice | an array |
| a pointer | the value, or null |
| an interface | whatever it holds, documented as accepting any JSON value |
A type that converts to and from text is a string everywhere: the binder parses it with
UnmarshalText in a parameter, the encoder writes it with MarshalText in a body, and the
schema calls it a string rather than describing the Go struct behind it.
type Currency string
type PriceOut struct {
Amount int64 `json:"amount_minor"`
Currency Currency `json:"currency"`
}
Response models are not storage models
// Item is one stored item. It is the store's own type, deliberately separate
// from the response models in the schemas package: a field added here does not
// become visible to clients until a handler puts it on a response type.
type Item struct {
ID string
Name string
}
Constructing the output is more typing than returning the stored record. In exchange, a column added to that record tomorrow cannot appear in a response, because it is not part of the response type, and the compiler is what tells you rather than a bug report.
Nesting
Named structs become named components in the document and are referenced by name, so a type used by several operations is described once.
type FeedEntry struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
Summary string `json:"summary,omitzero"`
}
type FeedOut struct {
Reader string `json:"reader"`
Trimmed bool `json:"trimmed"`
Tracked bool `json:"tracked"`
Entries []FeedEntry `json:"entries"`
UpdatedAt time.Time `json:"updated_at"`
}
Embedding promotes members the way JSON encoding does, so a shared group of fields is declared once and appears inline on every model that embeds it.
When the body is not JSON
- Return
muzak.HTMLfor a page. See Forms and HTML. - Return
muzak.Emptywithmuzak.Status(http.StatusNoContent)for no body at all. - Return
muzak.Bytes,muzak.Streamormuzak.FileResponsefor a file, an image or a download, andmuzak.Redirectto send the client elsewhere. See Files, Streams and Redirects. - Take
ctx.ResponseWriter()for anything else Muzak should not encode. See Responses. - Reach for Server-Sent Events for a body that never ends.
Deterministic output for the document
Document.Marshal renders the OpenAPI document with map keys in sorted order, so the
output is byte-for-byte reproducible. That is what lets a generated document be committed
and diffed. Ordinary responses are not sorted; a Go struct already has a field order, and
that order is what goes out.
Where to go next
Validation covers checking a decoded body, and Compression covers making a large one smaller.