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.
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 |
|---|---|
|
protocol with one requirement, |
|
the list being built; |
|
a |
|
protocol with |
|
a typealias for |
|
one key plus its |
|
all the |
|
thrown by the |
Source: Vapor docs: Validation
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
}
Source: Vapor docs: Validation
A few things to notice in the listing.
-
The
genreproperty is aStringon purpose. If it were declared as theGenreenum, a bad value would fail in theCodabledecoding 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. -
websiteis optional.required: falsemeans the key may be absent ornull;.nil || .urlthen says it is eithernilor a valid URL. -
customFailureDescriptionreplaces 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 withfailure.keyas the field-errors section does. -
The
validations(_:)function takes aninout Validations. The Vapor 3 form that returned a list ofValidationvalues 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)
}
Source: Vapor docs: Validation
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 |
|---|---|
|
one value; with the default |
|
a nested object; the closure declares the validations of the inner keys |
|
an array of objects; the handler runs for each element and receives its index |
|
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 |
"cannot be null" |
skipped, no failure |
value of another type ( |
"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 |
|---|---|
|
decodes |
|
decodes the query string of |
|
validates a JSON string; handy for unit tests |
|
validates the query of a |
|
validates any |
|
returns the |
Things that validate(content:) does before any rule runs, and what they answer:
-
No
Content-Typeheader:422 Unprocessable Entity, with the reasonMissing "Content-Type" header. -
No collected body (empty body, or a route declared with
body: .stream, whererequest.body.dataisnil):422, "Empty Body". -
Unparseable JSON: the decoder’s error, which Vapor’s error middleware answers with
400and 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.typeMismatchfor 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 |
|---|---|---|
|
any |
the number of characters or elements is within the range; accepts |
|
|
the value is within the range; |
|
|
matches the regular expression of the Vapor source and has at most 320 characters in total and 64 before the |
|
|
the same for addresses with non-ASCII letters |
|
|
parses as a file URL, or has both a scheme and a host; |
|
|
every character is in Foundation’s |
|
|
every character has a code point below 128 |
|
|
every character is in the given |
|
|
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) |
|
|
the value is one of the given items (variadic, or any |
|
|
the value is the raw value of a case of a |
|
any |
the collection is empty; negate it with |
|
|
the value is |
|
anything |
always passes; it only checks that the key exists and decodes as the type |
|
any |
the closure returns |
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 ignoresnil.
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)". -
.inaccepts 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. -
.patternusesString.range(of:options:)with the regular-expression option, so it is the Foundation (ICU) syntax, not Swift’sRegex. A pattern with nested quantifiers on user input is a ReDoS risk like in any engine; keep patterns simple and bound the length with.countin the same chain. -
The default
.emailis 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)
}
}
Source: Vapor docs: Validation
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 []
}
Source: Vapor docs: Content, query
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
}
Source: Vapor docs: Validation
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
}
}
Source: Vapor docs: Middleware
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
}
}
Source: Vapor docs: Validation
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, andreq.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: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
}
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)
}
}
}
Source: Vapor docs: Validation
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<b>"><p class="error">username contains '<' (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()
}
}
Source: Vapor docs: Testing
Swift 6 concurrency notes
-
Validations,Validator,ValidatorResultandValidationResultareSendable. Declare custom results asSendablestructs ofSendablefields, asPasswordStrengthdoes;ValidatorResultitself requires it. -
A
Validatoris built from a@Sendableclosure. A closure passed to.custom, toValidator.init(validate:)or toadd(each:)cannot capture a mutable variable or a class instance that is notSendable. (RequestisSendablein Vapor 4.122.2, so capturing it compiles, butvalidations(_:)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 asletconstants ofSendabletypes 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@MainActoror actor-isolated state. -
.case(of: Genre.self)requires the enum’s metatype to be sendable (Vapor’sVaporSendableMetatype); an ordinaryString-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: |
The |
Compiler: |
The validator does not apply to the declared type, for example |
Compiler: |
A custom validator closure captured mutable or non- |
|
The client sent a body without a |
"Empty Body" with status 422 |
No body was sent, or the route uses |
A missing key reports "is required" but the message is custom |
|
"José" passes |
It uses the Unicode |
Validation passes and then |
The rules are declared separately from the properties. A key that you forgot to validate, or a |
A |
It is an |
|
Query values are text. The declared type does the conversion; |
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,.patternanchored to what you expect. -
Bound every size:
.counton strings and arrays,.rangeon numbers. The request body itself is limited separately by the route’sbody: .collect(maxSize:)(16 KB by default) and the server’smaxBodySize; 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
usernamevalidated 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:)andvalidate(json:), and the protocol requirement isstatic func validations(_ validations: inout Validations)with string keys. Vapor 3 had a differentValidatable; 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 theValidatableprotocol in the Vapor 4 source still shows an oldervalidations() -> [Validation]form; it is stale, theinoutform 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: thevalidations(_:)signature above, that theValidationsErrormessage is one joined string (the structuredfailuresarray and the field-errors middleware here are the better fit for an API), and thatrequired: falsewith a.nil ||validator is how optional keys are declared. -
Validation never runs implicitly: decoding a
Contentdoes not validate, you callvalidate(...)yourself. -
Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released. In its source
validate(content:)becomesasync throws,validate(query:)andvalidate(json:)take aContentConfigurationargument (defaulting to.default()), validators useany ValidatorResult, andValidationsErrorstill conforms toAbortErrorwith a bad request status, now typedHTTPResponse.Statusfrom swift-http-types. Addtry awaitto yourvalidate(content:)calls when you move to Vapor 5. This page’s code is written for Vapor 4.122.x.