All Articles
CategoryBackend
Reading Time
23 min read
Published
2024-01-25
Word Count
1,377words

Grab a coffee — this one is a deep dive!

Backend API with Swift Vapor: Full-Stack Swift Development

Summary

Building RESTful APIs with the Vapor framework. Fluent ORM, JWT auth, middleware, WebSocket, Docker deployment, and sharing models between iOS and backend.

  • Vapor is a server-side framework that supports async/await from version 4.0+ and is maintained by the Swift Server Work Group.
  • With Fluent ORM, models and AsyncMigrations are defined using @ID, @Field, and @Parent.
  • JWT authentication middleware verifies a UserPayload to protect protected routes.
  • Benchmark: Vapor 85,000 req/sec, Express 42,000, FastAPI 28,000, Rails 12,000 req/sec.
Backend API with Swift Vapor: Full-Stack Swift Development

Full-stack Swift — the same language, the same types, on client and server. Vapor is Swift's most popular server-side framework. It lets you build type-safe, performant backend APIs as an alternative to Node.js or Django. The best part: you can reuse the models from your iOS app on the backend too.

💡 Quick Note: Vapor 4.0+ supports async/await. The Swift on Server ecosystem is maintained by Apple's Swift Server Work Group (SSWG).

Table of Contents


Vapor Setup

bash
1# Install the Vapor toolbox
2brew install vapor
3 
4# Create a new project
5vapor new MyAPI -n
6 
7# Run it
8cd MyAPI
9swift run
10# Server running on http://127.0.0.1:8080

Routing and Controllers

swift
1import Vapor
2 
3// routes.swift
4func routes(_ app: Application) throws {
5 // Simple route
6 app.get("hello") { req in
7 "Hello, World!"
8 }
9 
10 // Route parameter
11 app.get("users", ":userID") { req -> User in
12 guard let id = req.parameters.get("userID", as: UUID.self) else {
13 throw Abort(.badRequest)
14 }
15 guard let user = try await User.find(id, on: req.db) else {
16 throw Abort(.notFound)
17 }
18 return user
19 }
20 
21 // Using a controller
22 try app.register(collection: ProductController())
23}
24 
25// ProductController.swift
26struct ProductController: RouteCollection {
27 func boot(routes: RoutesBuilder) throws {
28 let products = routes.grouped("api", "v1", "products")
29 products.get(use: index)
30 products.post(use: create)
31 products.group(":productID") { product in
32 product.get(use: show)
33 product.put(use: update)
34 product.delete(use: delete)
35 }
36 }
37 
38 func index(req: Request) async throws -> [ProductDTO] {
39 let products = try await Product.query(on: req.db).all()
40 return products.map { $0.toDTO() }
41 }
42 
43 func create(req: Request) async throws -> ProductDTO {
44 let input = try req.content.decode(CreateProductRequest.self)
45 let product = Product(name: input.name, price: input.price)
46 try await product.save(on: req.db)
47 return product.toDTO()
48 }
49 
50 func show(req: Request) async throws -> ProductDTO {
51 guard let product = try await Product.find(req.parameters.get("productID"), on: req.db) else {
52 throw Abort(.notFound)
53 }
54 return product.toDTO()
55 }
56 
57 func update(req: Request) async throws -> ProductDTO {
58 guard let product = try await Product.find(req.parameters.get("productID"), on: req.db) else {
59 throw Abort(.notFound)
60 }
61 let input = try req.content.decode(UpdateProductRequest.self)
62 product.name = input.name ?? product.name
63 product.price = input.price ?? product.price
64 try await product.save(on: req.db)
65 return product.toDTO()
66 }
67 
68 func delete(req: Request) async throws -> HTTPStatus {
69 guard let product = try await Product.find(req.parameters.get("productID"), on: req.db) else {
70 throw Abort(.notFound)
71 }
72 try await product.delete(on: req.db)
73 return .noContent
74 }
75}

Fluent ORM for the Database

swift
1import Fluent
2import FluentPostgresDriver
3 
4// Model
5final class Product: Model, Content {
6 static let schema = "products"
7 
8 @ID(key: .id)
9 var id: UUID?
10 
11 @Field(key: "name")
12 var name: String
13 
14 @Field(key: "price")
15 var price: Double
16 
17 @Field(key: "is_active")
18 var isActive: Bool
19 
20 @Timestamp(key: "created_at", on: .create)
21 var createdAt: Date?
22 
23 @Parent(key: "category_id")
24 var category: Category
25 
26 init() {}
27 
28 init(name: String, price: Double, categoryID: UUID) {
29 self.name = name
30 self.price = price
31 self.isActive = true
32 self.$category.id = categoryID
33 }
34}
35 
36// Migration
37struct CreateProduct: AsyncMigration {
38 func prepare(on database: Database) async throws {
39 try await database.schema("products")
40 .id()
41 .field("name", .string, .required)
42 .field("price", .double, .required)
43 .field("is_active", .bool, .required, .custom("DEFAULT TRUE"))
44 .field("created_at", .datetime)
45 .field("category_id", .uuid, .required, .references("categories", "id"))
46 .create()
47 }
48 
49 func revert(on database: Database) async throws {
50 try await database.schema("products").delete()
51 }
52}

JWT Authentication

swift
1import JWT
2 
3// JWT Payload
4struct UserPayload: JWTPayload {
5 var sub: SubjectClaim
6 var exp: ExpirationClaim
7 var role: String
8 
9 func verify(using signer: JWTSigner) throws {
10 try exp.verifyNotExpired()
11 }
12}
13 
14// Auth middleware
15struct JWTAuthMiddleware: AsyncMiddleware {
16 func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
17 let payload = try request.jwt.verify(as: UserPayload.self)
18 request.auth.login(payload)
19 return try await next.respond(to: request)
20 }
21}
22 
23// Login endpoint
24app.post("auth", "login") { req -> TokenResponse in
25 let input = try req.content.decode(LoginRequest.self)
26 guard let user = try await User.query(on: req.db)
27 .filter(\.$email == input.email).first(),
28 try user.verify(password: input.password) else {
29 throw Abort(.unauthorized)
30 }
31 
32 let payload = UserPayload(
33 sub: .init(value: user.id!.uuidString),
34 exp: .init(value: Date().addingTimeInterval(3600)),
35 role: user.role
36 )
37 let token = try req.jwt.sign(payload)
38 return TokenResponse(token: token)
39}
40 
41// Protected route
42let protected = app.grouped(JWTAuthMiddleware())
43protected.get("me") { req -> User in
44 let payload = try req.auth.require(UserPayload.self)
45 guard let user = try await User.find(UUID(payload.sub.value), on: req.db) else {
46 throw Abort(.notFound)
47 }
48 return user
49}

Middleware

swift
1// CORS middleware
2app.middleware.use(CORSMiddleware(configuration: .init(
3 allowedOrigin: .all,
4 allowedMethods: [.GET, .POST, .PUT, .DELETE],
5 allowedHeaders: [.accept, .authorization, .contentType]
6)))
7 
8// Rate limiting middleware
9struct RateLimitMiddleware: AsyncMiddleware {
10 let maxRequests: Int
11 let window: TimeInterval
12 
13 func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
14 let ip = request.remoteAddress?.ipAddress ?? "unknown"
15 let key = "rate_limit:\(ip)"
16 let count = try await request.cache.get(key, as: Int.self) ?? 0
17 
18 guard count < maxRequests else {
19 throw Abort(.tooManyRequests)
20 }
21 
22 try await request.cache.set(key, to: count + 1, expiresIn: .seconds(Int(window)))
23 return try await next.respond(to: request)
24 }
25}

WebSocket Support

swift
1app.webSocket("ws", "chat") { req, ws in
2 ws.onText { ws, text in
3 // Broadcast to all connected clients
4 for client in ChatRoom.shared.clients {
5 client.send(text)
6 }
7 }
8 ws.onClose.whenComplete { _ in
9 ChatRoom.shared.remove(ws)
10 }
11 ChatRoom.shared.add(ws)
12}

Sharing Models Between iOS and Backend

swift
1// SharedModels SPM package - used in both iOS and Vapor
2// Package.swift
3let package = Package(
4 name: "SharedModels",
5 products: [.library(name: "SharedModels", targets: ["SharedModels"])],
6 targets: [.target(name: "SharedModels")]
7)
8 
9// SharedModels/Product.swift
10public struct ProductDTO: Codable, Sendable {
11 public let id: UUID
12 public let name: String
13 public let price: Double
14 
15 public init(id: UUID, name: String, price: Double) {
16 self.id = id; self.name = name; self.price = price
17 }
18}

Testing

swift
1@testable import App
2import XCTVapor
3 
4final class ProductTests: XCTestCase {
5 func testCreateProduct() async throws {
6 let app = Application(.testing)
7 defer { app.shutdown() }
8 try configure(app)
9 
10 try app.test(.POST, "api/v1/products",
11 beforeRequest: { req in
12 try req.content.encode(CreateProductRequest(name: "Test", price: 9.99))
13 },
14 afterResponse: { res in
15 XCTAssertEqual(res.status, .ok)
16 let product = try res.content.decode(ProductDTO.self)
17 XCTAssertEqual(product.name, "Test")
18 }
19 )
20 }
21}

Deployment with Docker

dockerfile
1FROM swift:5.9-jammy as build
2WORKDIR /app
3COPY . .
4RUN swift build -c release
5 
6FROM swift:5.9-jammy-slim
7COPY --from=build /app/.build/release/App /app/App
8EXPOSE 8080
9ENTRYPOINT ["/app/App", "serve", "--env", "production", "--hostname", "0.0.0.0"]

Performance Benchmarks

Framework
Requests/sec
Latency (p99)
Memory
Vapor (Swift)
85,000
2.1ms
45MB
Express (Node)
42,000
4.8ms
120MB
FastAPI (Python)
28,000
8.2ms
85MB
Rails (Ruby)
12,000
15ms
200MB

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Reader Reward

Congratulations! Since you read this article all the way through, I have a special gift for you:

Sources:

Tags

#Vapor#Swift#backend#API#server-side-swift#REST#full-stack
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share