Vai al contenuto

Aggiornamento a 4.0

Questa guida ti mostrerà come aggiornare un progetto Vapor 3.x a Vapor 4.x. La guida cercherà di coprire tutti i cambiamenti riguardanti i pacchetti Vapor e anche alcuni dei più comuni pacchetti di terze parti. Se noti che manca qualcosa, non esitare a chiedere aiuto nella chat del team Vapor. Anche issues e pull requests su GitHub sono ben accette.

Dipendenze

Per usare Vapor 4, avrai bisogno di almeno Xcode 11.4 e macOS 10.15.

La sezione Installazione della documentazione contiene le istruzioni per installare le dipendenze.

Package.swift

Il primo passo per aggiornare a Vapor 4 è aggiornare il file delle dipendenze del pacchetto. Qui è riportato un esempio di un Package.swift aggiornato. Puoi anche visitare il template Package.swift aggiornato.

-// swift-tools-version:4.0
+// swift-tools-version:5.2
 import PackageDescription

 let package = Package(
     name: "api",
+    platforms: [
+        .macOS(.v10_15),
+    ],
     dependencies: [
-        .package(url: "https://github.com/vapor/fluent-postgresql.git", from: "1.0.0"),
+        .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
+        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
-        .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"),
+        .package(url: "https://github.com/vapor/jwt.git", from: "4.0.0"),
-        .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"),
+        .package(url: "https://github.com/vapor/vapor.git", from: "4.3.0"),
     ],
     targets: [
         .target(name: "App", dependencies: [
-            "FluentPostgreSQL", 
+            .product(name: "Fluent", package: "fluent"),
+            .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
-            "Vapor", 
+            .product(name: "Vapor", package: "vapor"),
-            "JWT", 
+            .product(name: "JWT", package: "jwt"),
         ]),
-        .target(name: "Run", dependencies: ["App"]),
-        .testTarget(name: "AppTests", dependencies: ["App"])
+        .target(name: "Run", dependencies: [
+            .target(name: "App"),
+        ]),
+        .testTarget(name: "AppTests", dependencies: [
+            .target(name: "App"),
+        ])
     ]
 )

Tutti i pacchetti che sono stati aggiornati a Vapor 4 avranno la versione major incrementata di 1.

Warning

L'identificatore di pre-rilascio -rc indica che alcuni pacchetti di Vapor 4 non sono ancora stati rilasciati ufficialmente.

Vecchi Pacchetti

Alcuni dei pacchetti di Vapor 3 sono stati deprecati:

  • vapor/auth: Ora incluso in Vapor.
  • vapor/core: Assorbito in diversi moduli.
  • vapor/crypto: Ora incluso in vapor tramite SwiftCrypto
  • vapor/multipart: Ora incluso in Vapor.
  • vapor/url-encoded-form: Ora incluso in Vapor.
  • vapor-community/vapor-ext: Ora incluso in Vapor.
  • vapor-community/pagination: Ora incluso in Fluent.
  • IBM-Swift/LoggerAPI: Sostituito da SwiftLog.

Dipendenza Fluent

Ora vapor/fluent dev'essere aggiunto come una dipendenza separata. Tutti i pacchetti specifici per un database hanno ottenuto il suffisso -driver per rendere chiara la dipendenza di vapor/fluent.

- .package(url: "https://github.com/vapor/fluent-postgresql.git", from: "1.0.0"),
+ .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
+ .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),

Piattaforme

Ora il manifesto del pacchetto supporta esplicitamente macOS 10.15 o successivi. Questo implica che anche i progetti dovranno specificare quali piattaforme supportano.

+ platforms: [
+     .macOS(.v10_15),
+ ],

Vapor potrebbe eventualmente aggiungere il supporto per piattaforme aggiuntive in futuro. I pacchetti potrebbero supportare un qualsiasi sottoinsieme di tali piattaforme finché il numero di versione è uguale o maggiore rispetto al minimo richiesto da Vapor.

Xcode

Vapor 4 utilizza il supporto nativo di SPM di Xcode 11. Ciò significa che non ci sarà più bisogno di generare il file .xcodeproj. Per aprire un progetto Vapor 4 in Xcode, basterà aprire il file Package.swift tramite vapor xcode o open Package.swift, Xcode poi procederà a scaricare le dipendenze.

Una volta aggiornato il Package.swift, potresti dover chiudere Xcode e rimuovere i seguenti file dalla directory del progetto:

  • Package.resolved
  • .build
  • .swiftpm
  • *.xcodeproj

Una volta che le nuove dipendenze sono state scaricate, noterai errori di compilazione, probabilmente un bel po'. Non ti preoccupare! Ti mostreremo come risolverli.

Run

Prima di tutto bisogna aggiornare il file main.swift del modulo Run al nuovo formato:

import App
import Vapor

var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }
try configure(app)
try app.run()

Il file main.swift andrà a sostituire il file app.swift, quindi potete rimuovere quel file.

App

Diamo un'occhiata a come aggiornare la struttura di base di App.

configure.swift

Il metodo configure ora accetta un'istanza di Application.

- public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws
+ public func configure(_ app: Application) throws

Riportiamo un esempio di un file configure.swift aggiornato:

import Fluent
import FluentSQLiteDriver
import Vapor

// Called before your application initializes.
public func configure(_ app: Application) throws {
    // Serves files from `Public/` directory
    // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    // Configure SQLite database
    app.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite)

    // Configure migrations
    app.migrations.add(CreateTodo())

    try routes(app)
}

Cambiamenti di sintassi per cose come routing, middleware, fluent, ecc. sono menzionati nelle sezioni seguenti.

boot.swift

Il contenuto di boot può essere inserito nel metodo configure dal momento che ora accetta un'istanza di Application.

routes.swift

Il metodo routes ora accetta un'istanza di Application.

- public func routes(_ router: Router, _ container: Container) throws
+ public func routes(_ app: Application) throws

Più avanti ci saranno altre informazioni sui cambiamenti della sintassi di routing.

Servizi

La API dei servizi di Vapor 4 è stata semplificata in modo da rendere molto più facile l'aggiunta di nuovi servizi. Ora i servizi sono esposti come metodi e proprietà sulle istanze di Application e Request.

Per capire meglio questo concetto, diamo un'occhiata a qualche esempio:

// Cambiamento della porta di default del server a 8281
- services.register { container -> NIOServerConfig in
-     return .default(port: 8281)
- }
+ app.http.server.configuration.port = 8281

Invece che registrare un un NIOServerConfig ai servizi, la configurazione del server è esposta come una proprietà su Application e può essere modificata direttamente.

// Registrazione del middleware CORS
let corsConfiguration = CORSMiddleware.Configuration(
    allowedOrigin: .all,
    allowedMethods: [.POST, .GET, .PATCH, .PUT, .DELETE, .OPTIONS]
)
let corsMiddleware = CORSMiddleware(configuration: corsConfiguration)
- var middlewares = MiddlewareConfig() // Create _empty_ middleware config
- middlewares.use(corsMiddleware)
- services.register(middlewares)
+ app.middleware.use(corsMiddleware)

Invece che registrare un MiddlewareConfig ai servizi, i middleware possono essere aggiunti direttamente a Application tramite una proprietà

// Fare una richiesta in un route handler
- try req.make(Client.self).get("https://vapor.codes")
+ req.client.get("https://vapor.codes")

Come Application, anche Request espone servizi come proprietà e metodi. È fortemente consigliato l'uso di servizi specifici alla Request da dentro le closure dei route handler.

Questo nuovo pattern va a sostituire il vecchio pattern di Container e Service e Config che era usato in Vapor 3.

Provider

I provider sono stati rimossi in Vapor 4. I provider erano usati per registrare servizi e configurazioni ai servizi. Ora i pacchetti possono estendere direttamente Application e Request per registrare servizi e configurazioni.

Diamo un'occhiata a come è configurato Leaf in Vapor 4.

// Usa Leaf per renderizzare le view
- try services.register(LeafProvider())
- config.prefer(LeafRenderer.self, for: ViewRenderer.self)
+ app.views.use(.leaf)

Per usare Leaf, basta usare app.leaf.

// Disabilita il caching delle view di Leaf
- services.register { container -> LeafConfig in
-     return LeafConfig(tags: ..., viewsDir: ..., shouldCache: false)
- }
+ app.leaf.cache.isEnabled = false

Ambiente

Si può accedere all'ambiente attuale (produzione, sviluppo, ecc.) tramite la proprietà app.environment.

Servizi Personalizzati

I servizi personalizzati che implementano il protocollo Service ed erano registrati al container in Vapor 3 vengono ora espressi come estensioni di Application o Request.

struct MyAPI {
    let client: Client
    func foo() { ... }
}
- extension MyAPI: Service { }
- services.register { container -> MyAPI in
-     return try MyAPI(client: container.make())
- }
+ extension Request {
+     var myAPI: MyAPI { 
+         .init(client: self.client)
+     }
+ }

Per accedere a questo servizio non c'è più bisogno di usare make:

- try req.make(MyAPI.self).foo()
+ req.myAPI.foo()

Provider Personalizzati

La maggior parte dei servizi può essere implementata come indicato sopra, tuttavia se si deve poter accedere al Lifecycle dell'applicazione si può fare così:

struct PrintHello: LifecycleHandler {
    func willBoot(_ app: Application) throws {
        print("Hello!")
    }
}

app.lifecycle.use(PrintHello())

Per salvare dati su Application, si può utilizzare il nuovo Application.storage:

struct MyNumber: StorageKey {
    typealias Value = Int
}
app.storage[MyNumber.self] = 5
print(app.storage[MyNumber.self]) // 5

L'accesso a Application.storage può essere avvolto in una proprietà computata per rendere il codice più leggibile:

extension Application {
    var myNumber: Int? {
        get { self.storage[MyNumber.self] }
        set { self.storage[MyNumber.self] = newValue }
    }
}

app.myNumber = 42
print(app.myNumber) // 42

NIO

Vapor 4 utilizza le API asincrone di SwiftNIO direttamente senza fare l'overload di metodi come map e flatMap o tipi alias come EventLoopFuture. Vapor 3 forniva overload ed alias per retrocompatiblità con versioni di Vapor che non usavano SwiftNIO.

Cambi di nome

Il cambiamento più ovvio è che il typealias Future di EventLoopFuture è stato rimosso. Si può risolvere questo problema semplicemente usando "trova e sostituisci".

In più NIO non supporta il label to: che veniva usato da Vapor 3, che comunque dato il nuovo sistema di inferenza dei tipi di Swift 5.2 non è più necessario.

- futureA.map(to: String.self) { ... }
+ futureA.map { ... }

Metodi con il prefisso new, come newPromise, sono stati rinominati in make per essere più coerenti con lo standard di Swift.

- let promise = eventLoop.newPromise(String.self)
+ let promise = eventLoop.makePromise(of: String.self)

catchMap non è più disponibile. Si può usare mapError o flatMapErrorThrowing al suo posto.

Il flatMap globale di Vapor 3 per combinare diversi futuri non è più disponibile. Si può utilizzare and al suo posto.

- flatMap(futureA, futureB) { a, b in 
+ futureA.and(futureB).flatMap { (a, b) in
    // Fai qualcosa con a e b.
}

ByteBuffer

Molti metodi e proprietà che utilizzavano Data ora usano ByteBuffer, un tipo di storage di byte più potente e performante. Potete leggere di più su ByteBuffer nella documentazione di SwiftNIO.

Per convertire un ByteBuffer in Data si può usare:

Data(buffer.readableBytesView)

Throwing map / flatMap

La difficoltà maggiore è che map e flatMap non possono più lanciare errori. map ha una versione che può lanciare errori, flatMapThrowing. flatMap non ha una versione che può lanciare errori, questo potrebbe implicare il cambiamento della struttura del vostro codice asincrono.

Map che non lanciano errori dovrebbero continuare a funzionare senza problemi.

// Non lancia errori
futureA.map { a in
    return b
}

Map che lanciano errori devono essere aggiornate a flatMapThrowing:

- futureA.map { a in
+ futureA.flatMapThrowing { a in
    if ... {
        throw SomeError()
    } else {
        return b
    }
}

FlatMap che non lanciano errori dovrebbero continuare a funzionare senza problemi.

// Non lancia errori
futureA.flatMap { a in
    return futureB
}

Invece di lanciare errori dentro una flatMap, si può ritornare un errore futuro. Se l'errore ha origine da un altro metodo, si può utilizzare il costrutto do / catch.

// Ritorna un errore futuro:
futureA.flatMap { a in
    do {
        try doSomething()
        return futureB
    } catch {
        return eventLoop.makeFailedFuture(error)
    }
}

Se l'errore è generato direttamente dentro la flatMap, si può usare flatMapThrowing:

// Metodo che lancia errori riscritto con flatMapThrowing
futureA.flatMapThrowing { a in
    try (a, doSomeThing())
}.flatMap { (a, result) in
    // result è il valore ritornato da doSomething()
    return futureB
}

Routing

Ora le route sono registrate direttamente su Application.

app.get("hello") { req in
    return "Hello, world!"
}

Ciò significa che non c'è più bisogno di registrare un router ai servizi. Basta passare l'istanza di Application al metodo routes e si può cominciare ad aggiungere endpoint. Tutti i metodi disponibili sul RoutesBuilder sono disponibili su Application.

Contenuto Sincrono

La decodifica delle richieste è ora sincrona.

let payload = try req.content.decode(MyPayload.self)
print(payload) // MyPayload

Si può fare l'override di questo comportamento utilizzando la strategia di collezione del body .stream.

app.on(.POST, "streaming", body: .stream) { req in
    // Il body della richiesta è ora asincrono.
    req.body.collect().map { buffer in
        HTTPStatus.ok
    }
}

URL divisi da virgole

In Vapor 4 gli URL sono divisi da virgole e non devono contenere /.

- router.get("v1/users/", "posts", "/comments") { req in 
+ app.get("v1", "users", "posts", "comments") { req in
    // Gestisci la richiesta
}

Parametri di una route

Il protocollo Parameter è stato rimosso per promuovere l'uso di parametri chiamati esplicitamente. In questo modo si evitano problemi di parametri duplicati e il fetching non ordinato dei parametri nei middleware e nei gestori delle route.

- router.get("planets", String.parameter) { req in 
-     let id = req.parameters.next(String.self)
+ app.get("planets", ":id") { req in
+     let id = req.parameters.get("id")
      return "Planet id: \(id)"
  }

Si tratta dell'utilizzo dei parametri nelle routes nella sezione di Fluent.

Middleware

MiddlewareConfig è stato rinominato in MiddlewareConfiguration ed è ora una proprietà di Application. Si possono aggiungere dei middleware all'applicazione usando app.middleware.

let corsMiddleware = CORSMiddleware(configuration: ...)
- var middleware = MiddlewareConfig()
- middleware.use(corsMiddleware)
+ app.middleware.use(corsMiddleware)
- services.register(middlewares)

Ora è obbligatorio inizializzare i Middleware prima di registrarli.

- middleware.use(ErrorMiddleware.self)
+ app.middleware.use(ErrorMiddleware.default(environment: app.environment))

Per rimuovere tutti i middleware di default si può utilizzare:

app.middleware = .init()

Fluent

Ora l'API di Fluent è indipendente dal database su cui viene utilizzata. Basta importare Fluent.

- import FluentMySQL
+ import Fluent

Modelli

I modelli utilizzano il protocollo Model e devono essere delle classi:

- struct Planet: MySQLModel {
+ final class Planet: Model {

Tutti i campi sono dichiarati utilizzando @Field o @OptionalField.

+ @Field(key: "name")
var name: String

+ @OptionalField(key: "age")
var age: Int?

L'ID di un modello dev'essere definito utilizzando @ID.

+ @ID(key: "id")
var id: UUID?

I modelli che utilizzano un ID personalizzato devono utilizzare @ID(custom:).

Tutti i modelli devono avere il nome della loro tabella definito staticamente.

final class Planet: Model {
+     static let schema = "Planet"

I metodi save, update e create non ritornano più l'istanza del modello.

- model.save(on: ...)
+ model.save(on: ...).map { model }

I modelli non possono più venire utilizzati come parametri del route path. Si può utilizzare find e req.parameters.get.

- try req.parameters.next(ServerSize.self)
+ ServerSize.find(req.parameters.get("size"), on: req.db)
+     .unwrap(or: Abort(.notFound))

Model.ID è stato rinominato in Model.IDValue.

I timestamp sono ora dichiarati usando @Timestamp.

- static var createdAtKey: TimestampKey? = \.createdAt
+ @Timestamp(key: "createdAt", on: .create)
var createdAt: Date?

Relazioni

Le relazioni sono ora dichiarate usando @Parent, @Children e @Siblings.

Le relazioni @Parent contengono il campo internamente. La chiave passata a @Parent è il nome del campo che contiene la chiave esterna.

- var serverID: Int
- var server: Parent<App, Server> { 
-    parent(\.serverID) 
- }
+ @Parent(key: "serverID") 
+ var server: Server

Le relazioni @Children hanno un key path al relativo @Parent.

- var apps: Children<Server, App> { 
-     children(\.serverID) 
- }
+ @Children(for: \.$server) 
+ var apps: [App]

Le relazioni @Siblings hanno dei key path al relativo ad una tabella pivot.

- var users: Siblings<Company, User, Permission> {
-     siblings()
- }
+ @Siblings(through: Permission.self, from: \.$user, to: \.$company) 
+ var companies: [Company]

Le tabelle pivot sono modelli normali che conformano a Model con due proprietà @Parent e zero o più campi aggiuntivi.

Query

Si può accedere al contesto del database utilizzando req.db nei route handler.

- Planet.query(on: req)
+ Planet.query(on: req.db)

DatabaseConnectable è stato rinominato in Database.

Ora i key path ai campi hanno il prefisso $ per specificare che si tratta del wrapper e non del valore del campo.

- filter(\.foo == ...) 
+ filter(\.$foo == ...)

Migrazioni

Le migrazioni devono essere scritte manualmente e non si basano più sul concetto di reflection:

- extension Planet: Migration { }
+ struct CreatePlanet: Migration {
+     ...
+}

Ora le migrazioni sono tipate tramite stringe e non sono direttamente collegate ai modelli, utilizzano infatti il protocollo Migration.

- struct CreateGalaxy: <#Database#>Migration {
+ struct CreateGalaxy: Migration {

I metodi prepare e revert non sono più statici.

- static func prepare(on conn: <#Database#>Connection) -> Future<Void> {
+ func prepare(on database: Database) -> EventLoopFuture<Void> 

La creazione di un costruttore di schema è fatta tramite un metodo su Database.

- <#Database#>Database.create(Galaxy.self, on: conn) { builder in
-    // Usa builder.
- }
+ var builder = database.schema("Galaxy")
+ // Usa builder.

I metodi create, update e delete sono metodi del costruttore di schema e assomigliano al funzionamento di un costruttore di query.

La definizione dei campi è tipata tramite stringhe e usa il seguente pattern:

field(<name>, <type>, <constraints>)
- builder.field(for: \.name)
+ builder.field("name", .string, .required)

La costruzione degli schemi può essere concatenata come un costruttore di query:

database.schema("Galaxy")
    .id()
    .field("name", .string, .required)
    .create()

Configurazione di Fluent

DatabasesConfig è stato sostituito da app.databases.

try app.databases.use(.postgres(url: "postgres://..."), as: .psql)

MigrationsConfig è stato sostituito da app.migrations.

app.migrations.use(CreatePlanet(), on: .psql)

Repository

Dal momento che è cambiato il modo in cui funzionano i servizi, anche la struttura delle Repository è cambiata. Servirà comunque un protocollo come UserRepository, ma invece di creare una final class che implementi il protocollo, basta creare una struct.

- final class DatabaseUserRepository: UserRepository {
+ struct DatabaseUserRepository: UserRepository {
      let database: Database
      func all() -> EventLoopFuture<[User]> {
          return User.query(on: database).all()
      }
  }

Si può rimuovere l'utilizzo di ServiceType, dal momento che non esiste più.

- extension DatabaseUserRepository {
-     static let serviceSupports: [Any.Type] = [Athlete.self]
-     static func makeService(for worker: Container) throws -> Self {
-         return .init()
-     }
- }

Si può invece creare una UserRepositoryFactory:

struct UserRepositoryFactory {
    var make: ((Request) -> UserRepository)?
    mutating func use(_ make: @escaping ((Request) -> UserRepository)) {
        self.make = make
    }
}

Questa struct ha la responsabilità di ritornare una UserRepository per una Request.

Il prossimo passo è quello di estendere l'Application con una proprietà computata che ritorna una UserRepository:

extension Application {
    private struct UserRepositoryKey: StorageKey { 
        typealias Value = UserRepositoryFactory 
    }

    var users: UserRepositoryFactory {
        get {
            self.storage[UserRepositoryKey.self] ?? .init()
        }
        set {
            self.storage[UserRepositoryKey.self] = newValue
        }
    }
}

Per utilizzare l'effettiva repository all'interno di un route handler:

extension Request {
    var users: UserRepository {
        self.application.users.make!(self)
    }
}

L'ultimo passo è quello di specificare la factory nel metodo configure:

app.users.use { req in
    DatabaseUserRepository(database: req.db)
}

Si può ora accedere alla repository nei route handler con req.users.all() e si può facilmente sostituire la repository con una simulata per i test. Basta creare un nuovo file TestUserRepository:

final class TestUserRepository: UserRepository {
    var users: [User]
    let eventLoop: EventLoop

    init(users: [User] = [], eventLoop: EventLoop) {
        self.users = users
        self.eventLoop = eventLoop
    }

    func all() -> EventLoopFuture<[User]> {
        eventLoop.makeSuccededFuture(self.users)
    }
}

Si può usare la repository in questo modo:

final class MyTests: XCTestCase {
    func test() throws {
        let users: [User] = []
        app.users.use { TestUserRepository(users: users, eventLoop: $0.eventLoop) }
        ...
    }
}