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
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, 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

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
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 typeJSON
time.TimeAn RFC 3339 string, documented as "format": "date-time"
uuid.UUIDA string, documented as "format": "uuid"
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.
  • 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.