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

前回の記事👉「【SwiftUI】Strapi (REST API) を Swift で叩いてみた【GET編】

前回作った APIClient が SwiftPackage として、より汎用的に使えるものになったら便利な気がしたので試してみようと思います。

Swift Package を作ってみる

Creating Swift Packages – WWDC の Local Packageの作り方を参照に作ります。

STEP.1
Swift Package を新規作成する

まずは Swift Package をターミナルから新規作成します。

# 作業ディレクトリに移動する
$ mkdir -p SwiftPackages/StrapiAPI
$ cd SwiftPackages/StrapiAPI
# Swift Package を新規作成する
$ swift package init
Xcode から Swift Package を生成しない

ローカルのSwift Packageを作成する方法より、

注意:XcodeからSwift Packageを生成すると、Xcodeへの追加がうまくいきません。必ずTerminalから作成してください。

こちらの記事で書かれている通り、実際に作ったローカルパッケージを他のプロジェクトへ追加しようとしたところ、うまく追加ができませんでした。

STEP.2
プロジェクトに追加する

作成したフォルダを 前回の記事「【SwiftUI】Strapi (REST API) を Swift で叩いてみた【GET編】」のプロジェクトに追加します。

STEP.3
パッケージ化したいファイルをドラッグ&ドロップで移動させる

続いて、パッケージ化したいファイルをStrapiAPI/Sources/StrapiAPIの中にドラッグ&ドロップで移動させていきます。

前回RequestTypeとしていたものはStrapiRequestTypeにリネームしました。

現状ではまだ移動したファイルが見つからずビルドができません。

STEP.4
プロジェクトと紐づける

紐づけたいプロジェクトの TARGET > GENERAL > Frameworks, Libraries, and Embedded Content より StrapiAPIを追加します。

STEP.5
外部に公開したいものに public をつける
Pacakge 外で利用したい関数に関して、明示的にpublicをつける必要があります。

公開したいもの全てに public をつけます。

StrapiAPIClient.swift
import Foundation

public struct StrapiAPIClient {

    public init() {}

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

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

    public func request<T: StrapiRequestType>(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()
    }

}

また、initializer も明示的にpublicにしてあげる必要があります。

それぞれのファイルは以下のようになりました。

StrapiJSONDecoder.swift
import Foundation

public final class StrapiJSONDecoder: JSONDecoder {

    public 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)")
        })
    }
}
StrapiModel.swift
import Foundation

public struct StrapiModel<T: Codable>: Codable {

    public let data: T
    public let meta: StrapiMeta
}

public struct StrapiMeta: Codable {

    public let pagination: Pagination?
}

public struct Pagination: Codable {
    public let page: Int
    public let pageSize: Int
    public let pageCount: Int
    public let total: Int
}
StrapiRequestBody.swift
import Foundation

public typealias StrapiRequestBody = [String: Any?]

extension StrapiRequestBody {

    func toStrapiHTTPBody() -> Data? {
        let data = ["data": self]
        return try? JSONSerialization.data(withJSONObject: data)
    }
}
StrapiRequestType.swift
import Foundation

public protocol StrapiRequestType {

    associatedtype Model: Codable

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

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

extension StrapiRequestType {

    var urlRequest: URLRequest? {
        guard let url = baseURL.appendingPathComponent(path).addQuery(queryItems) else { return nil }
        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
    }
}
STEP.6
実際に使ってみる

使う時は import StrapiAPIを書くことで使うことができます。

GetNewsListRequestType.swift
import Foundation
import StrapiAPI

struct GetNewsListRequestType: StrapiRequestType {
    // 略
}
STEP.7
依存している部分を解消する

API Token をパッケージの内部に埋め込んでしまっているので、それを外部に出してやります。

まずは該当箇所の apiToken を設定してる場所を削除します。

StrapiRequestType.swift
extension StrapiRequestType {

    var urlRequest: URLRequest? {
        guard let url = baseURL.appendingPathComponent(path).addQuery(queryItems) else { return nil }
        var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
        request.httpMethod = httpMethod
        request.httpBody = body?.toStrapiHTTPBody()
        headers.forEach { key, value in
            request.addValue(value, forHTTPHeaderField: key)
        }
        return request
    }
}

StrapiRequestType に適合する構造体のほうに設定してあげました。

GetNewsListRequestType.swift
import Foundation
import StrapiAPI

struct GetNewsListRequestType: StrapiRequestType {

    typealias Model = [News]

    let baseURL: URL = URL(string: "https://api.uruly.xyz/api")!
    let httpMethod: String = "GET"
    let path: String = "/newslist"
    let body: StrapiRequestBody? = nil

    // 追加
    var headers: [String: String] {
        return [
            "authorization": "Bearer \(StrapiAPISampleApp.apiToken)"
        ]
    }

    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
    }

}

APIToken はどうやって扱うのが安全なのかがいまいちわかっていません。(info.plist に書くのは危ない、直接書くのも…🤔、いろいろ頑張って外部から注入も結局…?サーバ側におくのが一番安全らしい…が…)

StrapiAPISampleApp
@main
struct StrapiAPISampleApp: App {

    static let apiToken = "Strapi の API Token をいれてね"

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

おわりに

ローカルパッケージとして複数のプロジェクトで使いまわせるようになりました。
パッケージ自体はバージョニングをして公開する予定はありません。

パッケージ化の便利さを体感して終わりにしておきます。