🐥

[Swift] Codable で特定のプロパティを共通化しつつデコードする方法

2023/10/24に公開

概要

どういうことかと言うと、esa.io のクライアントアプリを作っているときにぶち当たった壁です。

esa.io のAPIで、配列を返すエンドポイントは基本的にページネーションに必要なプロパティが含まれています。
以下のようなレスポンスが返ってきます。

# GET /v1/teams HTTP/1.1

{
    "teams": [
        ...
    ],
    "prev_page": null,
    "next_page": 2,
    "total_count": 30,
    "page": 1,
    "per_page": 20,
    "max_per_page": 100
}

こんな感じで、配列のプロパティ + ページネーションに関するプロパティ群みたいな構成になっています。

他のエンドポイント用のエンティティの宣言で、意味合いも名前も全く同じプロパティの宣言をするのは骨が折れますし冗長ですよね。

今回はこのページネーション部分を共通化する方法を見つけたので、備忘録として書きます。

実装方針

  • Pagination モデルを作成する
  • 配列を返すエンドポイントのエンティティの作成 (本記事では例として Teams)
  • Teams に @dynamicMemberLookup を付与して、本来のレスポンスと同じ使い心地で書けるようにする
  • Teams の Decodableinit(from:) を定義

やっていく

  1. Pagination モデルの作成

    import Foundation
    
    struct Pagination: Codable {
        let previousPage: Int?
        let nextPage: Int?
        let totalCount: Int
        let page: Int
        let perPage: Int
        let maxPerPage: Int
        
        enum CodingKeys: String, CodingKey {
            case previousPage = "prev_page"
            case nextPage
            case totalCount
            case page
            case perPage
            case maxPerPage
        }
    }
    
  2. Team モデルの作成

    • Teams モデルの配列の一要素になるやつです
    import Foundation
    
    struct Team: Codable {
        enum PrivacyScope: String, Codable {
            case open
         case closed
        }
    
        let name: String
        let privacy: Team.PrivacyScope
        let description: String
        let icon: URL
        let url: URL
    }
    
  3. Teams モデルの作成

    • Pagination にぶら下がるプロパティに直接アクセスできるようにする方針のため、pagination プロパティは private にします。
    • が、モックデータを作りやすくするため、一応イニシャライザは用意します
    import Foundation
    
    struct Teams: Codable {
        private let pagination: Pagination
        
        let teams: [Team]
    
        init(teams: [Team], pagination: Pagination) {
            self.teams = teams
            self.pagination = pagination
        }
    }
    
  4. @dynamicMemberLookup を付与して、本来のレスポンスと同じ使い心地で書けるようにする

    import Foundation
    
    @dynamicMemberLookup
    struct Teams: Codable {
        private let pagination: Pagination
        
        let teams: [Team]
    
        init(teams: [Team], pagination: Pagination) {
            self.teams = teams
            self.pagination = pagination
        }
        
        subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T {
            pagination[keyPath: keyPath]
        }
    }
    
  5. init(from:) を定義

    • ここが一番の目玉だと思います
    • Xcode のコードスニペットで let container = try decoder.container(keyedBy: CodingKeys.self) が自動で定義される都合上、これを使いたくなりますが、実は使わないんですよね...。ここが結構詰まりどころでした
    • Pagination.init(from:) を呼び出して pagination プロパティを初期化する形になります
    import Foundation
    
    @dynamicMemberLookup
    struct Teams: Codable {
        private let pagination: Pagination
        
        let teams: [Team]
    
        init(teams: [Team], pagination: Pagination) {
            self.teams = teams
            self.pagination = pagination
        }
    
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
    
            // MARK: container を使わない点がミソ
            self.pagination = try Pagination(from: decoder)
            self.teams = try container.decode([Team].self, forKey: .teams)
        }
            
        subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T {
            pagination[keyPath: keyPath]
        }
    }
    

完成

  • 出来上がったコードが以下です
import Foundation

struct Pagination: Codable {
    let previousPage: Int?
    let nextPage: Int?
    let totalCount: Int
    let page: Int
    let perPage: Int
    let maxPerPage: Int
    
    enum CodingKeys: String, CodingKey {
        case previousPage = "prev_page"
        case nextPage
        case totalCount
        case page
        case perPage
        case maxPerPage
    }
}

struct Team: Codable {
    enum PrivacyScope: String, Codable {
        case open
        case closed
    }
    
    let name: String
    let privacy: Team.PrivacyScope
    let description: String
    let icon: URL
    let url: URL
}

@dynamicMemberLookup
struct Teams: Codable {
    private let pagination: Pagination
    
    let teams: [Team]
    
    subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T {
        pagination[keyPath: keyPath]
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.pagination = try Pagination(from: decoder)
        self.teams = try container.decode([Team].self, forKey: .teams)
    }
    
    init(teams: [Team], pagination: Pagination) {
        self.teams = teams
        self.pagination = pagination
    }
}

Playground で動かしてみる

  • 以下のコードを Xcode Playgrounds に貼り付けてお試しいただけます。
import Foundation

struct Pagination: Codable {
    let previousPage: Int?
    let nextPage: Int?
    let totalCount: Int
    let page: Int
    let perPage: Int
    let maxPerPage: Int
    
    enum CodingKeys: String, CodingKey {
        case previousPage = "prev_page"
        case nextPage
        case totalCount
        case page
        case perPage
        case maxPerPage
    }
}

struct Team: Codable {
    enum PrivacyScope: String, Codable {
        case open
        case closed
    }
    
    let name: String
    let privacy: Team.PrivacyScope
    let description: String
    let icon: URL
    let url: URL
}

@dynamicMemberLookup
struct Teams: Codable {
    private let pagination: Pagination
    
    let teams: [Team]
    
    subscript<T>(dynamicMember keyPath: KeyPath<Pagination, T>) -> T {
        pagination[keyPath: keyPath]
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.pagination = try Pagination(from: decoder)
        self.teams = try container.decode([Team].self, forKey: .teams)
    }
    
    init(teams: [Team], pagination: Pagination) {
        self.teams = teams
        self.pagination = pagination
    }
}

let teamsReponse: Data = """
{
  "teams": [
    {
      "name": "docs",
      "privacy": "open",
      "description": "esa.io official documents",
      "icon": "https://img.esa.io/uploads/production/teams/105/icon/thumb_m_0537ab827c4b0c18b60af6cdd94f239c.png",
      "url": "https://docs.esa.io/"
    }
  ],
  "prev_page": null,
  "next_page": null,
  "total_count": 1,
  "page": 1,
  "per_page": 20,
  "max_per_page": 100
}
""".data(using: .utf8)!

let decoder: JSONDecoder = .init()

decoder.keyDecodingStrategy = .convertFromSnakeCase

let teams: Teams = try decoder.decode(Teams.self, from: teamsReponse)

print(teams)

実行結果がこんな感じ

無事、teams.maxPerPage みたいな書き方ができるようになりました

余談

去年の頭ぐらいに esa クライアントを作ってみたくなって Swift を触り始めたときから、ずっとこのページネーションの共通化のやり方が気になっていたのでようやく解決できて本当に嬉しい限りでした☺️

それまで TypeScript 中心に触っていたのですが、TypeScript だと、

type Team = {
  name: string
  privacy: "open" | "closed"
  description: string
  icon: string
  url: string
}

type Pagination = {
  previousPage?: number
  nextPage?: number
  totalCount: number
  page: number
  perPage: number
  maxPerPage: number
}

type Teams = {
  teams: Team[]
} & Pagination

const teams: Teams = {
    teams: [
      {
        name: "esa"
	...
      }
    ],
    previousPage: undefined,
    ...
}

みたいな感じで、Intersection Type と呼ばれる機能で型の合成ができていたので、Swift で同等のことができないと分かったときは戸惑いました😇
(たしかに Swift にも typealias はありますが、これと全く同じことができるかというとそうじゃないんですよねぇ...)

Discussion