0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Swift製フレームワークVaporで初期データの投入からAPIを叩くところまで実装してみた

Last updated at Posted at 2023-10-14

はじめに

普段はiOSアプリエンジニアをしているのですが、個人的にバックエンドのAPIサーバーを作りたくなったので、Swiftのサーバーサイドフレームワークで主流となっているVaporを試しに使ってみました。

今回はVaporでもRailsと同じような構成で実装できないかを念頭におきながら、初期データの投入からAPIを叩くところまで実装してみましたので実装手順を紹介します。

Vaporのドキュメントは以下で見ることができます。
「はじめに」までは日本語訳が提供されていますが、そのあとの個々の説明のセクションは用意されていません。(2023/10/13現在)
https://docs.vapor.codes/

1. 環境構築

Xcodeとbrewコマンドがインストールされていること前提として、vaporをインストールします。

$ brew install vapor

2. 新規アプリ作成

Vaporでアプリを作るのは簡単。以下のコマンドを実行するだけです。
FluentとLeafを使用するか聞かれますが、いったんクリーンな状態でVaporを使用したいのでどちらもNoを選択します。

$ vapor new <YourApp>

TODO: ディレクトリ構成

3. データベースを用意する

Webアプリであればデータベースが必要です。
今回はDockerでPostgersを準備しておきます。

docker-compose.postgres.yml
version: '3'

services:
  db:
    container_name: postgres
    image: postgres:16.0
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - ./db/postgres:/var/lib/postgresql/data
    ports:
      - ${POSTGRES_PORT}:5432

データベースの接続情報は、Vaporからも使用できるようにenvファイルに書いておきます。

.env
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres1234
POSTGRES_DB=db
POSTGRES_HOSTNAME=localhost
POSTGRES_PORT=5432

コンテナのデータベースを永続化するためのストレージを用意します。

$ mkdir db
$ mkdir db/postgres

コンテナ起動

$ docker-compose -f docker-compose.postgres.yml up -d

4. Vaporでデータベースに接続する

FluentはORMマッパーです。新規アプリ作成時に使用しないを選択しましたが、手動で追加してみます。
今回データベースのドライバーはPostgresのものを使用します。

Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "<YourApp>",
    platforms: [
       .macOS(.v13)
    ],
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/fluent.git", from: "4.8.0"),
        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
        .package(url: "https://github.com/vapor/vapor.git", from: "4.83.1"),
    ],
    targets: [
        .executableTarget(
            name: "App",
            dependencies: [
                .product(name: "Fluent", package: "fluent"),
                .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
                .product(name: "Vapor", package: "vapor"),
            ]
        ),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "XCTVapor", package: "vapor"),

            // Workaround for https://github.com/apple/swift-package-manager/issues/6940
            .product(name: "Vapor", package: "vapor"),
        ])
    ]
)

つぎにデータベース接続情報を設定します。
設定値は環境変数から取得します。

configure.swift
import Vapor
import Fluent
import FluentPostgresDriver

// configures your application
public func configure(_ app: Application) async throws {
    // uncomment to serve files from /Public folder
    // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))

    let hostname = Environment.get("POSTGRES_HOSTNAME")!
    let username = Environment.get("POSTGRES_USER")!
    let password = Environment.get("POSTGRES_PASSWORD")!
    let database = Environment.get("POSTGRES_DB")!
    let port = Environment.get("POSTGRES_PORT")!
    app.databases.use(
        .postgres(
            hostname: hostname,
            port: Int(port)!,
            username: username,
            password: password,
            database: database
        ),
        as: .psql
    )

    try routes(app)
}

サーバーの起動は、Xcodeの実行ボタンからできます。
しかしここから実行すると、環境変数の.envファイルは読み込まれないので注意が必要です。
editSchemeを選択して、Environment Valiablesで設定します。

コマンドラインから実行するときは、以下のコマンドを実行します。

$ swift run App serve

5. モデルの作成

Appの下にModelsディレクトリを作成します。
モデルの例として、カテゴリーとTODOを作成してみます。
VaporではidにUUIDを使うことが一般的のようですが、今回は初期データ投入の実装例をつくる関係でIntとしておきます。

Category.swift

import Fluent
import Vapor

final class Category: Model, Content {
    // DBのテーブル名
    static let schema = "categories"

    @ID(custom: .id, generatedBy: .database)
    var id: Int?
    @Field(key: "name")
    var name: String
    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?
    @Timestamp(key: "updated_at", on: .update)
    var updatedAt: Date?
    
    init() {
    }

    init(
        id: Int? = nil,
        name: String,
        createdAt: Date? = nil,
        updatedAt: Date? = nil
    ) {
        self.id = id
        self.name = name
        self.createdAt = createdAt
        self.updatedAt = updatedAt
    }
}
Todo.swift
final class Todo: Model, Content {
    static let schema = "todos"

    @ID(custom: .id, generatedBy: .database)
    var id: Int?
    @Parent(key: "category_id")
    var category: Cateogory
    @Field(key: "title")
    var title: String
    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?
    @Timestamp(key: "updated_at", on: .update)
    var updatedAt: Date?
    
    init() {
    }

    init(
        id: Int? = nil,
        category: Category,
        title: String,
        createdAt: Date? = nil,
        updatedAt: Date? = nil
    ) throws {
        self.id = id
        self.$category.id = try category.requireID()
        self.title = title
        self.createdAt = createdAt
        self.updatedAt = updatedAt
    }
}

6. マイグレーションする

作成したモデルの情報から、テーブルを作成します。
マイグレーションファイルは、今回App/Migrationsに配置します。
ファイル名は作成順にソートされるように、今回はyyyyMMddHHmmを接頭語としてつけておきます。

202301011200_CreateCategory.swift
import Fluent

struct CreateCategory: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("categories")
            .field(.id, .int, .identifier(auto: true), .required)
            .field("name", .string, .required)
            .field("created_at", .datetime, .required)
            .field("updated_at", .datetime, .required)
            .unique(on: "name")
            .create()
    }
    
    func revert(on database: Database) async throws {
        try await database.schema("categories").delete()
    }
}
202302021200_CreateTodo.swift
import Fluent

struct CreateTodo: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("todos")
            .field(.id, .int, .identifier(auto: true), .required)
            .field("category_id", .int, .required, .references("categories", "id"))
            .field("title", .string, .required)
            .field("created_at", .datetime, .required)
            .field("updated_at", .datetime, .required)
            .unique(on: "title")
            .create()
    }
    
    func revert(on database: Database) async throws {
        try await database.schema("todos").delete()
    }
}

作成したマイグレーションファイルを設定します。

configure.swift

public func configure(_ app: Application) async throws {
  // 省略
   app.migrations.add(CreateCategory())
   app.migrations.add(CreateTodo())
}

そしてマイグレーションを実行します。
categoriesテーブルとtodosテーブルが作成されます。

$ swift run App migrate

7. データを投入する

Railsアプリにはシードの機能があり、データを投入することができます。これをVaporでも作成してみます。

今回シードのデータはcsv形式としてみます。スプレッドシートやエクセルから出力もでき、JSONよりも人間が扱いやすいためです。

シードファイルは、Modelのshemeの値を前提として、Resourcesの下にSeedsディレクトリを作成します。
今回の例だと以下のようになります。

  • Resources/Seeds/categories.csv
  • Resources/Seeds/todos.csv

次にCSVをデコードするDecorderを作成します。ChatGPTに聞きながら作成しました。
今回Appの下に、Utilitiesというディレクトリを作成して格納しました。

CSVDecoder.swift
class CSVDecoder {
    var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys

    enum KeyDecodingStrategy {
        case useDefaultKeys
        case convertFromSnakeCase
    }
    
    init() {
    }

    func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> [T] {
        let csvString = String(data: data, encoding: .utf8)!
        var items: [T] = []
        
        let lines = csvString.split(separator: "\n")
        guard lines.count > 1 else {
            throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing header or data."))
        }
        
        let header = lines[0].split(separator: ",")
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .map { columnName -> String in
                switch keyDecodingStrategy {
                case .useDefaultKeys:
                    return columnName
                case .convertFromSnakeCase:
                    return convertFromSnakeCase(columnName)
                }
            }
        
        let decoder = _CSVRowDecoder()
        
        for line in lines.dropFirst() {
            let values = line.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            guard values.count == header.count else {
                continue
            }
            
            decoder.source = Dictionary(uniqueKeysWithValues: zip(header, values))
            
            let item = try T(from: decoder)
            items.append(item)
        }
        
        return items
    }
    
    private func convertFromSnakeCase(_ string: String) -> String {

        return string
            .split(separator: "_")
            .enumerated()
            .map { (index, element) in
                return index > 0 ? element.capitalized : element.lowercased()
            }
            .joined()
        }
}

private class _CSVRowDecoder: Decoder {
    var codingPath: [CodingKey] = []
    var userInfo: [CodingUserInfoKey : Any] = [:]
    var source: [String: String]!
    
    func container<Key>(keyedBy type: Key.Type) -> KeyedDecodingContainer<Key> where Key : CodingKey {
        return KeyedDecodingContainer(_KeyedDecodingContainer(codingPath: codingPath, source: source))
    }
    
    func unkeyedContainer() -> UnkeyedDecodingContainer {
        fatalError("unkeyed decoding not supported")
    }
    
    func singleValueContainer() -> SingleValueDecodingContainer {
        fatalError("single value decoding not supported")
    }
}

private struct _KeyedDecodingContainer<K: CodingKey>: KeyedDecodingContainerProtocol {
    var codingPath: [CodingKey]
    var allKeys: [K] { source.keys.compactMap { K(stringValue: $0) } }
    var source: [String: String]
    
    func contains(_ key: K) -> Bool {
        return source.keys.contains(key.stringValue)
    }
    
    func decodeNil(forKey key: K) throws -> Bool {
        return source[key.stringValue] == nil
    }
    
    func decode(_ type: Bool.Type, forKey key: K) throws -> Bool {
        guard let value = source[key.stringValue], let decoded = Bool(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Bool value")
        }
        return decoded
    }
    
    func decode(_ type: String.Type, forKey key: K) throws -> String {
        guard let value = source[key.stringValue] else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Missing String value")
        }
        return value
    }
    
    func decode(_ type: Double.Type, forKey key: K) throws -> Double {
        guard let value = source[key.stringValue], let decoded = Double(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Double value")
        }
        return decoded
    }
    
    func decode(_ type: Float.Type, forKey key: K) throws -> Float {
        guard let value = source[key.stringValue], let decoded = Float(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Float value")
        }
        return decoded
    }
    
    func decode(_ type: Int.Type, forKey key: K) throws -> Int {
        guard let value = source[key.stringValue], let decoded = Int(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Int value")
        }
        return decoded
    }
    
    func decode(_ type: Int8.Type, forKey key: K) throws -> Int8 {
        guard let value = source[key.stringValue], let decoded = Int8(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Int8 value")
        }
        return decoded
    }
    
    func decode(_ type: Int16.Type, forKey key: K) throws -> Int16 {
        guard let value = source[key.stringValue], let decoded = Int16(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Int16 value")
        }
        return decoded
    }
    
    func decode(_ type: Int32.Type, forKey key: K) throws -> Int32 {
        guard let value = source[key.stringValue], let decoded = Int32(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Int32 value")
        }
        return decoded
    }
    
    func decode(_ type: Int64.Type, forKey key: K) throws -> Int64 {
        guard let value = source[key.stringValue], let decoded = Int64(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid Int64 value")
        }
        return decoded
    }
    
    func decode(_ type: UInt.Type, forKey key: K) throws -> UInt {
        guard let value = source[key.stringValue], let decoded = UInt(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid UInt value")
        }
        return decoded
    }
    
    func decode(_ type: UInt8.Type, forKey key: K) throws -> UInt8 {
        guard let value = source[key.stringValue], let decoded = UInt8(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid UInt8 value")
        }
        return decoded
    }
    
    func decode(_ type: UInt16.Type, forKey key: K) throws -> UInt16 {
        guard let value = source[key.stringValue], let decoded = UInt16(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid UInt16 value")
        }
        return decoded
    }
    
    func decode(_ type: UInt32.Type, forKey key: K) throws -> UInt32 {
        guard let value = source[key.stringValue], let decoded = UInt32(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid UInt32 value")
        }
        return decoded
    }
    
    func decode(_ type: UInt64.Type, forKey key: K) throws -> UInt64 {
        guard let value = source[key.stringValue], let decoded = UInt64(value) else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid UInt64 value")
        }
        return decoded
    }

    func decode<T>(_ type: T.Type, forKey key: K) throws -> T where T : Decodable {
        guard let decoder = self.source[key.stringValue] as? Decoder else {
            throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Invalid value for type \(T.self)")
        }
        return try T(from: decoder)
    }

    func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: K) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
        fatalError("nested decoding not supported")
    }
    
    func nestedUnkeyedContainer(forKey key: K) throws -> UnkeyedDecodingContainer {
        fatalError("nested unkeyed decoding not supported")
    }
    
    func superDecoder() throws -> Decoder {
        fatalError("super decoding not supported")
    }
    
    func superDecoder(forKey key: K) throws -> Decoder {
        fatalError("super decoding not supported")
    }
}

続いてモデルをデコードできるように、Decodableに準拠させます。

Category.swift
final class Category: Model, Content, Decodable {
   // 省略
     init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.name = try container.decode(String.self, forKey: .name)
    }

    enum CodingKeys: String, CodingKey {
        case id, name
    }
}
Todo.swift
final class Todo: Model, Content, Decodable {
   // 省略
     init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.$category.id = try container.decode(Int.self, forKey: .categoryId)
        self.title = try container.decode(String.self, forKey: .title)
    }

    enum CodingKeys: String, CodingKey {
        case id, categoryId, title
    }
}

最後に、Seedコマンドを実装します。
今回App/Commandsディレクトリをつくり、そのなかにコードを追加します。

Modelのメタタイプの配列で管理して、順番に処理していきます。
処理は、対象のcsvファイルを探索して中身をデコード、テーブルからデータを取得してすでに格納済みなら更新をおこない、新規データは登録をおこないます。

SeedCommand.swift
import Vapor
import Fluent

struct SeedCommand: Command {
    private let modelTypes: [any Model.Type] = [
        Category.self,
        Todo.self
    ]

    struct Signature: CommandSignature {
        @Option(name: "file", short: "f", help: "Specific seed file to use from Resources/Seeds directory.")
        var file: String?
    }

    var help: String {
        "Seeds initial data into the database."
    }
    
    func run(using context: CommandContext, signature: Signature) throws {
        let app = context.application

        if let file = signature.file {
            let url = URL(fileURLWithPath: file)

            let baseFileName = url.deletingPathExtension().lastPathComponent
            guard let modelType = modelTypes.first(where: { $0.schema == baseFileName }) else {
                throw Abort(.internalServerError, reason: "Failed to read the seed file.")
            }
            try seed(app, modelType: modelType)
        } else {
            for modelType in modelTypes {
                try seed(app, modelType: modelType)
            }
        }
    }
    
    private func seed(_ app: Application, modelType: (some Model).Type) throws {
        let file = modelType.schema + ".csv"
        let filePath = app.directory.resourcesDirectory + "Seeds/" + file
        
        guard let data = FileManager.default.contents(atPath: filePath) else {
            throw Abort(.internalServerError, reason: "Failed to read the seed file.")
        }
    
        let decoder = CSVDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        let modelList = try decoder.decode(modelType, from: data)

        let recordList = try modelType.query(on: app.db).all().wait()
        let saveFutures = modelList.compactMap { model -> EventLoopFuture<Void>? in
            if let record = recordList.first(where: { $0.id == model.id }) {
                record.updateFromModel(model)
                return record.update(on: app.db)
            } else {
                return model.create(on: app.db)
            }
        }
  
        let allSaves = EventLoopFuture.whenAllSucceed(saveFutures, on: app.eventLoopGroup.next())

        do {
            _ = try allSaves.wait()
            print("All \(modelType) saved successfully.")
        } catch {
            print("An error occurred while saving \(file): \(error)")
        }
    }
}

private extension Model {
    func updateFromModel(_ model: Self) {
        let mirrorSelf = Mirror(reflecting: self)
        let mirrorModel = Mirror(reflecting: model)
        
        for (labelSelf, valueSelf) in mirrorSelf.children {
            guard let label = labelSelf else { continue }

            if label == "id" { continue }
            
            if let valueModel = mirrorModel.children.first(where: { $0.label == label })?.value {
                
                if let intSelf = valueSelf as? FieldProperty<Self, Int>, let intModel = valueModel as? FieldProperty<Self, Int> {
                    intSelf.wrappedValue = intModel.wrappedValue
                } else if let stringSelf = valueSelf as? FieldProperty<Self, String>, let stringModel = valueModel as? FieldProperty<Self, String> {
                    stringSelf.wrappedValue = stringModel.wrappedValue
                }
                // その他の型についても同様に追加
            }
        }
    }
}

そして最後にコマンドの登録です。

configure.swift
public func configure(_ app: Application) async throws {
   // 省略
     app.commands.use(SeedCommand(), as: "seed")
}

シードは以下のように実行することができます。

# すべて
$ swift run App seed
# ファイル指定
$ swift run App seed -f categories.csv

8. Controllerの作成

App/Controllesの中に、Controllerを作成していきます。

CategoriesController.swift
import Vapor

extension CategoriesController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let categoriesRoute = routes.grouped("categories")
        categoriesRoute.get(use: index)
    }
}

final class CategoriesController {
    func index(req: Request) async throws -> [Category] {
        try await Category.query(on: req.db).all()
    }
}
TodosController.swift
import Vapor

extension TodosController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let todosRoute = routes.grouped("todos")
        todosRoute.get(use: index)
    }
}

final class TodosController {
    func index(req: Request) async throws -> [Todo] {
        try await Todo.query(on: req.db).all()
    }
}

9. ルーティングの作成

そして最後に、APIを叩けるようにControllerを追加します。
今回はAPIなのでパスを切って追加してみます。

routes.swift
import Vapor

func routes(_ app: Application) throws {
    let api = app.grouped("api")

    let v1 = api.grouped("v1")
    try v1.register(collection: CategoriesController())
    try v1.register(collection: TodosController())
}

お疲れさまです。APIをたたいてみましょう。
シードで投入したデータをDBから取得して返却されます。

$ curl http:localhost:8080/api/v1/categories
$ curl http:localhost:8080/api/v1/todos

さいごに

Vaporでもデータベースへのデータ投入から、APIでの一覧取得まで実装することがでました。
Railsと比べて生成されるファイルが少なく、構成を確認しやすいことは最初の学習としても学びやすいように思いました。
実際のサービスはもっと複雑になるので、それをどう実装していくかは今後検証できればと思います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?