every Tech Blog

株式会社エブリーのTech Blogです。

iOSでGraphQLを使ってみた

title

この記事は every Tech Blog Advent Calendar 2023 の 14 日目です。

DELISH KITCHEN iOSアプリの開発を担当しています久保です。

開発中のアプリでGraphQLを利用する機会があったので、導入と利用方法についてご紹介します。

なお、GraphQLについての紹介は、今更感があるので割愛させていただきます。

ライブラリの選定

GraphQLはcurlなどで実行してもらうとわかるのですが、単なるPOSTリクエストなので、そちらで書く方法もあります。その場合ライブラリの導入は不要になりますが、レスポンスに対応するオブジェクトの自動生成などのメリットを享受できないので見送りました。

今回Android側も同様に実装する必要があり、Androidにも同じようにライブラリを提供しているという理由から、apollo-ios を採用しました。

前提条件

  • Xcode 15.0.1
  • Apollo 1.7.1
  • ローカルに作成したパッケージから apollo-ios を利用してデータを取得します。
  • データ取得先としては star-wars-swapi を使用させていただきました。

データ取得までの流れ

今回は以下の順で作業しました。

  1. ローカルパッケージの作成
  2. apollo-iosの導入
  3. apollo-ios-cliを用いて必要なファイルを生成する
  4. データを取得する部分の実装

ローカルパッケージの作成

GraphQLSampleという名称でプロジェクトを作成後、ルート直下にPackagesというGroupを作成し、その下にAPIというパッケージを作成しました。

package

apollo-iosの導入

先ほど作成したPackage.swiftに依存関係を追記します。

import PackageDescription

let package = Package(
    name: "API",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "API",
            targets: ["API"]),
    ],
    dependencies: [
        .package(
            url: "https://github.com/apollographql/apollo-ios.git",
            .upToNextMajor(from: "1.0.0")
        ),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "API",
            dependencies: [
                .product(name: "Apollo", package: "apollo-ios"),
            ]
        ),
        .testTarget(
            name: "APITests",
            dependencies: ["API"]),
    ]
)

apollo-ios-cliを用いて必要なファイルを生成する

appollo-ios-cli コマンドを利用可能な状態にします

Sources配下にbinフォルダを作成し、そこに以下コマンドで生成します

$ swift package --allow-writing-to-package-directory apollo-cli-install

なお、実行可能なファイルを含んだままApp Store Connectにアップロードしようとするとエラーになるため、適宜シンボリックリンクに変更するか、ビルド時に除外するなりの対応が必要です。

設定ファイルを作成する

ここが個人的に一番面倒でした...まずコマンドを利用して apollo-codegen-config.json を生成します

$ bin/apollo-ios-cli init --schema-namespace SW --module-type embeddedInTarget --target-name API

schemaをダウンロードするための設定を追加&取得

生成された設定ファイルに追記します

  "schemaDownloadConfiguration": { // ここから丸っと追加。取得先の情報を設定。
    "downloadMethod": {
      "introspection": {
        "endpointURL": "https://swapi-graphql.netlify.app/.netlify/functions/index",
        "includeDeprecatedInputValues": false,
        "httpMethod": {
          "POST": {}
        },
        "outputFormat": "JSON"
      }
    },
    "outputPath": "./schema.json"
  }

この状態で下記コマンドを実行すると、schema.json が取得できます

$ bin/apollo-ios-cli fetch-schema

Queryを定義したgraphqlファイルの追加&設定変更

タイトルのリストを返却する簡単なQueryを AllTitles.graphql に定義します。

このファイルはどこにおいても大丈夫なのですが、Query というフォルダを作成しそこに格納しました。

query AllTitles {
    allFilms {
        films {
            title
        }
    }
}

続いて、設定(apollo-codegen-config.json)を変更します。

自動生成されるファイルは Generated フォルダに格納されるようにします。

{
  "schemaNamespace" : "SW",
  "input" : {
    "operationSearchPaths" : [
      "**/*.graphql"
    ],
    "schemaSearchPaths" : [
      "./schema.json" // ← 変更:今回はDLしてきたjsonファイルを指定
    ]
  },
  "output" : {
    "testMocks" : {
      "none" : {
      }
    },
    "schemaTypes" : {
      "path" : "./API/Generated", // ← 変更:自動生成されるファイルの置き場
      "moduleType" : {
        "embeddedInTarget" : {
          "name" : "API"
        }
      }
    },
    "operations" : {
      "inSchemaModule" : {
      }
    }
  },
  "schemaDownloadConfiguration": { ... }
}

swiftファイルの生成

generateコマンドを実行することにより、swiftファイルが生成されます

ここまでで一旦下準備は完了です

タイトルのリストを返却するサンプル

ApolloClient を直接利用しても良いのですが、簡易的にラップしたクラスを作成しました

import Apollo
import Foundation

public final class APIClient {
    
    private let apollo: ApolloClient
    
    public init(endpointURL: String) {
        // setup apollo client
        let cache = InMemoryNormalizedCache()
        let store = ApolloStore(cache: cache)
        let client = URLSessionClient()
        let provider = DefaultInterceptorProvider(client: client, store: store)
        let transport = RequestChainNetworkTransport(
            interceptorProvider: provider,
            endpointURL: URL(string: endpointURL)!
        )
        apollo = ApolloClient(networkTransport: transport, store: store)
    }
    
    public func allTitles() async throws -> [String] {
        try await withCheckedThrowingContinuation({ continution in
            apollo.fetch(query: SW.AllTitlesQuery()) { result in
                switch result {
                case .success(let val):
                    let titles = val.data?.allFilms?.films?.compactMap({ film in
                        film?.title
                    })
                    continution.resume(returning: titles ?? [])
                    
                case .failure(let error):
                    continution.resume(throwing: error)
                }
            }
        })
    }
}

これを用いて表示するViewのサンプルです。あらかじめAPIパッケージを利用できるようにXcodeの TARGETS > Frameworks, Libraries... で設定しておきます。

import SwiftUI
import API

struct ContentView: View {
    
    private var client = APIClient(endpointURL: "https://swapi-graphql.netlify.app/.netlify/functions/index")
    @State private var titles: [String] = []
    
    var body: some View {
        List(titles, id: \.self) { title in
            Text(title)
        }
        .onAppear(perform: {
            Task {
                titles = try await client.allTitles()
            }
        })
    }
}

実行結果

まとめ

プロジェクトに応じて設定ファイルをこねくり回す必要がありますが、一回作成してしまえば以降は必要なgraphqlファイルを追加するだけでほぼ作業が完結するので、開発体験は良かったです。

また、レスポンスに対応する型を手で書くと、Optionalの取り扱いなど慎重にならざるを得ないケースが多々ありますが、その問題も解決できるのが一番のメリットかなと思いました。

以上、何かの参考になれば幸いです。