1
1

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 3 years have passed since last update.

Custom URL Schemeをstructにマッピングしたい&どこが悪いかすぐ知りたい

Last updated at Posted at 2020-08-02

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()
    }
}

ValueURLQueryValueCompatibleである場合は簡単に生成できるようにします。

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
}

これは主にわかりやすさのためにつけます。

おわりに

当初この記事におわりにはなかったのですが、自分で読んでも解説が終わっているのか不明だったので付け足しました。
もう少しわかりやすい記事に書き換えたいですが、とりあえず全体のコードを読めばなんとかなると思うので……どこかのタイミングでリニューアルできたらとおもいます。

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?