Validation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the scratch package builds in the Swift 6 language mode with the upcoming features of the official template switched on), checked on 2026-10-17. Every Swift listing on this page was compiled with zero warnings, and the JSON, HTML and error messages quoted below were captured from those listings running under VaporTesting and Swift Testing, not typed by hand. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the "Version notes and outdated material" section lists what the beta changes (see also What’s New, Upgrading and Vapor 5). The running example is the Bookshelf API of this section (see API Design, Versioning and DTOs): creating a book, searching books with query parameters, publishing a book with nested publisher data, and registering a user.

Validation answers one question before any business logic runs: is the data that came from the client shaped the way the handler expects? It is not authorisation and it is not sanitisation, and it never replaces database constraints; the last section of this page returns to that. Swift’s own Codable decoding already rejects data of the wrong type, and the page Codable and Serialization of the Swift Reference explains how that works. Validation adds rules that Codable cannot express (length, range, format, membership) and collects all the failures of a request instead of stopping at the first one.

Where it runs in the request

Validation is not a middleware and not a router feature. It is an ordinary call that a handler (or a controller method) makes on a type of its own. In the request lifecycle (SwiftNIO parses HTTP, the middleware chain runs, the router matches a route, the handler runs, and the returned value is encoded into a response) it sits at the start of the handler: the handler calls validate(content:) or validate(query:), and a failing validation throws a ValidationsError that unwinds through the middleware chain. A middleware placed around the route can turn that error into the response the clients of the API expect, which is what the field-errors section below does.

flowchart TD A["POST /api/v1/books"] --> B["ErrorMiddleware (default)"] B --> C["ValidationProblemMiddleware (route group)"] C --> D["Router matches the route"] D --> E["Handler calls CreateBookRequest.validate(content:)"] E -->|"every rule passes"| F["req.content.decode then business logic"] E -->|"throws ValidationsError"| G["caught by ValidationProblemMiddleware"] G --> H["422 with a field error list"] F --> I["201 Created"]

How validation works

The design is unusual, and knowing it explains most of the behaviour described later. Validations are declared separately from the type’s stored properties, as a list of (key, expected type, validator) triples. To run them, Vapor decodes the request body (or the query string) through a private Decodable type that, for every declared key, asks the decoder for the value as the declared type and runs the validator on it. The body is therefore parsed twice when you validate and then decode: once by the validation pass, once by req.content.decode(_:). That is cheap for the usual API body, and it means a validation never depends on the Content struct’s own properties being correct.

The public types involved:

Type Role

Validatable

protocol with one requirement, static func validations(_ validations: inout Validations); conformance adds the validate(...) entry points

Validations

the list being built; add(_:as:is:required:customFailureDescription:), a nested form, an each form for arrays and a result: form

Validator<T>

a Sendable wrapper around a @Sendable closure from a decoded T to a ValidatorResult; the built-ins are static members

ValidatorResult

protocol with isFailure, successDescription and failureDescription; the built-ins live in the ValidatorResults namespace

ValidationKey

a typealias for BasicCodingKey; a string literal such as "title" is a key, and .index(0) addresses an array element

ValidationResult

one key plus its ValidatorResult and the optional custom description; it prefixes the key to the message

ValidationsResult

all the ValidationResult`s of one run, with `error (the optional ValidationsError) and assert() (throws it)

ValidationsError

thrown by the validate(...) entry points; carries failures, conforms to AbortError with status 400 Bad Request, and its reason joins the messages with commas

Declaring validations

A type opts in by conforming to Validatable and listing its rules. The Content conformance (see Content and Codable) is separate: it is what lets the handler decode the same body afterwards. The Bookshelf create-book request:

import Vapor

enum Genre: String, Codable, CaseIterable, Sendable {
    case fiction, history, science
}

struct CreateBookRequest: Content, Validatable {
    var title: String
    var isbn: String
    var authorEmail: String
    var pages: Int
    var genre: String
    var website: String?

    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String.self,
                        is: !.empty && .count(...200))
        validations.add("isbn", as: String.self,
                        is: .pattern("[0-9]{13}"),
                        customFailureDescription: "must be 13 digits without hyphens")
        validations.add("authorEmail", as: String.self, is: .email)
        validations.add("pages", as: Int.self, is: .range(1...10_000))
        validations.add("genre", as: String.self, is: .case(of: Genre.self))
        validations.add("website", as: String?.self,
                        is: .nil || .url, required: false)
    }
}

struct BookDTO: Content {
    var id: Int
    var title: String
    var isbn: String
    var genre: Genre
}

A few things to notice in the listing.

  • The genre property is a String on purpose. If it were declared as the Genre enum, a bad value would fail in the Codable decoding with a generic "Cannot initialize Genre from invalid String value" message before or after the validation depending on the order of the calls. Validating the raw string with .case(of: Genre.self) produces the message "is not fiction, history, or science" and reports it together with every other failure.

  • website is optional. required: false means the key may be absent or null; .nil || .url then says it is either nil or a valid URL.

  • customFailureDescription replaces the generated message as is, without the key prefix. The default message reads "authorEmail is not a valid email address"; the custom one reads exactly "must be 13 digits without hyphens". Write a complete sentence or map it together with failure.key as the field-errors section does.

  • The validations(_:) function takes an inout Validations. The Vapor 3 form that returned a list of Validation values is gone.

Using it in a handler:

api.post("books") { req async throws -> Response in
    try CreateBookRequest.validate(content: req)
    let input = try req.content.decode(CreateBookRequest.self)
    let book = BookDTO(id: 1, title: input.title, isbn: input.isbn,
                       genre: Genre(rawValue: input.genre) ?? .fiction)
    return try await book.encodeResponse(status: .created, for: req)
}

The full route file is in the field-errors section. A valid request answers 201 Created:

$ curl -i -X POST localhost:8080/api/v1/books -H 'Content-Type: application/json' \
    -d '{"title":"Dune","isbn":"9780441013593","authorEmail":"frank@example.com","pages":412,"genre":"fiction"}'
{"genre":"fiction","isbn":"9780441013593","title":"Dune","id":1}

A request that breaks every rule gets all of them reported at once (this is the body of the field-errors middleware below, produced by the listing’s rules):

{"status":422,"title":"Validation failed","errors":[
  {"field":"title","message":"is empty"},
  {"field":"isbn","message":"must be 13 digits without hyphens"},
  {"field":"authorEmail","message":"is not a valid email address"},
  {"field":"pages","message":"is less than minimum of 1"},
  {"field":"genre","message":"is not fiction, history, or science"},
  {"field":"website","message":"is not null and is an invalid URL"}]}

(The output was captured on one line; it is wrapped here for reading.)

The add methods and the required flag

Validations has four add methods.

Method Use

add(_ key:, as: T.Type, is: Validator<T> = .valid, required: Bool = true, customFailureDescription: String? = nil)

one value; with the default .valid validator it only checks that the key exists and decodes as T

add(_ key:, required: Bool = true, customFailureDescription: String? = nil, _ nested: (inout Validations) -> ())

a nested object; the closure declares the validations of the inner keys

add(each key:, required: Bool = true, customFailureDescription: String? = nil, _ handler: @Sendable (Int, inout Validations) -> ())

an array of objects; the handler runs for each element and receives its index

add(_ key:, result: ValidatorResult, customFailureDescription: String? = nil)

a result computed outside the decoder (for example from a header or a database lookup that you did before declaring the rules); it ignores the key’s value

What a validation reports depends on the state of the key in the data. These are the messages captured from the listing above and from the query listing below:

Data With required: true (default) With required: false

key missing

"is required"

skipped, no failure

key present with null

"cannot be null"

skipped, no failure

value of another type ("pages": "many")

"is not a(n) Int"

"is not a(n) Int"

value out of the rules

the validator’s message

the validator’s message

For a required: false key declared with an optional type (String?.self), combine the validator with .nil so the null case passes explicitly: .nil || .count(2...100). Declaring the type without the optional and required: false also works for an absent key, but a present null is then a type mismatch.

An unknown extra key in the body is ignored: validation checks the declared keys only, it does not enforce a schema. If the API must reject unknown fields, decode into a stricter type or check the keys yourself.

The entry points

Validatable adds these static methods. All of them throw a ValidationsError when a rule fails:

Method Behaviour

validate(content: Request)

decodes request.body.data with the decoder of the request’s Content-Type taken from ContentConfiguration.global; JSON and URL-encoded forms both work

validate(query: Request)

decodes the query string of request.url with the URL decoder

validate(json: String)

validates a JSON string; handy for unit tests

validate(query: URI)

validates the query of a URI value

validate(_ decoder: Decoder)

validates any Decoder; used when validating inside a custom init(from:)

validations()

returns the Validations value itself without running it

Things that validate(content:) does before any rule runs, and what they answer:

  • No Content-Type header: 422 Unprocessable Entity, with the reason Missing "Content-Type" header.

  • No collected body (empty body, or a route declared with body: .stream, where request.body.data is nil): 422, "Empty Body".

  • Unparseable JSON: the decoder’s error, which Vapor’s error middleware answers with 400 and the message "Data corrupted at path ''. The given data was not valid JSON." followed by the Foundation error text.

  • A body that parses but is not an object (an array at the top level): a DecodingError.typeMismatch for a dictionary.

Notice the inconsistency: those errors use 422 or 400 depending on the case, while the ValidationsError itself is a 400. The field-errors section below normalises the failing-rule case to 422; decide on one status for the API and document it.

If you need the results without throwing, call validations().validate(...) yourself. It returns a ValidationsResult whose results array holds a ValidationResult for every declared key, failed or not:

let result = try BookSearchQuery.validations().validate(json: #"{"page":0}"#)
let failures = result.results.filter(\.isFailure)
print(failures.map { $0.failureDescription ?? "" })
// ["page is not null and is less than minimum of 1"]

Built-in validators

All the built-ins are static members of Validator, so .email and .count(3...) are written with an implicit leading dot where a Validator<T> is expected.

Validator Applies to Passes when

.count(_:)

any Collection (String, arrays)

the number of characters or elements is within the range; accepts ClosedRange (3...10), Range (3..<10), PartialRangeFrom (3...), PartialRangeThrough (...10) and PartialRangeUpTo (..<10); messages say "character(s)" for strings and "item(s)" otherwise

.range(_:)

Comparable values such as Int and Double

the value is within the range; Range needs a Strideable type and PartialRangeUpTo a SignedInteger

.email

String

matches the regular expression of the Vapor source and has at most 320 characters in total and 64 before the @; a@b fails (a dot in the domain is required)

.internationalEmail

String

the same for addresses with non-ASCII letters

.url

String

parses as a file URL, or has both a scheme and a host; example.com fails, https://example.com passes

.alphanumeric

String and [String]

every character is in Foundation’s CharacterSet.alphanumerics, which is Unicode: "José" passes although the message says "A-Z, a-z, 0-9"

.ascii

String and [String]

every character has a code point below 128

.characterSet(_:)

String and [String]

every character is in the given CharacterSet; .alphanumerics + .whitespaces builds a union with the + operator Vapor defines

.pattern(_:)

String

the whole string matches the regular expression (the match must start at the first character and end at the last, so no anchors are needed)

.in(_:)

Equatable and CustomStringConvertible values

the value is one of the given items (variadic, or any Sequence)

.case(of:)

String, Int, …​ matching the raw value

the value is the raw value of a case of a RawRepresentable & CaseIterable enum

.empty

any Collection

the collection is empty; negate it with ! for "not empty"

.nil

Optional values

the value is nil

.valid

anything

always passes; it only checks that the key exists and decodes as the type

.custom(_:validationClosure:)

any Decodable & Sendable

the closure returns true (see below)

Validators combine with three operators, each of them a function over Validator<T>:

  • ! negates a validator: !.empty.

  • && requires both: .count(3...32) && .alphanumeric. When both sides fail, the messages are joined with " and ".

  • || requires at least one: .nil || .url. A mixed optional/non-optional pair is supported too: the non-optional side simply ignores nil.

Practical details that the table cannot show:

  • .count(..<3) and .range(..<3) exclude the upper bound. The generated message for the first one reads "greater than maximum of 2 character(s)".

  • .in accepts a sequence, so .in(["title", "-title", "published"]) and the variadic form are equivalent, and a constant array can be reused by an OpenAPI description or a UI.

  • .pattern uses String.range(of:options:) with the regular-expression option, so it is the Foundation (ICU) syntax, not Swift’s Regex. A pattern with nested quantifiers on user input is a ReDoS risk like in any engine; keep patterns simple and bound the length with .count in the same chain.

  • The default .email is a syntactic check. It cannot tell you that the mailbox exists; only sending the verification email does that.

Query parameters

validate(query:) works the same way over the query string. All values of a query string arrive as text, so the declared types do the conversion: page=abc for an Int? key produces "is not a(n) Int". The Bookshelf search:

import Vapor

struct BookSearchQuery: Content, Validatable {
    var q: String?
    var page: Int?
    var perPage: Int?
    var sort: String?

    static func validations(_ validations: inout Validations) {
        validations.add("q", as: String?.self,
                        is: .nil || .count(2...100), required: false)
        validations.add("page", as: Int?.self,
                        is: .nil || .range(1...), required: false)
        validations.add("perPage", as: Int?.self,
                        is: .nil || .range(1...100), required: false)
        validations.add("sort", as: String?.self,
                        is: .nil || .in("title", "-title", "published"), required: false)
    }
}

Every key is optional, so each rule starts with .nil ||. Calling it in the route and then decoding the same type:

api.get("books") { req async throws -> [BookDTO] in
    try BookSearchQuery.validate(query: req)
    let search = try req.query.decode(BookSearchQuery.self)
    req.logger.info("search", metadata: ["page": "\(search.page ?? 1)"])
    return []
}

GET /api/v1/books?q=a&page=0&perPage=500&sort=price fails four rules at once:

{"status":422,"title":"Validation failed","errors":[
  {"field":"q","message":"is not null and is less than minimum of 2 character(s)"},
  {"field":"page","message":"is not null and is less than minimum of 1"},
  {"field":"perPage","message":"is not null and is greater than maximum of 100"},
  {"field":"sort","message":"is not null and is not title, -title, or published"}]}

The "is not null and" prefix comes from the .nil || combination: both sides failed, and the messages are joined. If it bothers you, write customFailureDescription for these keys or strip the prefix when mapping to field errors.

Nested objects and arrays

The add overloads with a closure descend into an object, and add(each:) runs the closure once per array element. A book is published by one publisher and has a list of contributors:

import Vapor

struct PublishBookRequest: Content, Validatable {
    struct Publisher: Content {
        var name: String
        var country: String
    }
    struct Contributor: Content {
        var name: String
        var role: String
    }

    var title: String
    var publisher: Publisher
    var contributors: [Contributor]

    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String.self, is: !.empty)
        validations.add("publisher") { publisher in
            publisher.add("name", as: String.self, is: !.empty)
            publisher.add("country", as: String.self,
                          is: .count(2...2) && .characterSet(.uppercaseLetters))
        }
        validations.add(each: "contributors") { _, contributor in
            contributor.add("name", as: String.self, is: !.empty)
            contributor.add("role", as: String.self,
                            is: .in("author", "editor", "translator"))
        }
    }
}

A failing request reports the nested failures under the key of the container, joined, with the index for arrays ("publisher":{"country":"es"} fails the uppercase rule):

{"status":422,"title":"Validation failed","errors":[
  {"field":"title","message":"is empty"},
  {"field":"publisher","message":"name is empty and country contains 'e' (allowed: A-Z)"},
  {"field":"contributors","message":"at index 1 name is empty and role is not author, editor, or translator"}]}

A missing publisher object gives {"field":"publisher","message":"is required"}. Because the nested messages are folded into one string per container, a client that wants one error per inner field has to be given the structure itself: walk ValidationResult.result (it is a ValidatorResults.Nested or ValidatorResults.NestedEach whose results hold the inner ValidatorResult`s) and build paths such as `contributors[1].name. The folded string is enough for a form with a handful of fields.

The each handler is @Sendable, so it cannot capture mutable local state.

Custom validators

Two levels exist. For a one-off rule, .custom(_:validationClosure:) takes a description and a @Sendable closure returning Bool:

extension Validator where T == String {
    static var noLeadingOrTrailingSpaces: Validator<T> {
        .custom("not start or end with whitespace") { value in
            value == value.trimmingCharacters(in: .whitespacesAndNewlines)
        }
    }
}

The description is spliced into a fixed sentence: "is not successfully validated for custom validation 'not start or end with whitespace'." That is clumsy in an API response, which is the reason for the second level: a validator with its own ValidatorResult, which controls both messages. A password-strength rule that names what is missing:

import Vapor

extension ValidatorResults {
    /// Result of the password strength validator.
    struct PasswordStrength: Sendable {
        let missing: [String]
    }
}

extension ValidatorResults.PasswordStrength: ValidatorResult {
    var isFailure: Bool { !missing.isEmpty }
    var successDescription: String? { "is strong enough" }
    var failureDescription: String? {
        "is too weak, it needs " + missing.joined(separator: ", ")
    }
}

extension Validator where T == String {
    /// At least one uppercase letter, one lowercase letter and one digit.
    static var strongPassword: Validator<T> {
        .init { password in
            var missing: [String] = []
            if password.rangeOfCharacter(from: .uppercaseLetters) == nil {
                missing.append("an uppercase letter")
            }
            if password.rangeOfCharacter(from: .lowercaseLetters) == nil {
                missing.append("a lowercase letter")
            }
            if password.rangeOfCharacter(from: .decimalDigits) == nil {
                missing.append("a digit")
            }
            return ValidatorResults.PasswordStrength(missing: missing)
        }
    }

    /// A validator built from a closure with `.custom`.
    static var noLeadingOrTrailingSpaces: Validator<T> {
        .custom("not start or end with whitespace") { value in
            value == value.trimmingCharacters(in: .whitespacesAndNewlines)
        }
    }
}

struct RegisterRequest: Content, Validatable {
    var username: String
    var email: String
    var password: String
    var passwordConfirmation: String

    static func validations(_ validations: inout Validations) {
        validations.add("username", as: String.self,
                        is: .count(3...32) && .alphanumeric && .noLeadingOrTrailingSpaces)
        validations.add("email", as: String.self, is: .email)
        validations.add("password", as: String.self,
                        is: .count(12...) && .strongPassword)
        validations.add("passwordConfirmation", as: String.self)
    }
}

The built-in validators are written exactly this way (each has a ValidatorResults.X struct), so reading Count.swift or Email.swift in the Vapor source is the best reference for writing one. Both listings above declare the Validator and its result in extensions, so they work with &&, || and ! like the built-ins.

A weak sign-up body shows the combined messages (a leading space breaks both the character set and the custom rule):

{"status":422,"title":"Validation failed","errors":[
  {"field":"username","message":"contains ' ' (allowed: A-Z, a-z, 0-9) and is not successfully validated for custom validation 'not start or end with whitespace'."},
  {"field":"password","message":"is less than minimum of 12 character(s) and is too weak, it needs an uppercase letter, a digit"}]}

Rules across several fields

A Validator<T> sees only its own value, so "the password confirmation equals the password" and "the end date is after the start date" cannot be written as a validator. Do them in the handler after the structural validation, and report them in the same shape. The FieldErrors error of the next section is the carrier:

api.post("users") { req async throws -> HTTPStatus in
    try RegisterRequest.validate(content: req)
    let input = try req.content.decode(RegisterRequest.self)
    guard input.password == input.passwordConfirmation else {
        throw FieldErrors(errors: [
            FieldError(field: "passwordConfirmation",
                       message: "does not match password")
        ])
    }
    return .created
}

The same goes for rules that need a database, such as "this email is not registered yet": a validator is a synchronous closure with no access to the request, so look the value up in the handler (Fluent is covered in Fluent Queries and Pagination) and throw a FieldErrors or an Abort(.conflict). Keep the unique index on the column as well; two concurrent requests can both pass the lookup.

Reporting errors to the client

By default a thrown ValidationsError becomes a 400 Bad Request with Vapor’s standard error body {"error":true,"reason":"..."}, where the reason is the joined message list. That is readable by a human and awkward for a client that wants to mark individual form fields. The listing below turns the failures into a list of {field, message} objects with status 422, in a middleware attached to the route group. It handles both the ValidationsError of the rules and the FieldErrors that a handler throws for cross-field rules:

import Vapor

struct FieldError: Content, Equatable {
    var field: String
    var message: String
}

struct ValidationProblem: Content {
    var title = "Validation failed"
    var status = 422
    var errors: [FieldError]
}

/// Errors found after decoding, for rules that span several fields.
struct FieldErrors: Error {
    var errors: [FieldError]
}

extension ValidationsError {
    var fieldErrors: [FieldError] {
        failures.map { failure in
            FieldError(
                field: failure.key.stringValue,
                message: failure.customFailureDescription
                    ?? failure.result.failureDescription
                    ?? "is invalid")
        }
    }
}

struct ValidationProblemMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        do {
            return try await next.respond(to: request)
        } catch let error as ValidationsError {
            return try problem(error.fieldErrors)
        } catch let error as FieldErrors {
            return try problem(error.errors)
        }
    }

    private func problem(_ errors: [FieldError]) throws -> Response {
        let response = Response(status: .unprocessableEntity)
        try response.content.encode(ValidationProblem(errors: errors),
                                    as: .json)
        return response
    }
}

The Content types make the body encode as JSON without further code. failure.key.stringValue is the field name; customFailureDescription wins over the generated message when present. The routes that use it:

import Vapor

func validationRoutes(_ app: Application) throws {
    let api = app.grouped("api", "v1").grouped(ValidationProblemMiddleware())

    api.post("books") { req async throws -> Response in
        try CreateBookRequest.validate(content: req)
        let input = try req.content.decode(CreateBookRequest.self)
        let book = BookDTO(id: 1, title: input.title, isbn: input.isbn,
                           genre: Genre(rawValue: input.genre) ?? .fiction)
        return try await book.encodeResponse(status: .created, for: req)
    }

    api.get("books") { req async throws -> [BookDTO] in
        try BookSearchQuery.validate(query: req)
        let search = try req.query.decode(BookSearchQuery.self)
        req.logger.info("search", metadata: ["page": "\(search.page ?? 1)"])
        return []
    }

    api.post("publications") { req async throws -> HTTPStatus in
        try PublishBookRequest.validate(content: req)
        return .accepted
    }

    api.post("users") { req async throws -> HTTPStatus in
        try RegisterRequest.validate(content: req)
        let input = try req.content.decode(RegisterRequest.self)
        guard input.password == input.passwordConfirmation else {
            throw FieldErrors(errors: [
                FieldError(field: "passwordConfirmation",
                           message: "does not match password")
            ])
        }
        return .created
    }
}

Register the validationRoutes(_:) function in configure. The group’s middleware sits inside Vapor’s default error middleware, so it sees the error first and everything it does not handle (a decoding error, an Abort) goes on to the default handler unchanged.

Status choice: 400 versus 422. Vapor’s own ValidationsError is a 400. Many APIs reserve 400 for a request that cannot be parsed at all and use 422 Unprocessable Entity for a well-formed body whose values are unacceptable; others prefer the 400 uniformly. Both are defensible. Pick one, apply it to the missing-header, empty-body and failing-rule cases alike, and document it in the OpenAPI description. A standardised body for this is RFC 9457 Problem Details; the Error Handling and OpenAPI with swift-openapi-generator pages cover it, and ValidationProblem above is a simplified cousin.

Validating web forms

For server-rendered pages the response to a failed submission is the same form again, with the values the user typed and the messages next to the fields, at status 422 (or 200 if you prefer, as long as the browser does not cache it). Two rules matter more than the markup:

  • Do not redirect to the form with the errors in the query string. The URL ends up in logs, referrer headers and browser history, and anyone who can craft a link can put text of their choice into your page. Re-render in the response of the POST. When a redirect is unavoidable (post/redirect/get), carry the messages in the session as flash data (the session-based pattern belongs to Sessions and Cookies and Server-Rendered Web Apps and Forms) and delete them when they are shown.

  • Never echo a password back into the form, and escape every value you echo. The listing escapes the text it writes because it builds HTML by hand; a Leaf template escapes #(name) automatically, and req.view.render("signup", context) with the form values and the [FieldError] array in the context is the usual shape. (Leaf is not part of the scratch package, so only the plain-Swift version below was compiled.)

import Vapor

private func escapeHTML(_ text: String) -> String {
    text.replacingOccurrences(of: "&", with: "&amp;")
        .replacingOccurrences(of: "<", with: "&lt;")
        .replacingOccurrences(of: ">", with: "&gt;")
        .replacingOccurrences(of: "\"", with: "&quot;")
}

private func signupPage(username: String, email: String, errors: [FieldError]) -> String {
    func messages(for field: String) -> String {
        errors.filter { $0.field == field }
            .map { "<p class=\"error\">\(escapeHTML($0.field)) \(escapeHTML($0.message))</p>" }
            .joined()
    }
    return """
    <form method="post" action="/signup">
      <input name="username" value="\(escapeHTML(username))">\(messages(for: "username"))
      <input name="email" value="\(escapeHTML(email))">\(messages(for: "email"))
      <input name="password" type="password">\(messages(for: "password"))
      <input name="passwordConfirmation" type="password">\(messages(for: "passwordConfirmation"))
      <button>Sign up</button>
    </form>
    """
}

private func htmlResponse(_ html: String, status: HTTPResponseStatus) -> Response {
    var headers = HTTPHeaders()
    headers.contentType = .html
    return Response(status: status, headers: headers, body: .init(string: html))
}

func webFormRoutes(_ app: Application) throws {
    app.get("signup") { _ async -> Response in
        htmlResponse(signupPage(username: "", email: "", errors: []), status: .ok)
    }

    app.post("signup") { req async throws -> Response in
        // Never echo the password fields back, only the harmless ones.
        let form = try? req.content.decode(RegisterRequest.self)
        let username = (try? req.content.get(String.self, at: "username")) ?? ""
        let email = (try? req.content.get(String.self, at: "email")) ?? ""
        do {
            try RegisterRequest.validate(content: req)
            guard let form, form.password == form.passwordConfirmation else {
                throw FieldErrors(errors: [
                    FieldError(field: "passwordConfirmation", message: "does not match password")
                ])
            }
            return req.redirect(to: "/welcome", redirectType: .normal)
        } catch let error as ValidationsError {
            return htmlResponse(signupPage(username: username, email: email,
                                           errors: error.fieldErrors),
                                status: .unprocessableEntity)
        } catch let error as FieldErrors {
            return htmlResponse(signupPage(username: username, email: email,
                                           errors: error.errors),
                                status: .unprocessableEntity)
        }
    }
}

Posting username=al<b>&email=x&password=abc&passwordConfirmation=abd answers 422 Unprocessable Entity with Content-Type: text/html; charset=utf-8, the typed username and email escaped in the value attributes, and no password field values:

<form method="post" action="/signup">
  <input name="username" value="al&lt;b&gt;"><p class="error">username contains '&lt;' (allowed: A-Z, a-z, 0-9)</p>
  <input name="email" value="x"><p class="error">email is not a valid email address</p>
  <input name="password" type="password"><p class="error">password is less than minimum of 12 character(s) and is too weak, it needs an uppercase letter, a digit</p>
  <input name="passwordConfirmation" type="password">
  <button>Sign up</button>
</form>

A valid submission answers 303 See Other to /welcome. The validate(content:) call works on URL-encoded and multipart forms because it picks the decoder from the Content-Type. A form field that is not a number gives "is not a(n) Int", the same as JSON. A checkbox that is not ticked is absent from the form, so declare it required: false.

CSRF protection for state-changing form posts is a separate concern from validation; it is covered in Server-Rendered Web Apps and Forms and Security Hardening.

Testing validations

Because validate(json:) and validations().validate(...) run without a server, the rules are easy to unit test, and a route test with VaporTesting (see Testing) checks the response contract:

import Testing
import VaporTesting
@testable import App

@Suite struct BookValidationTests {
    @Test func reportsTheFailingFieldsWithoutAnHTTPServer() throws {
        let json = #"""
        {"title":"Dune","isbn":"123","authorEmail":"frank@example.com",
         "pages":412,"genre":"fiction"}
        """#
        let result = try CreateBookRequest.validations().validate(json: json)
        let failed = result.results.filter(\.isFailure).map(\.key.stringValue)
        #expect(failed == ["isbn"])
    }

    @Test func answersWithAProblemBody() async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await app.testing().test(
                .POST, "api/v1/books",
                headers: ["Content-Type": "application/json"],
                body: ByteBuffer(string: #"{"title":"","pages":0}"#)
            ) { res async throws in
                #expect(res.status == .unprocessableEntity)
                let problem = try res.content.decode(ValidationProblem.self)
                #expect(problem.errors.contains(
                    FieldError(field: "pages", message: "is less than minimum of 1")))
            }
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }
}

Swift 6 concurrency notes

  • Validations, Validator, ValidatorResult and ValidationResult are Sendable. Declare custom results as Sendable structs of Sendable fields, as PasswordStrength does; ValidatorResult itself requires it.

  • A Validator is built from a @Sendable closure. A closure passed to .custom, to Validator.init(validate:) or to add(each:) cannot capture a mutable variable or a class instance that is not Sendable. (Request is Sendable in Vapor 4.122.2, so capturing it compiles, but validations(_:) is static and has no request to capture.) The compiler diagnoses a mutable capture in the Swift 6 language mode with "mutation of captured var 'counter' in concurrently-executing code". Extract the values you need as let constants of Sendable types before building the validator.

  • Validatable.validations(_:) is a nonisolated static function. It can be called from any actor and from tests, which is why it must not touch an @MainActor or actor-isolated state.

  • .case(of: Genre.self) requires the enum’s metatype to be sendable (Vapor’s VaporSendableMetatype); an ordinary String-backed enum satisfies it without any code.

  • Validation is synchronous. There is no async validator, which is why checks that need I/O live in the handler.

See Actors, Isolation and Sendable for the language side.

Common errors and pitfalls

Symptom Cause and fix

Compiler: generic parameter 'T' could not be inferred on add

The is: validator is generic over the declared type. Pass as: String.self (or String?.self for an optional) so the leading-dot shorthand has a type to resolve against

Compiler: contextual member reference to static method 'count' requires 'Self' constraint in the protocol extension

The validator does not apply to the declared type, for example .count on an Int or .range on a String. Check the "Applies to" column

Compiler: mutation of captured var 'counter' in concurrently-executing code (or a non-Sendable capture error)

A custom validator closure captured mutable or non-Sendable state; copy what you need into a let of a Sendable type first

{"error":true,"reason":"Missing \"Content-Type\" header"} with status 422

The client sent a body without a Content-Type; the validation cannot choose a decoder. Send application/json or the form type

"Empty Body" with status 422

No body was sent, or the route uses body: .stream, which leaves request.body.data empty. Use the default .collect strategy for routes that validate the body

A missing key reports "is required" but the message is custom

customFailureDescription applies to every failure of that key, including missing and null values

"José" passes .alphanumeric

It uses the Unicode alphanumerics set. For ASCII only, use .alphanumeric && .ascii

Validation passes and then decode throws

The rules are declared separately from the properties. A key that you forgot to validate, or a Genre-typed property, can still fail in the decoder; keep the two in step

A ValidationsError answers 400 while the client expects 422

It is an AbortError with .badRequest. Catch it in a middleware, as above, or map it where you use it

validate(query:) rejects a number

Query values are text. The declared type does the conversion; page=abc is a type mismatch, not a range failure

Messages start with the key but the custom message does not

The custom description is used verbatim; the key is only prefixed to the generated messages

Security notes

  • Validation is a convenience layer, not a guarantee. Whatever a client sends can bypass a browser-side check, so the server rules are the only ones that count, and they are in addition to database constraints (NOT NULL, UNIQUE, CHECK), not instead of them.

  • Prefer allow-lists: .in(...) and .case(of:) for enumerated values, .pattern anchored to what you expect.

  • Bound every size: .count on strings and arrays, .range on numbers. The request body itself is limited separately by the route’s body: .collect(maxSize:) (16 KB by default) and the server’s maxBodySize; validation runs only after the body was accepted.

  • Do not log validation failures that contain the submitted value of a secret field. The messages above name the field, not the value, except for the character-set messages, which quote the first offending character; do not put a password in a character-set rule if you log those messages.

  • Validation does not escape output. Escape on output (Leaf does by default) and use parameterised queries; the username validated above as alphanumeric is safe by construction, a free-text field is not.

Version notes and outdated material

  • The entry points of Vapor 4 are validate(content:), validate(query:) and validate(json:), and the protocol requirement is static func validations(_ validations: inout Validations) with string keys. Vapor 3 had a different Validatable; snippets from Vapor 3 era books and tutorials, including code that builds the list as a return value or with key paths, do not compile against Vapor 4. The doc comment on the Validatable protocol in the Vapor 4 source still shows an older validations() -> [Validation] form; it is stale, the inout form is the real requirement.

  • Condon et al. (Kodeco), chapter 21, covers Validatable, the built-in validators, a custom password-confirmation rule and reporting the errors; it is one of the chapters still written for Vapor 3 in the early-access edition, and it reflects validation messages through the query string, which this page does not do. What to check when you follow it: the validations(_:) signature above, that the ValidationsError message is one joined string (the structured failures array and the field-errors middleware here are the better fit for an API), and that required: false with a .nil || validator is how optional keys are declared.

  • Validation never runs implicitly: decoding a Content does not validate, you call validate(...) yourself.

  • Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released. In its source validate(content:) becomes async throws, validate(query:) and validate(json:) take a ContentConfiguration argument (defaulting to .default()), validators use any ValidatorResult, and ValidationsError still conforms to AbortError with a bad request status, now typed HTTPResponse.Status from swift-http-types. Add try await to your validate(content:) calls when you move to Vapor 5. This page’s code is written for Vapor 4.122.x.