🔗

サーバーとクライアントでAPIの型を共有する方法

に公開

目次

  1. はじめに
  2. なぜ型安全なAPI定義が必要なのか
  3. swift-api-contractの設計思想
  4. 開発の全体像
  5. API契約の定義
  6. サーバー側での活用
  7. クライアント側での活用
  8. まとめ

1. はじめに

本記事では、Swiftマクロを使って型安全なAPI定義を実現するライブラリswift-api-contractを紹介します。

本記事は「サーバーサイドSwiftでiOSアプリ開発をどこまで効率化できるか」シリーズの第3回です。第1回はSwift Configuration第2回はswift-statableを取り上げました。今回はAPI定義の型安全な共有を扱います。

API契約とは

API契約(Contract)とは、クライアントとサーバー間の「取り決め」です。「このエンドポイントにこの形式でリクエストを送れば、この形式でレスポンスが返る」という約束を指します。

具体的には以下の情報を含みます。

  • エンドポイント: HTTPメソッドとパス(例: GET /v1/todos/:todoId
  • リクエスト: パスパラメータ、クエリパラメータ、リクエストボディの形式
  • レスポンス: 成功時に返却されるデータの形式
  • エラー: 失敗時に返却されるエラーの種類
  • デフォルト値: パラメータが省略された場合の値
  • 認証要件: エンドポイントが認証を必要とするか

従来のAPI開発では、これらの契約をOpenAPIなどのスキーマ言語で記述し、そこからコードを生成するか、クライアントとサーバーで別々に型定義を書いていました。

swift-api-contractは、これらのAPI契約をすべてSwiftの型システムで表現します。サーバーサイドSwiftを使えば、同じ型定義をクライアントとサーバーで直接共有できるため、契約の不一致が原理的に発生しません。


2. なぜ型安全なAPI定義が必要なのか

API開発には、いくつかの課題があります。

二重定義とデコードエラー

クライアントとサーバーで同じAPIを別々に定義すると、変更時の同期漏れでデコードエラーが発生します。

iOS開発者なら経験があるはずです。APIを呼び出したらデコードに失敗し、レスポンスをprintして原因を探る作業。多くの場合、サーバー側でフィールド名やデータ型が変わったことが原因です。

たとえば、クライアント側でuserIdというフィールドを期待しているのに、サーバーがuser_idに変更していた場合。あるいはDate型を期待しているのに、サーバーが文字列で返してきた場合。keyNotFoundtypeMismatchといったデコードエラーが発生し、ランタイムでしか検出できません。

struct User: Decodable {
    let userId: String
    let createdAt: Date
}

このようなエラーのデバッグには時間がかかります。

ボイラープレートの繰り返し

エンドポイントごとに、パス構築、クエリエンコード、JSON処理を繰り返し書く必要があります。

var components = URLComponents(string: baseURL + "/v1/todos")!
components.queryItems = [
    URLQueryItem(name: "limit", value: String(limit)),
    URLQueryItem(name: "offset", value: String(offset))
]
let request = URLRequest(url: components.url!)

swift-api-contractは、単一のSwift定義をクライアント・サーバー両方で使い、マクロでボイラープレートを自動生成することで、これらの課題を解決します。型が共有されているため、デコードエラーは原理的に発生しません。


3. swift-api-contractの設計思想

swift-api-contractは3つの原則に基づいて設計されています。

宣言的な定義

構造体にマクロを付けるだけでAPI定義が完成します。HTTPメソッド、パス、パラメータの配置を宣言するだけで、残りはマクロが生成します。

型安全の徹底

パスパラメータ、クエリパラメータ、リクエストボディ、レスポンス型すべてがコンパイル時にチェックされます。API変更時はコンパイルエラーで検知できます。

コード共有

SharedパッケージにAPI定義を置き、iOSアプリとサーバーの両方が同じ定義を参照します。


4. 開発の全体像

サーバーサイドSwiftとiOSアプリでswift-api-contractを使う場合、以下の3つのステップで開発を進めます。

Step 1: API契約の定義

まず、Sharedパッケージに@APIGroup@Endpointマクロを使ってAPI契約を定義します。ここで定義した型が、クライアントとサーバーの両方で使われる「単一の情報源」になります。

Step 2: サーバー側の実装

次に、サーバー側で自動生成されたServiceプロトコルを実装し、ルーターに登録します。VaporやHummingbirdなどのサーバーサイドSwiftフレームワークで、各エンドポイントのビジネスロジックをハンドラとして記述します。

Step 3: クライアント側からの呼び出し

最後に、iOSアプリからAPI契約を使ってリクエストを実行します。API契約がパス構築やパラメータのエンコードを自動で行うため、クライアント側ではHTTPの詳細を意識する必要がありません。

次のセクションから、各ステップの詳細を見ていきます。


5. API契約の定義

@APIGroupと@Endpoint

関連するエンドポイントを@APIGroupでグループ化し、各エンドポイントを@Endpointで定義します。

以下はTodo管理APIの例です。一覧取得、単一取得、作成、更新、削除の5つのエンドポイントを定義しています。

import APIContract

@APIGroup(path: "/v1/todos", auth: .required)
public enum TodosAPI {
    @Endpoint(.get)
    public struct List {
        @QueryParam public var limit: Int? = 20
        @QueryParam public var offset: Int? = 0
        public typealias Output = [Todo]
    }

    @Endpoint(.get, path: ":todoId")
    public struct Get {
        @PathParam public var todoId: String
        public typealias Output = Todo
    }

    @Endpoint(.post)
    public struct Create {
        @Body public var input: CreateTodoInput
        public typealias Output = Todo
    }

    @Endpoint(.patch, path: ":todoId")
    public struct Update {
        @PathParam public var todoId: String
        @Body public var input: UpdateTodoInput
        public typealias Output = Todo
    }

    @Endpoint(.delete, path: ":todoId")
    public struct Delete {
        @PathParam public var todoId: String
        public typealias Output = EmptyOutput
    }
}

パラメータマクロ

マクロ 用途
@PathParam URLパスに埋め込む /todos/:todoIdtodoId
@QueryParam クエリ文字列 ?limit=20&offset=0
@Body リクエストボディ(JSON) POST/PATCH/PUTのペイロード

@QueryParamはカスタム名もサポートしています。以下の例では、Swiftのプロパティ名はcategoryIdですが、クエリ文字列ではcategory_idとして送信されます。

@QueryParam(name: "category_id") public var categoryId: String?

マクロが生成するもの

@Endpointマクロは、以下を自動生成します。

生成されるもの 用途
pathParameters パスへの値埋め込み
queryParameters クエリ文字列の構築
encodeBody() リクエストボディのエンコード
init() パラメータを受け取る初期化子
decode() サーバー側でリクエストから復元
APIContract準拠 プロトコル準拠の自動追加

手動で書くと30〜40行になるコードが、4〜5行の宣言で済みます。


6. サーバー側での活用

自動生成されるServiceプロトコル

@APIGroupマクロは、対応するServiceプロトコルを自動生成します。TodosAPIからはTodosAPIServiceプロトコルが生成され、各エンドポイントに対応するhandleメソッドが定義されます。

public protocol TodosAPIService: APIService where Group == TodosAPI {
    func handle(_ input: TodosAPI.List, context: ServiceContext) async throws -> [Todo]
    func handle(_ input: TodosAPI.Get, context: ServiceContext) async throws -> Todo
    func handle(_ input: TodosAPI.Create, context: ServiceContext) async throws -> Todo
    func handle(_ input: TodosAPI.Update, context: ServiceContext) async throws -> Todo
    func handle(_ input: TodosAPI.Delete, context: ServiceContext) async throws -> EmptyOutput
}

Serviceの実装

生成されたプロトコルに準拠してハンドラを実装します。

struct TodosService: TodosAPIService {
    private let todoUseCase: TodoUseCase

    func handle(_ input: TodosAPI.List, context: ServiceContext) async throws -> [Todo] {
        let userId = try context.requireUserId()
        return try await todoUseCase.getTodos(userId: userId)
    }

    func handle(_ input: TodosAPI.Get, context: ServiceContext) async throws -> Todo {
        let userId = try context.requireUserId()
        guard let todo = try await todoUseCase.getTodo(userId: userId, todoId: input.todoId) else {
            throw TodosAPIError.notFound(todoId: input.todoId)
        }
        return todo
    }

    func handle(_ input: TodosAPI.Create, context: ServiceContext) async throws -> Todo {
        let userId = try context.requireUserId()
        return try await todoUseCase.createTodo(userId: userId, input: input.input)
    }
}

input.todoIdinput.inputのように、リクエストパラメータに型安全にアクセスできます。

ルート登録の自動化

@APIServicesマクロで複数のサービスをグループ化し、一括登録できます。

@APIServices
public struct AppServices: Sendable {
    let auth: AuthService
    let profile: ProfileAPIServiceImpl
    let todos: TodosService
    let categories: CategoriesService
}

エントリーポイントではregisterAllを呼び出すだけで、すべてのサービスがルーティングに登録されます。

services.registerAll(server.routes)

7. クライアント側での活用

リクエストの実行

エンドポイントを作成し、.execute(using:)で実行します。

let endpoint = TodosAPI.Get(todoId: "123")
let todo: Todo = try await endpoint.execute(using: apiClient)

apiClientAPIExecutableプロトコルに準拠した任意のクライアントです。

UseCaseでの利用パターン

実際のアプリでは、UseCaseがAPI定義を使ってリクエストを実行します。

public struct TodoUseCaseImpl<Executor: APIExecutable>: TodoUseCase, Sendable {
    private let executor: Executor

    public func getTodos(
        categoryId: String?,
        isCompleted: Bool?,
        limit: Int,
        offset: Int
    ) async throws -> [Todo] {
        try await TodosAPI.List(
            categoryId: categoryId,
            isCompleted: isCompleted,
            limit: limit,
            offset: offset
        ).execute(using: executor)
    }

    public func getTodo(id: String) async throws -> Todo {
        try await TodosAPI.Get(todoId: id).execute(using: executor)
    }

    public func createTodo(input: CreateTodoInput) async throws -> Todo {
        try await TodosAPI.Create(input: input).execute(using: executor)
    }
}

API定義が持つ情報(パス、メソッド、パラメータ)から、URLリクエストが自動構築されます。UseCaseはHTTPの詳細を意識する必要がありません。


8. まとめ

本記事では、Swiftマクロで型安全なAPI定義を実現するライブラリ「swift-api-contract」を紹介しました。

サーバーサイドSwiftだからこそ実現できる「型の共有」を活かし、以下を実現しています。

  • 単一定義: クライアントとサーバーで同じAPI定義を参照
  • 型安全: パラメータ・レスポンスすべてがコンパイル時チェック、デコードエラーが発生しない
  • ボイラープレート削減: マクロが必要なコードを自動生成

第1回のswift-env(環境設定)、第2回のswift-statable(状態管理)と組み合わせることで、フルスタックSwift開発の基盤が整います。


参考リンク

本記事で紹介したライブラリ

シリーズ記事

著者

Discussion