URLをstructにマッピングしたい
Custom URL SchemeをURLで受け取った場合、そのままでは使いにくいため、structなどにマッピングしたくなります。そのときの以下の要望を叶える部品が欲しくなったので作ってみました。
- 簡単かつできるだけType Safeにマッピングしたい
- URLに問題がありマッピングに失敗した場合、その原因を知りたい
部品を使ったマッピングと生成の例
myscheme://host/openArticle?title=title-string&url=https://google.com/&mode=0
を以下にマッピングする場合を考えます。
struct Article {
var title: String
var url: URL
}
enum Mode: Int {
case safari = 0
case webView = 1
}
struct OpenArticle {
var article: Article
var mode: Mode
}
以下のようにURLQueryKeys
を用意しURLComponentsCompatible
に対応させて…
extension URLQueryKeys {
static let title: URLQueryKey<String> = .init("title")
static let url: URLQueryKey<URL> = .init("url")
static let mode: URLQueryKey<Mode> = .init("mode")
}
struct Article: URLComponentsCompatible {
var title: String
var url: URL
init(urlComponents: URLComponents) throws {
title = try urlComponents.queryValue(key: .title)
url = try urlComponents.queryValue(key: .url)
}
}
enum Mode: Int, Codable, URLQueryValueCompatible {
case safari = 0
case webView = 1
}
struct OpenArticle: URLComponentsCompatible {
var article: Article
var mode: Mode
init(urlComponents: URLComponents) throws {
guard urlComponents.path == "/openArticle" else {
throw URLComponentsCompatibleError.incompatible(description: nil)
}
article = try Article(urlComponents: urlComponents)
mode = try urlComponents.queryValue(key: .mode)
}
}
実際に生成するときは以下のように呼び出します。
// urlComponentsは
// myscheme://host/openArticle?title=title-string&url=https://google.com/&mode=0
do {
try OpenArticle(urlComponents: urlComponents)
} catch {
print(error)
}
例えばurl
が存在しないURLだった場合
// urlComponentsは
// myscheme://host/openArticle?title=title-string&mode=0
do {
try OpenArticle(urlComponents: urlComponents)
} catch {
print(error) // => queryItemNotFound(name: "url")
}
queryItemNotFound(name: "url")
が投げられてurlが足りないことがすぐにわかります。
JSONをDecodableでstructにマッピングするくらい簡単にできると一番嬉しいのですが、これでも十分に役に立つかなと思います。
上記例では一部機能しか使っていませんが、以下の処理に対応しています。
- queryの型として
Int
Double
Float
Bool
String
Codable
Optional
に対応 - queryの型を追加可能
-
URLQueryKey
に(String?) throws -> Value
を渡し独自の変換処理が可能(100以下のInt
など) - queryが存在しない場合エラーを投げるか投げないか選択可能
- queryの文字列のフォーマットが異なる場合エラーを投げる
マッピングのための部品のコード
省略なしのコードはCustom URL Schemeをstructにマッピングする。 · GitHubを参照してください。
URLQueryItemのvalueを特定の型に変換する
URLQueryItemのvalueは String?
型です。まず String?
を各種型に変換します。
// URLQueryValue
public enum URLQueryValueCompatibleError: Error {
case none // nil
case empty // isEmpty
case format // 期待した書式ではない
}
public protocol URLQueryValueCompatible {
init(urlQueryValue: String?) throws
}
extension Int: URLQueryValueCompatible {
public init(urlQueryValue: String?) throws {
guard let urlQueryValue = urlQueryValue else { throw URLQueryValueCompatibleError.none }
guard !urlQueryValue.isEmpty else { throw URLQueryValueCompatibleError.empty }
guard let int = Int(urlQueryValue) else { throw URLQueryValueCompatibleError.format }
self = int
}
}
extension Optional: URLQueryValueCompatible where Wrapped: URLQueryValueCompatible {
public init(urlQueryValue: String?) throws {
do {
self = try Wrapped(urlQueryValue: urlQueryValue)
} catch let error as URLQueryValueCompatibleError {
switch error {
case .none, .empty:
self = .none
case .format:
throw error
}
}
}
}
extension URLQueryValueCompatible where Self: Codable {
public init(urlQueryValue: String?) throws {
guard let urlQueryValue = urlQueryValue else { throw URLQueryValueCompatibleError.none }
guard !urlQueryValue.isEmpty else { throw URLQueryValueCompatibleError.empty }
guard let urlQueryData = urlQueryValue.data(using: .utf8) else { throw URLQueryValueCompatibleError.format }
self = try JSONDecoder().decode(Self.self, from: urlQueryData)
}
}
独自の型にURLQueryValueCompatible
を実装することもできます。
URLQueryItemのnameと型を紐付ける
URLQueryItemのvar name: String
と実際に使用したい型を紐付けるURLQueryKeys
を用意します。
// URLQueryKey
public class URLQueryKeys {
public init() {}
}
public class URLQueryKey<Value>: URLQueryKeys {
public typealias Converter = (String?) throws -> Value
public var name: String
public var converter: Converter
public init(_ name: String, converter: @escaping Converter) {
self.name = name
self.converter = converter
super.init()
}
}
Value
がURLQueryValueCompatible
である場合は簡単に生成できるようにします。
extension URLQueryKey where Value: URLQueryValueCompatible {
public convenience init(_ name: String) {
self.init(name, converter: { source in try Value(urlQueryValue: source) })
}
}
queryの値を取り出す
おそらく一番面倒なのが[URLQueryItem]
から値を取り出す部分です。
これをできるだけ簡単に取り出せるようにしましょう。
// URLComponents
public enum URLComponentsCompatibleError: Error {
case notURL
case incompatible(description: String?)
case queryItemNotFound(name: String)
case queryValue(name: String, error: Error)
}
extension URLComponents {
public func queryValue<Value>(key: URLQueryKey<Value>) throws -> Value {
if let queryItem = queryItems?.first(where: { $0.name == key.name }) {
do {
return try key.converter(queryItem.value)
} catch {
throw URLComponentsCompatibleError.queryValue(name: key.name, error: error)
}
} else {
throw URLComponentsCompatibleError.queryItemNotFound(name: key.name)
}
}
public func queryValue<Value>(ifContainsKey key: URLQueryKey<Value>) throws -> Value? {
do {
return try queryValue(key: key)
} catch URLComponentsCompatibleError.queryItemNotFound(_) {
return nil
} catch {
throw error
}
}
public func queryValue<Value>(ifContainsKey key: URLQueryKey<Value?>) throws -> Value? {
do {
return try queryValue(key: key)
} catch URLComponentsCompatibleError.queryItemNotFound(_) {
return nil
} catch {
throw error
}
}
}
QueryItemが必ず必要なkey
版とと、QueryItemがなくてもいいifContainsKey
版があります。
またOptionalが二重にならないようにする対策で ifContainsKey
は2種類メソッドがあります。
URLComponentsからマッピングできる場合の目印
// URLComponentsCompatible
public protocol URLComponentsCompatible {
init(urlComponents: URLComponents) throws
}
これは主にわかりやすさのためにつけます。
おわりに
当初この記事におわりにはなかったのですが、自分で読んでも解説が終わっているのか不明だったので付け足しました。
もう少しわかりやすい記事に書き換えたいですが、とりあえず全体のコードを読めばなんとかなると思うので……どこかのタイミングでリニューアルできたらとおもいます。