cat swift/traccia-server-vapor-routing-database-deploy.md
Traccia server: Vapor, routing, database e deploy
Usare Swift sul server con Vapor: route async, validazione, casi d'uso, database, configurazione e deploy di base.
Swift può vivere anche lato server. Vapor è il framework più riconoscibile per costruire API web in Swift.
L’obiettivo del corso non è diventare specialisti backend in una lezione. È capire come portare lo stesso nucleo Swift dentro un server.
Struttura mentale
In un server Vapor, il flusso tipico e:
HTTP request
-> route
-> validazione input
-> caso d'uso
-> repository/database
-> response DTO
La route non dovrebbe contenere tutta la logica. Deve adattare HTTP al dominio.
Route semplice
import Vapor
func routes(_ app: Application) throws {
app.get("health") { req async -> String in
"ok"
}
}
Con async/await, il codice server può restare lineare.
Route con parametro
app.get("tasks", ":id") { req async throws -> TaskResponse in
guard let rawID = req.parameters.get("id") else {
throw Abort(.badRequest)
}
let id = TaskItem.ID(rawID)
let useCase = req.application.taskUseCases.loadTask
let task = try await useCase.execute(id: id)
return TaskResponse(task)
}
La route:
- legge parametro HTTP;
- costruisce un ID di dominio;
- chiama un caso d’uso;
- trasforma il risultato in response.
Request DTO
Input HTTP:
struct CreateTaskRequest: Content {
let title: String
let priority: String?
}
Mapping verso dominio:
extension CreateTaskRequest {
func toDraft() throws -> TaskDraft {
try TaskDraft(
title: title,
priority: TaskPriority(rawValue: priority ?? "normal") ?? .normal
)
}
}
Il DTO può essere permissivo. Il dominio decide cosa è valido.
Response DTO
struct TaskResponse: Content {
let id: String
let title: String
let completed: Bool
init(_ task: TaskItem) {
self.id = task.id.rawValue
self.title = task.title
self.completed = task.isCompleted
}
}
Non esporre automaticamente il dominio come JSON pubblico. La response è un contratto API.
Error mapping
Gli errori di dominio vanno tradotti in HTTP.
func mapError(_ error: Error) -> Abort {
switch error {
case TaskError.notFound:
Abort(.notFound)
case TaskError.emptyTitle:
Abort(.badRequest, reason: "Title is required")
default:
Abort(.internalServerError)
}
}
In una app reale puoi centralizzare questa traduzione in middleware o helper.
Repository database
Il caso d’uso dipende dal protocollo:
protocol TaskRepository: Sendable {
func task(id: TaskItem.ID) async throws -> TaskItem
func save(_ task: TaskItem) async throws
}
Implementazione server:
struct DatabaseTaskRepository: TaskRepository {
let database: Database
func task(id: TaskItem.ID) async throws -> TaskItem {
guard let model = try await TaskModel.find(id.rawValue, on: database) else {
throw TaskError.notFound(id)
}
return model.toDomain()
}
func save(_ task: TaskItem) async throws {
let model = TaskModel(task)
try await model.save(on: database)
}
}
Il database resta infrastruttura.
Modello database
final class TaskModel: Model {
static let schema = "tasks"
@ID(custom: "id")
var id: String?
@Field(key: "title")
var title: String
@Field(key: "is_completed")
var isCompleted: Bool
init() {}
init(_ task: TaskItem) {
self.id = task.id.rawValue
self.title = task.title
self.isCompleted = task.isCompleted
}
}
Mapping:
extension TaskModel {
func toDomain() -> TaskItem {
TaskItem(
id: TaskItem.ID(id ?? ""),
title: title,
isCompleted: isCompleted
)
}
}
In codice reale, anche id ?? "" andrebbe trattato con più cura. Qui serve a mostrare il confine.
Configurazione
Un server vive di configurazione:
- porta;
- ambiente;
- database URL;
- secret;
- logging;
- CORS;
- migrazioni.
Non hardcodare valori sensibili.
let databaseURL = Environment.get("DATABASE_URL")
La configurazione deve arrivare dall’ambiente o da un sistema di secret, non dal codice.
Deploy di base
Per una traccia server introduttiva bastano concetti:
- build release;
- variabili d’ambiente;
- migrazioni database;
- health check;
- logging;
- container opzionale;
- reverse proxy o piattaforma gestita;
- monitoring minimo.
Comando tipico:
swift build -c release
Poi l’eseguibile viene lanciato con configurazione di ambiente.
Test server
Puoi testare route e casi d’uso separatamente.
Test del caso d’uso:
@Test
func createTaskSalvaTaskValido() async throws {
let repository = InMemoryTaskRepository()
let useCase = CreateTaskUseCase(repository: repository, idGenerator: { TaskItem.ID("t-1") })
let task = try await useCase.execute(draft: TaskDraft(title: "Deploy", priority: .high))
#expect(task.id == TaskItem.ID("t-1"))
}
La route può avere test di integrazione più avanti.
Checklist
Una traccia server sana:
- route sottili;
- request e response DTO separati;
- dominio fuori da
Request; - casi d’uso iniettati;
- repository database come infrastruttura;
- errori tradotti in HTTP;
- configurazione via ambiente;
- test del dominio senza server avviato;
- deploy con health check e logging.
Esercizio
Porta il task manager su Vapor:
GET /health;GET /tasks/:id;POST /tasks;- DTO di request e response;
- mapping verso
TaskDraft; - repository in memoria per iniziare;
- error mapping per
notFoundeemptyTitle.
Obiettivo: nessuna route deve contenere la regola “il titolo non può essere vuoto”. Quella resta nel dominio.