【SwiftUI】Strapi (REST API)を Swift で叩いてみた【GET編】

以前、「Strapi + GraphQL API を各クライアントで利用してみた【iOSアプリ編】【未解決】」という記事を書いたのですが、その時に Swift で GraphQL を使うことを断念してしまいました。

なので、次は REST API で Strapi を利用していこうと思います。

前提

  1. 環境構築はStrapi を開発環境と本番環境のDockerで動かしたい【まとめ版】」の方法
  2. Strapi からのレスポンスの形は「Strapi のレスポンスの階層を浅くして扱いやすくする(v4の形からv3の形に変更)」に変更済み
  3. Strapi に Swagger を導入しておくこと(任意ですがオススメ)Strapi を開発環境と本番環境のDockerで動かしたい【その8】- Swagger の導入

Xcode 14.2 で SwiftUI を用います。(APIを叩くこと自体にはあまり関係ないですが)

マルチプラットフォームでプロジェクトを作成しました。

Strapi側の準備

Strapi側に Content-Type を用意しておきます。

Newsとそれに紐づくTagをそれぞれ用意しました。

News

  • title
  • content
  • tags

Tags

  • displayName
  • slug

で構成しました。

APIClient の雛形を作る

Xcode で作業をします。できるだけ汎用性の高いものになるようにします。

STEP.1
Entity を作る

まずは、News と Tag をそれぞれ Codableで用意しておきます。

News.swift
import Foundation

struct News: Codable {

    let id: Int
    let title: String
    let content: String
    let createdAt: Date
    let tags: [Tag]
}
Tag.swift
import Foundation

struct Tag: Codable {

    let id: Int
    let displayName: String
    let slug: String
}
id や createdAt

Strapi では idcreatedAt等は、特に定義しなくてもすべてのコンテンツに付与されています。

そのほかにupdatedAtpublishedAtもありますが、クライアント側で利用しないので定義していません。

STEP.2
RequestType を定義する

まずは、APIClient に必要なリクエストを汎用的に用意するためにプロトコルを作ります。

RequestType.swift
protocol RequestType {

    associatedtype Model: Codable

    var baseURL: URL { get }
    var httpMethod: String { get }
    var path: String { get }
    var headers: [String: String] { get }
    var body: RequestBody? { get }
    var queryItems: [URLQueryItem] { get }

    func decode(data: Data) throws -> Model
}

RequestBodyは以下のように定義しました。

RequestBody.swift
typealias RequestBody = [String: Any?]
STEP.3
APIClient を作る

先ほどのRequestTypeを使って APIClient を用意します。

APIClient.swift
import Foundation

struct APIClient {

    enum ResponseError: Error {
        case networkFailure
        case missingURL
        case invalidData
        case jsonDataParseError
    }

    enum Response<T: Codable> {
        case success(T)
        case failure(ResponseError)
    }

    func request<T: RequestType>(requestType: T, completion: @escaping ((Response<T.Model>) -> Void)) {
        guard let urlRequest = requestType.urlRequest else { return completion(.failure(.missingURL)) }
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 10
        let session = URLSession(configuration: config)

        let task = session.dataTask(with: urlRequest, completionHandler: { (data, _, error) in
            guard error == nil else {
                session.invalidateAndCancel()
                completion(.failure(.networkFailure))
                return
            }

            guard let data = data else {
                session.invalidateAndCancel()
                completion(.failure(.invalidData))
                return
            }
            do {
                let response = try requestType.decode(data: data)
                completion(.success(response))
            } catch {
                print("ERROR", error)
                completion(.failure(.jsonDataParseError))
            }
            session.finishTasksAndInvalidate()
        })

        task.resume()
    }

}
STEP.4
urlRequest を用意する

STEP.3 の状態ではrequestType.urlRequestの部分でエラーが出ている状態なので、RequestTypeに urlRequest を用意します。

これは extension で書きました。

RequestType.swift
extension RequestType {

    var urlRequest: URLRequest? {
        guard let url = baseURL.appendingPathComponent(path).addQuery(queryItems) else { return nil }
        // Strapi の API Token
        let apiToken = ""
        var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
        request.httpMethod = httpMethod
        request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "authorization")
        request.httpBody = body?.toStrapiHTTPBody()
        headers.forEach { key, value in
            request.addValue(value, forHTTPHeaderField: key)
        }
        return request
    }
}

addQueryという query(/api/tags?sort=id:desc の?以降) を付与するためのextensionを追加します。

URL+URLQueryItem.swift
import Foundation

extension URL {

    func addQuery(_ queryItems: [URLQueryItem]) -> URL? {
        var urlComponents = URLComponents(string: self.absoluteString)
        urlComponents?.queryItems = queryItems
        return urlComponents?.url
    }
}

さらにRequestBodytoStrapiHTTPBody()という関数を追加します。
これは、POST をする時に用いますが、Strapiのデータを { data : 実際のデータ }という形で送信するために利用します。

RequestBody.swift
typealias RequestBody = [String: Any?]

extension RequestBody {

    func toStrapiHTTPBody() -> Data? {
        let data = ["data": self]
        return try? JSONSerialization.data(withJSONObject: data)
    }
}
STEP.5
メタ情報を含む StrapiEntity を作成する

APIのレスポンスの形をStrapi のレスポンスの階層を浅くして扱いやすくする(v4の形からv3の形に変更)に変更しています。
この際に meta 情報が残るように整形しているので、レスポンスは以下の形になっています。

{
  "data": [実際のNewsのデータ]
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 2
    }
  }
}

これをCodableに落とし込みます。

StrapiModel
import Foundation

struct StrapiModel<T: Codable>: Codable {

    let data: T
    // meta が必要ならここに追加する
    let meta: StrapiMeta

    struct StrapiMeta: Codable {

        let pagination: Pagination?

        struct Pagination: Codable {
            let page: Int
            let pageSize: Int
            let pageCount: Int
            let total: Int
        }
    }
}

デコードする時は StrapiModel を通してデコードします。

let strapiModel = try decoder.decode(StrapiModel<[News]>.self, from: data)
let newsList = strapiModel.data

Newsを取得してみる

次に実際に Newsの取得を行って表示させてみます。

STEP.1
RequestType に適合した GetNewsListRequestType を作る
前セクションの STEP.2 で作成したRequestTypeに適合したGetNewsListRequestTypeを作成します。
これは、Newsを配列で取得します。

GetNewsListRequestType.swift
import Foundation

struct GetNewsListRequestType: RequestType {

    typealias Model = [News]

    let baseURL: URL = URL(string: "https://api.cookin.dev/api")!
    let httpMethod: String = "GET"
    let path: String = "/newslist"
    let headers: [String : String] = [:]
    let body: RequestBody? = nil

    var queryItems: [URLQueryItem] {
        return [
            URLQueryItem(name: "sort", value: "id:desc")
        ]
    }

    func decode(data: Data) throws -> [News] {
        let decoder = StrapiJSONDecoder()
        let strapiEntity = try decoder.decode(StrapiModel<[News]>.self, from: data)
        let newsList = strapiEntity.data
        return newsList
    }
}

RequestTypeに適合する形で指定していけばいいので楽ちんです。
URLQueryItem には新しいものから取得するように指定しました。

STEP.2
StrapiJSONDecoder を用意する

日付の変換が少し大変なので、StrapiJSONDecoderを用意しました。

StrapiJSONDecoder.swift
import Foundation

final class StrapiJSONDecoder: JSONDecoder {

    override init() {
        super.init()
        dateDecodingStrategy = .custom({ decoder in
            let container = try decoder.singleValueContainer()
            let dateString = try container.decode(String.self)
            let formatter = ISO8601DateFormatter()
            formatter.formatOptions.insert(.withFractionalSeconds)

            if let date = formatter.date(from: dateString) {
                return date
            }

            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
        })
    }
}

2023-04-28T04:29:49.151Zのように小数点が含まれる場合には、フォーマッターに.withFractionalSecondsを設定してあげなければいけません。

STEP.3
View と ViewModel を作る
これで準備ができたので、実際にAPIを叩いてみます。
Strapi の API Key をセットしておきます。

NewsListView.swift
import SwiftUI

struct NewsListView<ViewModel>: View where ViewModel: NewsListViewModelType {

    @ObservedObject var viewModel: ViewModel

    var body: some View {
        List(viewModel.newsList, id: \.self) { news in
            VStack(alignment: .leading, spacing: 16) {
                Text(news.title)
                    .font(.title)

                Text(news.content)
                    .lineLimit(nil)
            }.padding(16)
        }
    }
}
NewsListViewModelType.swift
import Foundation

protocol NewsListViewModelType: ObservableObject {

    /* input */

    /* output */
    var newsList: [News] { get }
}

final class NewsListViewModel: NewsListViewModelType {

    @Published var newsList: [News] = []

    private let apiClient = APIClient()

    init() {
        fetchNewsList()
    }

    private func fetchNewsList() {
        let requestType = GetNewsListRequestType()
        apiClient.request(requestType: requestType) { [weak self] response in
            DispatchQueue.main.async {
                switch response {
                    case .success(let newsList):
                        self?.newsList = newsList
                    case .failure(let responseError):
                        print("responseError", responseError)
                }
            }
        }
    }

}

ListNewsを表示させるためにIdentifableHashableに適合させておきます。

News.swift
extension News: Identifiable {}
extension News: Hashable {}
Tag.swift
extension Tag: Identifiable {}
extension Tag: Hashable {}

最後に ContentViewNewsListViewをセットします。

ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        NewsListView(viewModel: NewsListViewModel())
    }
}

これで実行するとお知らせが表示されるはずです。

おわりに

ファイル量が多いですが、一度用意してしまえばとても便利です。

全体像はこんな感じです。

これを SwiftPackage 化とかするといいのかもしれないです。気になったので次の記事にてやってみようと思います。

POST/PUT/DELETE 等についても時間があるときに書いていく予定です。