You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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:

RefusedWhy
An unknown memberA client's typo becomes an immediate 422 instead of a value that silently disappears
A duplicate memberTwo values for one field means the client and the server disagree about which one won
Invalid UTF-8A 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 fieldA 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 oneReported as {"field": "", "location": "body", "issue": "is required"}
A media type that is not JSON415, unless the type is application/json or ends in +json
A body with no Content-Type415. 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

TagEffect
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 typeJSON
time.TimeAn RFC 3339 string, documented as "format": "date-time"
uuid.UUIDA string, documented as "format": "uuid"
time.Durationa string such as "1500ms", both ways, documented as a string with a pattern
a type implementing encoding.TextMarshaler and encoding.TextUnmarshalera string, both ways
a mapan object
a slicean array
a pointerthe value, or null
an interfacewhatever 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.HTML for a page. See Forms and HTML.
  • Return muzak.Empty with muzak.Status(http.StatusNoContent) for no body at all.
  • Return muzak.Bytes, muzak.Stream or muzak.FileResponse for a file, an image or a download, and muzak.Redirect to 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.