1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftのMovable Type DataAPI SDKを今の自分が作り直すとしたら?

Posted at

この記事は?

この記事は Movable Type Advent Calendar 2022 24日目の記事です。

去年はバタバタしている間に枠が埋まっていたので「ああ、エントリーできないなぁ」なんて考えていたのですが、今年はえいやっと登録してみました。さてネタがないぞ、、、

ネタが無い場合は今やっている仕事から持ってくれば良いというのが通例(?)なので、最近やっている仕事の事を(可能な範囲で)お話ししつつ、あとはどこまで出来るかという時間との勝負といった感じで進めて行きたいと思います(毎年だなw)。

と書いてみた物の、実はこの書き出しは 2年前の自分の投稿 のオマージュになっております(誰得情報?)

簡単に自己紹介

  • 元Six Apart社員
  • TypePad(現Lekumo)チームで開発 ⇒ Movable Typeチームでドキュメント(プラグイン開発ガイド、MTMLガイド、等) ⇒ 退社してからはMTプラグイン構築 ⇒ 現在はMTからは遠ざかってます
  • 今はSwift中心に、PHP(Laravel)やらJavaScript(node.js)やらFlutterやら雑食系エンジニアやってます
  • 一昨年から2年ぶりにMovable Type Advent Calendarに参加

今やっている事、Swiftについて

仕事は今、某企業さんのiOSアプリの開発をSwiftを使ってやっています。一昨年の時点ではFlutterを使ってましたが、先々月からSwift使いになりました。まだまだ勉強することが多く、今回のAdvent Calendarも本来ならSwiftについて語れる程の知識を持ち合わせていないのですが、そこはいつもの強心臓で乗り切りたいと思います。
Flutterは1コードでiOS(iPadOS), Android, macOS, Windows, Linux, Webといったマルチユースができるのがウリのプラットフォームです。SwiftもApple製品に限られていますが、iOS, iPadOS, macOS, TV OS(Apple TV)のマルチユース(と言うのだろうか??)が出来るようになっています。ただSwiftは言語としてはWindowsやLinuxでも利用出来るので、左記はすこし語弊があります。ここではUIを含めた統合開発環境であるXcodeが利用でき、その上でiOS, iPadOS, macOS, TV OSの実行ファイルが作成出来る事を言っていると読み替えてください。
他のマルチプラットフォームに対応している物と言えば、先日Beta版になったKMM(Kotlin Multiplatform Mobile)があります。これはiOS(iPadOS)とAndroidのコアの部分を共通化し、UIの部分はそれぞれSwiftとKotlinで実装するという、Flutterとはちょっと違う方向の実装になっています。と、この話をし始めるとQiitaの投稿1つ分では収まらないので、Swiftに集中したいと思います。
SwiftはAppleがメインと位置づけている開発言語で、開発が始められたのは2010年。2014年のApple WWDCで一般に発表され、ユーザーが使い始めてから8年が経ちました(詳しくは Swift(プログラミング言語) by Wikipedia を読んでください)。それ以前に開発に利用されていたObjective-Cを自分も一時期使っていましたが、大変とっつきにくい印象がありSwiftの言語仕様に触れて「すんなり入ってくる!!」と思った物です。ただこれはあくまでSwiftの言語仕様についてであって、開発に用いられるStoryboardやSwiftUIの実装方法についてではありません。全く歯が立たない状態で日々苦労しています(苦笑)

どんな物を作るか?

Swift版のDataAPI SDKはSix Apart謹製の物が存在しています。しかしバージョンが古く、今のiOSにはそのままでは利用できなさそうです。試しに今のXcodeでSDKのソースコードを開いたところ、Swiftのエラーが出てしまってビルド出来ませんでした。自分のSwift力が足りないだけかも知れませんが、ここは作り直すくらいの勢いで行ってみたいと思います。
MovableType.netのMTDataAPIからのデータ取得の基本は 私が先日書いたQiita記事 を参照してみてください。

MTDataAPIのSwift用SDKは こちら

開発環境

  • MacBook Pro 2019 : macOS Monterey 12.6.1
  • Xcode 14.1
  • AppCode 2022.2.4

OS

Monterey使ってますが、早いところVenturaにしたいところ。

IDE

Swiftでの開発は基本的にXcodeを使うのですが、私はIntelliJ IDEA系のIDEが染みついてしまっているので、部分的にAppCodeも利用しています。(しかし先日AppCodeの開発終了がアナウンスされました。残念、、、)

セットアップ

早速プロジェクトを作製します。今回はSwift Packageとして作成するので、選択しNextをクリックします。ライブラリの保存場所を聞かれるので、今回はMTDataAPI-SDK-Nextとしました。

image.png

どんな基準で作成するか?

コードの作成を始める前に、どんな基準で今回のパッケージを作成するかを決めたいと思います。

  • Swift Package Mangerでパッケージ管理をする(Good bye, CocoaPods!!)
  • iOS 13, macOS 10.15 以降に対応
  • AlamofireやSwiftyJSONといった外部パッケージは利用しない
  • クロージャを使ったsuccess, failureを利用せず、CombineでAnyPublisher<T, Error>を用いる
  • JSONのデコード、エンコードは標準のCodableを利用する
  • Unitテストを書こう

以下でもうちょっと詳細に説明します。

Swift Package Mangerでパッケージ管理をする(Good bye, CocoaPods!!)

古くから使われていて、自分もFlutterで良くお世話になっているCocoaPods。これはパッケージ管理をするツールなのですが、AppleがSwift Package Mangerという謹製ツールを出してくれているので、そろそろ乗り換えても良いかなと思っています。共存もできるので、今後必要になれば追加すれば良いかなと考えています。

iOS 13, macOS 10.15 以降に対応

古いバージョンのiOS, macOSを切って、新しめの機能を使って実装していきたいと思います。今回は後述するCombineやCodableがそれに当たります。

AlamofireやSwiftyJSONといった外部パッケージは利用しない

HTTP通信を司るAlamofireと、JSONのエンコード・デコードを司るSwiftyJSONに現行のSDKは依存しています。もちろん利用しても良いのですが、Apple謹製で利用出来る物があればそちらを利用していきたいと思います。HTTP通信はURLSessionを、JSONのエンコード・デコードにはCodableというプロトコルを利用します。

クロージャを使ったsuccess, failureを利用せず、CombineでAnyPublisher<T, Error>を用いる

クロージャを使った非同期処理はどうしても記述が複雑になってしまいがちです。ここはApple純正のCombineを使った方法で書き換えたいと思います。

JSONのデコード、エンコードは標準のCodableを利用する

Swift標準のデコード、エンコードの書き方にCodableというプロトコル(Swift以外の言語で言うところのインターフェース)を利用します。Codableプロトコルに準拠した構造体は自動(もしくは半自動)でJSONとのエンコード、デコードを行えるようになります。

Unitテストを書こう

テストファーストで実装していきたいので、テストを積極的に書いていきたいと思います。

いざ実装

テストコード

まずはテストコードです。最初からこれを全部書いていた訳ではありませんが、だいたいこんな感じというイメージはありました。その最終形がこちらです。エンドポイントバージョンやAPI Base URLなどの動作は現行のDataAPI SDKに準拠した動作にしています。

import XCTest
import Combine
@testable import MTDataAPI_SDK_Next

final class DataAPITests: XCTestCase {
    
    var api: DataAPI = DataAPI()
    var cancellable = Set<AnyCancellable>()
    
    // デフォルトのエンドポイントバージョンのチェック
    func testShouldGetValidEndpointVersion() throws {
        XCTAssertEqual(api.endpointVersion, "v3")
    }
    
    // デフォルトのAPI Base URLのチェック
    func testShouldGetValidApiBaseURL() throws {
        XCTAssertEqual(api.apiBaseURL, "http://localhost/cgi-bin/MT-6.1/mt-data-api.cgi")
    }
    
    // エンドポイントバージョンの書き換えのチェック
    func testCanSetEndpointVersion() throws {
        api.endpointVersion = "v4"
        XCTAssertEqual(api.endpointVersion, "v4")
    }
    
    // API Base URLの書き換えのチェック
    func testCanSetApiBaseURL() throws {
        api.apiBaseURL = "https://movabletype.net/.data-api"
        XCTAssertEqual(api.apiBaseURL, "https://movabletype.net/.data-api")
    }
    
    // 正常にVersionがデコードできるかのチェック
    func testCanDecodeVersion() throws {
        // 元々のJSON文字列
        let json = """
                   {"endpointVersion":"v4","apiVersion":4}
                   """
        // JSON文字列から作製したDataオブジェクトインスタンス
        let jsonData = json.data(using: .utf8)!
        
        // デコード実装
        let version = try JSONDecoder().decode(DataAPI.Version.self, from: jsonData)
        
        // 値チェック
        XCTAssertEqual(version, DataAPI.Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4)))
    }
    
    // 正常にVersionがエンコードできるかのチェック
    func testCanEncodeVersion() throws {
        // Versionオブジェクトインスタンス
        let version = DataAPI.Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4))
        // エンコード実装
        let jsonData = try JSONEncoder().encode(version)
        
        // Dataオブジェクトインスタンスから文字列へ変換
        let json = String(data: jsonData, encoding: .utf8)!
        
        // 値チェック(ここではjson文字列のなかに、特定の文字列が含まれているかをチェックしている)
        XCTAssert(json.contains("\"apiVersion\":4"))
        XCTAssert(json.contains("\"endpointVersion\":\"v4\""))
    }
    
    // API経由で正しいVersionインスタンスが取得出来るかのテスト
    func testShouldGetValidVersionInstance() async throws {
        let expectation = expectation(description: "testShouldGetValidVersionInstance")
        
        StubURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/2", headerFields: ["Content-Type": "application/json"])!
            let json = """
                       {"endpointVersion":"v4","apiVersion":4}
                       """
            let jsonData = json.data(using: .utf8)
            return (response, jsonData)
        }
        
        let config = URLSessionConfiguration.default
        config.protocolClasses = [StubURLProtocol.self]
        let session = URLSession(configuration: config)
        
        var apiClient = DataAPI(session: session)
        apiClient.apiBaseURL = "https://www.example.com/.data-api"
        
        apiClient.version()
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let e):
                    print(e.localizedDescription)
                    XCTFail()
                }
            }, receiveValue: { version in
                // 値チェック
                XCTAssertEqual(version, DataAPI.Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4)))
                expectation.fulfill()
            })
            .store(in: &cancellable)
        wait(for: [expectation], timeout: 10)
    }
}

これに対して実装は以下のようになります。EntityとValueObjectをプロトコルにしてしまったのが、自分的にはちょっといけてないのですが、一旦はこちらで。

import Foundation
import Combine

public struct DataAPI {
    
    // MARK: - Properties
    
    let session: URLSession
    
    public var endpointVersion = "v3"
    public var apiBaseURL = "http://localhost/cgi-bin/MT-6.1/mt-data-api.cgi"
    fileprivate var apiURL: String {
        "\(apiBaseURL)/\(endpointVersion)"
    }
    
    public init(session: URLSession = URLSession(configuration: .ephemeral)) {
        self.session = session
    }
    
}

// MARK: - Enums

extension DataAPI {
    
    enum HTTPMethod: String {
        case get = "GET"
        case post = "POST"
        case put = "PUT"
        case delete = "DELETE"
    }
    
}

// MARK: - Entities

fileprivate protocol Entity: Codable, Equatable {
}

extension DataAPI {
    
    public struct Version: Entity {
        public let endpointVersion: DataAPI.EndpointVersion
        public let apiVersion: APIVersion
    }
    
}

// MARK: - ValueObjects

fileprivate protocol ValueObject: Codable, Equatable, RawRepresentable {
    associatedtype AssociatedType
    
    var rawValue: AssociatedType { get }
    
    init(rawValue: AssociatedType)
}

fileprivate extension ValueObject where Self.AssociatedType: CustomStringConvertible {
    var description: String {
        return rawValue.description
    }
}

extension DataAPI {
    
    public struct EndpointVersion: ValueObject {
        public let rawValue: String

        public init(rawValue: String) {
            self.rawValue = rawValue
        }
    }

    public struct APIVersion: ValueObject {
        public let rawValue: Float

        public init(rawValue: Float) {
            self.rawValue = rawValue
        }
    }
    
}

// MARK: - Methods

extension DataAPI {
    
    fileprivate func actionCommon<T: Decodable>(_ method: HTTPMethod, url: URL) -> AnyPublisher<T, Error> {
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        
        return session.dataTaskPublisher(for: request)
            .map(\.data)
            .decode(type: T.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
    
    fileprivate func get<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
        return actionCommon(.get, url: url)
    }
    
}

// MARK: - API Methods

extension DataAPI {
    
    // MARK: - version
    public func version() -> AnyPublisher<Version, Error> {
        guard let url = URL(string: apiBaseURL + "/version") else {
            return Fail(error: NSError(domain: "Invalid version API URL", code: -1)).eraseToAnyPublisher()
        }
        
        return get(url)
    }
    
}

さて、これで終わり。ではつまらないので、簡単なサンプルコードを書いて実際の動きを見てみたいと思います。コマンドラインツールのプロジェクトを作製し、main.swiftをversion.swiftとリネームした上で以下のようなコードを記述します。

version.swift
import Foundation
import Combine
import MTDataAPI_SDK_Next

@main
public struct FetchVersion {
    public static func main() {
        var cancellable = Set<AnyCancellable>()
        
        var apiClient = DataAPI()
        apiClient.apiBaseURL = "https://movabletype.net/.data-api"
        
        let dispatchGroup = DispatchGroup()
        dispatchGroup.enter()
        
        apiClient.version()
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("finished!!")
                    dispatchGroup.leave()
                    break
                case .failure(let e):
                    print(e.localizedDescription)
                    dispatchGroup.leave()
                    break
                }
            }, receiveValue: { version in
                print("EndpointVersion : \(version.endpointVersion.rawValue)")
                print("ApiVersion      : \(version.apiVersion.rawValue)")
            })
            .store(in: &cancellable)
        
        dispatchGroup.wait()
    }
}

こちらを実行すると以下のような出力結果が得られます。

image.png

試しに、 https://movabletype.net/.data-api/version にアクセスしてみます。

{"endpointVersion":"v4","apiVersion":4}

API経由でendpointVersionとapiVersionが取得出来ている事が分かります。JSONを見ると、apiVersionは整数のように見えますが、DataAPIのリファレンスを見ると、実数(Float)になっていたのでこれが正解のようです。

最後に

今回もバージョン番号を取得するところまでしか実装できませんでした。時間を作って完走したいところですが、年末年始に少しずつ進めたいと思います。今回の成果物は以下のGitHubリポジトリで公開しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?