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/yamlaccepting: [.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/xmlaccepting: [.json, .yaml]body.xml: error: media type application/xml is not in the accepted list
1 errorThe 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.
RFC 9110 and 6839, properly
Section titled “RFC 9110 and 6839, properly”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-8accepting: [.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.
The formats are values
Section titled “The formats are values”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.
JSON keeps its fast path
Section titled “JSON keeps its fast path”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.