Content and Codable
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode; Vapor 4.122.2 itself needs tools
version 6.0), with MultipartKit as resolved by that Vapor release, checked on 2026-10-17. Every Swift listing on this page was
compiled in the Swift 6 language mode with zero warnings in one scratch package, and the curl output was captured from that package
running on macOS (JSON key order in the captured bodies is not guaranteed, so do not depend on it). Vapor 5 is at 5.0.0-beta.4 and is
not released yet; this page describes Vapor 4. The Swift side of the same machinery (Codable, JSONEncoder, coding keys, date
strategies) is explained in Codable & Serialization; this page is about what
Vapor adds on top: the Content protocol, the registry that maps media types to coders, and the request and response containers.
What this page covers and where it sits in the request
Vapor does not parse bodies for you in the middleware. A request travels through the middleware chain, the router matches a route, and
the route’s body strategy decides whether the body is collected into memory (the default, up to routes.defaultMaxBodySize, which is
16 kB unless you change it) or streamed. Only then does your handler run, and decoding is something the handler (or the handler
signature) asks for explicitly:
The request side is req.content (the body) and req.query (the URL query string); the response side is res.content, or simply
returning a Content value from the handler, which Vapor turns into a Response. All of them go through one registry, the global
ContentConfiguration, that maps an HTTPMediaType to an encoder or decoder. Everything else on this page is a variation on that
picture.
The Content protocol
Content is the protocol that makes a Codable type usable as a request body, a response body or a query string:
public protocol Content: Codable, RequestDecodable, ResponseEncodable,
AsyncRequestDecodable, AsyncResponseEncodable, Sendable
{
static var defaultContentType: HTTPMediaType { get }
mutating func beforeEncode() throws
mutating func afterDecode() throws
}
(The listing above is a trimmed copy of the declaration to show the requirements; the real file also carries documentation comments.)
-
Codablemakes the type encodable and decodable by anyEncoder/Decoder. A conforming type is usually a plainstructwhose stored properties are themselvesCodable. -
ResponseEncodableandAsyncResponseEncodableare what let a route closure return the value directly: Vapor callsencodeResponse(for:), which builds aResponseand runsresponse.content.encode(self). -
RequestDecodableandAsyncRequestDecodableare what let a handler take the value as a parameter instead of theRequest(func create(book: CreateBook)), in the closure anduse:overloads that accept them. -
Sendableis required, and is the reason aContentstruct in Swift 6 mode needsSendablestored properties (see Swift 6 concurrency notes). -
defaultContentTypeis the media type used when the value is encoded without an explicit one. The default implementation returns.json. -
beforeEncode()andafterDecode()are hooks with empty default implementations, covered in the hooks section.
Vapor already provides the conformance for the common standard types, so you can return them from a handler without wrapping:
| Type | Default media type | Notes |
|---|---|---|
|
|
a plain-text body |
|
|
the number written as text |
|
|
|
|
|
a JSON array |
|
|
a JSON object |
Source: Vapor docs: Content
Two practical consequences. A route that returns [BookResponse] already produces a JSON array, with no wrapper type. And a route that
returns String produces text/plain, so a String is not the way to return JSON text: return a Content value or build a Response.
Decoding a request body
The body of a request is decoded with req.content. The container offers several entry points, all defined in
ContentContainer:
| Call | Behavior |
|---|---|
|
picks the decoder from the request |
|
ignores the header and uses the decoder registered for the given media type |
|
uses a decoder you provide, once, without registering it |
|
decodes only the value at a path of keys (and array indexes) and throws if it is missing or has the wrong type |
|
the same, but returns |
|
the parsed |
Source: Vapor docs: Content
The first example is the Bookshelf create-book endpoint. CreateBook is the request DTO, BookResponse the response DTO, and the
controller is a RouteCollection:
import Vapor
/// The request body of POST /books.
struct CreateBook: Content {
var title: String
var author: String
var year: Int?
var tags: [String] = []
}
/// What the API returns for a book.
struct BookResponse: Content {
var id: UUID
var title: String
var author: String
var year: Int?
var tags: [String]
var createdAt: Date
}
struct BookEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
let books = routes.grouped("books")
books.post(use: create)
books.post("echo-title", use: echoTitle)
books.post("optional", use: optionalFields)
}
@Sendable
func create(req: Request) async throws -> Response {
let input = try req.content.decode(CreateBook.self)
let book = BookResponse(
id: UUID(), title: input.title, author: input.author,
year: input.year, tags: input.tags, createdAt: Date(timeIntervalSince1970: 1_767_225_600))
// Encode into a Response with an explicit status; Content picks application/json.
let response = Response(status: .created)
try response.content.encode(book)
response.headers.replaceOrAdd(name: .location, value: "/books/\(book.id)")
return response
}
/// Read one nested value without declaring a whole struct.
@Sendable
func echoTitle(req: Request) async throws -> String {
let title = try req.content.get(String.self, at: "book", "title")
let firstTag: String? = req.content[at: "book", "tags", 0]
return "\(title) / \(firstTag ?? "no tags")"
}
/// Show the difference between get(at:) (throws) and the subscript (nil).
@Sendable
func optionalFields(req: Request) async throws -> [String: String] {
let year: Int? = req.content["year"]
let strict = try? req.content.get(Int.self, at: "year")
return ["subscript": year.map(String.init) ?? "nil", "get": strict.map(String.init) ?? "threw"]
}
}
Source: Vapor docs: Content
The handlers are marked @Sendable because they are passed as use: function references (see the concurrency notes). With the
server running, a valid POST /books produces a 201 Created, a Location header and the encoded BookResponse:
$ curl -si -X POST localhost:8080/books -H 'Content-Type: application/json' \
-d '{"title":"Dune","author":"Frank Herbert","year":1965,"tags":["sf"]}'
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
content-length: 146
location: /books/308B420F-9485-4F29-9103-8C9B5FE7B2C9
{"id":"308B420F-9485-4F29-9103-8C9B5FE7B2C9","year":1965,"tags":["sf"],"title":"Dune","author":"Frank Herbert","createdAt":"2026-01-01T00:00:00Z"}
Three things to notice in the example:
-
The optional
yearand the defaultedtagsmake those fields optional in the JSON. A required stored property (title,author) that is missing makes decoding fail; this is ordinaryCodablebehavior. -
Response(status: .created)followed byresponse.content.encode(book)is the explicit form of returning a value with a status other than 200. The shorter form, returningbook, always yields 200 unless you wrap it in a response. -
Decoding is a synchronous, throwing call in Vapor 4. There is no
await, because the body is already in memory when the handler runs. If the body is being streamed (the route used.stream),decodefails because there is no collected body; collect it or read the stream yourself.
Reading one value without a struct: get(at:) and the subscript
get(at:) walks a path of keys and indexes, which is convenient for a single field of a larger payload or for a body whose shape you
do not want to model. The throwing form is for required values, the subscript for optional ones:
$ curl -s -X POST localhost:8080/books/echo-title -H 'Content-Type: application/json' \
-d '{"book":{"title":"Dune","tags":["sf","classic"]}}'
Dune / sf
$ curl -s -X POST localhost:8080/books/optional -H 'Content-Type: application/json' -d '{"year":1965}'
{"get":"1965","subscript":"1965"}
$ curl -s -X POST localhost:8080/books/optional -H 'Content-Type: application/json' -d '{"year":"abc"}'
{"get":"threw","subscript":"nil"}
A path element is a String (a key) or an Int (an array index), because both conform to CodingKeyRepresentable. The subscript hides
every error, including a value of the wrong type, as nil; use get when you need to tell "missing" from "malformed".
Which decoder runs, and 415
decode(_:) does not look at the type you ask for. It looks at the request Content-Type header and asks
ContentConfiguration.global for the decoder registered for that media type. The registry matches on the type and subtype, so parameters such as
charset do not matter. Out of the box these decoders are registered:
| Media type | Decoder | Typical use |
|---|---|---|
|
|
API clients, |
|
|
HTML |
|
|
forms with file uploads |
|
|
a single |
text/html has an encoder only; there is no HTML decoder. If no decoder is registered for the header, or there is no Content-Type
at all, decode throws an Abort(.unsupportedMediaType), and the default error middleware turns it into a 415 Unsupported Media Type
response. These bodies were captured from the example above:
$ curl -si -X POST localhost:8080/books -H 'Content-Type: text/xml' -d '<a/>'
HTTP/1.1 415 Unsupported Media Type
{"reason":"Support for reading media type 'text\/xml' has not been configured.","error":true}
$ curl -si -X POST localhost:8080/books
HTTP/1.1 415 Unsupported Media Type
{"reason":"Can't decode data without a content type","error":true}
A request with a JSON content type but an empty body gets a 422 Unprocessable Entity instead (the container finds no collected body).
When a failure is a DecodingError (a missing key, a wrong type, malformed JSON) the default error middleware answers 400 Bad Request
with the decoder’s message in reason, as in the common-errors table at the end of this page.
JSON
JSON is the default for everything. The decoder and encoder Vapor registers are plain JSONDecoder and JSONEncoder instances
configured with ISO-8601 dates, so a Date property round-trips as "2026-01-01T00:00:00Z" with no configuration (as in the
createdAt field above). Vapor does not change the key strategy: a property called createdAt is createdAt in the JSON. To
get created_at, either map it with CodingKeys on a type or change the key strategy for the whole application, both shown below.
URL-encoded forms and multipart forms
The same Content struct decodes from JSON, from a URL-encoded form and from a multipart form, because the decoder is chosen by the
header. ContactForm is decoded identically from all three:
import Vapor
struct ContactForm: Content {
var name: String
var email: String
var message: String
}
struct CoverUpload: Content {
var title: String
var cover: File
}
struct MediaTypeEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
let media = routes.grouped("media")
media.post("contact", use: contact)
media.on(.POST, "cover", body: .collect(maxSize: "2mb"), use: cover)
media.post("text", use: text)
media.post("force-json", use: forceJSON)
media.get("page", use: page)
}
@Sendable
func contact(req: Request) async throws -> HTTPStatus {
// Works for application/json AND x-www-form-urlencoded: the decoder is picked by Content-Type.
let form = try req.content.decode(ContactForm.self)
req.logger.info("Contact from \(form.name)")
return .accepted
}
@Sendable
func cover(req: Request) async throws -> String {
let upload = try req.content.decode(CoverUpload.self)
return "\(upload.title): \(upload.cover.filename) (\(upload.cover.data.readableBytes) bytes, \(upload.cover.contentType?.serialize() ?? "unknown"))"
}
@Sendable
func text(req: Request) async throws -> String {
let body = try req.content.decode(String.self)
return String(body.reversed())
}
@Sendable
func forceJSON(req: Request) async throws -> ContactForm {
// Ignore the Content-Type header and decode as JSON.
try req.content.decode(ContactForm.self, as: .json)
}
@Sendable
func page(req: Request) async throws -> Response {
var headers = HTTPHeaders()
headers.contentType = .html
return Response(status: .ok, headers: headers, body: .init(string: "<h1>Bookshelf</h1>"))
}
}
Source: Vapor docs: Content
$ curl -si -X POST localhost:8080/media/contact -H 'Content-Type: application/json' \
-d '{"name":"Ann","email":"a@example.invalid","message":"hi"}'
HTTP/1.1 202 Accepted
$ curl -si -X POST localhost:8080/media/contact \
-d 'name=Ann&email=a%40example.invalid&message=hello+there'
HTTP/1.1 202 Accepted
$ curl -si -X POST localhost:8080/media/contact -F name=Ann -F email=a@example.invalid -F message=hi
HTTP/1.1 202 Accepted
(curl -d sends application/x-www-form-urlencoded by default, and curl -F sends multipart/form-data.) Specifics worth knowing:
-
Returning an
HTTPStatus(here.accepted) is the shortest way to answer with a status and no body. -
Multipart bodies can carry files. A property of type
Filereceives the filename and the bytes (ByteBuffer) of the part, andFile.contentTypederives a media type from the filename extension. The upload route above raises the collected-body limit to 2 MB with.collect(maxSize: "2mb"), because the default of 16 kB is too small for most files. For large uploads, stream the body to disk instead of collecting it; the File Uploads and Streaming page covers that. -
FileisCodablewith two keys,dataandfilename, so aContenttype with aFileproperty is meant for incoming multipart forms; it is not a convenient type for a JSON response.
$ curl -s -X POST localhost:8080/media/cover -F title=Dune -F cover=@cover.png
Dune: cover.png (7 bytes, image/png)
-
A URL-encoded body can also carry arrays and nested values (
tag[]=a&tag[]=b,author[name]=Ann); the options are in the next section because the same coder reads query strings. -
decode(_:as:)forces a decoder regardless of the header.forceJSONabove decodes a body sent without aContent-Type, whichcurl -dlabels as a URL-encoded form:
$ curl -s -X POST localhost:8080/media/force-json -d '{"name":"Ann","email":"e@example.invalid","message":"m"}'
{"message":"m","email":"e@example.invalid","name":"Ann"}
Plain text
PlaintextDecoder reads the whole body as UTF-8 text and can decode a single-value type: String, a number, a Bool, or any type whose
Decodable implementation uses a single-value container. Decoding a struct (a keyed container) from text/plain fails with a
400 ("Plaintext decoding does not support dictionaries"), which is what a mismatch of header and type looks like in practice:
$ curl -s -X POST localhost:8080/media/text -H 'Content-Type: text/plain' -d 'stressed'
desserts
$ curl -si -X POST localhost:8080/media/contact -H 'Content-Type: text/plain' -d 'x'
HTTP/1.1 400 Bad Request
{"reason":"Value was not of type 'Dictionary<String, Decodable>' at path ''. Plaintext decoding does not support dictionaries.","error":true}
Encoding a response
There are three equivalent ways to produce a response body, from least to most explicit:
-
Return a
Contentvalue (or an array or dictionary of them) from the handler. Vapor encodes it with the encoder registered fordefaultContentType: JSON for a struct, plain text for aStringor number. -
Create a
Response, thentry response.content.encode(value)as in the create handler above. Use this to set a status, headers or cookies together with the body. -
Call
response.content.encode(value, as: mediaType)to choose the media type, orresponse.content.encode(value, using: encoder)to supply a one-off encoder.
The as: form is how a single type can be served in several formats. HTML is written as a string with the right header, which is
what Response does when you build it from a string body and set headers.contentType = .html:
$ curl -si localhost:8080/media/page
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
<h1>Bookshelf</h1>
(Template rendering with Leaf produces a View, which is encoded as HTML in the same way; it belongs to the Leaf Templates page.) The same ContentContainer is available on the Request as well as on a Response, so a test can build a request body with
req.content.encode(...).
The URL query string
req.query is a URLQueryContainer, the query-string twin of req.content, with the same shape: decode, get(at:), a
subscript, and encode. It always uses the URL query coders, regardless of any Content-Type header. The decoder is
URLEncodedFormDecoder, so a query decodes the same way as a URL-encoded body.
import Vapor
struct BookSearch: Content {
var q: String?
var page: Int?
var perPage: Int?
var tag: [String]?
var inStock: Bool?
var since: Date?
enum CodingKeys: String, CodingKey {
case q, page, tag, since
case perPage = "per_page"
case inStock = "in_stock"
}
}
struct SearchEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.get("search", use: search)
routes.get("search", "raw", use: raw)
routes.get("search", "encode", use: encode)
}
@Sendable
func search(req: Request) async throws -> BookSearch {
var query = try req.query.decode(BookSearch.self)
query.page = max(query.page ?? 1, 1)
return query
}
@Sendable
func raw(req: Request) async throws -> [String: String] {
let page = req.query[Int.self, at: "page"] ?? 1
let sort: String = req.query["sort"] ?? "title"
let limit = try req.query.get(Int.self, at: "limit")
return ["page": "\(page)", "sort": sort, "limit": "\(limit)"]
}
@Sendable
func encode(req: Request) async throws -> String {
var uri = URI(path: "/search")
try URLEncodedFormEncoder(configuration: .init(arrayEncoding: .values))
.encode(BookSearch(q: "swift", page: 2, tag: ["a", "b"], inStock: true), to: &uri)
return uri.string
}
}
func configureQueryCoders() {
ContentConfiguration.global.use(
urlDecoder: URLEncodedFormDecoder(configuration: .init(
boolFlags: true,
arraySeparators: [","],
dateDecodingStrategy: .iso8601)))
ContentConfiguration.global.use(
urlEncoder: URLEncodedFormEncoder(configuration: .init(
arrayEncoding: .values,
dateEncodingStrategy: .iso8601)))
}
Source: Vapor docs: Content
Captured from the example:
$ curl -s 'localhost:8080/search?q=swift&page=0&per_page=20&tag=a,b&in_stock&since=2026-01-01T00:00:00Z'
{"in_stock":true,"page":1,"tag":["a","b"],"since":"2026-01-01T00:00:00Z","q":"swift","per_page":20}
$ curl -s 'localhost:8080/search?tag=a&tag=b'
{"page":1,"tag":["a","b"]}
$ curl -s 'localhost:8080/search?page=abc'
{"reason":"Value was not of type 'Int' at path 'page'. Data found at 'page' was not Int.","error":true}
$ curl -si localhost:8080/search/raw
HTTP/1.1 400 Bad Request
{"reason":"No value found (expected type 'Int') at path 'limit'. No Int was found at 'limit'.","error":true}
$ curl -s localhost:8080/search/encode
/search?in_stock=true&page=2&tag=a&tag=b&q=swift
The decoder and encoder have options (URLEncodedFormDecoder.Configuration, URLEncodedFormEncoder.Configuration) that you set through
configureQueryCoders() above:
| Option | Default | Meaning |
|---|---|---|
decoder |
|
a bare key such as |
decoder |
comma and pipe |
|
decoder |
|
the default reads a number; |
encoder |
|
|
encoder |
|
|
Notes:
-
use(urlDecoder:)anduse(urlEncoder:)configure the query only. The URL-encoded body decoder is registered separately withuse(decoder:for: .urlEncodedForm); set both if you want one behavior everywhere. -
Query values are text.
?page=abcfails with a400whenpageis anInt, which is usually the right answer; clamp or default values after decoding (assearchdoes forpage) rather than relying on the client. -
Use a
CodingKeysenum (as inBookSearch) when the public query names differ from Swift names (per_page,in_stock). -
req.query.encode(...)writes into the request URL, which is how a test client or a redirect builds a query string; theencoderoute above builds a URI the same way with the encoder directly.
Validation of the decoded values (a page range, a maximum page size) is the job of the Validation page; decoding only checks that the types fit.
Hooks: beforeEncode and afterDecode
afterDecode() runs once after the whole value has been decoded from a body or a query, before your handler sees it. beforeEncode()
runs just before the value is encoded into a response or a request. Use them for normalization and for last-line sanitizing, not for
business rules:
import Vapor
struct NewAuthor: Content {
var name: String
var email: String
var website: String?
// Called after the whole value was decoded, before the handler sees it.
mutating func afterDecode() throws {
name = name.trimmingCharacters(in: .whitespacesAndNewlines)
email = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else {
throw Abort(.badRequest, reason: "name must not be empty")
}
if let site = website, site.isEmpty {
website = nil
}
}
// Called just before the value is encoded into a response.
mutating func beforeEncode() throws {
email = "hidden@example.invalid"
}
}
struct HookEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.post("authors", use: create)
}
@Sendable
func create(req: Request) async throws -> NewAuthor {
// decode(_:) runs afterDecode(); returning the value runs beforeEncode().
try req.content.decode(NewAuthor.self)
}
}
Source: Vapor docs: Content
$ curl -si -X POST localhost:8080/authors -H 'Content-Type: application/json' \
-d '{"name":" Ursula ","email":" UKL@Example.INVALID ","website":""}'
HTTP/1.1 200 OK
{"email":"hidden@example.invalid","name":"Ursula"}
$ curl -si -X POST localhost:8080/authors -H 'Content-Type: application/json' -d '{"name":" ","email":"x"}'
HTTP/1.1 400 Bad Request
{"reason":"name must not be empty","error":true}
Points that surprise people:
-
The hooks only run for the
Contentoverloads (decode(T.self)whereT: Content,encode(content)). They do not run when a type is decoded through the genericDecodableoverload, or byget(at:). -
Throwing from
afterDecode()fails the request. A thrownAbortkeeps its status; any other error becomes a500(aDecodingErrorbecomes400). -
Both are
mutating, which is why the value is copied once into avarby the container. -
The hooks are a good place for normalization (trimming, lowercasing) and a poor place for validation rules; declare those with
Validatable, which reports every failing field at once and keeps the rules visible next to the model.
ContentConfiguration: choosing the coders
ContentConfiguration is the registry. ContentConfiguration.global is a lock-protected static that holds the encoders, decoders and
the URL query coders, and it has the documented defaults for JSON, JSON:API, plain text, HTML (encode only), URL-encoded forms and
multipart forms. You change it by registering a coder for a media type, ideally once, in configure(_:), before the server starts:
ContentConfiguration.global.use(encoder: coder, for: mediaType)
ContentConfiguration.global.use(decoder: coder, for: mediaType)
ContentConfiguration.global.use(urlEncoder: queryEncoder)
ContentConfiguration.global.use(urlDecoder: queryDecoder)
(A schematic of the four registration calls, not a program.) A lookup of a media type with no registered coder throws the
415 Abort shown earlier.
snake_case keys and a different date format for the whole app
The most common global change is switching the JSON key style, since many clients and databases expect snake_case. Register new
coders for .json:
func configureSnakeCaseJSON() {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
ContentConfiguration.global.use(decoder: decoder, for: .json)
ContentConfiguration.global.use(encoder: encoder, for: .json)
}
Source: Vapor docs: Content
With this call in configure, the first example answers with created_at instead of createdAt, and the request body can use
created_at:
$ curl -s -X POST localhost:8080/books -H 'Content-Type: application/json' -d '{"title":"Dune","author":"F","tags":["x"]}'
{"tags":["x"],"title":"Dune","created_at":"2026-01-01T00:00:00Z","id":"7FE400BE-5245-43F9-9C0A-A5B85FAD2001","author":"F"}
For dates, JSONEncoder.custom(dates:data:keys:format:floats:userInfo:) and JSONDecoder.custom(...) are Vapor conveniences that build
a coder from the strategies you name and leave the rest at the defaults:
ContentConfiguration.global.use(
encoder: JSONEncoder.custom(dates: .millisecondsSince1970, keys: .convertToSnakeCase),
for: .json)
Because this is global, a change here affects every route, including routes in code you do not own that decode JSON and the
Fluent models you return. Decide on one convention early: either keep Swift names and map the exceptions with CodingKeys, or switch
the whole app once.
One-off coders
Changing the global registry to serve one odd endpoint is a bad trade. The container methods take a coder for a single call:
import Vapor
extension HTTPMediaType {
static let bookshelfJSON = HTTPMediaType(type: "application", subType: "vnd.bookshelf.v1+json")
}
/// A ContentEncoder/ContentDecoder pair for a JSON-based media type. The encoder stamps its own
/// Content-Type on the headers: a registered encoder, not the Content type, decides the final header.
struct JSONVariantCoder: ContentEncoder, ContentDecoder {
let mediaType: HTTPMediaType
private func makeEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
return encoder
}
private func makeDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}
func encode<E: Encodable>(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders) throws {
headers.contentType = mediaType
try body.writeBytes(makeEncoder().encode(encodable))
}
func decode<D: Decodable>(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D {
try makeDecoder().decode(D.self, from: Data(body.readableBytesView))
}
}
struct Loan: Content {
var bookTitle: String
var dueDate: Date
}
struct CustomCoderEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.get("loans", "camel", use: camel)
routes.get("loans", "snake", use: snake)
routes.post("loans", "vendor", use: vendor)
}
@Sendable
func camel(req: Request) async throws -> Response {
let loan = Loan(bookTitle: "Dune", dueDate: Date(timeIntervalSince1970: 1_767_225_600))
let response = Response()
// One-off encoder, only for this response, without touching ContentConfiguration.
try response.content.encode(loan, using: JSONEncoder.custom(dates: .iso8601, keys: .convertToSnakeCase))
response.headers.contentType = .json
return response
}
@Sendable
func snake(req: Request) async throws -> Loan {
// One-off decoder for this request only.
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return try req.content.decode(Loan.self, using: decoder)
}
@Sendable
func vendor(req: Request) async throws -> Response {
let loan = try req.content.decode(Loan.self)
let response = Response()
try response.content.encode(loan, as: .bookshelfJSON)
return response
}
}
func configureVendorCoder() {
let coder = JSONVariantCoder(mediaType: .bookshelfJSON)
ContentConfiguration.global.use(encoder: coder, for: .bookshelfJSON)
ContentConfiguration.global.use(decoder: coder, for: .bookshelfJSON)
}
The listing above also contains the custom coder discussed next. The one-off forms in action:
$ curl -s localhost:8080/loans/camel
{"due_date":"2026-01-01T00:00:00Z","book_title":"Dune"}
A custom ContentEncoder and ContentDecoder
ContentEncoder and ContentDecoder are the two protocols a coder implements to join the registry, and each has one requirement
(the userInfo variants have default implementations that forward to the plain ones):
public protocol ContentEncoder: Sendable {
func encode<E: Encodable>(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders) throws
}
public protocol ContentDecoder: Sendable {
func decode<D: Decodable>(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D
}
(Trimmed to the required methods.) The JSONVariantCoder in the previous listing implements both for a vendor media type. An encoder
writes the bytes into body and sets the Content-Type header itself, because Vapor does not do it for you; that is why the
.problemJSON example below needs its own coder instead of reusing JSONEncoder. A request whose Content-Type is the vendor type
is then decoded with the same coder:
$ curl -si -X POST localhost:8080/loans/vendor -H 'Content-Type: application/vnd.bookshelf.v1+json' \
-d '{"book_title":"Dune","due_date":"2026-01-01T00:00:00Z"}'
HTTP/1.1 200 OK
content-type: application/vnd.bookshelf.v1+json
{
"book_title" : "Dune",
"due_date" : "2026-01-01T00:00:00Z"
}
Other formats (XML, MessagePack, CSV, Protocol Buffers) follow the same pattern: implement the two protocols, register them for the
media type with use(encoder:for:) and use(decoder:for:), and encode/decode as that type.
A Content type with its own media type
defaultContentType is what a type uses when it is returned from a handler. Overriding it, for instance for the RFC 9457 Problem
Details media type application/problem+json, needs two pieces: the override, and a registered coder that stamps that header:
import Vapor
extension HTTPMediaType {
static let problemJSON = HTTPMediaType(type: "application", subType: "problem+json")
}
/// A Content type with its own default media type (RFC 9457 Problem Details).
struct ProblemDetails: Content {
static let defaultContentType = HTTPMediaType.problemJSON
var type: String
var title: String
var status: Int
var detail: String?
}
func configureProblemJSON() {
// Vapor's JSON encoder would stamp application/json, so use a coder that sets the right header.
let coder = JSONVariantCoder(mediaType: .problemJSON)
ContentConfiguration.global.use(encoder: coder, for: .problemJSON)
ContentConfiguration.global.use(decoder: coder, for: .problemJSON)
}
struct ProblemEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.get("problem", use: problem)
}
@Sendable
func problem(req: Request) async throws -> ProblemDetails {
ProblemDetails(type: "about:blank", title: "Not Found", status: 404, detail: "No such book")
}
}
Source: Vapor docs: Content
$ curl -si localhost:8080/problem
HTTP/1.1 200 OK
content-type: application/problem+json
{
"detail" : "No such book",
"status" : 404,
"title" : "Not Found",
"type" : "about:blank"
}
The pitfall that the example avoids: if you register Vapor’s own JSONEncoder for the new media type, the response still carries
content-type: application/json, because that encoder writes its own header. The status of this response is 200 only because the route
returns the value directly; wrap it in a Response to send the 404 itself. How problem bodies are produced for thrown errors belongs to the
Error Handling page.
Content negotiation: HTML or JSON from one route
HTTP lets the client say what it can consume in the Accept header, with optional quality values (q, from 0 to 1, default 1). A
browser typically asks for text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8; a script asks for
application/json. Serving both from the same URL means reading that header and choosing a representation, and answering
406 Not Acceptable when you can produce none of what was asked. Vapor parses the header for you: req.headers.accept is an array of
HTTPMediaTypePreference values (mediaType and q), and req.headers.accept.comparePreference(for:to:) compares two types (.orderedDescending means the first is preferred).
comparePreference only answers "which of these two named types is preferred". It does not rank the whole header, it does not take
the order of the entries into account, and a */* entry in the header counts for both of the types being compared (media type equality
treats a wildcard as equal to everything), so it cannot tell you that a browser prefers HTML. It also cannot tell you that neither type is
acceptable. A small explicit ranking is clearer and handles wildcards and the 406 case:
import Vapor
struct BookSummary: Content {
var id: Int
var title: String
}
enum Representation {
case html, json
}
extension Request {
/// Rank the Accept header by q (ties keep header order) and return the first type we can produce.
/// No Accept header, or a wildcard, means JSON; `nil` means we cannot satisfy the client.
var preferredRepresentation: Representation? {
let preferences = headers.accept
if preferences.isEmpty {
return .json
}
let ranked = preferences.enumerated().sorted { lhs, rhs in
let lq = lhs.element.q ?? 1.0
let rq = rhs.element.q ?? 1.0
return lq != rq ? lq > rq : lhs.offset < rhs.offset
}
for (_, preference) in ranked where (preference.q ?? 1.0) > 0 {
switch (preference.mediaType.type, preference.mediaType.subType) {
case ("text", "html"):
return .html
case ("application", "json"), ("application", "*"), ("*", "*"):
return .json
default:
continue
}
}
return nil
}
}
struct NegotiationEndpoints: RouteCollection {
private let books = [BookSummary(id: 1, title: "Dune"), BookSummary(id: 2, title: "Emma")]
func boot(routes: any RoutesBuilder) throws {
routes.get("catalog", use: catalog)
}
@Sendable
func catalog(req: Request) async throws -> Response {
guard let representation = req.preferredRepresentation else {
throw Abort(.notAcceptable, reason: "This resource is available as text/html or application/json")
}
switch representation {
case .json:
return try await books.encodeResponse(for: req)
case .html:
let items = books.map { "<li>\($0.title)</li>" }.joined()
var headers = HTTPHeaders()
headers.contentType = .html
headers.add(name: .vary, value: "Accept")
return Response(status: .ok, headers: headers, body: .init(string: "<ul>\(items)</ul>"))
}
}
}
Captured results:
| Accept header | Representation |
|---|---|
|
HTML list |
|
JSON array |
|
HTML (higher q) |
|
JSON (higher q) |
|
JSON (the default) |
|
HTML |
|
406 with the reason from the |
A response whose body depends on a request header should say so: the HTML branch adds Vary: Accept, so shared caches keep the two
representations apart. An alternative that needs no header at all is a format suffix or a separate route (/books.json, /api/books),
which many applications prefer for debuggability. Negotiation on one URL is a good fit for the HTML-and-JSON applications where a browser
and an API client use the same resources; it is overkill for a pure API.
DTOs versus models
A Content struct is a wire format, and the wire format and the storage model are different things. The classic mistake, repeated in many
tutorials, is to make the database model Content and return it straight from the handler. The model usually holds fields the client
must never see (the password hash, internal flags, soft-delete markers) and fields the client must never set (the identifier, the role).
Encoding the model hands the first group to every caller; decoding it into a model lets a client set the second group
("mass assignment").
The fix is two small structs per use case: one for what a client may send, one for what it may see, and an explicit mapping between them and the model.
import Vapor
/// An internal model: never returned to clients.
struct Account: Sendable {
var id: UUID
var email: String
var passwordHash: String
var isAdmin: Bool
}
/// The request DTO: only what a client may send.
struct RegisterRequest: Content {
var email: String
var password: String
}
/// The response DTO: only what a client may see.
struct AccountResponse: Content {
var id: UUID
var email: String
init(_ account: Account) {
id = account.id
email = account.email
}
}
struct AccountEndpoints: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.post("accounts", use: register)
}
@Sendable
func register(req: Request) async throws -> AccountResponse {
let input = try req.content.decode(RegisterRequest.self)
// Placeholder: a real service hashes with req.password.async.hash(_:).
let account = Account(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
email: input.email, passwordHash: "$placeholder$", isAdmin: false)
return AccountResponse(account)
}
}
Source: Vapor docs: Content
$ curl -s -X POST localhost:8080/accounts -H 'Content-Type: application/json' \
-d '{"email":"a@example.invalid","password":"placeholder-pw"}'
{"email":"a@example.invalid","id":"00000000-0000-0000-0000-000000000001"}
The Account is not Content, so the compiler stops anyone from returning it by accident, and AccountResponse lists exactly the
fields that may leave the server. Advantages beyond secrecy: the API can evolve independently of the schema, the request DTO
can omit server-assigned fields, and validation attaches to the request DTO. The same reasoning applies to Fluent models, which are often declared Content for convenience; the Fluent Models page shows
the mapping. The wider question of how DTOs are versioned and shaped for a public API is on API Design, Versioning and DTOs, and sharing the same DTOs with an iOS client in a Swift package is on Full-Stack Swift with iOS Clients.
Two cautions. Make the response DTO an allow-list by construction (an initializer that copies named fields, as above) rather than
a copy of the model with fields deleted, so a newly added model field is private until someone exposes it. And never put a password or
password hash in any Content type that is logged: req.logger output and error reports can include decoded values.
Swift 6 concurrency notes
-
ContentrefinesSendable. AstructofSendableproperties (strings, numbers,UUID,Date, arrays and optionals of those, otherSendablestructs) is implicitlySendableand needs no annotation. AclassDTO must befinalwith immutable stored properties, or you get the usual Swift 6 diagnostics; prefer structs. -
FileisSendable, so aContentwith aFileproperty staysSendable. -
A route handler passed as a
use:function reference must be@Sendablein Swift 6 mode (a method of aSendablestruct conforming toRouteCollectionis marked@Sendableas in the examples); a closure passed inline is inferred. -
ContentConfiguration.globalis guarded by a lock, so mutating it is not a data race, but the value semantics make it a global that every request reads. Configure it inconfigure(_:)before the server accepts connections, and treat it as read-only afterwards. -
ContentEncoderandContentDecoderrefineSendable. A custom coder should be a struct of immutable values. The example builds a freshJSONEncoderper call, because that avoids sharing a mutable reference type across threads; Vapor itself registers sharedJSONEncoderinstances, which is safe as long as nobody changes their properties after registration. -
decodeandencodeare synchronous; they do not suspend, so decoding does not by itself make a handler needawait.
Common errors and pitfalls
| Symptom | Status | Cause and fix |
|---|---|---|
|
415 |
the |
|
415 |
no |
empty body with a JSON header |
422 |
the container found no collected body; also what a streamed body produces |
|
400 |
a required property is missing from the JSON |
|
400 |
the same for a form or query, where the key is absent |
|
400 |
the JSON has the wrong type for the property |
|
400 |
malformed JSON text |
|
400 |
the query decoder default reads dates as seconds since 1970, or the string is not full ISO-8601 |
|
400 |
a |
JSON field names do not match ( |
200 with missing data, or 400 |
the key strategy is the default; add |
the handler never sees the normalized value |
none |
the hooks only run for |
a returned |
200 |
|
upload larger than 16 kB fails |
413 |
the default collected-body limit; raise it with |
The 400 bodies above are the default JSON error shape, {"error":true,"reason":"..."}, produced by the error middleware; customizing
that shape is covered by the Error Handling page. Decoder messages can echo parts of the input and the structure of
your types; consider whether you want clients to see them in production.
Version notes and outdated material
-
Vapor 3 and the books written for it returned
EventLoopFuturevalues fromreq.content.decodeandreq.content.get, so code wasreq.content.decode(User.self).flatMap { ... }. In Vapor 4 these are synchronous and throwing. Rewrite.decode(User.self).mapchains aslet user = try req.content.decode(User.self). -
Vapor 3 registered coders through a
ContentConfigservice inconfigure(_:_:_:). Vapor 4 uses the staticContentConfiguration.globalshown above. -
Older tutorials make every model
Contentand return it directly, including models that carry a password hash; use the DTO pattern above, whatever an older example does. -
Content negotiation between HTML and JSON on the same routes, as used in server-rendered Vapor applications, still works as above; prefer the explicit ranking over
comparePreferencefor browserAcceptheaders. -
Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released, and may change these APIs. This page is written for Vapor 4.122.x; check the Vapor 5 release notes before moving (What’s New, Upgrading and Vapor 5).
References
-
Vapor API documentation (search for
Content,ContentConfiguration,ContentContainerandHTTPMediaTypePreference)