Server-Rendered Web Apps and Forms
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools
version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with Leaf 4.5.2 (LeafKit
1.14.3), fluent 4.13.0, fluent-sqlite-driver 4.9.0, Bootstrap 5.3.8 and htmx 2.0.11, checked on 2026-10-17. Every Swift
listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, every Leaf
template was rendered by that package, and the behaviour described was verified with a VaporTesting suite (eight tests,
all passing) and with curl against the running service on SQLite; the output shown is captured from those runs. The
JavaScript halves of the Bootstrap and htmx features were not exercised in a browser here: what the page claims about
them rests on the server-side requests and responses and on the official htmx and Bootstrap documentation, and it says
so where it matters. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the code targets 4.122.x, and the Leaf
integration for Vapor 5 is listed among the things to check before moving in the version notes.
The page builds the Bookshelf catalogue UI on the canonical models of the earlier pages of this section (Book,
Author, Review, Tag), with no schema changes. It uses the view and routing machinery explained elsewhere: the
Leaf Templates page covers the template language in depth, and this page uses
only what a CRUD application needs. Swift itself is covered by the Swift
Reference, and async functions, Sendable and actors by
Async/Await and Tasks and
Actors, Isolation and Sendable.
What "server-rendered" means here
In a server-rendered application the server answers a browser’s request with finished HTML. The browser never needs to
run JavaScript to show a page or to submit a form: links are GET requests, forms are POST requests, and every state
change is a full round trip that ends in another page. That model has properties worth wanting:
-
it works with the browser’s own features (back button, bookmarks, form autofill, password managers, screen readers) because the pages are plain documents;
-
all validation and authorisation runs on the server, where it has to run anyway, and the template is the only place that formats output;
-
there is one codebase and one deployment: no API versioning between a front end and a back end, no client-side state to keep in sync.
The costs are a round trip per interaction and the discipline of keeping state out of the page. Progressive enhancement is the bridge: the application is complete and correct without JavaScript, and a small library (htmx in this page) upgrades some interactions to partial-page updates, falling back to the full-page behaviour when it does not run. When to choose this model rather than a JSON API plus a separate front end (or an HTML DSL instead of Leaf) is discussed at the end of the Leaf Templates and Leaf Custom Tags and HTML DSLs pages; this page assumes the decision is made and shows the whole application.
The application at a glance
The routes below are everything the browser UI needs. A plain HTML form can only send GET and POST, so updating and
deleting use POST to a verb-carrying URL; the single DELETE route exists for htmx, which can send real DELETE
requests.
| Method and path | Handler | Result |
|---|---|---|
|
|
HTML list with search and pagination; only the table fragment when htmx asked; a JSON array when the client prefers JSON |
|
|
empty form |
|
|
303 to the new book’s page, or 422 with the form re-rendered |
|
|
HTML detail page, or the book as JSON on request |
|
|
form filled with the current values |
|
|
303 back to the book’s page, or 422 |
|
|
303 to the list with a flash message |
|
|
empty 200, so htmx can remove the table row |
Every route lives in a RouteCollection (Controllers and
Route Collections) behind the sessions middleware and a CSRF middleware, both described below.
Where it sits in the request
In the request lifecycle (SwiftNIO accepts the connection and parses HTTP, Vapor builds a Request, the responder chain
runs the middleware, the router matches a route, the handler runs, and its return value is encoded into the Response),
a server-rendered page touches more of the chain than a JSON endpoint does, because it needs a session and an HTML-aware
error path. This is the order configured in configure.swift, outermost first:
-
ErrorMiddleware(installed by default): turns anything still thrown into a JSON error response. -
SessionsMiddleware(app.sessions.middleware): reads the session cookie on the way in, loads theSessionfrom the driver, and on the way out saves it and sets the cookie. Everything inside it can usereq.session. -
FileMiddleware: serves files fromPublic/and falls through to the next link for anything else. -
HTMLErrorMiddleware(page-local): catches errors from the handlers and renders an HTML error page for browsers. -
CSRFMiddleware(a route-group middleware, only for/books): rejectsPOST,PUT,PATCHandDELETEwithout a valid token. -
The router, then the handler, which calls the database, builds a view model and renders a Leaf template into a
View, and theViewis encoded into aResponse.
Two ordering rules follow. The CSRF middleware must be inside the sessions middleware, because it reads and writes the
session. The HTML error middleware must be inside ErrorMiddleware (so it sees the thrown error before the default
converts it) and, here, inside the sessions middleware as well (so its error page can embed the session’s CSRF token and
flash messages). The Middleware page explains the chain; the
Routing page explains why group middleware does not run for an unknown URL (the router
finds no route, so no group is entered, which is why a 404 for an unmatched path is handled by the global
HTMLErrorMiddleware, not by a group).
Project setup
The scratch package is the pinned Bookshelf package of the earlier pages, with Leaf added. These are the two changes to
Package.swift:
.package(url: "https://github.com/vapor/leaf.git", exact: "4.5.2"),
// ...
.product(name: "Leaf", package: "leaf"),
The templates live in Resources/Views/, which is the directory Leaf reads by default (app.directory.viewsDirectory,
relative to the working directory the process runs in). configure.swift wires Leaf, the sessions, the middleware order
above and the routes:
import Fluent
import FluentSQLiteDriver
import Leaf
import Vapor
func configure(_ app: Application) async throws {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.migrations.add(CreateBookshelf())
try await app.autoMigrate()
if app.environment != .testing || Environment.get("SEED") == "1" { try await seed(on: app.db) }
// Templates: Resources/Views, cached only in production so edits show up on reload.
app.views.use(.leaf)
app.leaf.cache.isEnabled = app.environment.isRelease
// Sessions: in-memory (one instance only) with a hardened cookie.
let secure = app.environment.isRelease
app.sessions.use(.memory)
app.sessions.configuration = .init(cookieName: "bookshelf-session") { id in
HTTPCookies.Value(string: id.string, expires: Date(timeIntervalSinceNow: 8 * 3600), maxAge: nil, domain: nil,
path: "/", isSecure: secure, isHTTPOnly: true, sameSite: .lax)
}
// Order (outside in): default ErrorMiddleware, sessions, static files, HTML error pages.
app.middleware.use(app.sessions.middleware)
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(HTMLErrorMiddleware())
app.get { req in req.redirect(to: "/books", redirectType: .normal) }
// The site's own origin, for the CSRF middleware's Origin check. Set PUBLIC_ORIGIN in production.
let origin = Environment.get("PUBLIC_ORIGIN") ?? "http://127.0.0.1:8080"
try app.register(collection: WebBooksController(allowedOrigins: [origin]))
}
Notes on the choices:
-
app.views.use(.leaf)makes Leaf the renderer behindreq.view.app.leaf.cache.isEnabledis set toapp.environment.isRelease: with the cache on, a template is parsed once and edits to the.leaffiles are not seen until restart, which is right in production and wrong while developing. -
app.sessions.use(.memory)keeps sessions in the process’s memory. That is correct for a demo and for tests, and wrong for anything with two instances or restarts: the Fluent and Redis drivers of Sessions and Cookies replace it without touching the code on this page, because everything goes throughreq.session. -
The session cookie is configured explicitly:
HttpOnly(scripts cannot read it),SameSite=Lax(the browser does not send it on cross-sitePOSTrequests),Securein release builds (the browser sends it only over HTTPS, so a release build tried over plainhttp://localhostnever keeps its session), and an eight-hour expiry. -
The seed runs on start-up for the demo and is skipped in the testing environment, so the tests create their own rows.
The base layout and the Leaf tags used
Leaf 4 layouts use #extend, #export and #import: a page extends the base template and exports named
blocks, and the base template imports them where they belong. The layout carries everything shared by all pages: the
Bootstrap stylesheet and scripts with their integrity hashes, a skip link, the navigation, the flash messages and the
htmx configuration.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#(title) - Bookshelf</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<meta name="htmx-config" content='{"historyRestoreAsHxRequest": false, "responseHandling": [{"code": "204", "swap": false}, {"code": "[23]..", "swap": true}, {"code": "422", "swap": true}, {"code": "[45]..", "swap": false, "error": true}]}'>
</head>
<body hx-headers='{"X-CSRF-Token": "#(csrf)"}'>
<a class="visually-hidden-focusable" href="#main">Skip to main content</a>
<nav class="navbar navbar-expand bg-body-tertiary" aria-label="Main">
<div class="container">
<a class="navbar-brand" href="/books">Bookshelf</a>
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/books">Books</a></li>
<li class="nav-item"><a class="nav-link" href="/books/new">New book</a></li>
</ul>
</div>
</nav>
<main id="main" class="container my-4">
#for(flash in flashes):
<div class="alert alert-#(flash.kind)" role="#(flash.role)">#(flash.text)</div>
#endfor
#import("content")
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg" crossorigin="anonymous"></script>
</body>
</html>
Source: Vapor docs: Leaf, overview
Points to notice in the layout:
-
#(title),#(csrf)and#(flashes)come from the top level of the context object, so every page must provide them. ThePagewrapper below guarantees that. -
The page-specific data sits under
content, so a template reads#(content.book.title). Keeping the layout’s fields at the top and the page’s data in one named property stops a page from accidentally shadowingtitleorcsrf. -
#(value)HTML-escapes the value. A title of<script>x</script>is printed as text (<script>x</script>in the HTML source), which is the protection against stored cross-site scripting. Output that must not be escaped goes through#unsafeHTML(...); this page never needs it, and every use of it is a place to review. -
A
#followed by something that is not a tag, as inhref="#main", is left in the output untouched: the lexer in LeafKit 1.14.3 recognises it as a tag name candidate and falls back to plain text, and the rendered page containshref="#main"as written. -
The tags used on this page are
#extend,#export,#import,#for,#if/#else/#endifand#count. Leaf 4.5.2 has no ternary operator: a template that contains#(a == b ? "x" : "y")fails with aLexerError("invalidParameterToken") on the?. It also has no#emptybranch in a#forloop. Both limits are handled in Swift: the view models carry precomputed values (FlashView.role,BookRow.badge) and the templates use#if(count(list) == 0).
The page wrapper, flash messages and the CSRF token
Everything a page needs besides its own data goes through one generic wrapper and a few Request extensions. The flash
message is a value stored in the session and removed on the first read; the CSRF token, req.csrfToken, is created on
first use and kept in the session (it is defined next to the middleware, in CSRF protection).
import Vapor
/// A one-shot message shown on the next page (Bootstrap alert colour in `kind`).
struct FlashMessage: Codable, Sendable {
let kind: String
let text: String
}
extension Request {
/// Queues a flash message in the session; it is shown on the next rendered page.
func flash(_ kind: String, _ text: String) {
var all = peekFlashes()
all.append(FlashMessage(kind: kind, text: text))
if let data = try? JSONEncoder().encode(all) {
session.data["flash"] = String(decoding: data, as: UTF8.self)
}
}
private func peekFlashes() -> [FlashMessage] {
guard let json = session.data["flash"] else { return [] }
return (try? JSONDecoder().decode([FlashMessage].self, from: Data(json.utf8))) ?? []
}
/// Returns the queued messages and removes them, so each is displayed exactly once.
func takeFlashes() -> [FlashMessage] {
let all = peekFlashes()
session.data["flash"] = nil
return all
}
}
/// A flash message as the template sees it: Leaf has no ternary operator, so the ARIA role is computed here.
struct FlashView: Encodable, Sendable {
let kind: String
let text: String
let role: String
init(_ message: FlashMessage) {
kind = message.kind
text = message.text
role = message.kind == "danger" ? "alert" : "status"
}
}
/// Everything the base layout needs, plus the page-specific `content`.
struct Page<Content: Encodable & Sendable>: Encodable, Sendable {
let title: String
let csrf: String
let flashes: [FlashView]
let content: Content
init(_ title: String, _ req: Request, content: Content) {
self.title = title
self.csrf = req.csrfToken
self.flashes = req.takeFlashes().map(FlashView.init)
self.content = content
}
}
extension Request {
/// True when htmx issued the request (and it is not a history-cache restore).
var isHTMX: Bool {
headers.first(name: "HX-Request") == "true" && headers.first(name: "HX-History-Restore-Request") != "true"
}
/// JSON only when the client explicitly prefers it; a missing or wildcard `Accept` means HTML.
var wantsJSON: Bool {
headers.accept.comparePreference(for: .json, to: .html) == .orderedDescending
}
}
extension Response {
func varying(_ names: String...) -> Response {
headers.replaceOrAdd(name: .vary, value: names.joined(separator: ", "))
return self
}
}
Source: Vapor docs: Sessions
How this works:
-
Page<Content>is generic over anyEncodable & Sendablepayload. Leaf’s renderer accepts anyEncodablecontext (it encodes it with its own encoder), so no dictionary ofLeafDatais needed. Because the initializer takes theRequest, the token and the pending flash messages are always read at render time, for every page, in one place. -
flash(_:_:)appends to a JSON array held inreq.session.data["flash"];takeFlashes()reads and clears it. Session data is a[String: String], so structured values are stored as JSON text. After a303redirect the next request loads the session,Page.inittakes the messages, and the session is saved without them, so a reload shows no message. -
Two flash messages queued before one render are both shown.
SessionsMiddlewaresaves the session only when the inner chain returns aResponse; an error that is thrown all the way through it skips the save. HereHTMLErrorMiddlewareturns errors into responses inside the sessions middleware for browsers, so their sessions are saved, but a JSON client’s thrown error is not, so a change made to the session before the failure is lost. Flash only on the success path, as the controller does. -
Request.wantsJSONandRequest.isHTMXare the two decisions the controller makes about the representation, both based on request headers. They are covered in the content negotiation and htmx sections below. -
varying(_:)setsVaryon a response whose body depends on request headers, so a shared cache does not hand the JSON version to a browser.
The list page: search and pagination
The index handler combines a Fluent query with paginate, a search term and the three representations. A BookRow is
the view model for a row: the API fields (BookDTO) plus the presentation detail Leaf cannot compute.
import Vapor
import Fluent
func formatPrice(_ cents: Int) -> String {
let fraction = cents % 100
return "\(cents / 100).\(fraction < 10 ? "0" : "")\(fraction)"
}
/// What the list and detail templates need, and the JSON body for API clients.
struct BookDTO: Content {
let id: UUID
let title: String
let isbn: String
let author: String
let publishedYear: Int
let price: String
let status: String
init(_ book: Book) throws {
self.id = try book.requireID()
self.title = book.title
self.isbn = book.isbn
self.author = book.$author.value?.name ?? ""
self.publishedYear = book.publishedYear
self.price = formatPrice(book.priceCents)
self.status = book.status.rawValue
}
}
/// A table row: the same fields as the API body plus presentation-only data (Leaf cannot compute a badge colour).
struct BookRow: Encodable, Sendable {
let book: BookDTO
let badge: String
init(_ book: Book) throws {
self.book = try BookDTO(book)
self.badge = book.status == .published ? "success" : "secondary"
}
}
struct BookListView: Encodable, Sendable {
let rows: [BookRow]
let q: String
/// `q` percent-encoded for use inside an `href` (Leaf escapes HTML, not URLs).
let encodedQ: String
let page: Int
let pageCount: Int
let total: Int
let hasPrevious: Bool
let hasNext: Bool
let previousPage: Int
let nextPage: Int
}
struct ReviewView: Encodable, Sendable {
let rating: Int
let body: String
}
struct BookDetailView: Encodable, Sendable {
let book: BookDTO
let tags: [String]
let reviews: [ReviewView]
}
struct Option: Encodable, Sendable {
let value: String
let label: String
let selected: Bool
}
struct BookFormView: Encodable, Sendable {
let action: String
let heading: String
let submitLabel: String
let values: BookForm
let errors: FieldErrors
let errorList: [String]
let hasErrors: Bool
let authors: [Option]
let statuses: [Option]
static func make(action: String, heading: String, submitLabel: String, values: BookForm,
errors: FieldErrors, db: any Database) async throws -> BookFormView {
let authors = try await Author.query(on: db).sort(\.$name).all().map {
Option(value: $0.id?.uuidString ?? "", label: $0.name, selected: $0.id?.uuidString == values.authorId)
}
let statuses = [BookStatus.draft, .published, .archived].map {
Option(value: $0.rawValue, label: $0.rawValue.capitalized, selected: $0.rawValue == (values.status ?? "draft"))
}
return BookFormView(action: action, heading: heading, submitLabel: submitLabel, values: values,
errors: errors, errorList: ["title", "isbn", "publishedYear", "price", "authorId", "status", "tags"].compactMap { errors[$0] },
hasErrors: !errors.isEmpty, authors: authors, statuses: statuses)
}
}
struct ErrorView: Encodable, Sendable {
let status: Int
let reason: String
}
Models never reach a template. A Fluent model is @unchecked Sendable in this package, references a database, and
encodes its relations only when they are loaded; copying the fields a page needs into a plain Sendable struct keeps
the template independent of the schema (a renamed column does not break a template), keeps passwords and internal fields
out of the page, and keeps the Swift 6 concurrency checker happy. The same struct (BookDTO) doubles as the JSON body
for API clients.
The handler for the whole controller follows. It is shown in full because the other handlers use the same shape: parse input, run the query, build a view model, choose a representation.
import Vapor
import Fluent
struct WebBooksController: RouteCollection {
let allowedOrigins: Set<String>
func boot(routes: any RoutesBuilder) throws {
let books = routes.grouped("books").grouped(CSRFMiddleware(allowedOrigins: allowedOrigins))
books.get(use: index)
books.post(use: create)
books.get("new", use: newForm)
books.group(":bookID") { book in
book.get(use: show)
book.post(use: update)
book.delete(use: destroyHTMX)
book.get("edit", use: editForm)
book.post("delete", use: destroy)
}
}
// GET /books?q=&page=
@Sendable func index(req: Request) async throws -> Response {
let q = (try? req.query.get(String.self, at: "q"))?.trimmingCharacters(in: .whitespaces) ?? ""
let pageNumber = max(1, (try? req.query.get(Int.self, at: "page")) ?? 1)
var query = Book.query(on: req.db).with(\.$author).sort(\.$title)
if !q.isEmpty { query = query.filter(\.$title ~~ q) }
let page = try await query.paginate(PageRequest(page: pageNumber, per: 5))
let pageCount = max(1, page.metadata.pageCount)
let list = BookListView(
rows: try page.items.map(BookRow.init), q: q,
encodedQ: q.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "", page: pageNumber, pageCount: pageCount,
total: page.metadata.total, hasPrevious: pageNumber > 1, hasNext: pageNumber < pageCount,
previousPage: pageNumber - 1, nextPage: pageNumber + 1)
if req.wantsJSON { return try await list.rows.map(\.book).encodeResponse(for: req).varying("Accept", "HX-Request") }
let template = req.isHTMX ? "books/_table" : "books/index"
let view = try await req.view.render(template, Page("Books", req, content: list))
return try await view.encodeResponse(for: req).varying("Accept", "HX-Request")
}
// GET /books/:bookID : one URL, two representations
@Sendable func show(req: Request) async throws -> Response {
let book = try await loadBook(req, with: true)
let dto = try BookDTO(book)
if req.wantsJSON { return try await dto.encodeResponse(for: req).varying("Accept") }
let detail = BookDetailView(
book: dto, tags: try await book.$tags.get(on: req.db).map(\.name),
reviews: book.reviews.map { ReviewView(rating: $0.rating, body: $0.body) })
let view = try await req.view.render("books/show", Page(book.title, req, content: detail))
return try await view.encodeResponse(for: req).varying("Accept")
}
// GET /books/new
@Sendable func newForm(req: Request) async throws -> View {
let form = try await BookFormView.make(action: "/books", heading: "New book", submitLabel: "Create book",
values: BookForm(status: "draft"), errors: [:], db: req.db)
return try await req.view.render("books/form", Page("New book", req, content: form))
}
// POST /books : validate, then redirect (POST-redirect-GET) or re-render with 422
@Sendable func create(req: Request) async throws -> Response {
let form = try req.content.decode(BookForm.self)
switch try await form.validated(on: req.db) {
case .valid(let input):
let book = Book(title: input.title, isbn: input.isbn, publishedYear: input.publishedYear,
priceCents: input.priceCents, status: input.status, authorID: input.authorID)
try await req.db.transaction { db in
try await book.save(on: db)
try await book.syncTags(input.tags, on: db)
}
req.flash("success", "Created \u{201C}\(book.title)\u{201D}.")
return req.redirect(to: "/books/\(try book.requireID())", redirectType: .normal)
case .invalid(let errors):
let view = try await BookFormView.make(action: "/books", heading: "New book", submitLabel: "Create book",
values: form, errors: errors, db: req.db)
return try await invalid(req, "New book", view)
}
}
// GET /books/:bookID/edit
@Sendable func editForm(req: Request) async throws -> View {
let book = try await loadBook(req)
let values = BookForm(title: book.title, isbn: book.isbn, publishedYear: String(book.publishedYear),
price: formatPrice(book.priceCents), status: book.status.rawValue,
authorId: book.$author.id.uuidString,
tags: try await book.$tags.get(on: req.db).map(\.name).sorted().joined(separator: ", "))
let form = try await BookFormView.make(action: "/books/\(try book.requireID())", heading: "Edit book",
submitLabel: "Save changes", values: values, errors: [:], db: req.db)
return try await req.view.render("books/form", Page("Edit \(book.title)", req, content: form))
}
// POST /books/:bookID
@Sendable func update(req: Request) async throws -> Response {
let book = try await loadBook(req)
let id = try book.requireID()
let form = try req.content.decode(BookForm.self)
switch try await form.validated(on: req.db, excluding: id) {
case .valid(let input):
book.title = input.title
book.isbn = input.isbn
book.publishedYear = input.publishedYear
book.priceCents = input.priceCents
book.status = input.status
book.$author.id = input.authorID
try await req.db.transaction { db in
try await book.update(on: db)
try await book.syncTags(input.tags, on: db)
}
req.flash("success", "Saved \u{201C}\(book.title)\u{201D}.")
return req.redirect(to: "/books/\(id)", redirectType: .normal)
case .invalid(let errors):
let view = try await BookFormView.make(action: "/books/\(id)", heading: "Edit book", submitLabel: "Save changes",
values: form, errors: errors, db: req.db)
return try await invalid(req, "Edit book", view)
}
}
// POST /books/:bookID/delete : plain HTML forms can only send GET and POST
@Sendable func destroy(req: Request) async throws -> Response {
let book = try await loadBook(req)
try await book.delete(on: req.db)
req.flash("success", "Deleted \u{201C}\(book.title)\u{201D}.")
return req.redirect(to: "/books", redirectType: .normal)
}
// DELETE /books/:bookID : the htmx variant; an empty 200 lets htmx swap the row away
@Sendable func destroyHTMX(req: Request) async throws -> Response {
try await loadBook(req).delete(on: req.db)
return Response(status: .ok)
}
private func loadBook(_ req: Request, with details: Bool = false) async throws -> Book {
// A malformed id is a 404, not a 400: the URL simply names nothing.
guard let id = req.parameters.get("bookID", as: UUID.self) else { throw Abort(.notFound, reason: "No such book.") }
var query = Book.query(on: req.db).filter(\.$id == id).with(\.$author)
if details { query = query.with(\.$reviews) }
guard let book = try await query.first() else { throw Abort(.notFound, reason: "No such book.") }
return book
}
/// A rejected form is re-rendered with status 422 so that clients (and tests) can tell it was not saved.
private func invalid(_ req: Request, _ title: String, _ form: BookFormView) async throws -> Response {
let view = try await req.view.render("books/form", Page(title, req, content: form))
let response = try await view.encodeResponse(for: req)
response.status = .unprocessableEntity
return response
}
}
Source: Vapor docs: Routing
These routes have no login and no role check. Anyone who can reach the server can create, edit and delete books
(POST /books, POST /books/:bookID, POST /books/:bookID/delete and DELETE /books/:bookID); the CSRF middleware
only stops other sites from making a visitor’s browser do it. The omission keeps the page about forms, and it is the
same erratum as the books' unauthenticated DELETE, so do not deploy the controller as it is. In a real application
the reading routes stay public and the changing ones go into a group behind the session authenticator and a redirect
to the login page from Session Authentication and Web
Login, plus a librarian role check from Authorization: Roles, Ownership and
Policies.
For the list:
-
req.query.get(String.self, at: "q")reads the search term. An invalid page number falls back to 1 andmax(1, ...)rejects zero and negatives. -
The filter
\.$title ~~ qis Fluent’s "contains" operator, which becomes aLIKE '%...%'match. Fluent bindsqas a parameter, so a quote in the search term is not a SQL injection.%and_in the term are not escaped, so they act as wildcards; this is acceptable for a search box and wrong for anything that treats the term as an exact string. On SQLiteLIKEis case-insensitive for ASCII, on PostgreSQL it is case-sensitive (for case-insensitive search there, use anILIKEthrough SQLKit, as the SQLKit and Raw SQL shows). Fluent Queries and Pagination covers the operators and paging strategies; page-number paging, used here, gets slow on deep pages, and keyset paging is the alternative. -
paginate(PageRequest(page:per:))issues a count query and a limited query.page.metadatasuppliestotalandpageCount. -
The links in the pager carry
qpercent-encoded (encodedQ), because Leaf escapes HTML but does not URL-encode. Putting a raw search term into anhrefwould let&page=9typed in the search box rewrite the link.
The list is split into a page and a fragment, so htmx can request just the table:
#extend("base"):
#export("content"):
<h1>Books</h1>
<form class="row g-2 mb-3" method="get" action="/books" role="search">
<div class="col-auto">
<label class="visually-hidden" for="q">Search by title</label>
<input id="q" name="q" type="search" class="form-control" placeholder="Search by title" value="#(content.q)"
hx-get="/books" hx-trigger="input changed delay:300ms, search" hx-target="#book-table"
hx-push-url="true" hx-sync="this:replace" autocomplete="off">
</div>
<div class="col-auto"><button class="btn btn-outline-secondary" type="submit">Search</button></div>
</form>
<div id="book-table" aria-live="polite">
#extend("books/_table")
</div>
#endexport
#endextend
#if(content.total == 0):
<p>No books match.</p>
#else:
<table class="table table-striped align-middle">
<caption>#(content.total) books, page #(content.page) of #(content.pageCount)</caption>
<thead><tr><th scope="col">Title</th><th scope="col">Author</th><th scope="col">Year</th><th scope="col" class="text-end">Price</th><th scope="col">Status</th><th scope="col"><span class="visually-hidden">Actions</span></th></tr></thead>
<tbody>
#for(row in content.rows):
<tr>
<th scope="row"><a href="/books/#(row.book.id)">#(row.book.title)</a></th>
<td>#(row.book.author)</td>
<td>#(row.book.publishedYear)</td>
<td class="text-end">#(row.book.price)</td>
<td><span class="badge text-bg-#(row.badge)">#(row.book.status)</span></td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/books/#(row.book.id)/edit">Edit<span class="visually-hidden"> #(row.book.title)</span></a>
<button class="btn btn-sm btn-outline-danger" type="button" hx-delete="/books/#(row.book.id)"
hx-confirm="Delete this book?" hx-target="closest tr" hx-swap="outerHTML">Delete<span class="visually-hidden"> #(row.book.title)</span></button>
</td>
</tr>
#endfor
</tbody>
</table>
<nav aria-label="Pagination">
<ul class="pagination">
<li class="page-item#if(!content.hasPrevious): disabled#endif"><a class="page-link" href="/books?page=#(content.previousPage)&q=#(content.encodedQ)"#if(!content.hasPrevious): aria-disabled="true" tabindex="-1"#endif>Previous</a></li>
<li class="page-item#if(!content.hasNext): disabled#endif"><a class="page-link" href="/books?page=#(content.nextPage)&q=#(content.encodedQ)"#if(!content.hasNext): aria-disabled="true" tabindex="-1"#endif>Next</a></li>
</ul>
</nav>
#endif
#extend("books/_table") inside the page with no body includes the fragment as it is, so the full page and the htmx
response render the same markup. A small design point: the fragment reads content too, so the page and fragment share
one context type, and the controller chooses only the template name (books/index or books/_table).
The detail page and content negotiation
GET /books/:bookID serves two representations from one URL. Browsers send an Accept header that lists text/html
first, API clients send application/json, and the handler returns whichever the client prefers:
// From WebSupport.swift
var wantsJSON: Bool {
headers.accept.comparePreference(for: .json, to: .html) == .orderedDescending
}
headers.accept parses the header into media types with their quality values (q); comparePreference(for:to:)
compares two of them. The rule here is deliberately conservative: JSON is returned only when the client lists it and
either does not list HTML or gives it a lower quality. A missing header, */*, or a browser’s
text/html,...,*/*;q=0.8 all produce HTML. The Vary: Accept header the handler adds is not decoration: without it a
CDN or the browser’s own HTTP cache may store the JSON body for the URL and serve it to the next browser request. The
captured behaviour:
Accept: text/html,application/xhtml+xml,*/*;q=0.8 -> Content-Type: text/html; charset=utf-8
Accept: application/json -> Content-Type: application/json; charset=utf-8, Vary: Accept
Accept: */* -> Content-Type: text/html; charset=utf-8
(no Accept header) -> Content-Type: text/html; charset=utf-8
Content negotiation on one URL is convenient for small applications and for a JSON view of exactly what the page shows.
It has limits. The two representations must stay in step, a URL that returns two content types is harder to cache and to
document, and an API for mobile apps usually wants its own versioned URLs and error format;
API Design, Versioning and DTOs describes that route. For a real
public API, prefer a separate /api/v1 route group and keep the HTML routes for the browser. The Accept header is
also not a security boundary: authorisation applies to both representations alike.
Forms: decoding, validating and re-rendering
The create and edit pages share one template and one pair of types. The form type holds the raw text exactly as the browser sent it, and a validation function turns it into typed input or a message per field.
import Vapor
import Fluent
/// The raw form fields, exactly as the browser sent them. Everything is an optional String so a
/// missing or malformed field never fails decoding; validation reports it per field instead.
struct BookForm: Content {
var title: String?
var isbn: String?
var publishedYear: String?
var price: String?
var status: String?
var authorId: String?
var tags: String? // comma-separated tag names
}
/// The validated, typed result.
struct BookInput: Sendable {
let title: String
let isbn: String
let publishedYear: Int
let priceCents: Int
let status: BookStatus
let authorID: UUID
let tags: [String]
}
typealias FieldErrors = [String: String]
/// The outcome of validating a form: typed input, or one message per invalid field.
enum FormOutcome: Sendable {
case valid(BookInput)
case invalid(FieldErrors)
}
extension BookForm {
/// Validates the fields; `excluding` is the book being edited, so its own ISBN is not a duplicate.
func validated(on db: any Database, excluding bookID: UUID? = nil) async throws -> FormOutcome {
var errors = FieldErrors()
let title = (self.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty { errors["title"] = "Enter a title." }
else if title.count > 200 { errors["title"] = "The title must be at most 200 characters." }
let isbn = (self.isbn ?? "").filter { $0.isNumber || $0 == "X" }
if isbn.count != 10 && isbn.count != 13 { errors["isbn"] = "Enter a 10 or 13 digit ISBN." }
let maxYear = Calendar(identifier: .gregorian).component(.year, from: Date()) + 1
let year = Int((publishedYear ?? "").trimmingCharacters(in: .whitespaces))
if let year, (1450...maxYear).contains(year) {} else { errors["publishedYear"] = "Enter a year between 1450 and \(maxYear)." }
var cents: Int?
let priceText = (price ?? "").trimmingCharacters(in: .whitespaces)
let parts = priceText.split(separator: ".", omittingEmptySubsequences: false)
if (1...2).contains(parts.count), let whole = Int(parts[0]), whole >= 0,
parts.count == 1 || (parts[1].count <= 2 && !parts[1].isEmpty && parts[1].allSatisfy(\.isNumber)) {
let fraction = parts.count == 2 ? Int(parts[1].padding(toLength: 2, withPad: "0", startingAt: 0)) ?? 0 : 0
cents = whole * 100 + fraction
} else {
errors["price"] = "Enter a price such as 12.50."
}
let status = BookStatus(rawValue: self.status ?? "")
if status == nil { errors["status"] = "Choose a status." }
var authorID: UUID?
if let raw = authorId, let id = UUID(uuidString: raw), try await Author.find(id, on: db) != nil {
authorID = id
} else {
errors["authorId"] = "Choose an author."
}
let tags = Self.tagNames(from: self.tags, errors: &errors)
if errors["isbn"] == nil {
let clash = try await Book.query(on: db).filter(\.$isbn == isbn).first()
if let clash, clash.id != bookID { errors["isbn"] = "A book with this ISBN already exists." }
}
guard errors.isEmpty, let year, let cents, let status, let authorID else { return .invalid(errors) }
return .valid(BookInput(title: title, isbn: isbn, publishedYear: year, priceCents: cents,
status: status, authorID: authorID, tags: tags))
}
}
Source: Vapor docs: Content
Why the form type is all optional strings:
-
req.content.decode(BookForm.self)uses theURLEncodedFormDecoderforapplication/x-www-form-urlencodedbodies (and the multipart decoder formultipart/form-data). A field that is missing or of the wrong type makes a stricter type fail decoding with a400 Bad Requestand no information for the user. A browser form submits an empty string for an empty text input, andInt("")isnil, so apublishedYear: Intfield would turn every empty field into a decoding error. -
Optional strings always decode. Validation then decides what is wrong, per field, with a message for people, and the same strings go straight back into the form on a re-render, so the user’s input (including the wrong parts) is not lost.
-
The form type is separate from the model (
Book) on purpose. Decoding straight into a model is mass assignment: a hidden extra field in the request could setstatusorauthor_idwhere the form has no such input.
The validation function is plain Swift, not the Validatable protocol. Validatable (see
Validation) is the right tool when the body is JSON and the answer is one 400 with
every failure listed. Here each message belongs to one input and the rules need the database (the author exists, the
ISBN is unique), so a function that returns a dictionary keyed by field name is simpler and testable. Notes on the
rules:
-
The ISBN uniqueness check runs a query before the insert, which has a race: two requests can both pass it. The
uniqueconstraint onbooks.isbnin the schema is the real guarantee, and a request that loses the race fails with a database error. Convert that error to the same field message if the race matters for your traffic. -
Prices are parsed as text to cents so no floating-point value is involved; the editor shows
12.50and the model stores1250. -
\(maxYear)interpolates the current year into the message, so it never goes stale.
Re-rendering with errors: 422, not 200 or a redirect
When validation fails the handler renders the same form with the submitted values and the messages, and sets the status
to 422 Unprocessable Entity:
// From WebBooksController.swift
private func invalid(_ req: Request, _ title: String, _ form: BookFormView) async throws -> Response {
let view = try await req.view.render("books/form", Page(title, req, content: form))
let response = try await view.encodeResponse(for: req)
response.status = .unprocessableEntity
return response
}
Source: Vapor docs: Content
Three alternatives are worse. Redirecting back loses the submitted values unless they are stashed in the session.
Returning 200 makes the failure invisible to anything but the person reading the page: tests, monitoring, htmx and
screen-reader tooling all rely on the status. And throwing Abort(.badRequest) replaces the form with an error page.
Browsers render a 422 body exactly like a 200 one. htmx, by default, does not swap 4xx bodies into the page, which
is why the layout’s htmx-config adds a rule for 422 (see the htmx section).
The form template is where accessibility is decided. Every input has a visible <label> tied to it by for/id, an
invalid input is marked with aria-invalid="true" and points to its message with aria-describedby, the messages sit
in elements with stable ids, and a summary at the top (role="alert") lists everything that is wrong in one place:
#extend("base"):
#export("content"):
<h1>#(content.heading)</h1>
#if(content.hasErrors):
<div class="alert alert-danger" role="alert">
<p class="mb-1"><strong>The book was not saved. Fix these and try again:</strong></p>
<ul class="mb-0">
#for(message in content.errorList):
<li>#(message)</li>
#endfor
</ul>
</div>
#endif
<form method="post" action="#(content.action)" novalidate>
<input type="hidden" name="_csrf" value="#(csrf)">
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input id="title" name="title" type="text" required maxlength="200" value="#(content.values.title)"
class="form-control#if(content.errors.title): is-invalid#endif"
#if(content.errors.title):aria-invalid="true" aria-describedby="title-error"#endif>
<div id="title-error" class="invalid-feedback">#(content.errors.title)</div>
</div>
<div class="mb-3">
<label for="isbn" class="form-label">ISBN</label>
<input id="isbn" name="isbn" type="text" required inputmode="numeric" value="#(content.values.isbn)"
class="form-control#if(content.errors.isbn): is-invalid#endif" aria-describedby="isbn-help#if(content.errors.isbn): isbn-error#endif"
#if(content.errors.isbn):aria-invalid="true"#endif>
<div id="isbn-help" class="form-text">10 or 13 digits; hyphens are ignored.</div>
<div id="isbn-error" class="invalid-feedback">#(content.errors.isbn)</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="publishedYear" class="form-label">Year published</label>
<input id="publishedYear" name="publishedYear" type="text" inputmode="numeric" required value="#(content.values.publishedYear)"
class="form-control#if(content.errors.publishedYear): is-invalid#endif"
#if(content.errors.publishedYear):aria-invalid="true" aria-describedby="year-error"#endif>
<div id="year-error" class="invalid-feedback">#(content.errors.publishedYear)</div>
</div>
<div class="col-md-6 mb-3">
<label for="price" class="form-label">Price</label>
<input id="price" name="price" type="text" inputmode="decimal" required value="#(content.values.price)"
class="form-control#if(content.errors.price): is-invalid#endif"
#if(content.errors.price):aria-invalid="true" aria-describedby="price-error"#endif>
<div id="price-error" class="invalid-feedback">#(content.errors.price)</div>
</div>
</div>
<div class="mb-3">
<label for="authorId" class="form-label">Author</label>
<select id="authorId" name="authorId" required class="form-select#if(content.errors.authorId): is-invalid#endif"
#if(content.errors.authorId):aria-invalid="true" aria-describedby="author-error"#endif>
<option value="">Choose...</option>
#for(option in content.authors):
<option value="#(option.value)"#if(option.selected): selected#endif>#(option.label)</option>
#endfor
</select>
<div id="author-error" class="invalid-feedback">#(content.errors.authorId)</div>
</div>
<fieldset class="mb-3">
<legend class="form-label fs-6">Status</legend>
#for(option in content.statuses):
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="status" id="status-#(option.value)" value="#(option.value)"#if(option.selected): checked#endif>
<label class="form-check-label" for="status-#(option.value)">#(option.label)</label>
</div>
#endfor
</fieldset>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<input id="tags" name="tags" type="text" maxlength="400" value="#(content.values.tags)"
class="form-control#if(content.errors.tags): is-invalid#endif" aria-describedby="tags-help#if(content.errors.tags): tags-error#endif"
#if(content.errors.tags):aria-invalid="true"#endif>
<div id="tags-help" class="form-text">Separate tags with commas, for example: classics, computing.</div>
<div id="tags-error" class="invalid-feedback">#(content.errors.tags)</div>
</div>
<button class="btn btn-primary" type="submit">#(content.submitLabel)</button>
<a class="btn btn-link" href="/books">Cancel</a>
</form>
#endexport
#endextend
Source: Bootstrap docs: Form validation
Details that matter:
-
novalidateon the<form>switches off the browser’s own validation bubbles, so the server’s messages are the single source of truth and look the same everywhere. Therequiredandmaxlengthattributes stay: they still tell assistive technology and mobile keyboards what the field is, and a script can opt back in to client-side checks later (the HTML and CSS guides of this site cover native validation in Forms, Accessibility and Validation). -
The error
<div>exists for every field and is empty when there is no error;invalid-feedbackis only visible when the input has theis-invalidclass, so the empty ones take no space. Only an invalid input hasaria-describedby="title-error", so a screen reader does not announce an empty description. -
The status is a radio group inside a
<fieldset>with a<legend>, which is how a group of related choices is announced. The author is a<select>whose first option has an empty value, so an untouched selection fails validation instead of silently picking the first author. -
Link text is unique where it needs to be: the table’s
EditandDeletebuttons include the book title in visually hidden text, so a list of links read by a screen reader is not "Edit, Edit, Edit". The details of this checklist are in Web Accessibility and the Bootstrap Accessibility page. -
Leaf escapes attribute values the same way as text: a title of
Second "quoted"appears invalue="..."asSecond "quoted", which is why the form never breaks out of its attribute.
Tag inputs: a many-to-many field in a form
A book has many tags and a tag many books (the book_tag pivot of the canonical schema). The Kodeco book edits the
same kind of relation, its categories, with a multiple <select> enhanced by the Select2 script so that new names can
be typed (Condon ch. 14 to 17). The form here uses one plain text input of comma-separated names, which works without
a script, and a small function turns that text into the set of tags the book should have:
// Tags.swift
import Fluent
import Vapor
extension BookForm {
/// Turns "Classics, computing,classics" into ["classics", "computing"]: trimmed, lower-cased, empty parts and
/// duplicates dropped. Reports a field error for more than 10 tags or a tag longer than 30 characters.
static func tagNames(from text: String?, errors: inout FieldErrors) -> [String] {
var names: [String] = []
for part in (text ?? "").split(separator: ",") {
let name = part.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if !name.isEmpty, !names.contains(name) { names.append(name) }
}
if names.count > 10 {
errors["tags"] = "Use at most 10 tags."
} else if names.contains(where: { $0.count > 30 }) {
errors["tags"] = "Each tag can have at most 30 characters."
}
return names
}
}
extension Book {
/// Makes the book's tags exactly `names`, creating the tags that do not exist yet. Call it in the transaction
/// that saves the book, so a failure leaves neither the book nor its tags half-written.
func syncTags(_ names: [String], on db: any Database) async throws {
let existing = try await Tag.query(on: db).filter(\.$name ~~ names).all()
var tags = existing
for name in names where !existing.contains(where: { $0.name == name }) {
let tag = Tag(name: name)
try await tag.save(on: db)
tags.append(tag)
}
try await $tags.detachAll(on: db)
try await $tags.attach(tags, on: db)
}
}
How it fits into the form:
-
BookForm.tagsis one more optional string, andvalidatedcallstagNames(from:errors:), so a bad tag list is one more field message (re-rendered with422, like the others) andBookInput.tagscarries the cleaned names. The template has a labelled text input with a help text, wired toaria-describedbyandaria-invalidthe same way as the other fields;errorListincludestagsso the summary lists it. -
Names are lower-cased before they are compared or stored.
tags.nameis unique, and SQLite and PostgreSQL compare text case-sensitively, so without thatClassicsandclassicswould become two tags. -
syncTagsloads the existing tags in oneINquery (~~with an array), creates the missing ones, then replaces the book’s pivot rows with$tags.detachAll(on:)and$tags.attach(_:on:). The create and update handlers call it in the samereq.db.transactionas the save, so a failure leaves neither the book nor its tags half-written. -
The edit form shows the current tags joined with
", "; an empty input removes them all. Two people creating the same new tag at the same moment make one save fail on the unique index; retry, or catch the constraint error, if that matters for your traffic. -
A tag picker with suggestions (a script widget such as Tom Select, or Select2 as in the book) is progressive enhancement on top of this input, as long as it still submits the same comma-separated
tagsfield.
Captured from the scratch package’s test: Classics, computing,classics, ` creates `classics and computing;
re-saving with computing, history leaves computing and history on the book (three tags in the table); eleven
tags answer 422 with "Use at most 10 tags.".
POST-redirect-GET
After a successful create, update or delete the handler does not render a page. It stores a flash message and answers
303 See Other with a Location:
// From WebBooksController.create
try await req.db.transaction { db in
try await book.save(on: db)
try await book.syncTags(input.tags, on: db)
}
req.flash("success", "Created \u{201C}\(book.title)\u{201D}.")
return req.redirect(to: "/books/\(try book.requireID())", redirectType: .normal)
Source: Vapor docs: Routing, redirects
RedirectType.normal is 303 See Other; .permanent is 301 (cached by browsers, never use it after a POST) and
.temporary is 307 (the browser repeats the same method and body, which would submit the form again). A 303 makes the
browser fetch the target with GET. The result is that the page the user ends on is a GET that can be reloaded,
bookmarked and shared, and the Reload button does not offer to "resubmit the form", which is what produces duplicate
records when a POST is answered with a page directly. The flash message carries the "Created" notice across the
redirect.
The session cookie is created by the first request that touches req.session, and every page does (for the token), so
the redirect always carries the cookie the next request needs.
CSRF protection
Cross-site request forgery makes a logged-in user’s browser send a state-changing request the user did not intend, for
example a hidden form on another site that posts to /books/<id>/delete. The browser attaches the session cookie
automatically, so the request looks authentic. Vapor 4.122 does not include a CSRF middleware, so this section uses the
standard defence, the synchroniser token: a random per-session value placed in every form and checked on every
state-changing request. The middleware is the same one the Security
Hardening page builds and explains, unchanged, so the section has one CSRF implementation: the same session key, the
same body handling and the same origin layer.
// CSRF.swift
import Vapor
/// Session-bound CSRF protection for browser forms (synchroniser token pattern).
///
/// Safe methods pass. Every other method must (1) come from an allowed origin and
/// (2) carry the token that is stored in the session, in the `X-CSRF-Token` header or the `_csrf` form field.
struct CSRFMiddleware: AsyncMiddleware {
static let sessionKey = "csrf-token"
static let headerName = "X-CSRF-Token"
static let fieldName = "_csrf"
/// Origins that may submit state-changing requests, for example `https://bookshelf.example.com`.
let allowedOrigins: Set<String>
func respond(to req: Request, chainingTo next: any AsyncResponder) async throws -> Response {
// Mint the token on the first request of the session so that forms can embed it.
let token = req.csrfToken
switch req.method {
case .GET, .HEAD, .OPTIONS:
return try await next.respond(to: req)
default:
break
}
guard OriginPolicy(allowedOrigins: allowedOrigins).allows(req) else {
throw Abort(.forbidden, reason: "Cross-origin request rejected.")
}
let submitted: String?
if let header = req.headers.first(name: Self.headerName) {
submitted = header // fetch() and XMLHttpRequest clients
} else if req.headers.contentType == .urlEncodedForm {
// Middleware runs before the route collects the body, so collect it here (within the default limit).
_ = try await req.body.collect(max: req.application.routes.defaultMaxBodySize.value).get()
submitted = try? req.content.get(String.self, at: Self.fieldName)
} else {
submitted = nil
}
guard let submitted, constantTimeEqual(submitted, token) else {
throw Abort(.forbidden, reason: "Missing or invalid CSRF token.")
}
return try await next.respond(to: req)
}
}
extension Request {
/// The CSRF token of this session. Reading it creates the session (and the token) when needed.
var csrfToken: String {
if let existing = session.data[CSRFMiddleware.sessionKey] { return existing }
let token = Data([UInt8].random(count: 32)).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
session.data[CSRFMiddleware.sessionKey] = token
return token
}
}
/// Compares two strings without returning early at the first difference.
func constantTimeEqual(_ a: String, _ b: String) -> Bool {
let x = Array(a.utf8), y = Array(b.utf8)
guard x.count == y.count else { return false }
var difference: UInt8 = 0
for (l, r) in zip(x, y) { difference |= l ^ r }
return difference == 0
}
/// Fetch Metadata and `Origin` checks, the second layer of CSRF defence.
struct OriginPolicy: Sendable {
let allowedOrigins: Set<String>
func allows(_ req: Request) -> Bool {
// Modern browsers say how the request relates to the site.
if let site = req.headers.first(name: "Sec-Fetch-Site") {
return site == "same-origin" || site == "none"
}
// Older browsers and non-browser clients: fall back to Origin.
if let origin = req.headers.first(name: .origin) {
return allowedOrigins.contains(origin)
}
// Neither header: not a browser cross-site request (curl, a native app). The token still applies.
return true
}
}
WebBooksController registers it as CSRFMiddleware(allowedOrigins: allowedOrigins), with the site’s own origin from
PUBLIC_ORIGIN (see configure.swift). How the pieces fit:
-
The token is 32 random bytes from
[UInt8].random(count:)(Vapor’s helper over the system random generator), base64url-encoded, stored inreq.session.data[CSRFMiddleware.sessionKey]("csrf-token") and created by the first read ofreq.csrfToken. The middleware reads it for every request, includingGET, so a form rendered by aGETalways has a token to embed, and thePagewrapper puts the same value into every template ascsrf. -
The middleware collects the body itself. Vapor collects a request body inside the route’s closure, after all middleware has run, so a middleware that reads
req.contentstraight away sees no body. With a body that arrived in one piece that can go unnoticed; with a chunked post (Transfer-Encoding: chunked) the field is missing and every form gets a 403. For URL-encoded forms the middleware therefore collects the body first, up to the route’s default limit, and then reads the_csrffield. -
Forms put the token in a hidden
_csrfinput. For requests that are not form posts, htmx sends it as anX-CSRF-Tokenheader: the layout setshx-headerson<body>, and htmx adds those headers to every request it issues from inside the body. The middleware accepts either; the header wins. -
Only
GET,HEADandOPTIONSpass without a token. That is only safe if those methods never change state, which is why deletes and logouts must not be links (aGETthat deletes can be triggered by an<img src>on another site, with no token to block it). -
Before the token,
OriginPolicyrejects a request whoseSec-Fetch-Siteis notsame-originornone, or whoseOriginis not the site’s own; the Security Hardening page explains the two headers. -
constantTimeEqualcompares the bytes without returning at the first difference, the same constant-time pattern as in Middleware. Constant time is best effort in Swift; the main value is that the check is by equality, not by a prefix. -
The failure is
Abort(.forbidden), rendered byHTMLErrorMiddlewareas an HTML page. The user’s usual cause is an expired or lost session (a stale form opened before the session cookie expired), so the page text should say what to do, not just "Forbidden".
What the middleware deliberately does not cover, and what to add:
-
Defence in depth.
SameSite=Laxon the session cookie already stops the browser from sending it on cross-sitePOSTrequests; browsers increasingly treat an unset value asLax, but not uniformly, so set it explicitly and keep the token and the origin check. -
Login. After a successful login, rotate the session id with
try await req.rotateSession()(the helper from Sessions and Cookies; it keeps the session data, issues a new id and deletes the old record) and reset the CSRF token by settingreq.session.data[CSRFMiddleware.sessionKey] = nil, so the next read creates a fresh one. Keeping the pre-login session is session fixation. Do not callreq.session.destroy()and then set the user:destroy()invalidates the session, so nothing is saved afterwards. Session Authentication and Web Login shows the whole login flow. -
Uploads. The middleware collects only URL-encoded bodies, so a
multipart/form-dataform or a route registered withbody: .streamcannot carry the token in a_csrffield. Send it in theX-CSRF-Tokenheader from a small script (as htmx does). Never put it in the query string of the form action: URLs end up in logs, browser history andRefererheaders. File Uploads and Streaming covers the streaming case. -
JSON APIs authenticated by an
Authorizationheader need no CSRF token, because the browser does not attach that header by itself. Routes authenticated by the cookie do need it. -
The in-memory session store loses the token on restart, so a form opened before a restart posts a token that no longer exists and gets a 403. Production deployments use a shared session driver anyway.
HTML error pages
ErrorMiddleware renders errors as JSON, which is right for an API and unhelpful in a browser. The page-local
HTMLErrorMiddleware renders error.leaf instead, but only for clients that want HTML:
import Vapor
/// Renders an HTML error page for browsers. JSON clients get the standard error body.
struct HTMLErrorMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
do {
return try await next.respond(to: request)
} catch {
if request.wantsJSON || request.isHTMX { throw error }
let status = (error as? any AbortError)?.status ?? .internalServerError
let reason = status.code < 500 ? ((error as? any AbortError)?.reason ?? status.reasonPhrase) : "Something went wrong."
request.logger.report(error: error)
let view = try await request.view.render(
"error", Page("Error \(status.code)", request, content: ErrorView(status: Int(status.code), reason: reason)))
let response = try await view.encodeResponse(for: request)
response.status = status
return response
}
}
}
#extend("base"):
#export("content"):
<h1>#(content.status)</h1>
<p>#(content.reason)</p>
<a href="/books">Back to the catalogue</a>
#endexport
#endextend
Source: Vapor docs: Errors
The middleware rethrows for JSON clients and for htmx requests (htmx would otherwise swap a full HTML page into a
fragment of the page), so those still get the standard error body from ErrorMiddleware. For a 5xx it shows a generic
sentence and writes the real error to the log with logger.report(error:), so internal details never reach the page.
Because it is registered as the last of the user middleware, an unknown URL (RouteNotFound) and an
Abort(.notFound) from a handler both end here. If rendering the error page itself fails (for example, a template is
missing), the exception propagates to ErrorMiddleware and the client gets the JSON error; keep the error template as
simple as it is.
Styling with Bootstrap 5
The layout loads Bootstrap’s CSS and its bundle (Popper included) from jsDelivr with subresource integrity: the
integrity attribute holds a SHA-384 hash of the exact file, and the browser refuses to apply a file whose hash
differs, so a compromised CDN cannot silently change the code the page runs. Bootstrap 5 does not need jQuery. The
hashes in the layout were computed from the files served for the pinned URLs:
curl -s https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css | openssl dgst -sha384 -binary | openssl base64 -A
Pin the version in the URL (never @5 or @latest, because the content, and so the hash, would change), keep
crossorigin="anonymous" (SRI on a cross-origin file needs it), and recompute the hash whenever the version changes.
The alternative is to serve the files yourself: copy bootstrap.min.css and the bundle into Public/, reference them
with /bootstrap.min.css, and let FileMiddleware serve them (Static
Files and File I/O). That removes the third-party request and works offline, at the cost of updating the copies
yourself, and a Content-Security-Policy of default-src 'self' becomes possible. If you stay on the CDN, a CSP must
allow that host and should use the hashes, and the inline <script> blocks that Bootstrap examples sometimes use must
be avoided; this page uses none.
The markup in the templates is plain Bootstrap: container, navbar, table, form-control, form-select,
form-check, alert, badge and pagination. Bootstrap itself is covered by the
Bootstrap section of this site, its forms and
form validation pages in particular.
Progressive enhancement with htmx
Everything above works with JavaScript disabled. htmx is added as attributes on the existing markup, and the server answers a plain request with a page and an htmx request with a fragment. The two enhancements on the list page:
-
Live search. The search input has
hx-get="/books",hx-trigger="input changed delay:300ms, search",hx-target="#book-table"andhx-push-url="true": while typing, htmx requests/books?q=...(after a 300 ms pause) and replaces the table.hx-sync="this:replace"cancels an older request when a newer one starts.hx-push-urlupdates the address bar so the search can be reloaded and shared. Without JavaScript the same input is in a realGETform with a submit button, and the server renders the full page. -
Delete in place. The delete button has
hx-delete="/books/<id>",hx-confirm="Delete this book?",hx-target="closest tr"andhx-swap="outerHTML". The server deletes and answers an empty200, so the row is replaced with nothing. Without JavaScript the detail page offers a real form that posts to/books/<id>/delete.
On the server, req.isHTMX (the HX-Request: true header, which htmx adds to every request it makes) chooses the
fragment template. Three details are easy to get wrong:
-
Vary. A URL that answers with a page or a fragment depending on a header must say so: the handler sends
Vary: Accept, HX-Request. Otherwise the browser may reuse the fragment as the page when the user presses Back. -
History restoration. When the user navigates back to a URL the htmx cache no longer holds, htmx re-requests it and expects a full page. The layout sets
historyRestoreAsHxRequesttofalse, andisHTMXalso checks thatHX-History-Restore-Requestis nottrue, so such a request gets the page, not the fragment. -
4xx responses. In htmx 2, responses with 4xx and 5xx status codes are not swapped by default. The
htmx-configmeta tag in the layout adds a rule so that422is swapped, which is what lets a failed htmx form submission show its error messages. Other client errors still go to htmx’s error events, as the htmx documentation describes.
Two limits of what was checked here. The htmx attributes and configuration follow the official documentation (version 2.0.11, current at the time of writing); the browser behaviour was not run in this environment. And htmx forms are best added to an application that already works as plain HTML, as here, so that a failure of the script degrades to a working page instead of a broken one.
The token travels in the X-CSRF-Token header, taken from hx-headers on <body>. A request that omits it fails with
403, as the curl transcript shows.
Running it
The scratch package was run on SQLite in memory (swift run App serve) and driven with curl and a cookie jar. The
transcript, abridged to the status lines and the interesting headers, with the session and the CSRF token carried from
request to request:
$ curl -si -X POST localhost:8099/books -d title=x # no session, no token
HTTP/1.1 403 Forbidden
content-type: text/html; charset=utf-8
$ curl -si -b jar -X POST localhost:8099/books -d _csrf=... -d title= -d isbn=12 ... # invalid
HTTP/1.1 422 Unprocessable Entity
content-type: text/html; charset=utf-8
$ curl -si -b jar -X POST localhost:8099/books -d _csrf=... (valid)
HTTP/1.1 303 See Other
content-length: 0
location: /books/8E055189-6610-40D7-8DF3-E7573E492039
$ curl -s -b jar localhost:8099/books/8E055189-6610-40D7-8DF3-E7573E492039 | grep -m1 -A0 'role="status"'
<div class="alert alert-success" role="status">Created “Swift on the Server”.</div>
$ same URL again
0
$ curl -si -H "Accept: application/json" localhost:8099/books/8E055189-6610-40D7-8DF3-E7573E492039
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 180
vary: Accept
{"title":"Swift on the Server","publishedYear":2024,"status":"published","author":"Ada Lovelace","isbn":"9781402894626","price":"29.90","id":"8E055189-6610-40D7-8DF3-E7573E492039"}
$ curl -si -H "HX-Request: true" "localhost:8099/books?q=swift" | head
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 1427
vary: Accept, HX-Request
connection: keep-alive
date: Sat, 17 Oct 2026 16:06:24 GMT
$ update
HTTP/1.1 303 See Other
content-length: 0
location: /books/8E055189-6610-40D7-8DF3-E7573E492039
<div class="alert alert-success" role="status">Saved “Swift on the Server, 2nd ed.”.</div>
$ delete (plain form POST)
HTTP/1.1 303 See Other
content-length: 0
location: /books
<div class="alert alert-success" role="status">Deleted “Swift on the Server, 2nd ed.”.</div>
$ htmx-style DELETE with the token header
HTTP/1.1 200 OK
content-length: 0
HTTP/1.1 403 Forbidden
What the transcript shows, in order: a POST without a session or token is refused with 403; an invalid form is
answered with 422 and the page; a valid one is answered with 303 and a Location; following the redirect shows the
flash message, and a second request to the same URL shows none; the JSON representation of the same URL carries
Vary: Accept; the htmx request gets a fragment; editing and deleting also redirect with a message; the htmx-style
DELETE works with the header and fails without it.
Testing the pages
VaporTesting (see Testing) runs the whole chain in-process, including the sessions
and CSRF middleware, so the tests read exactly what a browser would: the Set-Cookie from a first GET, the token in
the HTML, then a POST carrying both. The helper methods collect the cookie and the token once and the tests reuse
them:
import Fluent
import Testing
import VaporTesting
@testable import App
@Suite(.serialized)
struct WebBooksTests {
/// A started application with one author, and a helper that carries the session cookie between requests.
func withApp(_ body: (Application, UUID) async throws -> Void) async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
let author = Author(name: "Test Author")
try await author.save(on: app.db)
try await body(app, author.requireID())
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
/// GET a page and return the session cookie header value and the CSRF token embedded in it.
func startSession(_ app: Application) async throws -> (cookie: String, token: String) {
var cookie = "", token = ""
try await app.testing().test(.GET, "books/new") { res async in
cookie = res.headers.setCookie?["bookshelf-session"]?.string ?? ""
let body = res.body.string
if let r = body.range(of: #"name="_csrf" value="([^"]+)""#, options: .regularExpression) {
token = String(body[r]).replacingOccurrences(of: #"name="_csrf" value=""#, with: "").replacingOccurrences(of: "\"", with: "")
}
}
return (cookie, token)
}
func form(_ pairs: [(String, String)]) -> ByteBuffer {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let text = pairs.map { "\($0.0)=\($0.1.addingPercentEncoding(withAllowedCharacters: allowed) ?? "")" }.joined(separator: "&")
return ByteBuffer(string: text)
}
func headers(cookie: String, extra: [(String, String)] = []) -> HTTPHeaders {
var h: HTTPHeaders = ["Content-Type": "application/x-www-form-urlencoded", "Cookie": "bookshelf-session=\(cookie)"]
for (k, v) in extra { h.add(name: k, value: v) }
return h
}
@Test func postWithoutTokenIsForbidden() async throws {
try await withApp { app, authorID in
let (cookie, _) = try await startSession(app)
try await app.testing().test(.POST, "books", headers: headers(cookie: cookie),
body: form([("title", "No token")])) { res async in
#expect(res.status == .forbidden)
}
}
}
@Test func createRedirectsAndFlashesOnce() async throws {
try await withApp { app, authorID in
let (cookie, token) = try await startSession(app)
var location = ""
let fields = [("_csrf", token), ("title", "Swift on the Server"), ("isbn", "978-1-4028-9462-6"),
("publishedYear", "2024"), ("price", "29.9"), ("status", "published"), ("authorId", authorID.uuidString)]
try await app.testing().test(.POST, "books", headers: headers(cookie: cookie), body: form(fields)) { res async in
#expect(res.status == .seeOther)
location = res.headers.first(name: .location) ?? ""
}
#expect(location.hasPrefix("/books/"))
let getHeaders: HTTPHeaders = ["Cookie": "bookshelf-session=\(cookie)"]
try await app.testing().test(.GET, location, headers: getHeaders) { res async in
#expect(res.status == .ok)
#expect(res.body.string.contains("Created"))
#expect(res.body.string.contains("29.90"))
}
try await app.testing().test(.GET, location, headers: getHeaders) { res async in
#expect(!res.body.string.contains("Created"))
}
}
}
@Test func tagInputCreatesAndSyncsTags() async throws {
try await withApp { app, authorID in
let (cookie, token) = try await startSession(app)
var location = ""
let base = [("_csrf", token), ("title", "Tagged"), ("isbn", "9781402894626"), ("publishedYear", "2024"),
("price", "10"), ("status", "published"), ("authorId", authorID.uuidString)]
try await app.testing().test(.POST, "books", headers: headers(cookie: cookie),
body: form(base + [("tags", "Classics, computing,classics, ")])) { res async in
#expect(res.status == .seeOther)
location = res.headers.first(name: .location) ?? ""
}
let book = try #require(try await Book.query(on: app.db).filter(\.$title == "Tagged").first())
#expect(try await book.$tags.get(on: app.db).map(\.name).sorted() == ["classics", "computing"])
try await app.testing().test(.GET, location + "/edit", headers: ["Cookie": "bookshelf-session=\(cookie)"]) { res async in
#expect(res.body.string.contains(#"value="classics, computing""#))
}
try await app.testing().test(.POST, location, headers: headers(cookie: cookie),
body: form(base + [("tags", "computing, history")])) { res async in
#expect(res.status == .seeOther)
}
#expect(try await book.$tags.get(reload: true, on: app.db).map(\.name).sorted() == ["computing", "history"])
#expect(try await Tag.query(on: app.db).count() == 3)
let tooMany = (1...11).map { "t\($0)" }.joined(separator: ",")
try await app.testing().test(.POST, location, headers: headers(cookie: cookie),
body: form(base + [("tags", tooMany)])) { res async in
#expect(res.status == .unprocessableEntity)
#expect(res.body.string.contains("Use at most 10 tags."))
}
}
}
@Test func invalidFormIs422AndKeepsValues() async throws {
try await withApp { app, authorID in
let (cookie, token) = try await startSession(app)
let fields = [("_csrf", token), ("title", ""), ("isbn", "12"), ("publishedYear", "abc"),
("price", "1.234"), ("status", "draft"), ("authorId", "")]
try await app.testing().test(.POST, "books", headers: headers(cookie: cookie), body: form(fields)) { res async in
#expect(res.status == .unprocessableEntity)
let body = res.body.string
#expect(body.contains("Enter a title."))
#expect(body.contains("Enter a 10 or 13 digit ISBN."))
#expect(body.contains(#"value="abc""#))
#expect(body.contains(#"aria-invalid="true""#))
}
}
}
@Test func titleIsHTMLEscaped() async throws {
try await withApp { app, authorID in
let book = Book(title: "<script>x</script>", isbn: "9780306406157", publishedYear: 2000, priceCents: 100, authorID: authorID)
try await book.save(on: app.db)
try await app.testing().test(.GET, "books/\(try book.requireID())") { res async in
#expect(!res.body.string.contains("<script>x"))
#expect(res.body.string.contains("<script>x</script>"))
}
}
}
@Test func oneUrlTwoRepresentations() async throws {
try await withApp { app, authorID in
let book = Book(title: "Negotiated", isbn: "9780306406157", publishedYear: 2000, priceCents: 100, authorID: authorID)
try await book.save(on: app.db)
let path = "books/\(try book.requireID())"
try await app.testing().test(.GET, path, headers: ["Accept": "text/html,application/xhtml+xml,*/*;q=0.8"]) { res async in
#expect(res.headers.contentType == .html)
}
try await app.testing().test(.GET, path, headers: ["Accept": "application/json"]) { res async throws in
#expect(res.headers.contentType == .json)
#expect(res.headers.first(name: .vary) == "Accept")
#expect(try res.content.decode(BookDTO.self).title == "Negotiated")
}
try await app.testing().test(.GET, path, headers: ["Accept": "*/*"]) { res async in
#expect(res.headers.contentType == .html)
}
}
}
@Test func htmxGetsOnlyThePartial() async throws {
try await withApp { app, authorID in
try await app.testing().test(.GET, "books", headers: ["HX-Request": "true"]) { res async in
#expect(!res.body.string.contains("<html"))
}
try await app.testing().test(.GET, "books") { res async in
#expect(res.body.string.contains("<html"))
}
}
}
@Test func htmlErrorPageForBrowsersJSONForClients() async throws {
try await withApp { app, _ in
try await app.testing().test(.GET, "books/00000000-0000-0000-0000-000000000000") { res async in
#expect(res.status == .notFound)
#expect(res.headers.contentType == .html)
}
try await app.testing().test(.GET, "books/00000000-0000-0000-0000-000000000000", headers: ["Accept": "application/json"]) { res async in
#expect(res.headers.contentType == .json)
}
}
}
}
Source: Vapor docs: Testing
The eight tests cover: the token check, create with redirect and a flash that appears exactly once, the tag input (cleaning, creating and re-syncing tags, and the limit), the 422 response with preserved values and ARIA attributes, HTML escaping of a hostile title, the two representations and the wildcard rule of one URL, the htmx fragment against the full page, and the HTML versus JSON error page. Output of the run:
✔ Test postWithoutTokenIsForbidden() passed after 0.020 seconds.
✔ Test createRedirectsAndFlashesOnce() passed after 0.019 seconds.
✔ Test tagInputCreatesAndSyncsTags() passed after 0.023 seconds.
✔ Test invalidFormIs422AndKeepsValues() passed after 0.012 seconds.
✔ Test titleIsHTMLEscaped() passed after 0.009 seconds.
✔ Test oneUrlTwoRepresentations() passed after 0.013 seconds.
✔ Test htmxGetsOnlyThePartial() passed after 0.010 seconds.
✔ Test htmlErrorPageForBrowsersJSONForClients() passed after 0.008 seconds.
✔ Suite WebBooksTests passed after 0.117 seconds.
✔ Test run with 8 tests in 1 suite passed after 0.117 seconds.
Rendered-HTML assertions by substring are brittle for layout and fine for the facts that matter (an escaped title, an
aria-invalid attribute, a message). To check the whole accessibility tree and the real htmx behaviour, drive a browser
with the tools of the e2e testing page of this site, End-to-End Testing in the Browser.
Swift 6 concurrency notes
-
A route handler is a
@Sendablefunction, as in the controller. It may useselfonly when the controller isSendable(astructwithout mutable state is). -
Requestis afinal classthat conforms toSendablein 4.122.2 (its mutable state is behind locks), so capturing it in aTaskcompiles. It still belongs to one request: a task that outlives the handler would use a request whose response has gone and whose session has been saved. The handlers read what they need and buildSendableview models before rendering, and anything that runs later gets copied values, notreq. -
Page<Content>requiresContent: Encodable & Sendable, so a view model that holds a non-Sendablereference (a Fluent model, which is@unchecked Sendablein this package, or a class) is a deliberate decision, not an accident. View models here arestruct`s of `String,Int,UUIDandBool. -
FlashMessage,BookForm,BookInput,FormOutcomeand the middleware structs areSendableby construction, so nothing needed@unchecked. TheFormOutcomeenum replacesResult<BookInput, FieldErrors>because the failure type ofResultmust conform toError, and a dictionary does not. -
Session access (
req.session.data) is synchronous and tied to the request. Reading and writing it before and after anawaitin the same handler is fine; passing the session to another task is not. -
Leaf rendering is asynchronous (
req.view.renderisasync), so a handler that renders must beasync; there is no blocking call on an event loop.
Common errors and pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
|
test the collection with |
|
a ternary in a template; LeafKit 1.14.3 does not parse it |
compute the value in the view model, or use |
Every form post gets 403 |
no |
embed the token in every form; check |
Edits to a template are not visible |
|
disable the cache in development; run from the project root or set |
|
the working directory is not the project root (an Xcode scheme without the working directory set) |
set the scheme’s working directory, or point |
The browser shows the JSON version of a page after going Back |
no |
add |
The htmx request returns a whole page nested inside the table |
the handler ignored |
branch on |
A failed htmx form shows nothing |
htmx does not swap 4xx bodies by default |
add the |
|
the form type used |
decode as |
Reload after saving shows "Resubmit the form?" |
the POST handler rendered a page instead of redirecting |
answer |
Deleting works from another site’s page |
deletes are |
use |
The flash message appears twice, or never |
it was read by two renders in one request, or queued on a path that errored |
read it once in |
The search term breaks the pager links |
the term was written raw into |
percent-encode it ( |
User text is rendered as HTML |
|
let |
The CDN stylesheet is blocked, with an SRI error in the console |
the hash does not match the pinned file, |
recompute the |
Sessions disappear with several instances |
the memory driver is per process |
use the Fluent or Redis driver |
Version notes and outdated material
-
This page uses the Vapor 4.122.x APIs:
app.views.use(.leaf),req.view.render,app.sessions.use(.memory),app.sessions.configuration,app.middleware.use,req.redirect(to:redirectType:),req.content.decode,Page/Encodablecontexts and the VaporTestingapp.testing().test. -
Leaf 4 (4.5.2 here) uses
#extend/#export/#importfor layouts. Leaf 2 and 3 used#embedand#set/#getfor the same purposes, a different#if(x) { ... }brace syntax and aLeafProviderregistered throughservices; none of that exists now. -
req.view.renderin older books returns anEventLoopFuture<View>. It isasync throwsnow, and theViewis converted to a response withencodeResponse(for:)when the handler needs to change the status or headers. -
Vapor 5 (5.0.0-beta.4, not released) is a major version. Its routing, middleware and content APIs change in places, and Vapor 5 introduces a new View type, so check how Leaf plugs into it; review What’s New, Upgrading and Vapor 5 before moving and re-test the forms.
-
htmx 2 differs from htmx 1 in its handling of error responses (the
responseHandlingconfiguration, used above) and in moving the WebSocket and SSE attributes into extensions. The htmx documentation recommends settinghistoryRestoreAsHxRequesttofalsefor servers that answer partials. Tutorials written for htmx 1 do not mention either. -
Bootstrap 5 dropped the jQuery requirement of Bootstrap 4; books that show Bootstrap 4 markup (
form-group,custom-select,data-toggle,.sr-only) need the 5.x names (mb-3,form-select,data-bs-toggle,.visually-hidden).
Where the books are outdated
-
The Kodeco book (Condon et al., chapters 16 and 17) and Yeung (chapters 6 and 7) build the same kind of CRUD site on Leaf 2 or early Leaf 4 with future-returning handlers (
req.view().render,.flatMap) andContent-decoding forms. The concept of POST-redirect-GET, the form-and-redirect cycle and flash messages carries over; the signatures, the Leaf tag syntax and the registration code do not, as the version notes list. -
None of the three books protects its forms against CSRF consistently: the Kodeco and Yeung forms post with only the session cookie, and Patel’s content-negotiation chapter (chapter 7) has a JSON and a browser interface on the same routes with no token. Treat the token,
SameSiteand the origin check as required for any cookie-authenticated form. -
Logout and delete as
GETlinks, validation text carried in the query string, and a template name taken from the URL, all of which appear in the books' sample code, are each a vulnerability or a bug: usePOST, keep messages in the session (the flash mechanism above) and choose templates in code. -
Patel’s chapter 7 shows content negotiation by setting the response type from the request; today’s
Acceptparsing isheaders.accept.comparePreference(for:to:), and theVaryheader, which the books do not set, is needed for correct caching.