Vapor en Swift: routes, controllers, Fluent ORM, middleware y deploy en Linux

Swift en el servidor es una realidad productiva gracias a Vapor, el framework web más completo del ecosistema. Funciona en Linux y macOS, tiene un rendimiento comparable a Go en benchmarks de concurrencia, y aprovecha el sistema de tipos de Swift para hacer que errores comunes en otros frameworks sean imposibles en tiempo de compilación. Este artículo cubre el ciclo completo: rutas, controladores, Fluent ORM, middleware y despliegue.

Estructura de un proyecto Vapor

vapor new MiAPI --template api
cd MiAPI
swift run

La estructura básica:

// Sources/App/configure.swift
import Vapor
import Fluent
import FluentPostgresDriver

public func configure(_ app: Application) async throws {
    // Base de datos
    app.databases.use(
        .postgres(configuration: .init(
            hostname: Environment.get("DB_HOST") ?? "localhost",
            username: Environment.get("DB_USER") ?? "vapor",
            password: Environment.get("DB_PASSWORD") ?? "",
            database: Environment.get("DB_NAME") ?? "miapi"
        )),
        as: .psql
    )

    // Migraciones
    app.migrations.add(CrearUsuarios())
    app.migrations.add(CrearPosts())
    try await app.autoMigrate()

    // Rutas
    try routes(app)
}

Rutas: definición y agrupación

Las rutas en Vapor son funciones que reciben un Request y retornan algo que conforme a ResponseEncodable:

// Sources/App/routes.swift
func routes(_ app: Application) throws {
    // Ruta simple
    app.get("health") { req in
        ["status": "ok"]
    }

    // Parámetros de ruta
    app.get("usuarios", ":id") { req async throws -> Usuario in
        guard let id = req.parameters.get("id", as: UUID.self) else {
            throw Abort(.badRequest, reason: "ID inválido")
        }
        guard let usuario = try await Usuario.find(id, on: req.db) else {
            throw Abort(.notFound)
        }
        return usuario
    }

    // Grupos de rutas con prefijo
    let api = app.grouped("api", "v1")
    try api.register(collection: UsuariosController())
    try api.register(collection: PostsController())
}

RouteCollection: controladores

Los controladores organizan las rutas relacionadas en un tipo que conforma a RouteCollection:

struct UsuariosController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let usuarios = routes.grouped("usuarios")
        usuarios.get(use: listar)
        usuarios.post(use: crear)
        usuarios.group(":usuarioID") { usuario in
            usuario.get(use: obtener)
            usuario.put(use: actualizar)
            usuario.delete(use: eliminar)
        }

        // Rutas protegidas con autenticación
        let protegidas = usuarios.grouped(TokenAuthMiddleware())
        protegidas.get("perfil", use: miPerfil)
    }

    func listar(_ req: Request) async throws -> [Usuario.Public] {
        let pagina = try req.query.decode(Paginacion.self)
        let usuarios = try await Usuario.query(on: req.db)
            .filter(.$activo == true)
            .sort(.$createdAt, .descending)
            .paginate(pagina)
        return usuarios.items.map { $0.toPublic() }
    }

    func crear(_ req: Request) async throws -> HTTPStatus {
        let entrada = try req.content.decode(Usuario.Create.self)
        try Usuario.Create.validate(content: req)

        let hash = try req.application.password.hash(entrada.password)
        let usuario = Usuario(nombre: entrada.nombre, email: entrada.email, passwordHash: hash)
        try await usuario.save(on: req.db)
        return .created
    }

    func obtener(_ req: Request) async throws -> Usuario.Public {
        guard let usuario = try await Usuario.find(
            req.parameters.get("usuarioID"),
            on: req.db
        ) else {
            throw Abort(.notFound)
        }
        return usuario.toPublic()
    }
}

Fluent ORM: modelos y migraciones

Fluent es el ORM de Vapor. Los modelos conforman al protocolo Model:

import Fluent

final class Usuario: Model, Content, @unchecked Sendable {
    static let schema = "usuarios"

    @ID(key: .id) var id: UUID?
    @Field(key: "nombre") var nombre: String
    @Field(key: "email") var email: String
    @Field(key: "password_hash") var passwordHash: String
    @Field(key: "activo") var activo: Bool
    @Children(for: .$autor) var posts: [Post]
    @Timestamp(key: "created_at", on: .create) var createdAt: Date?
    @Timestamp(key: "updated_at", on: .update) var updatedAt: Date?

    init() { }

    init(nombre: String, email: String, passwordHash: String) {
        self.nombre = nombre
        self.email = email
        self.passwordHash = passwordHash
        self.activo = true
    }

    // DTO para respuestas públicas (sin el hash)
    struct Public: Content {
        let id: UUID
        let nombre: String
        let email: String
    }

    func toPublic() -> Public {
        Public(id: id!, nombre: nombre, email: email)
    }
}

// Migración
struct CrearUsuarios: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("usuarios")
            .id()
            .field("nombre", .string, .required)
            .field("email", .string, .required)
            .unique(on: "email")
            .field("password_hash", .string, .required)
            .field("activo", .bool, .required, .sql(.default(true)))
            .field("created_at", .datetime)
            .field("updated_at", .datetime)
            .create()
    }

    func revert(on database: Database) async throws {
        try await database.schema("usuarios").delete()
    }
}

Middleware de autenticación

struct TokenAuthMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        guard let bearerToken = request.headers.bearerAuthorization?.token else {
            throw Abort(.unauthorized, reason: "Token requerido")
        }

        guard let sesion = try await SesionToken.query(on: request.db)
            .filter(.$token == bearerToken)
            .filter(.$expiracion > Date())
            .with(.$usuario)
            .first() else {
            throw Abort(.unauthorized, reason: "Token inválido o expirado")
        }

        request.auth.login(sesion.usuario)
        return try await next.respond(to: request)
    }
}

// Middleware de rate limiting
struct RateLimitMiddleware: AsyncMiddleware {
    let limite: Int
    let ventana: TimeInterval

    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        let ip = request.remoteAddress?.ipAddress ?? "unknown"
        let clave = "ratelimit:(ip)"

        let contador = try await request.application.caches.memory.get(clave, as: Int.self) ?? 0
        guard contador < limite else {
            throw Abort(.tooManyRequests)
        }

        try await request.application.caches.memory.set(clave, to: contador + 1, expiresIn: .seconds(Int(ventana)))
        return try await next.respond(to: request)
    }
}

Despliegue en Linux con systemd

# Compilar en modo release
swift build -c release

# Archivo de servicio systemd
# /etc/systemd/system/miapi.service
[Unit]
Description=Mi API en Vapor
After=network.target postgresql.service

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/miapi
ExecStart=/opt/miapi/.build/release/Run serve --env production --port 8080
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

Environment=DB_HOST=localhost
Environment=DB_USER=vapor
Environment=DB_PASSWORD=secreto
Environment=DB_NAME=miapi

[Install]
WantedBy=multi-user.target
systemctl enable miapi
systemctl start miapi
systemctl status miapi

Nginx como proxy inverso

server {
    listen 443 ssl;
    server_name api.midominio.com;

    location / {
        proxy_pass http://localhost:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 86400; # Para WebSocket
    }
}

Resumen

Vapor proporciona todo lo necesario para una API REST o WebSocket de producción: routing expresivo con RouteCollection, Fluent ORM con migraciones tipadas, middleware para autenticación y rate limiting, y un modelo async/await que escala bien. El despliegue en Linux es sencillo con systemd, y Swift en el servidor comparte código con el cliente iOS, lo que reduce la duplicación de modelos entre frontend y backend.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP