cat swift/traccia-server-vapor-routing-database-deploy.md

Lezione 67 70 min

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:

  1. GET /health;
  2. GET /tasks/:id;
  3. POST /tasks;
  4. DTO di request e response;
  5. mapping verso TaskDraft;
  6. repository in memoria per iniziare;
  7. error mapping per notFound e emptyTitle.

Obiettivo: nessuna route deve contenere la regola “il titolo non può essere vuoto”. Quella resta nel dominio.