Skip to Content
Course content

55: Building a Simple Server-Side Swift API with Vapor

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Moving from iOS development to server-side Swift with Vapor can feel like stepping into a mirror dimension. You're using the same language, but the environment is entirely different. There's no UIKit, no SwiftUI, and instead of managing a view hierarchy, you're managing a request-response lifecycle. I've seen a lot of developers treat their first Vapor project like a script, throwing everything into the routing file because it's the fastest way to see a result in the browser.

The Temptation of the Giant Route File

When you first set up a project, Vapor gives you a routes.swift file. It's tempting to just start writing your logic right there. Let's say we're building a simple Book Inventory API. The naive approach is to handle the database query, the validation, and the JSON response all inside a single closure.

app.get("books", ":bookID") { req -> EventLoopFuture<Book> in
    let bookID = req.parameters.get("bookID", as: UUID.self)
    
    // Imagine doing validation, logging, and DB queries all here...
    return Book.find(bookID, on: req.db)
        .unwrap(or: Abort(.notFound))
}

At first, this feels efficient. You have one place to look, and it's only a few lines of code. But here is where it falls apart: the moment you add a second or third endpoint—like adding a book or updating a price—your routes.swift becomes a "God file." It grows into a thousand-line monster where business logic is tangled up with routing definitions. If you want to write a unit test for the logic of "how we find a book," you can't, because that logic is trapped inside a route handler that requires a full Request object to function.

Moving Toward Controller-Based Logic

The better way, and the way we actually do this in production, is to decouple the routing (the "where" of the request) from the controller (the "what" of the request). I think of the route file as a switchboard operator; its only job is to take the call and plug it into the right department.

Instead of an anonymous closure, we create a BookController. This allows us to group related functionality and, more importantly, keep our routing table clean. Here is how that looks in practice:

struct BookController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.get(":bookID", use: find),
             books.post(use: create)
    }

    func find(req: Request) throws -> EventLoopFuture<Book> {
        guard let bookID = req.parameters.get("bookID", as: UUID.self) else {
            throw Abort(.badRequest)
        }
        return Book.find(bookID, on: req.db).unwrap(or: Abort(.notFound))
    }

    func create(req: Request) throws -> EventLoopFuture<Book> {
        let book = try req.content.decode(Book.self)
        return book.save(on: req.db).map { book }
    }
}

Now, in your routes.swift, you simply call try app.register(collection: BookController()). You've just moved the "mess" into a dedicated space where it can be managed.

The Trade-off: Boilerplate vs. Maintainability

I'll be honest with you: this approach requires more files and more typing upfront. You have to define a struct, implement RouteCollection, and explicitly map your methods. When you're building a prototype in an hour, that feels like overkill.

But consider the "break point." The naive approach breaks the moment you need to change your database schema or add complex validation logic. If you have 20 routes all living in one file, a single syntax error can stop the entire server from compiling, and finding the culprit becomes a game of hide-and-seek. By using controllers, you isolate the failure. If the BookController has a bug, your UserController is still perfectly fine. It also makes the code much more readable for your teammates; they can look at the folder structure and know exactly where the book-related logic lives without scrolling through a 2,000-line file.

One last tip: notice the use of req.content.decode(Book.self). In the naive approach, people often try to manually parse JSON dictionaries. Never do that in Vapor. Leverage the Content protocol. It handles the heavy lifting of serialization and deserialization, ensuring that if the client sends a malformed JSON body, Vapor automatically throws a 400 Bad Request before the code even hits your controller logic.




📋 Practical Task

Exercise: Implementing a Validated Book Archive Endpoint

You are tasked with adding a new feature to the Book Inventory API. You need to create a specialized endpoint that archives a book, but only if the book exists and is not already archived.

Requirements:

  • Do not add this logic to routes.swift. Instead, add a new method to the BookController.
  • Create a route: PATCH /books/:bookID/archive.
  • The logic should:
    1. Fetch the book using the bookID from the parameters.
    2. Check if the book's isArchived property is already true. If it is, throw an Abort(.conflict) error with a custom reason: "Book is already archived."
    3. Set isArchived to true, save the book, and return the updated book object.
  • Ensure the route is properly registered within the boot function of the BookController.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.