Skip to content
Assay

Assay

HTTP bodies

Sometimes the format is not a decision you made in the code. It arrives in a header, and your endpoint has to cope with more than one.

@Schema(keys: .snakeCase, formats: .all)
struct Deployment {
@Validate(.min(1), .max(63)) var name: String
var image: String
@Validate(.min(1)) var replicas: Int
var region: String = "eu-west-1"
@Validate(.url) var healthCheck: String?
}
let deployment = try Deployment.parse(
body: request.body,
contentType: request.headers["content-type"],
accepting: [.json, .yaml]
)
Content-Type: application/yaml
accepting: [.json, .yaml]
Deployment(name: "api", image: "img:1", replicas: 3, region: "eu-west-1", healthCheck: nil)

There is a diagnose(body:contentType:accepting:) beside it with the same arguments, for when you want the issues rather than an error.

accepting: is required, and has no default

Section titled “accepting: is required, and has no default”

This is the load-bearing decision on the page, so here is what it buys.

Content-Type: application/xml
accepting: [.json, .yaml]
body.xml: error: media type application/xml is not in the accepted list
1 error

The XML body never entered an XML parser. It was not partially parsed and then rejected — negotiation happens first, and a media type outside the list stops there.

That matters because a body you did not ask for is the cheapest attack surface on a server. An XML bomb or an XXE attempt offered to a JSON endpoint should cost you one comparison, not a parse. Making the list a required argument means a server opts in to each parser a request can reach, in writing, at the call site.

unsupported_media_type is its own issue code, separate from any parse failure, so your handler can map it to a 415 rather than a 400 without inspecting the message.

The format is never guessed from the bytes

Section titled “The format is never guessed from the bytes”

There is no sniffing here. None. A missing Content-Type is an issue, an unparseable one is an issue, and a body whose bytes happen to look like JSON while the header says something else is not JSON.

The only place in the library that reads bytes to pick a decoder is property lists, where the caller has already named the format and the two flavours are encodings of it.

Structured suffixes work, which is not a nicety: most versioned APIs on the internet spell their content type that way.

Content-Type: application/vnd.acme.deploy+yaml; charset=utf-8
accepting: [.yaml]
Deployment(name: "api", image: "img:1", replicas: 3, region: "eu-west-1", healthCheck: nil)

application/vnd.acme.deploy+yaml is YAML because the +yaml suffix says so. application/vnd.github.v3+json is JSON for the same reason.

The charset parameter is checked and never transcoded. A UTF-8 charset, or no charset at all, is fine. Anything else is refused rather than misread, because the alternative is decoding Latin-1 bytes as UTF-8 and handing you mojibake that validates.

accepting: takes [WireFormat], and each format’s value lives in its own module:

value needs
.json import Assay
.yaml import AssayYAML
.xml import AssayXML
.toml import AssayTOML

That is why they are values rather than enum cases. Assay cannot depend on AssayYAML, so a closed enum listing every format would drag every parser into every build. A server that only ever writes accepting: [.json] never links a YAML parser at all.

You can write your own, too. A WireFormat is a name, a predicate over the parsed media type, and a closure that produces a RawValue:

extension WireFormat {
static let csv = WireFormat(
name: "csv",
matches: { $0.type == "text" && $0.subtype == "csv" },
decode: { bytes, sink, limits in
// your reader, producing a RawValue
MyCSV.decode(bytes, into: &sink, limits: limits)
})
}
try Report.parse(body: bytes, contentType: ct, accepting: [.json, .csv])

Everything downstream — your rules, your keys, your presence states, your errors — works unchanged, because it all runs on the far side of RawValue.

Worth saying because it would be an easy thing to lose: a .json match through this entry point is routed back onto the byte-direct JSON decoder, not through the value model that every other format uses.

Negotiating the format costs you a string comparison. It does not cost you the performance thesis.

  • Limits — the budgets behind every parser this reaches.
  • Errors — including the RFC 9457 problem-details renderer, which pairs with this rather well.