【SwiftUI】Strapi (REST API)を Swift で叩いてみた【GET編】
以前、「Strapi + GraphQL API を各クライアントで利用してみた【iOSアプリ編】【未解決】」という記事を書いたのですが、その時に Swift で GraphQL を使うことを断念してしまいました。
なので、次は REST API で Strapi を利用していこうと思います。
前提
- 環境構築はStrapi を開発環境と本番環境のDockerで動かしたい【まとめ版】」の方法
- Strapi からのレスポンスの形は「Strapi のレスポンスの階層を浅くして扱いやすくする(v4の形からv3の形に変更)」に変更済み
- 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 で作業をします。できるだけ汎用性の高いものになるようにします。
まずは、News と Tag をそれぞれ Codable
で用意しておきます。
import Foundation
struct News: Codable {
let id: Int
let title: String
let content: String
let createdAt: Date
let tags: [Tag]
}
import Foundation
struct Tag: Codable {
let id: Int
let displayName: String
let slug: String
}
Strapi では id
やcreatedAt
等は、特に定義しなくてもすべてのコンテンツに付与されています。
そのほかにupdatedAt
やpublishedAt
もありますが、クライアント側で利用しないので定義していません。
まずは、APIClient に必要なリクエストを汎用的に用意するためにプロトコルを作ります。
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
は以下のように定義しました。
typealias RequestBody = [String: Any?]
先ほどのRequestType
を使って APIClient を用意します。
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.3 の状態ではrequestType.urlRequest
の部分でエラーが出ている状態なので、RequestTypeに urlRequest を用意します。
これは extension で書きました。
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を追加します。
import Foundation
extension URL {
func addQuery(_ queryItems: [URLQueryItem]) -> URL? {
var urlComponents = URLComponents(string: self.absoluteString)
urlComponents?.queryItems = queryItems
return urlComponents?.url
}
}
さらにRequestBody
にtoStrapiHTTPBody()
という関数を追加します。
これは、POST をする時に用いますが、Strapiのデータを { data : 実際のデータ }
という形で送信するために利用します。
typealias RequestBody = [String: Any?]
extension RequestBody {
func toStrapiHTTPBody() -> Data? {
let data = ["data": self]
return try? JSONSerialization.data(withJSONObject: data)
}
}
APIのレスポンスの形をStrapi のレスポンスの階層を浅くして扱いやすくする(v4の形からv3の形に変更)に変更しています。
この際に meta 情報が残るように整形しているので、レスポンスは以下の形になっています。
{
"data": [実際のNewsのデータ]
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 2
}
}
}
これをCodableに落とし込みます。
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
の取得を行って表示させてみます。
RequestType
に適合したGetNewsListRequestType
を作成します。これは、Newsを配列で取得します。
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 には新しいものから取得するように指定しました。
日付の変換が少し大変なので、StrapiJSONDecoder
を用意しました。
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
を設定してあげなければいけません。
Strapi の API Key をセットしておきます。
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)
}
}
}
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)
}
}
}
}
}
List
でNews
を表示させるためにIdentifable
とHashable
に適合させておきます。
extension News: Identifiable {}
extension News: Hashable {}
extension Tag: Identifiable {}
extension Tag: Hashable {}
最後に ContentView
にNewsListView
をセットします。
import SwiftUI
struct ContentView: View {
var body: some View {
NewsListView(viewModel: NewsListViewModel())
}
}
これで実行するとお知らせが表示されるはずです。
おわりに
ファイル量が多いですが、一度用意してしまえばとても便利です。
全体像はこんな感じです。
これを SwiftPackage 化とかするといいのかもしれないです。気になったので次の記事にてやってみようと思います。
POST/PUT/DELETE 等についても時間があるときに書いていく予定です。