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 openapi.yaml; swift-openapi-generator generates Swift types, a server protocol and a client from it; you implement the protocol.

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 Input values instead of a Request.

Code-first

You write ordinary Vapor routes and annotate them; a library walks app.routes and emits a document.

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

flowchart LR Y["openapi.yaml and openapi-generator-config.yaml"] --> P["OpenAPIGenerator build plugin"] P -->|"generate: types"| T["BookshelfOpenAPI: Components.Schemas, Operations, APIProtocol"] P -->|"generate: server"| S["App: registerHandlers"] P -->|"generate: client"| C["BookshelfClient: Client"] T --> S T --> C S --> V["swift-openapi-vapor: VaporTransport"] V --> R["Vapor routes under /api/v1"] C --> U["URLSessionTransport: iOS app or another service"] Y -.->|"served as a file"| D["openapi.yaml, Swagger UI, Redoc"]

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 reads openapi.yaml and openapi-generator-config.yaml from 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, the ServerTransport, ClientTransport, ServerMiddleware and ClientMiddleware protocols, 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 to ServerTransport. This is the only piece that knows about Vapor.

  • swift-openapi-urlsession (apple/swift-openapi-urlsession) is a client transport on URLSession for 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' }

Four details of this document drive the generated code, so they are worth deciding on purpose:

  • operationId becomes the method name (listBooks) and the namespace under Operations. Without it the generator invents a name from the path and method; always write it.

  • servers: - url: /api/v1 is the path prefix. It is not applied automatically on the server; see the section on registerHandlers below.

  • A $ref into components/responses (NotFound, Unauthorized) is reused by several operations and appears as one case per status on each Output enum, so a handler cannot return a status the document does not list without going through undocumented.

  • format: uuid is not mapped to Foundation.UUID: the generated property is a String (the generator maps date-time to Date and leaves most other formats as String). The handler converts with UUID(uuidString:), as the listing below does. Schemas can be pointed at your own type with typeOverrides, 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

generate (required)

Any of types, client, server. client and server code refers to the types, so they need either types in the same target or an import of the target that has them (additionalImports).

accessModifier

internal (default), package or public. The shared types module must be public (or package inside one package) for other modules to use it; a server target that is the only consumer can stay internal.

additionalImports

Module names added as import lines to every generated file; this is how server and client find the shared types.

namingStrategy

defensive (default, never collides) or idiomatic (Operations.ListBooks, .notFound, lower-camel properties). Every target that shares the types must use the same strategy, otherwise the server refers to names the types module did not generate.

nameOverrides

A dictionary from an OpenAPI identifier to the Swift identifier to use for it, to resolve collisions that idiomatic produces.

typeOverrides

schemas: maps a schema name to your own type, for example to use a hand-written type that the server also uses elsewhere.

filter

operations, tags, paths, schemas lists; only matching parts are generated. Useful to generate a client for one tag of a large document, or to leave out admin endpoints from the public client.

additionalFileComments

Comment lines added to each generated file, for example swift-format-ignore-file or swiftlint:disable all.

featureFlags

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.X is one Codable, Hashable, Sendable struct (or enum, for enum schemas, or an array or oneOf wrapper) per schema. Required properties are non-optional; optional ones are Optional. Sendable means they cross actor and task boundaries without further work.

  • Operations.X.Input has path, query, headers, cookies and body members 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 over input.body. A required: true body is non-optional, an optional body is nil when absent.

  • Operations.X.Output is 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 has headers (if the response declares any) and body. The accessors try output.ok and try output.ok.body.json throw if the value is another case, which is what a client or a test uses.

  • Servers.Server1.url() returns the first servers entry as a URL.

  • 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 Location header.

  • Unused-import warnings: with Swift 6.4 the generated files produce public import of 'Foundation' was not used in public declarations warnings, 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 typed Input. It cannot read req.application, req.db, req.logger or req.auth. Everything it needs is passed to the handler’s initialiser (the store above, a Database, a mail service), which is ordinary dependency injection and plays well with Sendable; 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 Abort becomes a response with that status, because swift-openapi-vapor makes Abort an HTTPResponseConvertible error; any other error becomes a 500. The runtime wraps everything it catches in a ServerError; the middleware section shows how to shape it.

  • Concurrency. The protocol is Sendable, so the handler struct must be too; its stored properties are let constants that are Sendable (an actor, a Logger). Handlers are async functions 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)]
    )
}

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, so req.parameters holds 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’s maxBodySize limit is not what bounds uploads; see "Bodies, streaming and limits".

  • It maps the Vapor request to the swift-http-types HTTPRequest and an HTTPBody backed by the request stream, calls the generated closure, and maps the returned HTTPResponse and HTTPBody back to a Vapor Response, streaming the body out.

Three consequences matter in practice:

  • The server URL is not applied by default. registerHandlers has serverURL: URL = .defaultOpenAPIServerURL, which is /. Without serverURL: 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 generated Servers.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, ErrorMiddleware and RouteLoggingMiddleware included, wrap generated routes exactly like hand-written ones.

  • Generated and hand-written routes coexist. Both go through the same router. /health above 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:

  1. Vapor middleware (app.middleware, groups): the usual chain described in Middleware. It sees a Vapor Request.

  2. OpenAPI server middleware (the middlewares: parameter of registerHandlers): ServerMiddleware values from the runtime. They see swift-http-types HTTPRequest, an HTTPBody?, the path parameters and the operationID, 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. VaporTransport registers every route with body: .stream, so neither Vapor’s app.routes.defaultMaxBodySize nor a route’s maxBodySize applies. A reverse-proxy limit (client_max_body_size in nginx) is the first line; a ServerMiddleware that checks Content-Length is 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). An HTTPBody created 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 an AsyncSequence of 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>

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 generated Servers.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 BookshelfOpenAPI types 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.yaml feeds 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()
        }
    }
}

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 on SwiftOpenAPI) walks app.routes and returns an OpenAPIObject; each route is annotated with .openAPI(summary:description:body:response:) and groups with groupedOpenAPI. 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 needing CaseIterable. 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 Request?

No, only the typed Input; request-scoped values travel through middleware and task-locals.

Yes, the ordinary Vapor handler with req.db, req.auth and req.content.

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 Input/Output style.

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. registerHandlers was called without serverURL:, so the routes are /books and not /api/v1/books. Pass Servers.Server1.url().

  • "unable to resolve module dependency: 'BookshelfOpenAPI'". The types target has no ordinary source file, or its openapi-generator-config.yaml lacks accessModifier: public while other modules use it. Add a file and the access modifier.

  • The server target does not see Components.Schemas.Book. The server config lacks additionalImports: [BookshelfOpenAPI], or the namingStrategy differs 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: uuid properties are String. The generator does not map them to UUID; convert in the handler or use typeOverrides.

  • A response header with a path in it is percent-encoded. A Location: /api/v1/books/... declared as a string header and set through the generated headers: initialiser arrived on the wire as Location: %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 a Location header set the field on the response in a ServerMiddleware (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 ServerError with status 500 by default; without ProblemMiddleware the description (including request headers) reaches the response outside production. Map DecodingError to 400 and keep the details in the log.

  • security in the document does nothing. The generator ignores it. Write a ServerMiddleware and keep its operation list in step with the document.

  • Request-scoped state in a static variable. Use a @TaskLocal, which is per request, not a static var shared 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 a Content-Length middleware (the section on bodies), and collecting: 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.