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 |
| 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, ends in +json, or is absent |
{
"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 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.
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 scratch value of the same type and only the body-bound fields are copied
out, so a request carrying {"id": "somebody-elses-item"} cannot overwrite the path
parameter.
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 |
doc:"..." | The member's description in the generated schema |
default:"..." | The member's default in the generated schema, and the value used when a parameter is absent |
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 also decides requiredness in the generated schema: a member is required unless it is a
pointer, carries omitzero or omitempty, carries a default, or is tagged
required:"false".
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" |
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. - Take
ctx.ResponseWriter()for a file download or 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.