APNS のペイロードデータを struct にマッピングする


TL;DR

struct APNSPayload: Decodable {
    struct APS: Decodable {
        struct Alert: Decodable {
            let title: String
            let body: String
        }

        let alert: Alert
        let badge: Int?
    }

    struct CustomField: Decodable {
        let hogeList: [String]
        let fuga: String?
    }

    let aps: APS
    let customField: CustomField?

    init(decoding userInfo: [AnyHashable: Any]) throws {
        let json = try JSONSerialization.data(withJSONObject: userInfo, options: [])

        // snake_caseをcamelCaseに変換する場合
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase

        self = try decoder.decode(APNSPayload.self, from: json)
    }
}

// {
//   "aps" : {
//     "alert" : {
//       "title" : "タイトル",
//       "body" : "本文",
//     },
//     "badge": 9,
//   },
//   "custom_field": {
//     "hoge_list": [
//       "piyo"
//     ]
//   }
// }
let payload: APNSPayload? = try? APNSPayload(decoding: userInfo)

モチベーション

APNS のペイロードデータは REST APIのレスポンスと同様に JSON 形式で表現されるのですが、クライアント側での取得インターフェイスが

var userInfo: [AnyHashable : Any] { get }

となっているため、ペイロード中のカスタムデータを取得するとなると、

guard let aps = userInfo["custom_field"] as? NSDictionary,
      let hogeList = aps["hoge_list"] as? [String] else {
    return
}

のような 辛い実装が発生することがあります。

これを、よくある REST API のレスポンスモデルへのマッピングのような形で実装したいというのがモチベーションとなります。

概要

stack overflow のSwift read userInfo of remote notification に対する回答そのままです。

userInfo: [AnyHashable: Any] 自体は JSON フォーマットを表現しているため、

  1. userInfo を JSONObject として扱い、JSONSerialization.data で JSONData に変換
  2. JSONDecoder で JSONData を元に Decodable へのデコードを試みる

という流れで、 Decodable に適合した struct へのマッピングを実現しています。

上述の例ではペイロードのキー名が snake_case で表現されている前提なのですが、実際には APNS ペイロードデータ内で利用されるキー名に応じて、 Decodable に適合する際に enum CodingKeys: String, CodingKey を用意する or プロパティ名の調整が必要となってきます。
(APNS標準のペイロードデータでは、 content-available のように kebab-case が利用されています。)

参考