OpenAPI with swift-openapi-generator
|
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), with swift-openapi-generator 1.14.1, swift-openapi-runtime 1.13.1,
swift-openapi-vapor
1.2.0 and swift-openapi-urlsession 1.3.2, checked on 2026-10-17. Every Swift listing on this page was compiled in a
scratch
package without warnings in its own code, the seven tests shown pass, and the HTTP behaviour described was verified by
running
the service and calling it with curl; the output shown is captured from those runs. Vapor 5 is at 5.0.0-beta.4 and is
not
released yet; the last section says what changes for this stack. The page extends the Bookshelf service of the earlier
pages
(for the hand-written alternative see API Design, Versioning and
DTOs): the document describes a small slice of the versioned /api/v1 books API. Swift itself is covered by the
Swift Reference, packages and plugins by
Swift Package Manager, and the Codable machinery that the
generated types use by Codable and Serialization.
Why an OpenAPI document
An OpenAPI document is a machine-readable contract for an HTTP API: paths, methods, parameters, request and response
bodies,
status codes, schemas and security requirements. One document can feed documentation pages, a mock server, contract
tests,
client libraries in any language, and server stubs. Vapor itself does not read or produce one: routes are closures
registered
with app.get, app.post and friends (see Routing), and nothing in the framework
knows
the shape of the Content type a handler returns. OpenAPI support therefore comes from packages, and there are two
directions:
| Approach | How it works | Trade-offs |
|---|---|---|
Spec-first (this page) |
You write |
The document is the source of truth and cannot drift from the code, because the code does not compile until it matches. The price is a build plugin, generated code you do not own, and handlers that receive typed |
Code-first |
You write ordinary Vapor routes and annotate them; a library walks |
Familiar Vapor code and no generated types. The document is only as accurate as the annotations, which the compiler does not check against the handler’s real behaviour. The section "Code-first alternatives" below covers it. |
The same two options exist elsewhere on this site: the Axum page derives the document from annotated handlers, the FastAPI page and the NestJS page derive it from type annotations and decorators.
The pieces and how they fit
The packages have separate jobs:
-
swift-openapi-generator (
apple/swift-openapi-generator) is a SwiftPM build-tool plugin,OpenAPIGenerator, plus a command-line tool. It readsopenapi.yamlandopenapi-generator-config.yamlfrom a target’s source directory and writes Swift files into the build directory at build time. Nothing generated is committed. It understands OpenAPI 3.0 and 3.1, with preliminary 3.2 support. -
swift-openapi-runtime (
apple/swift-openapi-runtime) is the library the generated code calls:HTTPBody, theServerTransport,ClientTransport,ServerMiddlewareandClientMiddlewareprotocols,ServerError, the URI and JSON coders. Generated targets depend on it. -
swift-openapi-vapor (
vapor/swift-openapi-vapor) is the server transport: it adapts Vapor’s router toServerTransport. This is the only piece that knows about Vapor. -
swift-openapi-urlsession (
apple/swift-openapi-urlsession) is a client transport onURLSessionfor Apple platforms. Transports for AsyncHTTPClient and others exist as separate packages.
Types, server and client can be generated into one target, or split so that the types target is shared; this page uses
the split because it is what a project with an iOS app wants: the server and the app import the same
BookshelfOpenAPI module, so both sides use the same Book type. The idea is the same as the hand-written
BookshelfShared DTO package described on
Full-Stack Swift with iOS Clients, with the schema types
generated instead of hand-written. The generated target gets its own name, BookshelfOpenAPI, because its types
(Components.Schemas.Book, Operations) are not the hand-written BookDTO of BookshelfShared; a project picks one
of the two as its wire format.
The document
The scratch package puts the document in the types target, Sources/BookshelfOpenAPI/openapi.yaml. It describes five
operations
and one bearerAuth security scheme:
openapi: 3.1.0
info:
title: Bookshelf API
version: 1.0.0
description: Books, authors and loans of the Bookshelf library.
servers:
- url: /api/v1
description: This server
tags:
- name: books
paths:
/books:
get:
operationId: listBooks
tags: [books]
summary: List books, newest first
parameters:
- name: limit
in: query
description: Page size.
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: after
in: query
description: Keyset cursor, the id of the last book of the previous page.
schema: { type: string, format: uuid }
responses:
'200':
description: A page of books.
content:
application/json:
schema: { $ref: '#/components/schemas/BookPage' }
post:
operationId: createBook
tags: [books]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewBook' }
responses:
'201':
description: Created.
content:
application/json:
schema: { $ref: '#/components/schemas/Book' }
'401':
$ref: '#/components/responses/Unauthorized'
'422':
description: Validation failed.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
/books/{bookId}:
get:
operationId: getBook
tags: [books]
parameters:
- name: bookId
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: The book.
content:
application/json:
schema: { $ref: '#/components/schemas/Book' }
'404':
$ref: '#/components/responses/NotFound'
delete:
operationId: deleteBook
tags: [books]
security:
- bearerAuth: []
parameters:
- name: bookId
in: path
required: true
schema: { type: string, format: uuid }
responses:
'204':
description: Deleted.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/books/{bookId}/cover:
put:
operationId: uploadCover
tags: [books]
security:
- bearerAuth: []
parameters:
- name: bookId
in: path
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
image/png:
schema: { type: string, format: binary }
responses:
'204':
description: Stored.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
Book:
type: object
required: [id, title, author, publishedYear]
properties:
id: { type: string, format: uuid }
title: { type: string }
author: { type: string }
publishedYear: { type: integer }
NewBook:
type: object
required: [title, author]
properties:
title: { type: string, minLength: 1, maxLength: 200 }
author: { type: string }
publishedYear: { type: integer }
BookPage:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/Book' }
next:
type: string
format: uuid
description: Pass as `after` to get the next page; absent on the last page.
Problem:
type: object
required: [title, status]
properties:
title: { type: string }
status: { type: integer }
responses:
NotFound:
description: No such resource.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
Unauthorized:
description: Missing or invalid bearer token.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
Source: swift-openapi-generator: Practicing spec-driven API development and OpenAPI Specification 3.1.0
Four details of this document drive the generated code, so they are worth deciding on purpose:
-
operationIdbecomes the method name (listBooks) and the namespace underOperations. Without it the generator invents a name from the path and method; always write it. -
servers: - url: /api/v1is the path prefix. It is not applied automatically on the server; see the section onregisterHandlersbelow. -
A
$refintocomponents/responses(NotFound,Unauthorized) is reused by several operations and appears as one case per status on eachOutputenum, so a handler cannot return a status the document does not list without going throughundocumented. -
format: uuidis not mapped toFoundation.UUID: the generated property is aString(the generator mapsdate-timetoDateand leaves most other formats asString). The handler converts withUUID(uuidString:), as the listing below does. Schemas can be pointed at your own type withtypeOverrides, covered with the configuration keys.
The generator does not implement security. It parses the section, and the Swagger UI shows the padlock, but no
generated code
checks a token; the section on middleware below adds the check.
Package layout and plugin
// swift-tools-version:6.3
import PackageDescription
let package = Package(
name: "Bookshelf",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", exact: "4.122.2"),
.package(url: "https://github.com/apple/swift-openapi-generator.git", exact: "1.14.1"),
.package(url: "https://github.com/apple/swift-openapi-runtime.git", exact: "1.13.1"),
.package(url: "https://github.com/vapor/swift-openapi-vapor.git", exact: "1.2.0"),
.package(url: "https://github.com/apple/swift-openapi-urlsession.git", exact: "1.3.2"),
],
targets: [
// Shared types only: usable by the server, the iOS app and tests.
.target(
name: "BookshelfOpenAPI",
dependencies: [.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime")],
plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
),
// The Vapor server: generated server stubs plus the hand-written handlers.
.executableTarget(
name: "App",
dependencies: [
"BookshelfOpenAPI",
.product(name: "Vapor", package: "vapor"),
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
],
plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
),
// The generated client, for the iOS app or another service.
.target(
name: "BookshelfClient",
dependencies: [
"BookshelfOpenAPI",
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
],
plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
),
.testTarget(name: "AppTests", dependencies: [
"App", "BookshelfClient",
.product(name: "VaporTesting", package: "vapor"),
]),
]
)
The directory layout is what the plugin reads:
Package.swift
Public/ openapi.yaml (symlink), swagger.html, redoc.html
Sources/
BookshelfOpenAPI/ openapi.yaml, openapi-generator-config.yaml, Empty.swift
App/ openapi.yaml (symlink), openapi-generator-config.yaml, handlers, routes
BookshelfClient/ openapi.yaml (symlink), openapi-generator-config.yaml, BookshelfAPIClient.swift
Tests/AppTests/
Each target that uses the plugin needs both files in its own source directory, even when it generates only server or
client. The server and client targets use symbolic links to the one real document, so there is a single source of
truth.
A target that contains only generated code still needs one ordinary .swift file (Empty.swift above), otherwise
SwiftPM
treats the target as having no sources, the plugin output is not compiled, and importing the module fails with "unable
to
resolve module dependency".
The three configuration files differ only in generate:
# Sources/BookshelfOpenAPI/openapi-generator-config.yaml
generate:
- types
accessModifier: public
namingStrategy: idiomatic
# Sources/App/openapi-generator-config.yaml
generate:
- server
namingStrategy: idiomatic
additionalImports:
- BookshelfOpenAPI
# Sources/BookshelfClient/openapi-generator-config.yaml
generate:
- client
additionalImports:
- BookshelfOpenAPI
namingStrategy: idiomatic
accessModifier: public
The keys of openapi-generator-config.yaml:
| Key | Meaning |
|---|---|
|
Any of |
|
|
|
Module names added as |
|
|
|
A dictionary from an OpenAPI identifier to the Swift identifier to use for it, to resolve collisions that |
|
|
|
|
|
Comment lines added to each generated file, for example |
|
Opt-in features of the generator that are not yet stable. |
The plugin can be replaced in two ways when it is not usable: the command plugin
(swift package plugin generate-code-from-openapi --target App) writes the files into the source tree so that they can
be
committed, and the command-line tool can be called from a Makefile. Both produce the same code; checking generated code
into
git trades build-time work and plugin trust for noisy diffs.
What the generator produces
For the document above the types target contains (names trimmed):
// Generated into BookshelfOpenAPI, abridged.
public protocol APIProtocol: Sendable {
func listBooks(_ input: Operations.ListBooks.Input) async throws -> Operations.ListBooks.Output
func createBook(_ input: Operations.CreateBook.Input) async throws -> Operations.CreateBook.Output
func getBook(_ input: Operations.GetBook.Input) async throws -> Operations.GetBook.Output
func deleteBook(_ input: Operations.DeleteBook.Input) async throws -> Operations.DeleteBook.Output
func uploadCover(_ input: Operations.UploadCover.Input) async throws -> Operations.UploadCover.Output
}
public enum Components {
public enum Schemas {
public struct Book: Codable, Hashable, Sendable {
public var id: Swift.String
public var title: Swift.String
public var author: Swift.String
public var publishedYear: Swift.Int
public init(id: Swift.String, title: Swift.String, author: Swift.String, publishedYear: Swift.Int)
}
// NewBook, BookPage, Problem ...
}
}
public enum Operations {
public enum ListBooks {
public struct Input: Sendable, Hashable {
public struct Query: Sendable, Hashable { public var limit: Swift.Int?; public var after: Swift.String? }
public var query: Operations.ListBooks.Input.Query
public var headers: Operations.ListBooks.Input.Headers
}
@frozen public enum Output: Sendable, Hashable {
case ok(Operations.ListBooks.Output.Ok) // .ok(.init(body: .json(page)))
case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload)
}
}
// CreateBook, GetBook, DeleteBook, UploadCover ...
}
How to read the generated shape:
-
Components.Schemas.Xis oneCodable,Hashable,Sendablestruct (or enum, forenumschemas, or an array oroneOfwrapper) per schema. Required properties are non-optional; optional ones areOptional.Sendablemeans they cross actor and task boundaries without further work. -
Operations.X.Inputhaspath,query,headers,cookiesandbodymembers for the parts the operation declares. A body is an enum with one case per content type, so a handler that accepts JSON and form data switches overinput.body. Arequired: truebody is non-optional, an optional body isnilwhen absent. -
Operations.X.Outputis an enum with one case per documented response, named by status (.ok,.created,.noContent,.notFound,.unprocessableContent) and a final.undocumented(statusCode:_:)case for any other status. Each case’s payload hasheaders(if the response declares any) andbody. The accessorstry output.okandtry output.ok.body.jsonthrow if the value is another case, which is what a client or a test uses. -
Servers.Server1.url()returns the firstserversentry as aURL. -
A response header declared in the document is a typed property on the response, and its value goes through the runtime’s URI coders; see the pitfalls at the end for what that does to a
Locationheader. -
Unused-import warnings: with Swift 6.4 the generated files produce
public import of 'Foundation' was not used in public declarationswarnings, which come from generated code the project does not edit. A project that treats warnings as errors should not apply that setting to the generated targets.
The server target generates the other half: an extension APIProtocol with
registerHandlers(on:serverURL:configuration:middlewares:) and the plumbing that decodes each request into an Input,
calls
your method and encodes the Output.
Implementing the handlers
A type conforms to APIProtocol. The compiler checks the whole contract: adding an operation to openapi.yaml breaks
the
build until the matching method exists, and removing a response breaks every return that used it.
import Foundation
import Vapor
import BookshelfOpenAPI
import OpenAPIRuntime
/// Implements every operation of openapi.yaml. The compiler enforces the list:
/// a new operation in the document is a build error here until it is implemented.
struct BookshelfHandler: APIProtocol {
let store: BookStore
let logger = Logger(label: "bookshelf.handler")
func listBooks(_ input: Operations.ListBooks.Input) async throws -> Operations.ListBooks.Output {
let limit = input.query.limit ?? 20
let after = input.query.after.flatMap(UUID.init(uuidString:))
let page = await store.page(limit: limit, after: after)
return .ok(.init(body: .json(.init(items: page.items.map(Components.Schemas.Book.init), next: page.next?.uuidString))))
}
func createBook(_ input: Operations.CreateBook.Input) async throws -> Operations.CreateBook.Output {
guard case .json(let new) = input.body else { throw Abort(.unsupportedMediaType) }
let author = new.author.trimmingCharacters(in: .whitespaces)
guard !new.title.isEmpty, !author.isEmpty else {
return .unprocessableContent(.init(body: .json(.init(title: "title and author must not be empty", status: 422))))
}
let record = await store.insert(title: new.title, author: author, publishedYear: new.publishedYear ?? 0)
logger.info("book created", metadata: ["subject": "\(RequestContext.subject ?? "anonymous")"])
return .created(.init(body: .json(.init(record))))
}
func getBook(_ input: Operations.GetBook.Input) async throws -> Operations.GetBook.Output {
guard let record = await store.find(id: input.path.bookId) else {
return .notFound(.init(body: .json(.init(title: "Book not found", status: 404))))
}
return .ok(.init(body: .json(.init(record))))
}
func deleteBook(_ input: Operations.DeleteBook.Input) async throws -> Operations.DeleteBook.Output {
guard await store.remove(id: input.path.bookId) else {
return .notFound(.init(body: .json(.init(title: "Book not found", status: 404))))
}
return .noContent(.init())
}
func uploadCover(_ input: Operations.UploadCover.Input) async throws -> Operations.UploadCover.Output {
guard case .png(let body) = input.body else { throw Abort(.unsupportedMediaType) }
// Collect with an explicit limit; a streaming consumer would write chunks to disk instead.
let bytes = try await [UInt8](collecting: body, upTo: 2 * 1024 * 1024)
guard await store.setCover(bytes, forID: input.path.bookId) else {
return .notFound(.init(body: .json(.init(title: "Book not found", status: 404))))
}
return .noContent(.init())
}
}
extension Components.Schemas.Book {
init(_ record: BookStore.Record) {
self.init(id: record.id.uuidString, title: record.title, author: record.author, publishedYear: record.publishedYear)
}
}
The data layer is a stand-in, an actor holding books in memory (a real handler would call Fluent, see
Fluent Queries and Pagination; its keyset next cursor is what
after
and next model here):
import Foundation
/// An in-memory store; a real service would use Fluent here.
actor BookStore {
struct Record: Sendable {
var id: UUID
var title: String
var author: String
var publishedYear: Int
var cover: [UInt8]?
}
private var books: [UUID: Record] = [:]
private var order: [UUID] = [] // newest first
func page(limit: Int, after: UUID?) -> (items: [Record], next: UUID?) {
var ids = order[...]
if let after, let index = order.firstIndex(of: after) {
ids = order[(index + 1)...]
}
let slice = Array(ids.prefix(limit))
let items = slice.compactMap { books[$0] }
let next = ids.count > limit ? slice.last : nil
return (items, next)
}
func find(id: String) -> Record? { UUID(uuidString: id).flatMap { books[$0] } }
func insert(title: String, author: String, publishedYear: Int) -> Record {
let record = Record(id: UUID(), title: title, author: author, publishedYear: publishedYear)
books[record.id] = record
order.insert(record.id, at: 0)
return record
}
func setCover(_ bytes: [UInt8], forID id: String) -> Bool {
guard let id = UUID(uuidString: id), books[id] != nil else { return false }
books[id]?.cover = bytes
return true
}
func remove(id: String) -> Bool {
guard let id = UUID(uuidString: id), books.removeValue(forKey: id) != nil else { return false }
order.removeAll { $0 == id }
return true
}
}
Source: Vapor docs: Async and the actor rules in Actors, Isolation and Sendable
Points about handlers:
-
No
Request. A handler gets only its typedInput. It cannot readreq.application,req.db,req.loggerorreq.auth. Everything it needs is passed to the handler’s initialiser (the store above, aDatabase, a mail service), which is ordinary dependency injection and plays well withSendable; see Services, Storage and Dependency Injection. Where a value comes from the request itself (the authenticated user), a middleware puts it into a task-local, shown below. -
Errors. Returning a documented response is the normal path. Throwing is for the unexpected. A thrown Vapor
Abortbecomes a response with that status, becauseswift-openapi-vapormakesAbortanHTTPResponseConvertibleerror; any other error becomes a 500. The runtime wraps everything it catches in aServerError; the middleware section shows how to shape it. -
Concurrency. The protocol is
Sendable, so the handler struct must be too; its stored properties areletconstants that areSendable(anactor, aLogger). Handlers areasyncfunctions like any other (see Swift Concurrency in Vapor); they do not get an event loop. -
The compiler as contract checker. Because the handler conforms to a protocol generated from the document, a status or body shape that the document does not list does not compile. The document can still be wrong about the real behaviour of the service; contract tests (below) check the other direction.
swift-openapi-vapor
OpenAPIVapor contributes one type, VaporTransport, which conforms to the runtime’s ServerTransport. Its
initialiser takes
a Vapor RoutesBuilder, and the generated registerHandlers(on:) calls it once per operation:
import Vapor
import OpenAPIRuntime
import OpenAPIVapor
import BookshelfOpenAPI
func routes(_ app: Application) throws {
// A hand-written route next to the generated ones.
app.get("health") { _ in "ok" }
// Generated routes. The default serverURL is "/"; pass the document's server URL to get the /api/v1 prefix.
// Only development and testing may run without API_TOKEN; anywhere else the application refuses to start.
let token: String
if let fromEnvironment = Environment.get("API_TOKEN") {
token = fromEnvironment
} else if app.environment == .development || app.environment == .testing {
app.logger.warning("API_TOKEN is not set; using the development placeholder")
token = "change-me-placeholder"
} else {
throw Abort(.internalServerError, reason: "API_TOKEN is not set")
}
let transport = VaporTransport(routesBuilder: app)
try BookshelfHandler(store: BookStore()).registerHandlers(
on: transport,
serverURL: try Servers.Server1.url(), // "/api/v1" from the document's servers list
middlewares: [ProblemMiddleware(), BodyLimitMiddleware(maxBytes: 1_000_000), BearerAuthMiddleware(validToken: token)]
)
}
Source: swift-openapi-vapor 1.2.0 and Vapor docs: Routing
What VaporTransport.register does for each operation, from the 1.2.0 sources:
-
It converts the document path (
/books/{bookId}) to Vapor path components:books,:bookId. A{name}segment is a Vapor route parameter, soreq.parametersholds it and Vapor’s router matches as usual. -
It registers the route with
body: .stream, so Vapor does not collect the body into memory before the handler runs and the route’smaxBodySizelimit is not what bounds uploads; see "Bodies, streaming and limits". -
It maps the Vapor request to the swift-http-types
HTTPRequestand anHTTPBodybacked by the request stream, calls the generated closure, and maps the returnedHTTPResponseandHTTPBodyback to a VaporResponse, streaming the body out.
Three consequences matter in practice:
-
The server URL is not applied by default.
registerHandlershasserverURL: URL = .defaultOpenAPIServerURL, which is/. WithoutserverURL:the routes are/books, not/api/v1/books, and the symptom is a 404 for every request that the document says should work. Pass the generatedServers.Server1.url(), or build the URL by hand to prefix a version (/api/v2). -
Routes are registered on the builder you pass.
VaporTransport(routesBuilder: app)puts the routes on the application, under its global middleware.VaporTransport(routesBuilder: app.grouped(RateLimitMiddleware()))puts them behind a Vapor middleware, which is the way to apply an existing Vapor middleware (CORS, rate limits, sessions) to generated routes; see Middleware. Global middleware,ErrorMiddlewareandRouteLoggingMiddlewareincluded, wrap generated routes exactly like hand-written ones. -
Generated and hand-written routes coexist. Both go through the same router.
/healthabove is an ordinary route. A hand-written route can also be a generated route’s neighbour in the same group, but a route that is not in the document is invisible to the generated client and to Swagger UI; add it to the document or keep it explicitly outside the contract (health checks, metrics, the spec itself).
A RoutesBuilder is also how generated routes end up in a RouteCollection:
struct BookshelfAPIRoutes: RouteCollection {
let handler: BookshelfHandler
func boot(routes: any RoutesBuilder) throws {
try handler.registerHandlers(on: VaporTransport(routesBuilder: routes), serverURL: try Servers.Server1.url())
}
}
Middleware: Vapor’s and the runtime’s
There are two middleware layers around a generated operation, and they run in this order:
-
Vapor middleware (
app.middleware, groups): the usual chain described in Middleware. It sees a VaporRequest. -
OpenAPI server middleware (the
middlewares:parameter ofregisterHandlers):ServerMiddlewarevalues from the runtime. They see swift-http-typesHTTPRequest, anHTTPBody?, the path parameters and theoperationID, and run only for generated operations. The first in the array is outermost.
Use the runtime layer when the logic depends on the operation (operationID) or should not apply to hand-written
routes; use
the Vapor layer for things Vapor already provides.
Authentication, because the generator does not do it
The document declares security: [bearerAuth: []] on three operations. A ServerMiddleware enforces it, using the
operationID to know which operations are protected:
import Foundation
import HTTPTypes
import OpenAPIRuntime
/// Request-scoped values for generated handlers. A generated handler receives only its `Input`,
/// not the Vapor `Request`, so anything it needs from the request travels in a task-local.
enum RequestContext {
@TaskLocal static var subject: String?
}
/// Operations whose `security` entry in openapi.yaml lists `bearerAuth`.
/// The generator does not enforce `security`; a middleware does.
struct BearerAuthMiddleware: ServerMiddleware {
static let protectedOperations: Set<String> = ["createBook", "deleteBook", "uploadCover"]
let validToken: String // a real service verifies a JWT or looks the token up
func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
metadata: ServerRequestMetadata,
operationID: String,
next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) {
guard Self.protectedOperations.contains(operationID) else {
return try await next(request, body, metadata)
}
guard Self.constantTimeEqual(request.headerFields[.authorization] ?? "", "Bearer \(validToken)") else {
var response = HTTPResponse(status: .unauthorized)
response.headerFields[.contentType] = "application/json"
response.headerFields[.wwwAuthenticate] = "Bearer"
return (response, HTTPBody("{\"title\":\"Missing or invalid bearer token\",\"status\":401}"))
}
// A real service puts the verified JWT subject here.
return try await RequestContext.$subject.withValue("librarian") {
try await next(request, body, metadata)
}
}
/// Compares without returning at the first differing byte, so response time does not reveal a matching prefix.
static 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
}
}
A single shared token keeps the listing short; a real service verifies a JWT (see JWT) or
looks a hashed token up (see Authentication). Even the shared token is compared
with constantTimeEqual, which does not return at the first differing byte: == on strings stops early, so its
response time tells an attacker how much of a guessed token was right. The token itself comes from API_TOKEN, and
routes refuses to start outside development and testing when it is missing, instead of accepting a placeholder that
anyone can read in the source. The task-local is how the middleware’s result reaches the
handler:
withValue makes RequestContext.subject visible to everything that runs inside the closure, which includes the
generated
deserializer, the handler and the serializer, and it is isolated per request. A value in a static var that is not a
task-local
would be shared by all requests and is a data race.
The operation list is duplicated knowledge: it must match the security entries of the document. A test that reads the
document and compares the two sets keeps it honest, and is cheap compared with an unauthenticated DELETE.
Turning runtime errors into the documented error body
When a request cannot be decoded (a missing required property, a malformed number), the runtime throws a ServerError
whose
underlyingError is the DecodingError. It conforms to HTTPResponseConvertible with status 500, which Vapor’s
ErrorMiddleware renders: in development it prints the runtime’s full description, including the request’s headers (and
so
the Authorization header). The middleware below maps those errors to the document’s own Problem shape and logs the
cause
instead:
import Foundation
import HTTPTypes
import Logging
import OpenAPIRuntime
/// Turns the runtime's `ServerError` into a small `Problem` body that matches openapi.yaml,
/// instead of letting Vapor's `ErrorMiddleware` print the runtime's detailed description.
struct ProblemMiddleware: ServerMiddleware {
let logger = Logger(label: "bookshelf.openapi")
func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
metadata: ServerRequestMetadata,
operationID: String,
next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) {
do {
return try await next(request, body, metadata)
} catch let error as ServerError {
// A body that does not decode is the client's fault; everything else keeps the status
// the runtime computed (500, or the status of an Abort thrown by a handler).
let status: HTTPResponse.Status = error.underlyingError is DecodingError ? .badRequest : error.httpStatus
logger.warning("\(operationID) failed", metadata: ["status": "\(status.code)", "cause": "\(error.causeDescription)"])
var response = HTTPResponse(status: status)
response.headerFields[.contentType] = "application/json"
let json = #"{"title":"\#(status.reasonPhrase)","status":\#(status.code)}"#
return (response, HTTPBody(json))
}
}
}
It is first in the middlewares: array of registerHandlers, so it is outermost and also sees errors thrown by the
authentication middleware. The runtime ships a simpler ErrorHandlingMiddleware that turns any error conforming to
HTTPResponseConvertible into
a response with that error’s status, headers and body; use it when the default shape is acceptable. A 415 for a wrong
Content-Type comes out of
this middleware unchanged, because the runtime’s content-type error already carries that status.
Tracing and metrics have the same split. Vapor’s TracingMiddleware covers the HTTP request; operation-level spans (a
span
named after the operationID) are written as another ServerMiddleware, and the generated client has the matching
ClientMiddleware.
Bodies, streaming and limits
Generated handlers see bodies as HTTPBody, an AsyncSequence of byte chunks with a known or unknown length. A JSON
request
body is collected by the generated deserializer before the handler runs, and in swift-openapi-runtime 1.13.1 that
collection
has no size limit (the source calls Data(collecting: body, upTo: .max)). A type: string, format: binary body such
as the
PNG above is not collected: the handler gets the HTTPBody and decides.
-
Cap requests before they are decoded.
VaporTransportregisters every route withbody: .stream, so neither Vapor’sapp.routes.defaultMaxBodySizenor a route’smaxBodySizeapplies. A reverse-proxy limit (client_max_body_sizein nginx) is the first line; aServerMiddlewarethat checksContent-Lengthis the second:import Foundation import HTTPTypes import OpenAPIRuntime /// The runtime collects JSON bodies without a size limit, and swift-openapi-vapor registers routes with /// `body: .stream`, so Vapor's `maxBodySize` does not apply. Reject oversized requests up front. struct BodyLimitMiddleware: ServerMiddleware { let maxBytes: Int func intercept( _ request: HTTPRequest, body: HTTPBody?, metadata: ServerRequestMetadata, operationID: String, next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { if let length = request.headerFields[.contentLength].flatMap({ Int($0) }), length > maxBytes { var response = HTTPResponse(status: .contentTooLarge) response.headerFields[.contentType] = "application/json" return (response, HTTPBody(#"{"title":"Content Too Large","status":413}"#)) } // A chunked request has no Content-Length: also cap the stream by wrapping `body` or limit it at the proxy. return try await next(request, body, metadata) } }A chunked request carries no
Content-Length, which is why the proxy limit stays necessary. See Server Configuration. -
Bound memory when collecting a binary body.
try await [UInt8](collecting: body, upTo: 2 * 1024 * 1024)throws when the body is larger; without a limit a client can make the service allocate the whole upload. -
Avoid buffering. Iterate with
for try await chunk in body { ... }and write each chunk to a file or an S3 multipart part (see File Uploads and Streaming). AnHTTPBodycreated from a request can be iterated once (iterationBehavior: .single); a second iteration throws. -
Responses stream too. A response body is an
HTTPBody, so a handler can return a large payload without building it in memory, by creating the body from anAsyncSequenceof bytes with a known length.
Serving the document and a UI
Clients, Swagger UI and contract tools want the document over HTTP. The simplest way is the one the static-files page
already
describes (Static Files and File I/O): put the file in Public/ and
let
FileMiddleware serve it. A symbolic link Public/openapi.yaml to ../Sources/BookshelfOpenAPI/openapi.yaml keeps one
source of
truth in a development checkout; in a container image copy the file into Public/ instead, because a link to the
sources
points nowhere once only the built product is shipped.
import Vapor
func configure(_ app: Application) async throws {
// Serves Public/openapi.yaml (a symlink to the source document), swagger.html and redoc.html.
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
try routes(app)
}
Public/swagger.html loads Swagger UI from a CDN, pinned to a version and with subresource-integrity hashes so that a
changed
or compromised file is refused by the browser; the initialisation lives in a separate script file so that no inline
script is
needed:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bookshelf API - Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.33.3/swagger-ui.css"
integrity="sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW" crossorigin="anonymous">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.33.3/swagger-ui-bundle.js"
integrity="sha384-h00llMzD1afyNow1pDmnZKlHsUZgp9KE9HXLFXF2CvbwsBY0+WBO4f75SfGwvM4K" crossorigin="anonymous"></script>
<script src="/swagger-init.js"></script>
</body>
</html>
// Public/swagger-init.js
window.ui = SwaggerUIBundle({ url: "/openapi.yaml", dom_id: "#swagger-ui" });
Redoc is a single element and one script, Public/redoc.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bookshelf API - Redoc</title>
</head>
<body>
<redoc spec-url="/openapi.yaml"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@3.0.1/bundles/redoc.standalone.js"
integrity="sha384-j07qRKHNNmcwU1/Y1elJc7pDLHXP/QfbQXTtSkbOJ7uYGJikqAzsFVbY2zR9FqOT" crossorigin="anonymous"></script>
</body>
</html>
Source: swagger-api/swagger-ui and Redocly/redoc
The server side of this was verified (the document and both HTML files are served with status 200 and the document
starts with
openapi: 3.1.0); the pages themselves were not opened in a browser for this page, so the hashes above are those of the
files at the pinned versions on the day of writing and must be recomputed when the versions change. If the service sets
a
Content-Security-Policy, allow the CDN host for script-src and style-src, or copy the bundles into Public/ and
serve
them yourself; see Security Hardening. A production API often serves the
documentation
only in non-production environments, which is a condition on registering the middleware or a separate directory.
A Swift client from the same document
BookshelfClient is the third target. It depends on the shared types and the URLSession transport, and its config
generates only client. The hand-written part is small: a transport, optional middleware, and a factory.
import Foundation
import HTTPTypes
import OpenAPIRuntime
import OpenAPIURLSession
import BookshelfOpenAPI
/// Adds `Authorization: Bearer ...` to every request.
public struct BearerTokenMiddleware: ClientMiddleware {
let token: String
public init(token: String) { self.token = token }
public func intercept(
_ request: HTTPRequest,
body: HTTPBody?,
baseURL: URL,
operationID: String,
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) {
var request = request
request.headerFields[.authorization] = "Bearer \(token)"
return try await next(request, body, baseURL)
}
}
/// The generated `Client` with this app's transport and middleware.
public func makeBookshelfClient(baseURL: URL, token: String) -> Client {
Client(
serverURL: baseURL,
transport: URLSessionTransport(),
middlewares: [BearerTokenMiddleware(token: token)]
)
}
Using it is plain async code. The generated Client conforms to a generated APIProtocol, so the same methods the
server
implements are called, with the same Input and Output types:
let client = makeBookshelfClient(baseURL: URL(string: "https://api.example.com/api/v1")!, token: token)
let created = try await client.createBook(body: .json(.init(title: "Dune", author: "Frank Herbert", publishedYear: 1965)))
let book = try created.created.body.json // throws if the status was not 201
let page = try await client.listBooks(query: .init(limit: 10)).ok.body.json
switch try await client.getBook(path: .init(bookId: book.id)) {
case .ok(let ok): print(try ok.body.json.title)
case .notFound(let problem): print(try problem.body.json.title)
case .undocumented(let status, _): print("unexpected status", status)
}
Three notes for the app side:
-
Base URL.
Client(serverURL:)takes the base URL including the version prefix; the generated paths are relative to it. The generatedServers.Server1.url()is/api/v1, a relative URL, so a client needs an absolute one passed in. -
Where the generated code lives. In an iOS app the simplest layout is an Xcode project or a Swift package for the app that depends on the shared
BookshelfOpenAPItypes package and either generates the client with the same plugin or uses the checked-in output of the command plugin. The server and the app must be built from the same version of the document; publishing the types package with the document’s version as its tag does that. The networking conventions of the app (retry, background sessions, certificate handling) stay in the app: see Networking in Apple apps. -
Other languages. The same
openapi.yamlfeeds any other generator, such as openapi-generator for Kotlin or TypeScript clients, which is the main reason to keep the document as an ordinary file and not as an artefact of the Swift build.
Testing the stack
A VaporTesting suite (see Testing) exercises the generated routes like any other route,
with app.testing().test(...), and the generated
client can be pointed at a real listening socket to test client and server against each other.
@testable import App
import BookshelfOpenAPI
import BookshelfClient
import Foundation
import Testing
import Vapor
import VaporTesting
@Suite("Bookshelf OpenAPI", .serialized)
struct BookshelfOpenAPITests {
@Test("a generated route answers and a hand-written route coexists")
func routes() async throws {
try await withApp(configure: configure) { app in
try await app.testing().test(.GET, "health") { res async in
#expect(res.status == .ok)
#expect(res.body.string == "ok")
}.test(.GET, "api/v1/books") { res async in
#expect(res.status == .ok)
#expect(res.headers.contentType == .json)
}
}
}
@Test("security is enforced by the middleware, not by the generator")
func security() async throws {
try await withApp(configure: configure) { app in
let body = ByteBuffer(string: #"{"title":"Dune","author":"Frank Herbert","publishedYear":1965}"#)
try await app.testing().test(.POST, "api/v1/books", headers: ["Content-Type": "application/json"], body: body) { res async in
#expect(res.status == .unauthorized)
#expect(res.headers.first(name: .wwwAuthenticate) == "Bearer")
}.test(.POST, "api/v1/books", headers: ["Content-Type": "application/json", "Authorization": "Bearer change-me-placeholder"], body: body) { res async throws in
#expect(res.status == .created)
let created = try res.content.decode(Components.Schemas.Book.self)
#expect(created.title == "Dune")
}
}
}
@Test("a body that does not match the schema is a 400 with a Problem, not a 500 with internals")
func malformedBody() async throws {
try await withApp(configure: configure) { app in
let headers: HTTPHeaders = ["Content-Type": "application/json", "Authorization": "Bearer change-me-placeholder"]
try await app.testing().test(.POST, "api/v1/books", headers: headers, body: ByteBuffer(string: #"{"title":"Dune"}"#)) { res async throws in
#expect(res.status == .badRequest)
let problem = try res.content.decode(Components.Schemas.Problem.self)
#expect(problem.status == 400)
#expect(!res.body.string.contains("Authorization"))
}
}
}
@Test("a streamed binary body is uploaded and a book is deleted")
func coverAndDelete() async throws {
try await withApp(configure: configure) { app in
let json: HTTPHeaders = ["Content-Type": "application/json", "Authorization": "Bearer change-me-placeholder"]
let png: HTTPHeaders = ["Content-Type": "image/png", "Authorization": "Bearer change-me-placeholder"]
var id = ""
try await app.testing().test(.POST, "api/v1/books", headers: json, body: ByteBuffer(string: #"{"title":"Dune","author":"Frank Herbert"}"#)) { res async throws in
id = try res.content.decode(Components.Schemas.Book.self).id
}
try await app.testing().test(.PUT, "api/v1/books/\(id)/cover", headers: png, body: ByteBuffer(bytes: [0x89, 0x50, 0x4E, 0x47])) { res async in
#expect(res.status == .noContent)
}.test(.DELETE, "api/v1/books/\(id)", headers: ["Authorization": "Bearer change-me-placeholder"]) { res async in
#expect(res.status == .noContent)
}.test(.GET, "api/v1/books/\(id)") { res async in
#expect(res.status == .notFound)
}
}
}
@Test("an oversized body is rejected before it is decoded")
func bodyLimit() async throws {
try await withApp(configure: configure) { app in
let headers: HTTPHeaders = ["Content-Type": "application/json", "Authorization": "Bearer change-me-placeholder"]
let big = ByteBuffer(repeating: UInt8(ascii: "a"), count: 1_000_001)
try await app.testing().test(.POST, "api/v1/books", headers: headers, body: big) { res async in
#expect(res.status == .payloadTooLarge)
}
}
}
@Test("the spec and the documentation pages are served")
func spec() async throws {
try await withApp(configure: configure) { app in
try await app.testing().test(.GET, "openapi.yaml") { res async in
#expect(res.status == .ok)
#expect(res.body.string.hasPrefix("openapi: 3.1.0"))
}.test(.GET, "swagger.html") { res async in
#expect(res.status == .ok)
}
}
}
@Test("the generated client talks to the generated server")
func client() async throws {
try await withApp(configure: configure) { app in
let port = 48217
try await app.server.start(address: .hostname("127.0.0.1", port: port))
do {
let client = makeBookshelfClient(baseURL: URL(string: "http://127.0.0.1:\(port)/api/v1")!, token: "change-me-placeholder")
let created = try await client.createBook(body: .json(.init(title: "Dune", author: "Frank Herbert", publishedYear: 1965)))
let book = try created.created.body.json
#expect(book.title == "Dune")
let page = try await client.listBooks(query: .init(limit: 10)).ok.body.json
#expect(page.items.map(\.id) == [book.id])
let missing = try await client.getBook(path: .init(bookId: UUID().uuidString))
let problem = try missing.notFound.body.json
#expect(problem.status == 404)
} catch {
await app.server.shutdown()
throw error
}
await app.server.shutdown()
}
}
}
Source: Vapor docs: Testing
Running swift test prints (abridged):
✔ Test "a generated route answers and a hand-written route coexists" passed
✔ Test "security is enforced by the middleware, not by the generator" passed
✔ Test "a body that does not match the schema is a 400 with a Problem, not a 500 with internals" passed
✔ Test "a streamed binary body is uploaded and a book is deleted" passed
✔ Test "an oversized body is rejected before it is decoded" passed
✔ Test "the spec and the documentation pages are served" passed
✔ Test "the generated client talks to the generated server" passed
✔ Test run with 7 tests in 1 suite passed
The last test has to start a real server because URLSessionTransport opens a socket; app.testing() is an in-process
tester that never listens. app.server.start(address:) must be paired with app.server.shutdown(), otherwise Vapor’s
HTTPServer asserts "did not shutdown before deinitializing" and the whole test process crashes; calling
app.startup()
instead fails inside a test runner because it parses the runner’s command-line arguments as Vapor commands. A fixed port
makes the test fragile when another process uses it; a CI job should pick a free port.
For the same service from outside, the commands and responses are these (the token is the placeholder demo-token set
with
API_TOKEN=demo-token):
curl -si -X POST http://127.0.0.1:8080/api/v1/books -H 'Content-Type: application/json' \
-d '{"title":"Dune","author":"Frank Herbert"}'
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer
# {"title":"Missing or invalid bearer token","status":401}
curl -si -X POST http://127.0.0.1:8080/api/v1/books -H 'Content-Type: application/json' \
-H 'Authorization: Bearer demo-token' -d '{"title":"Dune"}'
# HTTP/1.1 400 Bad Request
# {"title":"Bad Request","status":400}
curl -si -X POST http://127.0.0.1:8080/api/v1/books -H 'Content-Type: text/plain' \
-H 'Authorization: Bearer demo-token' -d 'x'
# HTTP/1.1 415 Unsupported Media Type
curl -si http://127.0.0.1:8080/api/v1/books/not-a-uuid
# HTTP/1.1 404 Not Found
# {"status":404,"title":"Book not found"}
Contract testing in the other direction (does the running service match the document?) is separate: the generated code
guarantees the shapes it produces, but a hand-written route, a middleware response such as the 401 above, or a header
outside
the document are not checked. Tools such as Schemathesis or Dredd read openapi.yaml, call the running service and
compare; they
run in CI against a container (see Deployment with Docker). The 401
above is a good example of why it is useful: the body
is hand-written JSON in the middleware, and only a test or a contract tool notices if it stops matching the Problem
schema.
Code-first alternatives
When the service already exists, or generated handlers are too constraining, a library can derive a document from the routes:
-
VaporToOpenAPI (
dankinsoid/VaporToOpenAPI, 4.9.2, built onSwiftOpenAPI) walksapp.routesand returns anOpenAPIObject; each route is annotated with.openAPI(summary:description:body:response:)and groups withgroupedOpenAPI. It generates OpenAPI 3.0.1 (the generator above reads 3.1), and types that appear in documented bodies must conform to its protocols, with enums needingCaseIterable. It was not run for this page; consult its README for the current API. -
Hand-written or checked-in document, served as a static file and used only for documentation and client generation. This is spec-first without the Swift generator: nothing ties the document to the code, so drift is only caught by contract tests.
-
Documenting from tests: recording requests in the test suite and writing the document from the recordings. Tooling is ad hoc.
The trade-offs, from the experience with the equivalent choices in other stacks:
| Question | Spec-first generator | Code-first library |
|---|---|---|
Can the document drift from behaviour? |
No for shapes the generator controls (types, statuses, parameters); yes for security, middleware responses and hand-written routes. |
Yes: annotations and handlers are separate, and nothing fails when they disagree. |
Does the compiler find a missing implementation? |
Yes, a new operation does not compile until implemented. |
No. |
Does a handler get a |
No, only the typed |
Yes, the ordinary Vapor handler with |
Client generation |
One document drives server and client in Swift, and any other language’s generator. |
The document can drive other languages' generators; a Swift client needs the same generator and the document. |
Cost |
A build plugin (plugin trust, build time), generated code, a naming convention, and a rewrite of existing handlers to the |
A library to maintain; accuracy depends on annotation discipline. |
Fit |
New API, an API shared with an iOS app, a contract negotiated before implementation. |
An existing Vapor service whose handlers should stay as they are. |
A pragmatic path for an existing service: add the generator for one new, self-contained feature (its own servers
prefix such
as /api/v2 and its own document), leave the old routes alone, and move routes across one tag at a time using the
filter
key to generate only the part that is ready.
Vapor 5
swift-openapi-vapor has a 2.0.0-beta.1 release alongside the 1.x line. Its Package.swift depends on Vapor
5.0.0-beta.4, uses tools version 6.4 and raises the platform floor to the 26.2 releases of Apple’s systems, so it
cannot be
resolved against Vapor 4. The 1.2.0 release used on this page depends on Vapor 4.106.7 or later. The generator and
runtime are
independent of the Vapor major version: the OpenAPIGenerator plugin, the document, the generated types and the client
transport are the same on both, and the changes for a server moving to Vapor 5 are in the Vapor-level code around the
transport (the routes/configure functions, Application setup, testing). A migration plan is to keep the
document and the handlers, and switch the package pin when 2.0.0 is tagged and Vapor 5 is generally available; track
the
repository’s releases, since this is a beta of both packages.
Common mistakes
-
Every generated route returns 404.
registerHandlerswas called withoutserverURL:, so the routes are/booksand not/api/v1/books. PassServers.Server1.url(). -
"unable to resolve module dependency: 'BookshelfOpenAPI'". The types target has no ordinary source file, or its
openapi-generator-config.yamllacksaccessModifier: publicwhile other modules use it. Add a file and the access modifier. -
The server target does not see
Components.Schemas.Book. The server config lacksadditionalImports: [BookshelfOpenAPI], or thenamingStrategydiffers between targets so the names do not match. -
The generator fails with "no openapi.yaml". Every target with the plugin needs the document and the config file in its own directory; use symbolic links to a single copy.
-
format: uuidproperties areString. The generator does not map them toUUID; convert in the handler or usetypeOverrides. -
A response header with a path in it is percent-encoded. A
Location: /api/v1/books/...declared as astringheader and set through the generatedheaders:initialiser arrived on the wire asLocation: %2Fapi%2Fv1%2Fbooks%2F..., because the runtime encodes header values with its URI coder. Check any header whose value contains/or other reserved characters; for aLocationheader set the field on the response in aServerMiddleware(response.headerFields[.location] = ...) instead of declaring it as a typed header. -
A bad request gives a 500 and a long message. A body that does not decode is a
ServerErrorwith status 500 by default; withoutProblemMiddlewarethe description (including request headers) reaches the response outside production. MapDecodingErrorto 400 and keep the details in the log. -
securityin the document does nothing. The generator ignores it. Write aServerMiddlewareand keep its operation list in step with the document. -
Request-scoped state in a static variable. Use a
@TaskLocal, which is per request, not astatic varshared by all requests. -
An unbounded request body. JSON bodies are collected without a limit and the routes bypass Vapor’s
maxBodySize; add a reverse-proxy limit and aContent-Lengthmiddleware (the section on bodies), andcollecting: body, upTo:for binary bodies. -
The Swagger UI shows the padlock but "Try it out" is rejected. The UI needs a token entered with Authorize; the authentication middleware is what rejects the call.
-
Warnings about unused public imports. They come from generated files on Swift 6.4, not from your code; do not turn warnings into errors for the generated target.
Where the books are outdated
None of the section’s books (Condon et al., Yeung, Patel) covers OpenAPI code generation. They were written for earlier
Vapor versions, before Apple released swift-openapi-generator (announced in 2023), and the nearest topics they treat
are API versioning and documenting an API by hand. The idea is the same, but the mechanism has changed: today a
document and generated code, not annotations or hand-maintained Swagger files, are the usual way, and the versioned
/api/v1 prefix the books describe is the servers entry of the document here.