【SwiftUI】Strapi (REST API)を Swift で叩いてみた【Swift Package 化】
前回の記事👉「【SwiftUI】Strapi (REST API) を Swift で叩いてみた【GET編】」
前回作った APIClient が SwiftPackage として、より汎用的に使えるものになったら便利な気がしたので試してみようと思います。
Swift Package を作ってみる
Creating Swift Packages – WWDC の Local Packageの作り方を参照に作ります。
まずは Swift Package をターミナルから新規作成します。
# 作業ディレクトリに移動する
$ mkdir -p SwiftPackages/StrapiAPI
$ cd SwiftPackages/StrapiAPI
# Swift Package を新規作成する
$ swift package init
注意:XcodeからSwift Packageを生成すると、Xcodeへの追加がうまくいきません。必ずTerminalから作成してください。
こちらの記事で書かれている通り、実際に作ったローカルパッケージを他のプロジェクトへ追加しようとしたところ、うまく追加ができませんでした。
続いて、パッケージ化したいファイルをStrapiAPI/Sources/StrapiAPI
の中にドラッグ&ドロップで移動させていきます。
前回RequestType
としていたものはStrapiRequestType
にリネームしました。
現状ではまだ移動したファイルが見つからずビルドができません。
紐づけたいプロジェクトの TARGET > GENERAL > Frameworks, Libraries, and Embedded Content より StrapiAPI
を追加します。
public
をつける必要があります。
公開したいもの全てに public をつけます。
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
にしてあげる必要があります。
それぞれのファイルは以下のようになりました。
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)")
})
}
}
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
}
import Foundation
public typealias StrapiRequestBody = [String: Any?]
extension StrapiRequestBody {
func toStrapiHTTPBody() -> Data? {
let data = ["data": self]
return try? JSONSerialization.data(withJSONObject: data)
}
}
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
}
}
使う時は import StrapiAPI
を書くことで使うことができます。
import Foundation
import StrapiAPI
struct GetNewsListRequestType: StrapiRequestType {
// 略
}
API Token をパッケージの内部に埋め込んでしまっているので、それを外部に出してやります。
まずは該当箇所の apiToken を設定してる場所を削除します。
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 に適合する構造体のほうに設定してあげました。
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 に書くのは危ない、直接書くのも…🤔、いろいろ頑張って外部から注入も結局…?サーバ側におくのが一番安全らしい…が…)
@main
struct StrapiAPISampleApp: App {
static let apiToken = "Strapi の API Token をいれてね"
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
おわりに
ローカルパッケージとして複数のプロジェクトで使いまわせるようになりました。
パッケージ自体はバージョニングをして公開する予定はありません。
パッケージ化の便利さを体感して終わりにしておきます。