iOSエンジニアがバックエンド入門する #Swift - Qiita

  • webサーバー楽しそう

  • Rustを学びたいが,サーバーの経験がないからまずは慣れたSwiftで書きたい

  • LLMは次の順番で各領域の技術水準が低・中程度の人間プログラマーを駆逐していきそう(根拠なき直感)だから、より代替困難な側に避難したい

    1. WEBフロントエンド
    2. モバイル(クロスプラットフォーム,ネイティブ)
    3. バックエンド
    4. 組み込み系?(あまりよく知らない)

住所検索サービス
郵便番号を入力すると,それに紐づいた住所が返ってくる

動くものの動画

正常系 異常系

まずはAPI仕様から決める.

openapi.yaml

openapi: "3.1.0"
info:
  title: AddressSearchService
  version: "1.0.0"
servers:
  - url: "http://127.0.0.1:8080/openapi"
    description: "住所検索!"
paths:
  /address:
    get:
      operationId: searchAddresses
      summary: 郵便番号に基づく住所候補の検索
      description: 郵便番号(またはその一部)をクエリパラメータとして受け取り、候補となる住所情報の配列を返します。
      parameters:
        - in: query
          name: postal_code
          required: false
          description: 郵便番号(例:"1000001" または途中入力 "123")
          schema:
            type: string
            pattern: "^[0-9]{0,7}$"
            example: "1000001"
      responses:
        "200":
          description: 郵便番号に基づく住所候補一覧
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/AddressInfo"
        "400":
          description: 不正な郵便番号の形式です。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  schemas:
    AddressInfo:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: 一意な識別子
        postalCode:
          type: string
          description: 郵便番号
        prefectureKana:
          type: string
          description: 都道府県のカナ表記
        prefectureKanji:
          type: string
          description: 都道府県の漢字表記
        cityKana:
          type: string
          description: 市区町村のカナ表記
        cityKanji:
          type: string
          description: 市区町村の漢字表記
        streetKana:
          type: string
          description: 町域等のカナ表記
        streetKanji:
          type: string
          description: 町域等の漢字表記
      required:
        - postalCode
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: エラー詳細
      required:
        - error

ちなみにOpenAPIはVSCode + 拡張機能のOpenAPI (Swagger) Editorだと見やすい 参考
image.png

本章ではデータベースを構築する.

データの準備

住所データは住所の郵便番号(1レコード1行、UTF-8形式)(CSV形式)の最新全データ(utf_ken_all.zip)を用いる.
ダウンロードして中身を見てみると次のとおり.

utf_ken_all.csv

01101,"060  ","0600000","ホッカイドウ","サッポロシチュウオウク","イカニケイサイガナイバアイ","北海道","札幌市中央区","以下に掲載がない場合",0,0,0,0,0,0
01101,"064  ","0640941","ホッカイドウ","サッポロシチュウオウク","アサヒガオカ","北海道","札幌市中央区","旭ケ丘",0,0,1,0,0,0
01101,"060  ","0600041","ホッカイドウ","サッポロシチュウオウク","オオドオリヒガシ","北海道","札幌市中央区","大通東",0,0,1,0,0,0
...

よくわからない数字が並ぶので,解説を見る

  1. 全国地方公共団体コード(JIS X0401、X0402)……… 半角数字
  2. (旧)郵便番号(5桁)……………………………………… 半角数字
  3. 郵便番号(7桁)……………………………………… 半角数字
  4. 都道府県名 ………… 全角カタカナ(コード順に掲載) (※1)
  5. 市区町村名 ………… 全角カタカナ(コード順に掲載) (※1)
  6. 町域名 ……………… 全角カタカナ(五十音順に掲載) (※1)
  7. 都道府県名 ………… 漢字(コード順に掲載) (※1,2)
  8. 市区町村名 ………… 漢字(コード順に掲載) (※1,2)
  9. 町域名 ……………… 漢字(五十音順に掲載) (※1,2)
  10. 一町域が二以上の郵便番号で表される場合の表示 (※3) (「1」は該当、「0」は該当せず)
  11. 小字毎に番地が起番されている町域の表示 (※4) (「1」は該当、「0」は該当せず)
  12. 丁目を有する町域の場合の表示 (「1」は該当、「0」は該当せず)
  13. 一つの郵便番号で二以上の町域を表す場合の表示 (※5) (「1」は該当、「0」は該当せず)
  14. 更新の表示(※6)(「0」は変更なし、「1」は変更あり、「2」廃止(廃止データのみ使用))
  15. 変更理由 (「0」は変更なし、「1」市政・区政・町政・分区・政令指定都市施行、「2」住居表示の実施、「3」区画整理、「4」郵便区調整等、「5」訂正、「6」廃止(廃止データのみ使用))

はええ.なんだか難しい.
今回はCSVを編集するのも面倒なので,次のようにヘッダーを追加するのみ.
あとはそのまま用いる.(機能拡張したいときに,テーブル作り直すのも面倒そうだし…)

csv.diff

+ zenkoku_chiho_kokyodantai_code,"old_postal_code","postal_code","prefecture_kana","city_kana","street_kana","prefecture_kanji","city_kanji","street_kanji","street_has_multiple_postalcode","street_has_koazas_with_conflictable_banch","street_has_chome","street_shares_postalcode_with_others","has_update","update_reason"
01101,"060  ","0600000","ホッカイドウ","サッポロシチュウオウク","イカニケイサイガナイバアイ","北海道","札幌市中央区","以下に掲載がない場合",0,0,0,0,0,0,
...

PostgreSQLとGUIのpgAdmin 4を導入する

DBは何もわからんが,PostgreSQLとGUIのpgAdmin 4で構築する.
インストールの手順は次の資料などに丸投げします.

DBテーブルの作成

SQL何もわからんのでGUIからテーブルを作る
image.png

次のように名前をaddressInfoTableと指定する
image.png

次のように列を追加
image.png

DBの各行にはIDとなる主キーが欲しい.IDはnone-nullableで一意である必要がある.しかし,元データ(utf_ken_all.csv)にidなる列は含まれないので,今回作る.
uuid-ossp拡張を有効に設定(参考)してuuid_generate_v4()を初期値とする.

image.png

あとは次のように各列の名前と型,nullabilityを設定する.
image.png

ちなみに,上記操作に対応するSQLコマンドは次のよう.

CREATE TABLE public."addressInfoTable"
(
    id uuid NOT NULL DEFAULT uuid_generate_v4(),
    zenkoku_chiho_kokyodantai_code text,
    old_postal_code text,
    postal_code text NOT NULL,
    prefecture_kana text,
    city_kana text,
    street_kana text,
    prefecture_kanji text,
    city_kanji text,
    street_kanji text,
    street_has_multiple_postalcode smallint,
    street_has_koazas_with_conflictable_banch smallint,
    street_has_chome smallint,
    street_shares_postalcode_with_others smallint,
    has_update smallint,
    update_reason smallint,
    PRIMARY KEY (id)
);

ALTER TABLE IF EXISTS public."addressInfoTable"
    OWNER to postgres;

DBテーブルにデータをインポートする

次のようにデータをインポートする

image.png

一般/ファイル名 でutf_ken_all.csvを指定する
image.png

オプション/ヘッダ を有効にする
image.png

列/Columns to export からidを削除

image.png

OKボタンを押下すると数秒でプロセスが完了するはず.
image.png

これでDBの用意は完了.

Vaporをhomebrewでインストールする.
VaporはSwiftでweb apps APIが作れるweb framework.

vaporを用いたXcode Projectを作成する

$ vapor new AddressSearchServer
Cloning template...
Would you like to use Fluent (ORM)? (--fluent/--no-fluent)
y/n> y 
fluent: Yes
# FluentはORMライブラリ.
# ChatGPT: プログラムで使うオブジェクト(クラスやインスタンスなど)と、リレーショナルデータベースのテーブルやレコードとの対応関係を自動的に管理する仕組みです。
# これにより、SQL文を直接書かなくてもデータベース操作ができるようになり、コードとデータベース間の橋渡し的な役割を果たします。
Which database would you like to use? (--fluent.db)
1: Postgres (Recommended)
2: MySQL
3: SQLite
> 1
db: Postgres (Recommended)
# 推奨のPostgresを使用する
Would you like to use Leaf (templating)? (--leaf/--no-leaf)
y/n> n
leaf: No
# leafは動的なHTMLが作成できるライブラリ.今は不要.
Generating project files
Creating git repository
Adding first commit
Project AddressSearchServer has been created!
Use cd 'AddressSearchServer' to enter the project directory
Then open your project, for example if using Xcode type open Package.swift or code . if using VSCode

これでvaporを用いたXcode Projectが作成できた.

Databaseに保存されたDataを返してみる

Databaseに保存したDataに対応するエンティティオブジェクトを作成する.

AddressInfo.swift

import Fluent
import Vapor


final class AddressInfo: Model, Content, @unchecked Sendable {
    static let schema = "addressInfoTable"

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

    @Field(key: "postal_code")
    var postalCode: String

    @Field(key: "prefecture_kana")
    var prefectureKana: String?
    @Field(key: "prefecture_kanji")
    var prefectureKanji: String?

    @Field(key: "city_kana")
    var cityKana: String?
    @Field(key: "city_kanji")
    var cityKanji: String?

    @Field(key: "street_kana")
    var streetKana: String?
    @Field(key: "street_kanji")
    var streetKanji: String?

    init(){}

    init(
        id: UUID? = nil,
        postalCode: String,
        prefectureKana: String?,
        prefectureKanji: String?,
        cityKana: String?,
        cityKanji: String?,
        streetKana: String?,
        streetKanji: String?
    ) {
        self.id = id
        self.postalCode = postalCode
        self.prefectureKana = prefectureKana
        self.prefectureKanji = prefectureKanji
        self.cityKana = cityKana
        self.cityKanji = cityKanji
        self.streetKana = streetKana
        self.streetKanji = streetKanji
    }
}

使用するdatabaseを設定する

configure.swift

public func configure(_ app: Application) async throws {
    app.databases.use(
        .postgres(
            configuration: .init(
                hostname: "localhost",
                username: "vapor",
                password: "",
                database: "addressdb",
                tls: .disable
            )
        ),
        as: .psql
    )
}

新しいパスと,それに対応する処理を追加する.

AddressInfoListController.swift

import Fluent
import Vapor

struct AddressInfoListController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        // `{baseURL}/getAddressInfoList` に対してGET methodを設定する
        routes.get("getAddressInfoList", use: getAddressInfoList)
    }

    @Sendable
    func getAddressInfoList(req: Request) async throws -> Response {
        let addressInfoList: [AddressInfo] = try await AddressInfo.query(on: req.db)
            .filter(\.$postalCode == "1000001") // 一旦DB内の郵便番号1000001に該当するデータを正しく返せるか確認
            .all()
        return try await addressInfoList.encodeResponse(for: req)
    }
}

routes.swift.diff

import Fluent
import Vapor

func routes(_ app: Application) throws {
...
    try app.register(collection: TodoController())
+   try app.register(collection: AddressInfoListController())
}

これでビルドして実行する.
問題なければhttp://127.0.0.1:8080/getAddressInfoList にアクセスすると次のようなデータが返ってくる(見やすく整形した.またidの値はDBごとに異なる)

[
  {
    "id": "FB5FE262-7A0D-461E-9268-E04B2BFD18CA", 
    "cityKanji": "千代田区",
    "postalCode": "1000001",
    "prefectureKanji": "東京都",
    "streetKana": "チヨダ",
    "streetKanji": "千代田",
    "cityKana": "チヨダク",
    "prefectureKana": "トウキョウト"
  }
]

これで,サーバーはDBに保存されたデータを返せるようになった.

なお,次のようなSQLコマンドで権限を付与する必要があるかも.
addressdb=# GRANT SELECT, INSERT ON public.addressInfoTable To vapor;
*psqlというPostgreSQLの対話的なコマンドラインツールのプロンプト(addressdb=#)上で実行する

Swift-OpenAPI-Generatorを追加する

次はOpenAPIを設定する.
AddressSearchServer/Sources/AddressSearchServer直下に次の2つのファイルを配置する

  • 上述のopenapi.yaml
  • 次のopenapi-generator-config.yaml

    openapi-generator-config.yaml

    generate:
      - types
      - server
    

次にSwift OpenAPI Generatorなどへの依存を追加する

Package.swift.diff

diff --git a/Package.swift b/Package.swift
index 71bbe70..a23cfff 100644
--- a/Package.swift
+++ b/Package.swift
@@ -15,6 +15,11 @@ let package = Package(
         .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
         // 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
         .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
+
+        // OpenAPI
+        .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.3.0"),
+        .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.5.0"),
+        .package(url: "https://github.com/swift-server/swift-openapi-vapor", from: "1.0.1"),
     ],
     targets: [
         .executableTarget(
@@ -25,8 +30,14 @@ let package = Package(
                 .product(name: "Vapor", package: "vapor"),
                 .product(name: "NIOCore", package: "swift-nio"),
                 .product(name: "NIOPosix", package: "swift-nio"),
+                // OpenAPI
+                .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
+                .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
             ],
-            swiftSettings: swiftSettings
+            swiftSettings: swiftSettings,
+            plugins: [
+              .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
+            ]
         ),
         .testTarget(
             name: "AddressSearchServerTests",

ビルドするとビルドフェーズでAPIエンドポイントのコードが自動生成される.自動生成に問題がなければ,例えばentrypoint.swiftで次のコードを追加しても問題なくコンパイルできる.

entrypoint.swift

...
typealias Generated_AddressInfo_JustForTest = Components.Schemas.AddressInfo // コンパイルできることを確認したら消す.

Swift-OpenAPI-Generatorが生成したAPIエンドポイントを実装する

OpenAPIController.swiftで自動生成されたAPI仕様(APIProtocol)を満たす

OpenAPIController.swift

import Vapor

struct OpenAPIController: APIProtocol { // この`APIProtocol`がOpenAPIによって生成された
    // APIProtocolがopenapi.yamlに定義されたAPIエンドポイントの実装を義務化してくれるので実装漏れが防げる
    func searchAddresses(_ input: Operations.searchAddresses.Input) async throws -> Operations.searchAddresses.Output {
        // 一旦stubを返す
        .ok(
            .init(
                body: .json(
                    [
                        .init(
                            id: "1",
                            postalCode: "1000001",
                            prefectureKana: "トウキョウト",
                            prefectureKanji: "東京都",
                            cityKana: "チヨダク",
                            cityKanji: "千代田区",
                            streetKana: "チヨダ",
                            streetKanji: "千代田"
                        )
                    ]
                )
            )
        )
    }
}

Swift-OpenAPI-Generatorで生成されたAPIのエンドポイントを、Vaporのルーティングシステムに登録する.

entrypoint.swift.diff

 import Vapor
 import Logging
 import NIOCore
 import NIOPosix
+import OpenAPIVapor
 
 @main
 enum Entrypoint {
@@ -17,7 +18,9 @@ enum Entrypoint {
         // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
         // let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
         // app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
         
+        // VaporTransport:OpenAPIで生成されたコードとVapor(HTTPサーバー)の間を橋渡しするためのアダプタ.
+        // app:Vaporのルート登録(Routing)のためのオブジェクト(RoutesBuilder)
+        let transport = VaporTransport(routesBuilder: app)
+        
+        let handler = OpenAPIController()
+        
+        // Servers.Server1.url():OpenAPIで生成されたURL, 今回は`http://127.0.0.1:8080/openapi`
+        try handler.registerHandlers(on: transport, serverURL: Servers.Server1.url())
         do {
             try await configure(app)
             try await app.execute()

ここで一旦XcodeのtargetをMy Macにしてビルド+実行する.
好きなブラウザでhttp://127.0.0.1:8080/openapi/address にアクセスする.
問題がなければ次が返ってくる.

[
  {
    "cityKana" : "チヨダク",
    "cityKanji" : "千代田区",
    "id" : "1",
    "postalCode" : "1000001",
    "prefectureKana" : "トウキョウト",
    "prefectureKanji" : "東京都",
    "streetKana" : "チヨダ",
    "streetKanji" : "千代田"
  }
]

OpenAPIエンドポイントとDBを接続する

現在OpenAPIで定義したURL(http://127.0.0.1:8080/openapi/address )にアクセスするとStubが返ってくる.
これから,Databaseに保存されたDataが返ってくるようにする.

Databaseに保存されたDataを返してみるの章では,次のようにDBからデータを取り出して返していた.

AddressInfoListController.swift

struct AddressInfoListController: RouteCollection {
...
    @Sendable
    func getAddressInfoList(req: Request) async throws -> Response {
        let addressInfoList: [AddressInfo] = try await AddressInfo.query(on: req.db)
            .filter(\.$postalCode == "1000001")
            .all()
        return try await addressInfoList.encodeResponse(for: req)
    }
}

今回はstruct OpenAPIController: APIProtocolsearchAddresses内部で,DBに保存された対応するDataを返したい.しかし,OpenAPIController.searchAddressesAddressInfoListController.getAddressInfoListと異なりRequestを持たないので,req.dbにアクセスできない.

OpenAPIController.swift

// 本コードは正常に動作しない.あくまでも説明のためのもの
import Fluent
import Vapor

struct OpenAPIController: APIProtocol {
    var request: Request! // こんなふうにRequestにアクセスしたい!

    func searchAddresses(_ input: Operations.searchAddresses.Input) async throws -> Operations.searchAddresses.Output {
        let addressInfoList: [AddressInfo] = try await AddressInfo.query(on: request.db) // ここでDatabaseにアクセスするから
            .filter(\.$postalCode == request.query.get(at: "postal_code")) // ここで検索すべき郵便番号にもアクセスしたい
            .all()
        return .ok(
            .init(
                body: .json(
                    addressInfoList.map(Components.Schemas.AddressInfo.init)
                )
            )
        )
    }
}

// DatabaseのエンティティオブジェクトであるAddressInfoから,APIエンドポイントの型であるComponents.Schemas.AddressInfoを生成する
extension Components.Schemas.AddressInfo {
    init(from entity: AddressInfo) {
    ... // 後述
    }
}

そこでOpenAPIControllerRequestを差し込む.
差し込むために,依存注入ライブラリであるPointFreeのswift-dependenciesを用いる.

swift-dependenciesを追加

Package.swift.diff

@@ -20,6 +20,9 @@ let package = Package(
         .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.3.0"),
         .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.5.0"),
         .package(url: "https://github.com/swift-server/swift-openapi-vapor", from: "1.0.1"),
+        // DI
+        .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.3.9"),
     ],
     targets: [
         .executableTarget(
@@ -33,6 +36,8 @@ let package = Package(
                 // OpenAPI
                 .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
                 .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
+                // DI
+                .product(name: "Dependencies", package: "swift-dependencies"),
             ],
             swiftSettings: swiftSettings,
             plugins: [

差し込み可能なプロパティrequest: Requestを定義

DependencyValues+App.swift

import Dependencies
import Vapor

extension DependencyValues {

  var request: Request {
    get { self[RequestKey.self] }
    set { self[RequestKey.self] = newValue }
  }

  private enum RequestKey: DependencyKey {
    static var liveValue: Request {
      fatalError("Value of type \(Value.self) is not registered in this context")
    }
  }
}

OpenAPIController.swift.diff

+import Fluent
 import Vapor
+import Dependencies
 
 struct OpenAPIController: APIProtocol {
+    @Dependency(\.request) var request // これで差し込まれたrequestを使用できる
+
     func searchAddresses(_ input: Operations.searchAddresses.Input) async throws -> Operations.searchAddresses.Output {
-        // 一旦stubを返す
-        .ok(
+        let addressInfoList: [AddressInfo] = try await AddressInfo.query(on: request.db)
+            .filter(\.$postalCode == request.query.get(at: "postal_code"))
+            .all()
+        return .ok(
             .init(
                 body: .json(
-                    [
-                        .init(
-                            id: "1",
-                            postalCode: "1000001",
-                            prefectureKana: "トウキョウト",
-                            prefectureKanji: "東京都",
-                            cityKana: "チヨダク",
-                            cityKanji: "千代田区",
-                            streetKana: "チヨダ",
-                            streetKanji: "千代田"
-                        )
-                    ]
+                    addressInfoList.map(Components.Schemas.AddressInfo.init)
                 )
             )
         )
     }
 }
+
+extension Components.Schemas.AddressInfo {
+    init(from entity: AddressInfo) {
+        id = entity.id?.uuidString
+        postalCode = entity.postalCode
+        prefectureKana = entity.prefectureKana
+        prefectureKanji = entity.prefectureKanji
+        cityKana = entity.cityKana
+        cityKanji = entity.cityKanji
+        streetKana = entity.streetKana
+        streetKanji = entity.streetKanji
+    }
+}

requestを差し込むためのAsyncMiddlewareを作成する

AsyncMiddleware は Vapor における非同期処理が可能なミドルウェアの仕組み
リクエストの受け渡し時に前処理や後処理、共通処理を挟める

OpenAPIRequestInjectionMiddleware.swift

import Dependencies
import Vapor

struct OpenAPIRequestInjectionMiddleware: AsyncMiddleware {
  func respond(
    to request: Request,
    chainingTo responder: any AsyncResponder
  ) async throws -> Response {
    try await withDependencies {
      $0.request = request // 新しいrequestが来るたびに,Dependenciesの環境値のrequestを上書きする
    } operation: {
      try await responder.respond(to: request) // 上書きしたrequestで続きの処理をする
    }
  }
}

AsyncMiddlewareを使用してrequestを差し込む

entrypoint.swift.diff

enum Entrypoint {
         // let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
         // app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
         
-        let transport = VaporTransport(routesBuilder: app)
+        let requestInjectionMiddleware = OpenAPIRequestInjectionMiddleware()
+        let transport = VaporTransport(routesBuilder: app.grouped(requestInjectionMiddleware))
         let handler = OpenAPIController()
         try handler.registerHandlers(on: transport, serverURL: Servers.Server1.url())
         do {

これでOpenAPI経由でもDatabaseの値を得られるようになった!
http://127.0.0.1:8080/openapi/address?postal_code=1000001 なら

[
  {
    "cityKana" : "チヨダク",
    "cityKanji" : "千代田区",
    "id" : "FB5FE262-7A0D-461E-9268-E04B2BFD18CA",
    "postalCode" : "1000001",
    "prefectureKana" : "トウキョウト",
    "prefectureKanji" : "東京都",
    "streetKana" : "チヨダ",
    "streetKanji" : "千代田"
  }
]

http://127.0.0.1:8080/openapi/address?postal_code=1000002 なら

[
  {
    "cityKana" : "チヨダク",
    "cityKanji" : "千代田区",
    "id" : "5C0A6E12-D474-4FD9-B86E-9081E31777D5",
    "postalCode" : "1000002",
    "prefectureKana" : "トウキョウト",
    "prefectureKanji" : "東京都",
    "streetKana" : "コウキョガイエン",
    "streetKanji" : "皇居外苑"
  }
]

複数の住所を返す

今はレスポンス配列だが,郵便番号が完全一致した住所しか返さない.
そこで,郵便番号の前方一致でも返るようにする.

OpenAPIController.swift.diff

        let addressInfoList: [AddressInfo] = try await AddressInfo.query(on: request.db)
-           .filter(\.$postalCode == request.query.get(at: "postal_code"))
+           .filter(\.$postalCode =~ request.query.get(at: "postal_code"))
            .all()

この辺の演算子面白い.
資料にはいろいろ載っている.

演算子 条件の内容 日本語訳
~~ Value in set. 含まれる
!~ Value not in set. 含まれない
~~ Contains substring. 文字列を含む
!~ Does not contain substring. 文字列を含まない
=~ Matches prefix. 前方一致
!=~ Does not match prefix. 前方一致しない
~= Matches suffix. 後方一致
!~= Does not match suffix. 後方一致しない

これdhttp://127.0.0.1:8080/openapi/address?postal_code=100000 に対して複数の値が変えるようになった.

[
  {
    "cityKana" : "チヨダク",
    "cityKanji" : "千代田区",
    "id" : "9D9DBEB6-892B-4A67-8B9D-92CC6416A39F",
    "postalCode" : "1000000",
    "prefectureKana" : "トウキョウト",
    "prefectureKanji" : "東京都",
    "streetKana" : "イカニケイサイガナイバアイ",
    "streetKanji" : "以下に掲載がない場合"
  },
  {
    "cityKana" : "チヨダク",
    "cityKanji" : "千代田区",
    "id" : "BEC21571-B730-4D91-83B3-C63B174C8A66",
    "postalCode" : "1000004",
    "prefectureKana" : "トウキョウト",
    "prefectureKanji" : "東京都",
    "streetKana" : "オオテマチ(ツギノビルヲノゾク)",
    "streetKanji" : "大手町(次のビルを除く)"
  },
  {
    "cityKana" : "チヨダク",
    "cityKanji" : "千代田区",
    "id" : "5C0A6E12-D474-4FD9-B86E-9081E31777D5",
    "postalCode" : "1000002",
    "prefectureKana" : "トウキョウト",
    "prefectureKanji" : "東京都",
    "streetKana" : "コウキョガイエン",
    "streetKanji" : "皇居外苑"
  },
  ...
]

エラーを返す

openapi.yamlにはエラーが定義されている.queryとして渡された郵便番号が不正な場合は,空配列ではなくエラーを返すことで利用者に教えてあげたい.

OpenAPIController.swift.diff

struct OpenAPIController: APIProtocol {
     @Dependency(\.request) var request
 
     func searchAddresses(_ input: Operations.searchAddresses.Input) async throws -> Operations.searchAddresses.Output {
+        func validate(postalCode: String) -> Bool {
+            let postalCodePattern: Regex = /[0-9]{0, 7}/
+            if let match = try! postalCodePattern.wholeMatch(in: postalCode) {
+                return match.0 == postalCode
+            } else {
+                return false
+            }
+        }
+
+        var postalCode: String = try request.query.get(at: "postal_code")
+        postalCode.replace("-", with: "") // ハイフンを取り除いて正規化する.例:100-0001→1000001
+
+        guard validate(postalCode: postalCode) else {
+            return .badRequest(.init(body: .json(.init(error: "不正な郵便番号です"))))
+        }
         let addressInfoList: [AddressInfo] = try await AddressInfo.query(on: request.db)
-            .filter(\.$postalCode =~ request.query.get(at: "postal_code"))
+            .filter(\.$postalCode =~ postalCode)
             .all()
         return .ok(
             .init(

これで次のような不正な郵便番号でアクセスするとエラーが返るようになった.

{
  "error" : "不正な郵便番号です"
}

新規SwiftUIプロジェクトを作成する.

Swift-OpenAPI-Generatorを追加する

  1. 上記openapi.yamlをプロジェクトに追加する

  2. 次のopenapi-generator-config.yamlも同じ階層に配置する

    openapi-generator-config.yaml

    generate:
      - types
      - client
    
  3. 公式資料を参考に以下3packageをXcode Projectに追加する.

    追加するときのtargetの設定

    • swift-openapi-generator : None
    • swift-openapi-runtime : {あなたのProject}
    • swift-openapi-urlsession : {あなたのProject}
  4. コンパイル時に,openapi.yamlに基づいてコードを自動生成するために,Project/Targetsで{あなたのプロジェクト}を選択し,/Build Phase/Run Build Tool Plug-insで追加ボタンを押下し,OpenAPIGenerator(swift-openapi-generator)を追加する.

ビルドするとAPIエンドポイントが自動生成される

Swift-OpenAPI-Generatorが生成したAPIエンドポイントを使用する

ContentView.swift

import SwiftUI
import OpenAPIRuntime
import OpenAPIURLSession

typealias AddressInfo = Components.Schemas.AddressInfo
    
struct ContentView: View {
    @State private var postalCode = ""
    @State private var addressInfoList: [AddressInfo] = []
    @State private var errorMessage: String?
    
    private let addressInfoService = AddressInfoService()

    var body: some View {
        VStack {
            HStack {
                TextField(
                    "郵便番号",
                    text: $postalCode,
                    prompt: Text("1000001")
                )
                .keyboardType(.numberPad)
                Button("Search", systemImage: "magnifyingglass") {
//                    guard validate(postalCode: postalCode) else { return } // 普段はフロント側でもvalidationすべきだが,今回はAPIがエラーを返した時の挙動を確認するためコメントアウト
                    Task {
                        do {
                            addressInfoList = try await addressInfoService.getAddress(postalCode: postalCode)
                        } catch {
                            errorMessage = error.localizedDescription
                        }
                    }
                }
            }

            List(addressInfoList, id: \.id, rowContent: makeAddressInfoView)
        }
        .padding()
        .alert(
            "エラー",
            isPresented: Binding(
                get: { errorMessage != nil },
                set: { _,_ in }
            ),
            actions: {
                Button("OK") {
                    errorMessage = nil
                }
            },
            message: {
                Text(errorMessage ?? "")
            }
        )
    }

    private func makeAddressInfoView(_ addressInfo: AddressInfo) -> some View {
        VStack(alignment: .leading) {
            Text(addressInfo.postalCode)
            HStack {
                Text(addressInfo.prefectureKana ?? "")
                Text(addressInfo.cityKana ?? "")
                Text(addressInfo.streetKana ?? "")
            }
            .font(.caption)
            HStack {
                Text(addressInfo.prefectureKanji ?? "")
                Text(addressInfo.cityKanji ?? "")
                Text(addressInfo.streetKanji ?? "")
            }
        }
    }

    private func validate(postalCode: String) -> Bool {
        let match = try! /[0-9]{0,7}/.wholeMatch(in: postalCode)
        if let matchedString = match?.0  {
            return matchedString == postalCode
        } else {
            return false
        }
    }
}

struct AddressInfoService {
    init() {}
    func getAddress(postalCode: String) async throws -> [AddressInfo] {
        let transport: ClientTransport = URLSessionTransport()
        let client = Client(serverURL: try Servers.Server1.url(), transport: transport)
        let response = try await client.searchAddresses(query: .init(postalCode: postalCode))
        return try response.ok.body.json
    }
}


#Preview {
    ContentView()
}

Webを支える技術 -HTTP、URI、HTML、そしてREST (WEB+DB PRESSプラスシリーズ)
(技術評論社公式ならepub版を購入できるのでKindleよりもおすすめです)



フラッグシティパートナーズ海外不動産投資セミナー 【DMM FX】入金

Source link