Requests and Responses
|
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 (the latest 4.x release on 2026-10-17) on Swift 6.4 in the Swift 6 language
mode
(swift-tools-version:6.3, the version of the current vapor/template; Vapor itself needs tools 6.0), with SwiftNIO
2.104.0.
Vapor 5 is at 5.0.0-beta.4 and is not generally available; its changes to Request and Response are summarised in the
last
sections. Every example below was compiled in a scratch package with those versions pinned, with no warnings, and the
HTTP
examples were run against a live server with curl (the output shown is real, trimmed for the date and connection
headers) and covered by Swift Testing tests that use VaporTesting.
Every Vapor handler receives one Request and produces one Response. Everything else in the framework
(routing, Content
decoding, validation, middleware,
authentication, Fluent) is
machinery for building the second from the first. This page
covers the two types themselves, with every property, initialiser and helper that matters in day-to-day work:
-
What HTTP guarantees and what it leaves open, so that handlers return the right status code and headers.
-
How
Requestexposes the method, URL, headers, body, cookies, client address and identity of the exchange. -
How a request body is collected or streamed, and how the size limit works.
-
How a
Responseis built: status, headers, body (buffer, string, stream), cookies. -
How handlers return things that are not a
Response:HTTPStatus,String,Contenttypes and your ownAsyncResponseEncodabletypes. -
Redirects, caching headers, downloads, pagination links, compression and HTTP/2.
Decoding bodies and query strings into Swift types (req.content, req.query) is a topic of its own
(Content and Codable and Validation);
this page only shows
the two lines needed to use it. The language features used here are explained in the Swift Reference, for example
Async/await and tasks,
Actors, isolation and Sendable and
Codable and serialization. For the Vapor application
that
these handlers live in see Getting started and
Project structure and SwiftPM targets; for the threading model
behind async handlers see Swift concurrency in Vapor.
A refresher on HTTP
HTTP is a request/response protocol: a client sends one request, the server sends one response. HTTP semantics (what the methods, status codes and headers mean) are defined by RFC 9110; the wire formats are RFC 9112 (HTTP/1.1) and RFC 9113 (HTTP/2); caching is RFC 9111. A request and its response on the wire (HTTP/1.1) look like this:
POST /books HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
Content-Length: 44
{"title":"Emma","author":"Jane Austen"}
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /books/4
Content-Length: 46
{"author":"Jane Austen","id":4,"title":"Emma"}
The first line of the request is the request line (method, target, version); the first line of the response is the
status line (version, status code, reason phrase). Then come header fields, an empty line and an optional body. Vapor
parses the request into a Request before your code runs and serialises the Response you return.
Methods: safe, idempotent, cacheable
The method states the intent of the request. Two properties decide how clients, proxies and retry logic treat it: a safe method does not change server state (so crawlers and prefetchers may send it), and an idempotent method has the same effect when sent once or several times (so a client may retry it after a timeout).
| Method | Typical use | Safe | Idempotent | Request body |
|---|---|---|---|---|
|
read a resource or a collection |
yes |
yes |
none |
|
like |
yes |
yes |
none |
|
create a resource, run an action, submit a form |
no |
no |
yes |
|
replace a resource at a known URL |
no |
yes |
yes |
|
partially update a resource |
no |
no (usually) |
yes |
|
remove a resource |
no |
yes |
rare |
|
ask which methods and headers are allowed (CORS preflight) |
yes |
yes |
rare |
In Vapor each method is a HTTPMethod value (.GET, .POST, .PUT, .PATCH, .DELETE, …) and a route is
registered
per method (app.get, app.post, …). A few behaviours are worth knowing before they surprise you. A HEAD request
is
answered by the GET route for the same path, and Vapor drops the body (checked against the example server: curl -I
on a
GET and POST route returns 200 with the headers and no body). OPTIONS is not answered automatically: without
CORSMiddleware a preflight request to a
route that has no OPTIONS handler gets 404. A path that exists but not for that method also gets 404 in Vapor, not
405 Method Not Allowed.
Status codes
The status code tells the client what happened, grouped by the first digit. The constants below are HTTPStatus values
(a typealias for SwiftNIO’s HTTPResponseStatus).
| Class | Meaning | Codes you will return from a Vapor API |
|---|---|---|
1xx |
informational |
|
2xx |
success |
|
3xx |
redirection |
|
4xx |
the client is wrong |
|
5xx |
the server is wrong |
|
Two rules save most API review comments: use 201 plus a Location header for creation (not 200), and distinguish
401
("I do not know who you are") from 403 ("I know who you are, and no"). Error responses and the Abort type are
covered
on the Error Handling page; this page uses only throw Abort(.status, reason:).
Headers
Header fields are name/value pairs; names are case-insensitive and a field may repeat. The ones that control the exchange:
-
Content negotiation:
Accept(what the client can read),Content-Type(what the body is),Content-Language,Accept-EncodingandContent-Encoding(compression). -
Body framing:
Content-Length(exact size) orTransfer-Encoding: chunked(size unknown in advance, a stream). -
Caching and conditional requests:
Cache-Control,ETag,Last-Modified,Expires,If-None-Match,If-Modified-Since. -
Identity and security:
Authorization,Cookie,Set-Cookie,WWW-Authenticate. -
Navigation:
Location(where a created or moved resource is),Link(related resources such as the next page). -
Proxies:
ForwardedandX-Forwarded-For(the original client address),Host.
HTTP/1.1 and HTTP/2
HTTP/2 keeps the same methods, status codes and headers but changes the framing: one connection carries many concurrent
streams (multiplexing, no head-of-line blocking at the HTTP layer), header fields are compressed, and browsers only
speak it
over TLS. Your handlers do not change. In Vapor, app.http.server.configuration.supportVersions defaults to [.one]
when no
TLS is configured and [.one, .two] when tlsConfiguration is set (HTTP/2 is negotiated with ALPN); cleartext HTTP/2
(h2c) is not supported by the server in 4.122.2 (the source calls fatalError on that configuration). In production
TLS is
usually terminated by a reverse proxy, which is why req.peerAddress (below) exists.
Where requests and responses sit in the lifecycle
The server decodes bytes into a Request, passes it through the middleware you registered (in registration order, so
ErrorMiddleware is outermost), then through the router, which picks a route, fills req.parameters, sets req.route
and
applies the route’s body strategy. Your handler returns something; Vapor turns it into a Response with
encodeResponse(for:); the response travels back out through the same middleware in reverse, and the server serialises
it.
Two consequences follow. First, anything a middleware changes on the Request (headers, req.storage, req.logger
metadata) is visible to the handler, and anything it changes on the Response is visible to the client. Second, the
body is
read from the socket after the headers: by default the router collects it before calling the handler, but a route can
ask
for a stream instead, which changes what req.body contains (see Request bodies).
The Request type
Request is a final class and Sendable: its mutable state sits behind locks, so a handler can capture req in
child
tasks and @Sendable closures. Because it is a reference type, req.content, req.query and req.body are cheap
views
onto the same object. The table lists the stored and computed properties used in day-to-day work.
| Property | Type | Meaning |
|---|---|---|
|
|
|
|
|
Request target: |
|
|
|
|
|
The request header fields (SwiftNIO’s |
|
|
Collected |
|
|
Parsed |
|
|
Captured path components: |
|
|
Decodes |
|
|
Decodes the body (JSON, form, multipart) and encodes content onto the request (used by the test client). |
|
|
Value of the |
|
|
The address of the TCP peer as seen by SwiftNIO (the proxy, behind a reverse proxy). |
|
|
Best-effort client address: the |
|
|
Client TLS certificate chain, when mutual TLS is configured with |
|
|
The matched route; |
|
|
A swift-log logger whose metadata already contains |
|
|
Request-scoped context for distributed tracing. |
|
|
The owning application (services, configuration, |
|
|
The event loop that owns the connection. Needed only with the legacy futures API. |
|
|
Allocator used for buffers created for this request. |
|
|
Request-local key/value store keyed by |
The initialiser is public: Request(application:method:url:headers:collectedBody:on:). It is used by tests and by code
that
needs to call a handler directly; production requests are created by the server.
Inspecting a request
The first example builds a RequestSummary that reports each of the properties above. The BookDTO and Library types
are the small in-memory Bookshelf data set used by all examples on this page.
import Vapor
struct BookDTO: Content {
var id: Int
var title: String
var author: String
}
enum Library {
static let books: [BookDTO] = [
BookDTO(id: 1, title: "Dune", author: "Frank Herbert"),
BookDTO(id: 2, title: "Hyperion", author: "Dan Simmons"),
BookDTO(id: 3, title: "Solaris", author: "Stanislaw Lem"),
]
}
import Vapor
struct RequestSummary: Content {
var id: String
var method: String
var path: String
var query: String?
var httpVersion: String
var host: String?
var userAgent: String?
var contentType: String?
var contentLength: Int?
var remote: String?
var peer: String?
var route: String?
var bodyBytes: Int
}
func summarize(_ req: Request) -> RequestSummary {
RequestSummary(
id: req.id,
method: req.method.rawValue,
path: req.url.path,
query: req.url.query,
httpVersion: "\(req.version.major).\(req.version.minor)",
host: req.headers.first(name: .host),
userAgent: req.headers.first(name: .userAgent),
contentType: req.headers.contentType?.serialize(),
contentLength: req.headers.first(name: .contentLength).flatMap { Int($0) },
remote: req.remoteAddress?.description,
peer: req.peerAddress?.ipAddress,
route: req.route?.description,
bodyBytes: req.body.data?.readableBytes ?? 0
)
}
func inspectRoutes(_ routes: some RoutesBuilder) {
routes.get("inspect") { req in summarize(req) }
routes.post("inspect") { req in summarize(req) }
}
Run against a live server (the X-Request-Id header is echoed back as id; without it Vapor generates a UUID):
$ curl -i "http://127.0.0.1:8080/inspect?x=1&y=two" -H 'X-Request-Id: demo-1234'
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 243
server: bookshelf
{"httpVersion":"1.1","query":"x=1&y=two","method":"GET","path":"\/inspect","host":"127.0.0.1:8080","userAgent":"curl\/8.7.1","remote":"[IPv4]127.0.0.1\/127.0.0.1:57355","id":"demo-1234","peer":"127.0.0.1","bodyBytes":0,"route":"GET \/inspect"}
Three details to notice. The JSON key order is not stable (JSONEncoder does not guarantee it), so never compare JSON
as a
string in tests; decode it. req.url.path has no query string; req.parameters gives the captured path components. And
req.remoteAddress prints SwiftNIO’s debug format; use ipAddress or port on
SocketAddress when you need a clean value.
The client address behind a proxy
Behind a load balancer remoteAddress is always the proxy. peerAddress looks first at the Forwarded header
(RFC 7239), then at X-Forwarded-For, and falls back to remoteAddress. Two
cautions follow from the source: when a forwarded value is used, the port is set to 80 regardless of the real one, and
the headers are client controlled unless your proxy overwrites them. Use peerAddress for logging and rate-limit keys
only if the proxy strips incoming Forwarded and X-Forwarded-For headers; never for access control based on a
spoofable value.
Request identity and logging
Every request has an id: the incoming X-Request-Id header if there is one, a generated UUID otherwise. The same
value
is added to req.logger as the request-id metadata key, so all log lines written through req.logger during the
request can be correlated. A common pattern is to echo the id on the response so that clients can quote it in bug
reports;
a middleware that sets the response header for every route is shown on the
Middleware page. A per-request key/value store
is req.storage, keyed by a type that conforms to StorageKey (whose Value must be Sendable):
import Vapor
struct RequestStartKey: StorageKey {
typealias Value = ContinuousClock.Instant
}
/// Echoes the request id back, logs with request metadata and shares a value through req.storage.
func contextRoutes(_ routes: some RoutesBuilder) {
routes.get("context") { req async throws -> Response in
req.storage[RequestStartKey.self] = .now
req.logger.info("handling request", metadata: ["route": "\(req.route?.description ?? "-")"])
let response = Response(status: .ok, body: .init(string: "request \(req.id)\n"))
response.headers.replaceOrAdd(name: "X-Request-Id", value: req.id)
if let start = req.storage[RequestStartKey.self] {
let elapsed = start.duration(to: .now)
req.logger.debug("handler duration", metadata: ["elapsed": "\(elapsed)"])
}
return response
}
}
Source: Vapor docs: Request and Vapor docs: Routing
Headers
req.headers and res.headers are SwiftNIO’s HTTPHeaders. Vapor extends it with HTTPHeaders.Name constants (one
per
registered header, such as .accept, .cacheControl, .eTag, .location) and with typed accessors that parse and
serialise the common headers for you.
| Accessor | Type | Header |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
date headers, parsed to and from |
|
|
|
|
|
parsed |
|
|
|
|
|
byte ranges for partial content |
|
|
|
|
|
|
|
|
|
|
|
per-response compression override (see below) |
The raw API of SwiftNIO’s HTTPHeaders is available with HTTPHeaders.Name overloads: first(name:) returns the first
value, subscript(name) returns every value, add(name:value:) appends, replaceOrAdd(name:value:) replaces,
remove(name:) deletes and contains(name:) tests. Use replaceOrAdd for headers that must occur once. Content-Length
and
Transfer-Encoding are not set by hand: assigning response.body updates them.
import Vapor
struct HeaderReport: Content {
var preferredFormat: String
var acceptedTypes: [String]
var bearerToken: String?
var basicUser: String?
var forwardedFor: [String]
var range: String?
var cookieNames: [String]
}
func headerRoutes(_ routes: some RoutesBuilder) {
routes.get("headers") { req async throws -> HeaderReport in
// Typed accessors parse the raw header for you.
let accept = req.headers.accept
let preference = accept.comparePreference(for: .json, to: .html)
let preferred = preference == .orderedAscending ? "html" : "json"
// Raw access: names are case-insensitive, a header can occur several times.
let allAccept = req.headers[.accept]
_ = allAccept
return HeaderReport(
preferredFormat: preferred,
acceptedTypes: accept.mediaTypes.map { $0.serialize() },
bearerToken: req.headers.bearerAuthorization?.token,
basicUser: req.headers.basicAuthorization?.username,
forwardedFor: req.headers.forwarded.compactMap { $0.for },
range: req.headers.range.map { $0.serialize() },
cookieNames: req.cookies.all.keys.sorted()
)
}
routes.get("headers", "set") { req async throws -> Response in
let response = Response(status: .ok)
response.headers.add(name: "X-Library", value: "bookshelf")
response.headers.replaceOrAdd(name: .cacheControl, value: "no-store")
response.headers.contentType = .plainText
response.headers.remove(name: "X-Debug")
response.body = .init(string: "headers set\n")
return response
}
}
Source: vapor/vapor 4.122.2: HTTP/Headers and Vapor docs: Request
Running it shows the typed accessors at work (the q=0.5 makes JSON less preferred than HTML, so preferredFormat is
html; the cookie names come from the Cookie header):
$ curl -s http://127.0.0.1:8080/headers \
-H 'Accept: text/html, application/json;q=0.5' -H 'Authorization: Bearer abc.def' \
-H 'Cookie: a=1; b=2' -H 'Forwarded: for=192.0.2.60;proto=http' -H 'Range: bytes=0-99'
{"range":"bytes=0-99","preferredFormat":"html","acceptedTypes":["text\/html","application\/json; q=0.5"],"forwardedFor":["192.0.2.60"],"cookieNames":["a","b"],"bearerToken":"abc.def"}
$ curl -i http://127.0.0.1:8080/headers/set
HTTP/1.1 200 OK
X-Library: bookshelf
cache-control: no-store
content-type: text/plain; charset=utf-8
content-length: 12
server: bookshelf
headers set
Note that X-Library keeps the capitalisation it was given while headers set through HTTPHeaders.Name constants are
lower-cased on the wire; HTTP header names are case-insensitive (HTTP/2 requires lower case), so clients must not depend
on either. Decoding the Accept header by hand is rarely needed: serving HTML or JSON from one route by content
negotiation is covered on the Content and Codable page.
One quirk in 4.122.2: the cacheControl setter, when given nil, removes the Expires header instead of
Cache-Control
(the else branch in HTTPHeaderCacheControl.swift removes .expires). To clear the header call
headers.remove(name: .cacheControl).
Request bodies
A request body is a sequence of bytes whose length is announced by Content-Length or, for streams, by
Transfer-Encoding: chunked. Vapor offers two ways to deal with it, chosen per route with the body: parameter of
the
route registration methods (HTTPBodyStreamStrategy):
| Strategy | What the handler sees | Use for |
|---|---|---|
|
the whole body is read into memory before the handler runs, up to the limit; |
JSON and form APIs, small uploads |
|
the handler starts when the headers have arrived; |
large uploads, proxying, anything you do not want in memory |
The default limit comes from app.routes.defaultMaxBodySize, which is 16kb (the value type is ByteCount,
expressible
as a string literal with the suffixes kb, mb, gb and tb, or as an integer number of bytes). A larger body gets
413 Payload Too Large. This surprises people who upload a 100 kB JSON document or a photo to a default route; raise
the
limit for the route that needs it with .collect(maxSize:) rather than raising it for the whole app, because every
collected body is held in memory per connection, which is a denial-of-service lever. The Request.Body API:
-
data: ByteBuffer?andstring: String?: the collected bytes (stringdecodes the bytes as UTF-8 text).nilwhen the body is streamed or absent. -
collect(max: Int? = 1 << 14) -> EventLoopFuture<ByteBuffer?>: collect a streamed body now, failing with413abovemax. The default of 16 KiB is notdefaultMaxBodySize. It is the legacy futures API; call.get()to use it from async code.nilmeans "no limit", so never passnilfor untrusted input. -
drain(_:): legacy callback API withBodyStreamResultvalues (.buffer,.error,.end) and back-pressure through the returned future. -
AsyncSequenceconformance withElement == ByteBuffer:for try await chunk in req.body. It applies back-pressure with a high/low watermark of 20 and 5 chunks, so a slow consumer slows the sender.
If the body is not collected, req.content.decode(...) fails: the source throws Abort(.unprocessableEntity) and
logs
that decoding streaming bodies is not supported. A route that must both stream and decode small JSON has to collect
explicitly first with try await req.body.collect(max: 1 << 20).get().
import Vapor
struct UploadResult: Content {
var bytes: Int
var lines: Int
}
func bodyRoutes(_ routes: some RoutesBuilder) {
// Default: the body is collected (up to app.routes.defaultMaxBodySize, 16kb) before the handler runs.
routes.post("echo") { req async throws -> String in
guard let text = req.body.string else {
throw Abort(.badRequest, reason: "Expected a UTF-8 body")
}
return text
}
// A larger collected limit for one route.
routes.on(.POST, "import", body: .collect(maxSize: "2mb")) { req async throws -> UploadResult in
guard let buffer = req.body.data else { throw Abort(.badRequest) }
let lines = buffer.readableBytesView.filter { $0 == UInt8(ascii: "\n") }.count
return UploadResult(bytes: buffer.readableBytes, lines: lines)
}
// Streaming: the handler starts as soon as the headers are in; the body arrives chunk by chunk.
routes.on(.PUT, "blobs", ":name", body: .stream) { req async throws -> UploadResult in
let limit = 50 * 1024 * 1024
var bytes = 0
var lines = 0
for try await chunk in req.body {
bytes += chunk.readableBytes
if bytes > limit { throw Abort(.payloadTooLarge) }
lines += chunk.readableBytesView.filter { $0 == UInt8(ascii: "\n") }.count
}
return UploadResult(bytes: bytes, lines: lines)
}
}
Source: Vapor docs: Routing, body streaming, RoutesBuilder+Method.swift, Request+Body.swift and RequestBody+Concurrency.swift
Verified with curl (the application sets defaultMaxBodySize = "1mb" in its configure, shown later; a 2,000,000
byte
body to /echo is rejected, /import allows 2 MB, and the streaming route counts bytes without holding them):
$ curl -si -X POST http://127.0.0.1:8080/echo --data-binary @big.txt # 2,000,000 bytes
HTTP/1.1 413 Payload Too Large
content-type: application/json; charset=utf-8
content-length: 43
{"error":true,"reason":"Payload Too Large"}
$ printf 'a\nb\nc\n' | curl -s -X POST http://127.0.0.1:8080/import --data-binary @-
{"lines":3,"bytes":6}
$ printf 'a\nb\nc\n' | curl -s -X PUT http://127.0.0.1:8080/blobs/x --data-binary @-
{"lines":3,"bytes":6}
A streaming handler that writes the bytes somewhere (a file, object storage) is the right shape for large uploads; the
file API and its async writers are covered with static files. In the streaming handler above, the loop also enforces its
own limit (throw Abort(.payloadTooLarge)): with .stream there is no server-side limit, so a streaming route
without
a check accepts unbounded bodies.
Multipart and URL-encoded form bodies are decoded with req.content once collected; the multipart-kit and form
decoders
are configured on the Content and Codable page (uploads are in
File Uploads and Streaming). Request bodies can also arrive
compressed; enable decompression, with a ratio limit
against decompression bombs, in the server configuration (shown in the compression section and on the
Server Configuration page).
Cookies
req.cookies is an HTTPCookies: a dictionary-like value keyed by cookie name whose elements are HTTPCookies.Value.
Reading req.cookies["visits"] parses the Cookie header; each Value has a string and, on the response side, the
attributes of Set-Cookie (RFC 6265). The HTTPCookies.Value initialiser:
| Parameter | Meaning |
|---|---|
|
the cookie value (required) |
|
lifetime as an absolute |
|
scope of the cookie; |
|
send only over HTTPS; forced to |
|
hide the cookie from JavaScript ( |
|
|
HTTPCookies.Value.expired is a ready-made value that deletes a cookie (empty string, epoch expiry). Session and
authentication cookies should always set isHTTPOnly: true and isSecure: true in production; sessions are covered in
Sessions and Cookies and
Session Authentication and Web Login.
import Vapor
func cookieRoutes(_ routes: some RoutesBuilder) {
routes.get("visits") { req async throws -> Response in
let previous = req.cookies["visits"].flatMap { Int($0.string) } ?? 0
let response = Response(status: .ok, body: .init(string: "visit number \(previous + 1)\n"))
response.cookies["visits"] = HTTPCookies.Value(
string: String(previous + 1),
maxAge: 60 * 60 * 24,
path: "/",
isSecure: true,
isHTTPOnly: true,
sameSite: .lax
)
return response
}
routes.post("visits", "reset") { req async throws -> Response in
let response = Response(status: .noContent)
response.cookies["visits"] = .expired
return response
}
}
$ curl -si http://127.0.0.1:8080/visits | grep -i -e set-cookie -e visit
set-cookie: visits=1; Max-Age=86400; Path=/; Secure; HttpOnly; SameSite=Lax
visit number 1
$ curl -s http://127.0.0.1:8080/visits -H 'Cookie: visits=4'
visit number 5
$ curl -si -X POST http://127.0.0.1:8080/visits/reset | grep -i -e HTTP/ -e set-cookie
HTTP/1.1 204 No Content
set-cookie: visits=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax
Cookie values are client-controlled data: never trust a plain cookie for identity or privilege (use a session or a signed token), and never put secrets into one. The example stores a harmless counter.
The Response type
Response is also a final class and Sendable. It has four parts you set directly, plus metadata:
| Property | Type | Meaning |
|---|---|---|
|
|
status code and reason phrase; default |
|
|
header fields; |
|
|
the payload (below) |
|
|
the |
|
|
|
|
|
defaults to HTTP/1.1; the server adapts it to the connection |
|
|
protocol-upgrade behaviour; WebSockets use it, you rarely do |
|
|
response-local key/value store |
Response(status:version:headers:body:) has defaults for everything, so Response() is 200 OK with no body.
Response.Body
Response.Body is a value type with one initialiser per kind of payload:
| Initialiser | Use |
|---|---|
|
no body ( |
|
text; you also set |
|
binary or pre-encoded content already in memory |
|
chunked (or fixed-length) stream written with an |
|
same, but Vapor ends the stream when the closure returns and fails it when the closure throws |
|
legacy callback writer ( |
Read accessors on a body are string, data, buffer and count; count is -1 for an unknown-length stream, which
makes Vapor send Transfer-Encoding: chunked instead of Content-Length. The accessors return nil for streams (use
collect(on:), a legacy future API, to read one, for example in tests).
Building responses
import Vapor
struct NewBook: Content {
var title: String
var author: String
}
func responseRoutes(_ routes: some RoutesBuilder) {
// 1. Build a Response by hand: status, headers, body.
routes.get("hello-response") { req async throws -> Response in
var headers = HTTPHeaders()
headers.contentType = .plainText
headers.add(name: "X-Greeting", value: "hello")
return Response(status: .ok, headers: headers, body: .init(string: "Hello from a Response\n"))
}
// 2. Encode a Content value into a Response, then adjust it.
routes.get("books", ":id") { req async throws -> Response in
guard let id = req.parameters.get("id", as: Int.self),
let book = Library.books.first(where: { $0.id == id })
else { throw Abort(.notFound, reason: "No book with that id") }
let response = Response()
try response.content.encode(book, as: .json)
response.headers.replaceOrAdd(name: .cacheControl, value: "max-age=60")
return response
}
// 3. Status-only responses.
routes.delete("books", ":id") { req async throws -> HTTPStatus in
.noContent
}
// 4. 201 Created with a Location header, using encodeResponse(status:headers:for:).
routes.post("books") { req async throws -> Response in
let input = try req.content.decode(NewBook.self)
let created = BookDTO(id: 4, title: input.title, author: input.author)
return try await created.encodeResponse(
status: .created,
headers: ["Location": "/books/\(created.id)"],
for: req
)
}
// 5. Redirects.
routes.get("old-books") { req in
req.redirect(to: "/books", redirectType: .permanent)
}
routes.post("login-form") { req in
req.redirect(to: "/account", redirectType: .normal)
}
}
Five patterns appear above. Route 1 builds the response by hand. Route 2 lets response.content.encode serialise a
Content value (JSON unless you pass another media type) and then adjusts headers; this is the way to return JSON and
customise the response, because a handler that returns Content directly cannot set headers. Route 3 returns an
HTTPStatus directly. Route 4 uses encodeResponse(status:headers:for:), the shortcut for "encode this value but make
it
201 with this header". Route 5 uses req.redirect. Their real output:
$ curl -si http://127.0.0.1:8080/books/2
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 50
cache-control: max-age=60
{"author":"Dan Simmons","id":2,"title":"Hyperion"}
$ curl -si -X POST http://127.0.0.1:8080/books -H 'Content-Type: application/json' \
-d '{"title":"Emma","author":"Jane Austen"}'
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
content-length: 46
Location: /books/4
{"author":"Jane Austen","id":4,"title":"Emma"}
$ curl -si -X DELETE http://127.0.0.1:8080/books/2
HTTP/1.1 204 No Content
$ curl -si http://127.0.0.1:8080/old-books | grep -i -e HTTP/ -e location
HTTP/1.1 301 Moved Permanently
location: /books
$ curl -si -X POST http://127.0.0.1:8080/login-form | grep -i -e HTTP/ -e location
HTTP/1.1 303 See Other
location: /account
A Content type must be declared at the top level. Declaring the request DTO inside the route closure, which looks
tidy, is rejected by the compiler in a generic route-builder function:
Responses.swift:30:16: error: type 'NewBook' cannot be nested in closure in generic context
The fix, used above, is a file-scope struct NewBook: Content. The same applies to any type declared in a function that
takes some RoutesBuilder.
Status-only responses and HTTPStatus
HTTPStatus is HTTPResponseStatus from SwiftNIO with a Vapor typealias. A handler can return one directly because
it conforms to ResponseEncodable and AsyncResponseEncodable (it becomes Response(status: self) with no body). It
also
conforms to Codable (encoding the integer code), so a Content type can carry a status. Create custom values with
HTTPStatus(statusCode: 418, reasonPhrase: "Teapot"). Use .noContent for deletes, .accepted for queued work,
.created only with a body or Location, and remember that 204 and 304 must not have a body: Vapor omits it.
Redirects
req.redirect(to:redirectType:) returns a Response with a Location header. The Redirect values and their
statuses:
| Value | Status | Behaviour |
|---|---|---|
|
303 See Other |
the client re-requests with |
|
301 Moved Permanently |
cacheable; clients may switch the method to |
|
307 Temporary Redirect |
method and body are preserved |
|
308 Permanent Redirect |
method and body are preserved, permanently |
The older redirect(to:type:) with the RedirectType enum is deprecated in 4.x in favour of redirectType:. Redirect
targets that come from user input (a next or returnTo query parameter) must be validated against an allow-list of
paths,
otherwise the endpoint is an open redirect.
What a handler may return
A route closure returns any AsyncResponseEncodable (the async throws protocol) or, for the legacy API,
ResponseEncodable (the EventLoopFuture protocol). The router wraps whichever it gets into a Response by calling
encodeResponse(for: req). Built-in conformances:
| Type | Result |
|---|---|
|
itself, unchanged |
|
|
|
that status, empty body |
any |
encoded with the default media type (JSON) |
|
HTML |
|
a response relayed from the HTTP client |
|
legacy; awaits the future, then encodes |
|
type-erased wrapper around any other encodable |
You can add your own. The protocol has one requirement, encodeResponse(for:). Typical reasons: a handler that returns
one of several outcomes (an enum), a non-JSON format (CSV, PDF, XML), or a domain type that always sets the same
headers.
import Vapor
/// A handler result that is either a book, or a tombstone.
enum BookLookup: AsyncResponseEncodable {
case found(BookDTO)
case gone(reason: String)
func encodeResponse(for request: Request) async throws -> Response {
switch self {
case .found(let book):
return try await book.encodeResponse(for: request)
case .gone(let reason):
let response = Response(status: .gone)
try response.content.encode(["error": reason], as: .json)
return response
}
}
}
/// A plain value wrapped to be returned as CSV.
struct CSVDocument: AsyncResponseEncodable {
var header: [String]
var rows: [[String]]
var filename: String
func encodeResponse(for request: Request) async throws -> Response {
let text = ([header] + rows).map { $0.joined(separator: ",") }.joined(separator: "\n") + "\n"
let response = Response(status: .ok, body: .init(string: text))
response.headers.contentType = HTTPMediaType(type: "text", subType: "csv", parameters: ["charset": "utf-8"])
response.headers.contentDisposition = .init(.attachment, filename: filename)
return response
}
}
func encodableRoutes(_ routes: some RoutesBuilder) {
routes.get("lookup", ":id") { req async throws -> BookLookup in
let id = try req.parameters.require("id", as: Int.self)
if id == 99 { return .gone(reason: "withdrawn by the publisher") }
guard let book = Library.books.first(where: { $0.id == id }) else { throw Abort(.notFound) }
return .found(book)
}
routes.get("books.csv") { req async throws -> CSVDocument in
CSVDocument(
header: ["id", "title", "author"],
rows: Library.books.map { [String($0.id), $0.title, $0.author] },
filename: "books.csv"
)
}
// A route that returns one of several unrelated types: type-erase with AnyAsyncResponse.
routes.get("either", ":kind") { req async throws -> AnyAsyncResponse in
switch req.parameters.get("kind") {
case "text": return AnyAsyncResponse("plain text\n")
case "status": return AnyAsyncResponse(HTTPStatus.accepted)
default: return AnyAsyncResponse(Library.books)
}
}
}
$ curl -s http://127.0.0.1:8080/lookup/1
{"author":"Frank Herbert","id":1,"title":"Dune"}
$ curl -si http://127.0.0.1:8080/lookup/99 | sed -n '1p;$p'
HTTP/1.1 410 Gone
{"error":"withdrawn by the publisher"}
$ curl -si http://127.0.0.1:8080/books.csv
HTTP/1.1 200 OK
content-length: 84
content-type: text/csv; charset=utf-8
content-disposition: attachment; filename="books.csv"
id,title,author
1,Dune,Frank Herbert
2,Hyperion,Dan Simmons
3,Solaris,Stanislaw Lem
Swift 6 notes for custom encodables. AsyncResponseEncodable refines VaporSendableMetatype, which is
SendableMetatype
on Swift 6.2 and later (and Any on older compilers), so the type may be used from concurrent contexts; the value
itself
is not required to be Sendable, but returning one from a @Sendable route closure means you build it inside the
closure, which is how the examples work. Make enums and structs that you return Sendable anyway. When two conformances
exist (ResponseEncodable and AsyncResponseEncodable) the async one is used on async routes.
A route that really needs to return unrelated types can wrap them in AnyAsyncResponse(_:), as in /either/:kind
above (AnyResponse is the futures twin). An enum with an AsyncResponseEncodable conformance is usually clearer
because the set of outcomes is explicit in the type.
Streaming responses
A response body can be produced incrementally with an async closure. managedAsyncStream is the one to prefer: Vapor
ends
the response when your closure returns and reports a failure to the client when it throws; with plain asyncStream you
must write .end or .error yourself, and forgetting to do so leaves the connection hanging. Without a count, the
response is chunked. With a count, Vapor sends Content-Length and the closure must write exactly that many bytes.
import Vapor
import NIOCore
func streamingRoutes(_ routes: some RoutesBuilder) {
// Chunked response (no Content-Length): lines of newline-delimited JSON.
routes.get("books", "stream") { req async throws -> Response in
let response = Response(status: .ok)
response.headers.contentType = HTTPMediaType(type: "application", subType: "x-ndjson")
let encoder = JSONEncoder()
response.body = .init(managedAsyncStream: { writer in
for book in Library.books {
try Task.checkCancellation()
var line = ByteBuffer(bytes: try encoder.encode(book))
line.writeString("\n")
try await writer.writeBuffer(line)
try await Task.sleep(for: .milliseconds(50))
}
})
return response
}
// Known size: a Content-Length is sent, and the closure must write exactly that many bytes.
routes.get("fixed-size") { req async throws -> Response in
let payload = "0123456789"
let response = Response(status: .ok)
response.headers.contentType = .plainText
response.body = .init(managedAsyncStream: { writer in
try await writer.writeBuffer(ByteBuffer(string: payload))
}, count: payload.utf8.count)
return response
}
}
$ curl -si http://127.0.0.1:8080/books/stream
HTTP/1.1 200 OK
content-type: application/x-ndjson
transfer-encoding: chunked
{"author":"Frank Herbert","id":1,"title":"Dune"}
{"author":"Dan Simmons","id":2,"title":"Hyperion"}
{"author":"Stanislaw Lem","id":3,"title":"Solaris"}
$ curl -si http://127.0.0.1:8080/fixed-size
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 10
0123456789
The stream closure is @Sendable, so under Swift 6 it cannot mutate a captured var. The compiler rejects this
immediately:
error: mutation of captured var 'sent' in concurrently-executing code
Keep counters inside the closure, or in an actor or a Mutex. The writer applies back-pressure: try await
writer.writeBuffer(_:) suspends while the connection cannot accept more data, so a slow client does not make the
process
buffer the whole export. A client that disconnects makes the write throw, which ends your closure; check
try Task.checkCancellation() in long loops as shown. To send a file from disk prefer req.fileio.streamFile, which
sets ETag, handles Range and If-None-Match, and reads in chunks.
Caching, downloads and pagination headers
Typed accessors turn the usual response headers into one-liners: cacheControl, lastModified, expires,
contentDisposition and links. The conditional GET pattern below saves bandwidth: the server sends an ETag (a
version
token of the representation); the client repeats it in If-None-Match; if it still matches, the server answers 304
with
no body (RFC 9111, RFC 9110 section 13).
import Vapor
func cachingRoutes(_ routes: some RoutesBuilder) {
let published = Date(timeIntervalSince1970: 1_700_000_000)
routes.get("catalog") { req async throws -> Response in
let etag = "\"catalog-v\(Library.books.count)\""
// Conditional GET: If-None-Match matches, so no body is sent.
if req.headers.first(name: .ifNoneMatch) == etag {
let notModified = Response(status: .notModified)
notModified.headers.replaceOrAdd(name: .eTag, value: etag)
return notModified
}
let response = Response()
try response.content.encode(Library.books, as: .json)
response.headers.replaceOrAdd(name: .eTag, value: etag)
response.headers.lastModified = .init(published)
response.headers.cacheControl = .init(isPublic: true, maxAge: 300)
return response
}
routes.get("books", "page", ":n") { req async throws -> Response in
let page = try req.parameters.require("n", as: Int.self)
let response = Response()
try response.content.encode(Library.books, as: .json)
response.headers.links = [
.init(uri: "/books/page/\(page + 1)", relation: .next, attributes: [:]),
.init(uri: "/books/page/1", relation: .first, attributes: [:]),
]
return response
}
}
$ curl -si http://127.0.0.1:8080/catalog
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 153
etag: "catalog-v3"
last-modified: Tue, 14 Nov 2023 22:13:20 GMT
cache-control: public, max-age=300
$ curl -si http://127.0.0.1:8080/catalog -H 'If-None-Match: "catalog-v3"'
HTTP/1.1 304 Not Modified
etag: "catalog-v3"
$ curl -si http://127.0.0.1:8080/books/page/2 | grep -i '^link'
link: </books/page/3>; rel="next", </books/page/1>; rel="first"
The ETag here is a made-up version string to keep the example short; derive a real one from a content hash or a database
version column. Weak validators (W/"...") and If-None-Match lists ("a", "b") need a small parser; the file API
in
Vapor generates its ETag from the modification time and size and handles the comparison for you. Cache-Control:
private
(not public) is mandatory for responses that depend on the user, and no-store for sensitive data.
Compression, decompression and body limits in the server configuration
Several body-related settings live on the application rather than on a route. They are normally set in configure:
import Vapor
func configure(_ app: Application) async throws {
// Body limit for routes that use the default .collect strategy (the default is 16kb).
app.routes.defaultMaxBodySize = "1mb"
// Compression of responses (only if the client sends Accept-Encoding) and of request bodies.
app.http.server.configuration.responseCompression = .enabledForCompressibleTypes
app.http.server.configuration.requestDecompression = .enabled(limit: .ratio(10))
app.http.server.configuration.serverName = "bookshelf"
try routes(app)
}
-
app.routes.defaultMaxBodySizeis the limit of the default.collectstrategy (default16kb). -
responseCompressioncan be.disabled(default, but routes may opt in),.enabledForCompressibleTypes(text-like content types only),.enabled(everything except already compressed types such as images) or.forceDisabled. The server compresses only when the client sendsAccept-Encoding. A single route or group opts in or out withResponseCompressionMiddlewareorroute.responseCompression(.enable); a middleware may also setresponse.headers.responseCompression. -
requestDecompressionis.disabledby default. When enabled, always pass alimit(.ratio(10)here: an upload that expands more than ten times is rejected). Without a limit, a small compressed body can expand to gigabytes. -
serverNameadds aServerheader; leave it unset (or generic) in production if you do not want to advertise the stack.
With the configuration above, a text response larger than a few bytes is gzip-compressed when the client asks for it.
The
check against the live server (100,000 bytes of x posted to /echo come back as 132 bytes):
$ curl -si -X POST http://127.0.0.1:8080/echo --data-binary @b100.txt -H 'Accept-Encoding: gzip' -o /dev/null -D - | grep -i -e content-encoding -e content-length
Content-Encoding: gzip
content-length: 132
Testing requests and responses
See also the Testing page for the general setup.
VaporTesting (the testing library bundled in the vapor package since 4.110) runs a request through the complete
middleware and routing stack without a socket. app.testing().test(.METHOD, path, headers:, body:, beforeRequest:,
afterResponse:)
takes a configure closure for the request and an assertion closure that receives a TestingHTTPResponse with status,
headers, body and content. The default in-memory mode does not run the HTTP server: the body-size limit is not
enforced (a 4 KiB body posted to a route
with a 1 KiB limit answers 200 in memory and 413 when running), and compression, which is applied by the server
pipeline, is not exercised either. Chunked streaming responses do work in memory. For limits and compression use
app.testing(method: .running(port: 0)), which binds an ephemeral port
and goes through a real HTTP client. Port 0 matters: with the default port, tests that run in parallel (Swift Testing
runs
tests concurrently) collide with Address already in use.
import VaporTesting
import Testing
@testable import App
@Suite("Request and response")
struct RequestAndResponseTests {
private func withApp(_ test: (Application) async throws -> Void) async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
try await test(app)
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
@Test("inspect reports method, path, query, id and body size")
func inspect() async throws {
try await withApp { app in
try await app.testing().test(
.POST, "inspect?x=1",
headers: ["X-Request-Id": "test-1", "Content-Type": "text/plain"],
body: ByteBuffer(string: "hello")
) { res async throws in
#expect(res.status == .ok)
let summary = try res.content.decode(RequestSummary.self)
#expect(summary.id == "test-1")
#expect(summary.method == "POST")
#expect(summary.query == "x=1")
#expect(summary.bodyBytes == 5)
#expect(summary.route == "POST /inspect")
}
}
}
@Test("POST /books answers 201 with a Location header")
func created() async throws {
try await withApp { app in
try await app.testing().test(.POST, "books", beforeRequest: { req in
try req.content.encode(NewBook(title: "Emma", author: "Jane Austen"))
}, afterResponse: { res async throws in
#expect(res.status == .created)
#expect(res.headers.first(name: .location) == "/books/4")
#expect(try res.content.decode(BookDTO.self).title == "Emma")
})
}
}
@Test("a body over the limit is rejected with 413")
func tooLarge() async throws {
try await withApp { app in
app.routes.defaultMaxBodySize = "1kb"
try await app.testing(method: .running(port: 0)).test(
.POST, "echo",
body: ByteBuffer(repeating: UInt8(ascii: "x"), count: 4096)
) { res async in
#expect(res.status == .payloadTooLarge)
}
}
}
@Test("cookies are read from the request and written to the response")
func cookies() async throws {
try await withApp { app in
try await app.testing().test(.GET, "visits", headers: ["Cookie": "visits=4"]) { res async in
#expect(res.body.string == "visit number 5\n")
#expect(res.headers.setCookie?["visits"]?.string == "5")
#expect(res.headers.setCookie?["visits"]?.isHTTPOnly == true)
}
}
}
@Test("a matching If-None-Match gives 304 without a body")
func conditionalGet() async throws {
try await withApp { app in
try await app.testing().test(.GET, "catalog", headers: ["If-None-Match": "\"catalog-v3\""]) { res async in
#expect(res.status == .notModified)
#expect(res.body.readableBytes == 0)
}
}
}
@Test("the streamed body arrives as newline-delimited JSON")
func streaming() async throws {
try await withApp { app in
try await app.testing(method: .running(port: 0)).test(.GET, "books/stream") { res async in
#expect(res.headers.first(name: .transferEncoding) == "chunked")
#expect(res.body.string.split(separator: "\n").count == 3)
}
}
}
}
Source: Vapor docs: Testing
Swift 6 and concurrency notes
-
RequestandResponseareSendableclasses. Reading and writing their properties from several tasks is safe (every property is behind a lock), but order is yours to manage. -
Route closures are
@Sendable; they may capture onlySendablevalues. Models, DTOs and services you capture must beSendabletoo (structs ofSendablevalues are, classes need locks or an actor). Mutable state belongs in an actor or aMutex, or inapp.storage/req.storagebehind aSendablevalue. -
Do not block inside a handler or a stream closure: no
Thread.sleep, no synchronous file reads of large files, no.wait()on a future. UseTask.sleep,req.fileioand.get(). -
for try await chunk in req.bodyruns on the cooperative pool and suspends while waiting for data; the NIO event loop keeps reading the socket and applies back-pressure through the watermark. -
The legacy
EventLoopFutureAPI (req.body.collect,Body.collect(on:),drain) is still present in 4.122; the.get()bridge is documented in the concurrency page.
Common errors and pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
default |
|
|
the body was not collected |
collect first ( |
|
streamed route, or the body is not valid UTF-8, or the request has no body |
check the route strategy; use |
JSON body not decoded, |
missing |
set the header; decoding picks the decoder from it |
|
|
declare it at file scope |
|
stream or route closures are |
use a local variable inside the closure, an actor or |
Client hangs on a streamed response |
|
use |
|
|
write exactly |
|
several |
use |
|
Vapor answers |
add the route, or a custom middleware that maps it |
|
setter removes |
|
Wrong client IP in logs |
reading |
use |
Version notes
-
Vapor 3 (the era of most books and tutorials) had
Request.http, wrapped request and response inHTTPRequestandHTTPResponse, returnedFuture<Response>and read bodies withreq.content.decode(User.self).flatMap. Vapor 4 (2020) removedreq.http, madeRequestandResponsethe HTTP types and addedreq.contentas a container; the handler signature(Request) throws -> ResponseEncodableis the one that survives. -
Later 4.x releases added async/await handlers and
AsyncResponseEncodable(Swift 5.5), then theSendableconformances that makeRequestandResponsesafe to capture under Swift 6, async body streaming (Body(managedAsyncStream:),Request.Bodyas anAsyncSequence) and the response-compression configuration. The exact release of each is in the GitHub release notes; the page documents the 4.122.2 behaviour, so check them if you must support an older 4.x. -
Vapor 5 (5.0.0-beta.4, not generally available): per the
5.0.0-beta.4sources,RequestandResponsebecomestruct`s (still `Sendable, with the request body exposed as a non-escapable view), method, status and header types come from swift-http-types (HTTPRequest.Method,HTTPResponse.Status,HTTPFields) instead of SwiftNIO’sHTTPMethod,HTTPStatusandHTTPHeaders,ResponseEncodableis the singleasync throwsprotocol (theEventLoopFutureflavour andAsyncResponseEncodableare gone), and response streaming usesBody(stream:)with anHTTPBodyWriterinstead of the writer protocols above. Application code written for 4.122 with async/await handlers,Contenttypes andAbortports with mechanical renames; code that touchesreq.eventLoop, futures orHTTPHeaders.Namedoes not.
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco) introduces HTTP,
RequestandResponsewithEventLoopFuturereturn types andreq.content.decode(...).flatMap. The concepts (methods, status codes, headers,Content) are unchanged; the signatures are nowasync throwsand.get()bridges the old API. -
Yeung and Patel (Packt) show Vapor 3 and early Vapor 4 code with
req.http,HTTPResponse,req.response(),try req.parameters.next(Int.self)andFuture<...>handlers. In Vapor 4.122 those arereq.method/req.headers,Response,req.parameters.get(_:as:)andasync throwshandlers. -
Older material rarely mentions body size limits: Vapor 3 collected whole bodies without a small default. In Vapor 4 the default of
16kb(and.streamfor large uploads) is something every upload feature has to handle. -
Cookie and caching advice in older tutorials predates
SameSite=Laxas the browser default; setsameSite,isSecureandisHTTPOnlyexplicitly.
References
-
Vapor docs: Request (properties, body, cookies, headers,
peerAddress,id) -
Vapor docs: Routing (handlers, body streaming strategies,
Responsecreation, redirects) -
Vapor docs: Server (HTTP/2, TLS, compression, request decompression,
supportVersions) -
Vapor docs: Middleware and Files (
FileIO, ETag handling) -
vapor/vapor 4.122.2: Sources/Vapor (
Request,Response,HTTP/Headers,Concurrency) and 5.0.0-beta.4: Request.swift with ResponseCodable.swift -
RFC 9110: HTTP Semantics, RFC 9111: HTTP Caching, RFC 9112: HTTP/1.1 and RFC 9113: HTTP/2
-
RFC 6265: Cookies, RFC 7239: Forwarded, RFC 8288: Web Linking and RFC 6266: Content-Disposition
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)