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:

  1. What HTTP guarantees and what it leaves open, so that handlers return the right status code and headers.

  2. How Request exposes the method, URL, headers, body, cookies, client address and identity of the exchange.

  3. How a request body is collected or streamed, and how the size limit works.

  4. How a Response is built: status, headers, body (buffer, string, stream), cookies.

  5. How handlers return things that are not a Response: HTTPStatus, String, Content types and your own AsyncResponseEncodable types.

  6. 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

GET

read a resource or a collection

yes

yes

none

HEAD

like GET but only the status and headers

yes

yes

none

POST

create a resource, run an action, submit a form

no

no

yes

PUT

replace a resource at a known URL

no

yes

yes

PATCH

partially update a resource

no

no (usually)

yes

DELETE

remove a resource

no

yes

rare

OPTIONS

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

100 Continue (handled by the server), 101 Switching Protocols (WebSocket upgrades)

2xx

success

.ok 200 for reads and updates, .created 201 after a POST with a Location header, .accepted 202 for work queued for later, .noContent 204 for a successful DELETE or an update with nothing to return

3xx

redirection

.movedPermanently 301, .seeOther 303 (after a form POST), .temporaryRedirect 307, .permanentRedirect 308, .notModified 304 (conditional GET)

4xx

the client is wrong

.badRequest 400, .unauthorized 401 (no or bad credentials), .forbidden 403 (authenticated but not allowed), .notFound 404, .conflict 409, .gone 410, .payloadTooLarge 413, .unprocessableEntity 422 (well-formed but invalid), .tooManyRequests 429

5xx

the server is wrong

.internalServerError 500, .notImplemented 501, .badGateway 502, .serviceUnavailable 503

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-Encoding and Content-Encoding (compression).

  • Body framing: Content-Length (exact size) or Transfer-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: Forwarded and X-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.

flowchart LR C([Client]) -->|bytes| D["HTTP decoder: Request (method, URL, headers, body strategy)"] D --> M1["Middleware 1 (ErrorMiddleware)"] M1 --> M2["Middleware 2 ... n"] M2 --> R["Router: route match, req.parameters, req.route"] R --> H["Handler: async throws -> some AsyncResponseEncodable"] H --> E["encodeResponse(for: req) builds the Response"] E --> M2o["Middleware n ... 2 (response phase)"] M2o --> M1o["Middleware 1 (turns thrown errors into responses)"] M1o --> S["HTTP encoder: status line, headers, body"] S -->|bytes| C

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

method

HTTPMethod

.GET, .POST, …​ Settable (middleware may rewrite it, for example for method override in HTML forms).

url

URI

Request target: url.path, url.query (raw string), url.string; also scheme, host, port, fragment when present. Not Foundation’s URL.

version

HTTPVersion

.major and .minor of the HTTP version of the request.

headers

HTTPHeaders

The request header fields (SwiftNIO’s HTTPHeaders plus Vapor’s typed accessors).

body

Request.Body

Collected data and string, or a stream (see below). Also an AsyncSequence of ByteBuffer.

cookies

HTTPCookies

Parsed Cookie header. Settable.

parameters

Parameters

Captured path components: req.parameters.get("id", as: Int.self) or require.

query

URLQueryContainer

Decodes url.query into a Decodable type.

content

ContentContainer

Decodes the body (JSON, form, multipart) and encodes content onto the request (used by the test client).

id

String

Value of the X-Request-Id header when present, otherwise a generated UUID string.

remoteAddress

SocketAddress?

The address of the TCP peer as seen by SwiftNIO (the proxy, behind a reverse proxy).

peerAddress

SocketAddress?

Best-effort client address: the Forwarded header (for=), then X-Forwarded-For, then remoteAddress.

peerCertificateChain

ValidatedCertificateChain?

Client TLS certificate chain, when mutual TLS is configured with customCertificateVerifyCallbackWithMetadata.

route

Route?

The matched route; req.route?.description is "GET /inspect", handy for metrics labels.

logger

Logger

A swift-log logger whose metadata already contains request-id.

serviceContext

ServiceContext

Request-scoped context for distributed tracing.

application

Application

The owning application (services, configuration, app.logger, app.environment).

eventLoop

any EventLoop

The event loop that owns the connection. Needed only with the legacy futures API.

byteBufferAllocator

ByteBufferAllocator

Allocator used for buffers created for this request.

storage

Storage

Request-local key/value store keyed by StorageKey types.

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

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

contentType

HTTPMediaType?

Content-Type; settable (.json, .html, .plainText, .formData, …​)

accept

[HTTPMediaTypePreference]

Accept, with .mediaTypes and comparePreference(for:to:)

cacheControl

HTTPHeaders.CacheControl?

Cache-Control directives as properties (isPublic, maxAge, noStore, immutable, …​)

expires, lastModified

Expires?, LastModified?

date headers, parsed to and from Date

cookie, setCookie

HTTPCookies?

Cookie and Set-Cookie; use req.cookies and res.cookies

bearerAuthorization, basicAuthorization

BearerAuthorization?, BasicAuthorization?

parsed Authorization credentials (token, or username and password)

contentDisposition

ContentDisposition?

Content-Disposition (.attachment, .inline, .formData, with filename)

range, contentRange

Range?, ContentRange?

byte ranges for partial content

links

[Link]?

Link header entries (uri, relation, attributes) for pagination and discovery

forwarded

[Forwarded]

Forwarded entries (by, for, host, proto)

connection

Connection?

Connection: close or keep-alive

responseCompression

ResponseCompression

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

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

.collect (default) or .collect(maxSize: "2mb")

the whole body is read into memory before the handler runs, up to the limit; req.body.data and req.body.string are non-nil; req.content.decode works

JSON and form APIs, small uploads

.stream

the handler starts when the headers have arrived; req.body.data is nil; read chunks with for try await chunk in req.body, req.body.drain(_:) or req.body.collect(max:)

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? and string: String?: the collected bytes (string decodes the bytes as UTF-8 text). nil when the body is streamed or absent.

  • collect(max: Int? = 1 << 14) -> EventLoopFuture<ByteBuffer?>: collect a streamed body now, failing with 413 above max. The default of 16 KiB is not defaultMaxBodySize. It is the legacy futures API; call .get() to use it from async code. nil means "no limit", so never pass nil for untrusted input.

  • drain(_:): legacy callback API with BodyStreamResult values (.buffer, .error, .end) and back-pressure through the returned future.

  • AsyncSequence conformance with Element == 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)
    }
}

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

string

the cookie value (required)

expires, maxAge

lifetime as an absolute Date or in seconds; maxAge wins in modern browsers

domain, path

scope of the cookie; path defaults to "/"

isSecure

send only over HTTPS; forced to true when sameSite is .none

isHTTPOnly

hide the cookie from JavaScript (document.cookie)

sameSite

.lax (default), .strict or .none, controlling cross-site sending

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

HTTPStatus

status code and reason phrase; default .ok

headers

HTTPHeaders

header fields; Content-Length and Transfer-Encoding are maintained when body is assigned

body

Response.Body

the payload (below)

cookies

HTTPCookies

the Set-Cookie header, as a dictionary of HTTPCookies.Value

content

ContentContainer

res.content.encode(value) serialises a Codable value into body and sets Content-Type

version

HTTPVersion

defaults to HTTP/1.1; the server adapts it to the connection

upgrader

Upgrader?

protocol-upgrade behaviour; WebSockets use it, you rarely do

storage

Storage

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

Body.empty or Body()

no body (Content-Length: 0)

Body(string:), Body(staticString:), string literal

text; you also set Content-Type

Body(data:), Body(buffer:), Body(dispatchData:)

binary or pre-encoded content already in memory

Body(asyncStream:), Body(asyncStream:count:)

chunked (or fixed-length) stream written with an AsyncBodyStreamWriter; the closure must write .end or .error itself

Body(managedAsyncStream:), Body(managedAsyncStream:count:)

same, but Vapor ends the stream when the closure returns and fails it when the closure throws

Body(stream:), Body(stream:count:)

legacy callback writer (BodyStreamWriter) based on futures

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

.normal (default)

303 See Other

the client re-requests with GET; use after a form POST (the Post/Redirect/Get pattern)

.permanent

301 Moved Permanently

cacheable; clients may switch the method to GET; only for GET and HEAD

.temporary

307 Temporary Redirect

method and body are preserved

.permanentPost

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

Response

itself, unchanged

String, StaticString

200, Content-Type: text/plain; charset=utf-8

HTTPStatus

that status, empty body

any Content type, and Array and Dictionary of Content

encoded with the default media type (JSON)

View (Leaf)

HTML

ClientResponse

a response relayed from the HTTP client

EventLoopFuture<T> where T: ResponseEncodable

legacy; awaits the future, then encodes

AnyAsyncResponse, AnyResponse

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.defaultMaxBodySize is the limit of the default .collect strategy (default 16kb).

  • responseCompression can 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 sends Accept-Encoding. A single route or group opts in or out with ResponseCompressionMiddleware or route.responseCompression(.enable); a middleware may also set response.headers.responseCompression.

  • requestDecompression is .disabled by default. When enabled, always pass a limit (.ratio(10) here: an upload that expands more than ten times is rejected). Without a limit, a small compressed body can expand to gigabytes.

  • serverName adds a Server header; 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)
            }
        }
    }
}

Swift 6 and concurrency notes

  • Request and Response are Sendable classes. 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 only Sendable values. Models, DTOs and services you capture must be Sendable too (structs of Sendable values are, classes need locks or an actor). Mutable state belongs in an actor or a Mutex, or in app.storage / req.storage behind a Sendable value.

  • 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. Use Task.sleep, req.fileio and .get().

  • for try await chunk in req.body runs 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 EventLoopFuture API (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

413 Payload Too Large on a modest upload

default .collect limit is 16kb

.collect(maxSize: "10mb") on that route, or app.routes.defaultMaxBodySize

422 Unprocessable Entity from req.content.decode on a .stream route

the body was not collected

collect first (req.body.collect(max:)) or read the stream yourself

req.body.string is nil

streamed route, or the body is not valid UTF-8, or the request has no body

check the route strategy; use data for bytes

JSON body not decoded, 415 Unsupported Media Type

missing Content-Type: application/json on the request

set the header; decoding picks the decoder from it

type cannot be nested in closure in generic context

Content type declared inside a function that takes some RoutesBuilder

declare it at file scope

mutation of captured var in concurrently-executing code

stream or route closures are @Sendable

use a local variable inside the closure, an actor or Mutex

Client hangs on a streamed response

asyncStream closure never wrote .end

use managedAsyncStream

Content-Length mismatch, connection reset

count: does not equal the bytes written

write exactly count bytes, or omit count

Address already in use in tests

several .running tests on the same port

use .running(port: 0)

405 expected, 404 returned

Vapor answers 404 for a known path with an unsupported method

add the route, or a custom middleware that maps it

cacheControl = nil leaves the header

setter removes Expires (4.122.2)

headers.remove(name: .cacheControl)

Wrong client IP in logs

reading remoteAddress behind a proxy

use peerAddress and make the proxy overwrite the forwarding headers

Version notes

  • Vapor 3 (the era of most books and tutorials) had Request.http, wrapped request and response in HTTPRequest and HTTPResponse, returned Future<Response> and read bodies with req.content.decode(User.self).flatMap. Vapor 4 (2020) removed req.http, made Request and Response the HTTP types and added req.content as a container; the handler signature (Request) throws -> ResponseEncodable is the one that survives.

  • Later 4.x releases added async/await handlers and AsyncResponseEncodable (Swift 5.5), then the Sendable conformances that make Request and Response safe to capture under Swift 6, async body streaming (Body(managedAsyncStream:), Request.Body as an AsyncSequence) 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.4 sources, Request and Response become struct`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’s HTTPMethod, HTTPStatus and HTTPHeaders, ResponseEncodable is the single async throws protocol (the EventLoopFuture flavour and AsyncResponseEncodable are gone), and response streaming uses Body(stream:) with an HTTPBodyWriter instead of the writer protocols above. Application code written for 4.122 with async/await handlers, Content types and Abort ports with mechanical renames; code that touches req.eventLoop, futures or HTTPHeaders.Name does not.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco) introduces HTTP, Request and Response with EventLoopFuture return types and req.content.decode(...).flatMap. The concepts (methods, status codes, headers, Content) are unchanged; the signatures are now async throws and .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) and Future<...> handlers. In Vapor 4.122 those are req.method/req.headers, Response, req.parameters.get(_:as:) and async throws handlers.

  • Older material rarely mentions body size limits: Vapor 3 collected whole bodies without a small default. In Vapor 4 the default of 16kb (and .stream for large uploads) is something every upload feature has to handle.

  • Cookie and caching advice in older tutorials predates SameSite=Lax as the browser default; set sameSite, isSecure and isHTTPOnly explicitly.

References