Swiftで簡単なAPIサーバーを作ってみた(Vapor編)

2023.01.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

前回、サーバーサイドSwiftのフレームワークVaporを使用してHello, world!と出力するところまで試してみました。

今回はAPIサーバーっぽく、データをPOSTしたりGETしたり出来る様にしていきたいと思います。

Vaporについては前回の記事で触れている箇所も多いので、そちらをご覧いただければと思います。

環境

  • Xcode 14.1
  • Vapor 4

Vaporプロジェクトの作成

ターミナルで任意のディレクトリに移動します。

cd ~/desktop

新しいVaporプロジェクトを作成します。your-project-nameには任意の名前を入力します。今回、lilossa-coordinateという名前にしました。

vapor new your-project-name

Fluentフレームワークを使用するか問われます。

Cloning template...
name: hello-world
Would you like to use Fluent? (--fluent/--no-fluent)
y/n>

今回はFluentフレームワークを使用するのでyを入力します。

使用するデータベースを問われるので推奨されているPostgressを選択します。

Cloning template...
name: lilossa-coordinate
Would you like to use Fluent? (--fluent/--no-fluent)
y/n> y
fluent: Yes
Which database would you like to use? (--fluent.db)
1: Postgres (Recommended)
2: MYSQL
3: SQLite
4: Mongo

次にLeafフレームワークを使用するか問われます。

Would you like to use Leaf? (--leaf/--no-leaf)
y/n>

今回は使用しない為、nを入力します。

アスキーアートのようなものが表示されたら、新しいVaporプロジェクトの作成は完了です。

データベースの作成

今回はPostgresを使用するのでアプリケーションをダウンロードします。

こちらのページから無料でダウンロードを行えます。

Postgresのダウンロードが出来たら、Postgresを起動します。

最初は1つのみデータベースが表示されていると思うので、その1つをダブルクリックします。

ターミナルが立ち上がります。

CREATE DATABASEと入力します。大文字でも小文字で問題ありません。その後ろに任意のデータベースを入力します。末尾のセミコロンは必須なので忘れないようにしましょう。

CREATE DATABASE yourdatabasename;

入力後、CREATE DATABASEと表示されれば完了です。

Vaporプロジェクトの設定

データベースの作成は出来たので、Vaporプロジェクト側の設定を進めていきます。

Modelの作成

作成したVaporプロジェクトのSource > App > Models フォルダ内で新規ファイルを追加します。デフォルトでTODO用のモデル等が存在していますが、そちらは削除して問題ありません。

今回は、LilossaCoordinateというModelを作成しました。

import Fluent
import Vapor

final class LilossaCoordinate: Model, Content {
    static let schema = "littleossaCoordinates"

    enum DataField: String {
        case createdAt = "created_at"
        case latitude
        case longitude

        var key: FieldKey {
            return FieldKey(stringLiteral: self.rawValue)
        }
    }

    @ID(key: .id)
    var id: UUID?

    /// 日付
    @Timestamp(key: DataField.createdAt.key, on: .create, format: .iso8601)
    var createdAt: Date?

    /// 緯度
    @Field(key: DataField.latitude.key)
    var latitude: Double

    /// 経度
    @Field(key: DataField.longitude.key)
    var longitude: Double

    init() { }

    init(id: UUID? = nil, latitude: Double, longitude: Double) {
        self.id = id
        self.latitude = latitude
        self.longitude = longitude
    }
}
  • id
    • 一意のid
  • createdAt
    • モデルの作成日
  • latitude
    • 緯度
  • longitude
    • 経度

schema

モデルをデータベース上で使用する為に任意の文字列を設定します。

@ID(key:)

@IDを付与することで、自動で一意のIDが付与されます。keyは、こちらで値を設定する必要もない為、今回は.idを指定しました。

@Timestamp(key:, on:, format:)

@Timestampを付与することで、選択したトリガーにしたがって自動に付与されます。今回は作成日をトリガーに値を付与したかったのでon: .createを指定しています。

その他のトリガーについてはドキュメントで確認出来ます。

keyにはFieldKey型の任意のキー値を入力します。

@Field(key:)

@Fieldを付与することで、一般的なキー値で検索可能な値を作成することが出来ます。keyには任意のキー値を入力します。

マイグレーションファイルを作成

まだデータベース上にテーブルが作成されていない為、マイグレーションファイルを作成し、データベースとマイグレーションを行います。

Source > App > Migrationsフォルダ内で新規ファイルを作成します。今回はCreateCoordinateというファイルを作成しました。

import Fluent

struct CreateCoordinate: AsyncMigration {

    // データテーブルを用意
    func prepare(on database: Database) async throws {
        try await database.schema(LilossaCoordinate.schema) // Table name
            .id()
            .field(LilossaCoordinate.DataField.createdAt.key, .string) // Column name 日付
            .field(LilossaCoordinate.DataField.latitude.key, .double, .required) // Column name 緯度
            .field(LilossaCoordinate.DataField.longitude.key, .double, .required) // Column name 経度
            .create()
    }

    // データテーブルを元に戻す
    func revert(on database: Database) async throws {
        try await database.schema(LilossaCoordinate.schema).delete()
    }
}

作成したLilossaCoordinateをマイグレーションする為に、データベースのスキーマ、フィールドにキー値を設定します。

.field(LilossaCoordinate.DataField.createdAt.key, .string)は、モデル側では@TimestampDate?型を設定しているのですが、マイグレーションの際には.stringを指定する必要があります。

.field

field(_ key: FieldKey, _ dataType: DatabaseSchema.DataType, _ constraints: DatabaseSchema.FieldConstraint...) -> Self

.filedには、キー値、データタイプと制約を指定出来ます。

今回のデータタイプは.double、制約はnilを許容しない.requiredを指定していますが、その他のタイプについてはドキュメントに詳しく記載があります。

.create

今回のマイグレーションは作成時のマイグレーションなので.create()を呼び出します。更新時のマイグレーションは、.update()を呼び出します。

マイグレーションをconfigureに追加

マイグレーションファイルをconfigureに追加します。

Source > App > configureファイルを変更します。

import Fluent
import FluentPostgresDriver
import Vapor

// configures your application
public func configure(_ app: Application) throws {

    app.databases.use(.postgres(
        hostname: "localhost",
        username: "postgres",
        password: "",
        database: "littleossacoordinatedb"
    ), as: .psql)

    // マイグレーションの実行
    app.migrations.add(CreateCoordinate())

    // register routes
    try routes(app)
}

データベースを指定

app.databases.useで使用するデータベースを指定します。

hostnameはローカルホストを使用するので、localhost

usernamepasswordについては今回は変更していないのでデフォルト値を設定します。

databaseは作成したデータベース名を設定します。

マイグレーションを追加

作成したマイグレーションを追加します。

app.migrations.add(CreateCoordinate())

以上でマイグレーションの準備は整いました。

マイグレーションを実行

Vaporディレクトリに移動しているターミナルで下記を実行

vapor run migrate

Would you like to continue?と問われるので、yを選択し、Migration successfulと表示されるとマイグレーションが完了です。

y/n> y
[ INFO ] [Migrator] Starting prepare [database-id: psql, migration: App.CreateCoordinate]
[ INFO ] [Migrator] Finished prepare [database-id: psql, migration: App.CreateCoordinate]
Migration successful

Controllerの実装

routesに関する処理を行うControllerを作成します。

まず、Source > App > Controllerに、新規ファイルを追加します。デフォルトでTodoControllerが存在していますので、そちらを参考にしながら処理を書き進めると良さそうです。

今回は、LilossaCoordinateControllerという名前で作成しました。

import Fluent
import Vapor

struct LilossaCoordinateController: RouteCollection {
    func boot(routes: Vapor.RoutesBuilder) throws {
        /// localhost:8080/littleossa_coordinates
        let coordinates = routes.grouped(PathComponent(stringLiteral: LilossaCoordinate.schema))
        // GET
        coordinates.get(use: index)
        // POST
        coordinates.post(use: create)
        // DELETE
        /// localhost:8080/littleossa_coordinates/:coordinateID
        coordinates.group(":coordinateID") { coordinate in
            coordinate.delete(use: delete)
        }
    }

    func index(req: Request) async throws -> [LilossaCoordinate] {
        try await LilossaCoordinate.query(on: req.db)
            .sort(\.$createdAt, .descending) // 生成日の降順に並び替え
            .all()
    }

    func create(req: Request) async throws -> LilossaCoordinate {
        let coordinate = try req.content.decode(LilossaCoordinate.self)
        try await coordinate.save(on: req.db)
        return coordinate
    }

    func delete(req: Request) async throws -> HTTPStatus {
        guard let coordinate = try await LilossaCoordinate.find(req.parameters.get("coordinateID"), on: req.db) else {
            throw Abort(.notFound)
        }
        try await coordinate.delete(on: req.db)
        return .noContent
    }
}

boot(routes:)

RouteCollectionに準拠すると、boot(routes:)メソッドを追加する必要があり、そのメソッドの中で細かいroutesの処理を書いていきます。

今回は例にならって、GETPOSTDELETEの3つの処理を追加しました。

index(req:)

boot(route:)内でcoordinates.get(use: index)と呼んでいるGETメソッドの処理です。

データベースからLilossaCoordinateモデルを取得して、生成日の降順に並び替えた全ての要素を取得するものになります。

func index(req: Request) async throws -> [LilossaCoordinate] {
    try await LilossaCoordinate.query(on: req.db)
        .sort(\.$createdAt, .descending) // 生成日の降順に並び替え
        .all()
}

create(req:)

boot(route:)内でcoordinates.post(use: create)と呼んでいるPOSTメソッドの処理です。

受け取ったリクエストからLilossaCoordinateを生成して、データベースに保存しています。

func create(req: Request) async throws -> LilossaCoordinate {
    let coordinate = try req.content.decode(LilossaCoordinate.self)
    try await coordinate.save(on: req.db)
    return coordinate
}

delete(req:)

boot(route:)内でcoordinates.delete(use: delete)と呼んでいるDELETEメソッドの処理です。

littleossa_coordinates後ろのパスをIDとして受け取ります。

/// localhost:8080/littleossa_coordinates/:coordinateID
coordinates.group(":coordinateID") { coordinate in
    coordinate.delete(use: delete)
}

受け取ったIDを使用して、対象のLilossaCoordinateを見つけ、そのLilossaCoordinateをデータベースから削除しています。

func delete(req: Request) async throws -> HTTPStatus {
    guard let coordinate = try await LilossaCoordinate.find(req.parameters.get("coordinateID"), on: req.db) else {
        throw Abort(.notFound)
    }
    try await coordinate.delete(on: req.db)
    return .noContent
}

routesにControllerを追加

Source > Appにあるroutesファイルに作成したControllerを追加します。

import Fluent
import Vapor

func routes(_ app: Application) throws {

    try app.register(collection: LilossaCoordinateController())
}

動作確認

サーバーの起動

Vaporプロジェクトで作成したアプリを起動します。

デバッグコンソールにサーバーが起動されたことが表示されます。

[ NOTICE ] Server starting on http://127.0.0.1:8080

Postmanで確認

動作確認には、Postmanを使用しました。Postmanは、APIを構築して使用するためのAPIプラットフォームで、APIの動きを確認するのに便利です。

POST

Postman画面でPOSTを選択し、URLlocalhost:8080/littleossa_coordinatesを入力します。

ヘッダーのKeyにContent-Type、valueにapplication/jsonを入力します。

任意の緯度経度をBodyに入力し、Sendボタンを押すと、コンソール下部に200ステータスと共に登録されたLilossaCoordinateの値が確認出来ます。

GET

実際にPOSTしたものが保存されているか確認します。

Postman画面でGETを選択し、URLlocalhost:8080/littleossa_coordinatesを入力します。

特にBodyは不要な為、Sendボタンを押します。

GETで取得した配列内にPOSTで追加したLilossaCoordinateを確認出来ました。

DELETE

Postman画面でDELETEを選択します。今回は、削除する対象IDが必要な為、URLlocalhost:8080/littleossa_coordinates/853839CB-B2FF-4EDD-B4D2-51610725D5B2を入力します。

特にBodyは不要な為、Sendボタンを押します。

削除された戻り値204No Contentを確認出来ました。

また、再度GETを実行してみると、空の配列が返ってくるのが確認出来ました。

おわりに

コードはGitHubに置いておきます。

まだ試していないhttpメソッドはあるものの、データベースを更新したり、値を取得、削除することが出来ました。普段から使用しているSwiftを使用出来たことで、触ったことのサーバーサイド側の勉強も負担なく出来たように感じます。まだまだサーバーサイドも入門編なのでこれからも少しずつ触れて仲良くなっていきたいと思います。

参考