Skip to content

Instantly share code, notes, and snippets.

@ollieatkinson
Created May 22, 2024 20:16
Show Gist options
  • Save ollieatkinson/98e39f0c6a2799d4e06b112237dfd936 to your computer and use it in GitHub Desktop.
Save ollieatkinson/98e39f0c6a2799d4e06b112237dfd936 to your computer and use it in GitHub Desktop.
Decoder for decoding URL into a concrete type
import Foundation
public final class URLDecoder: Decoder {
public var codingPath: [CodingKey] = []
public var userInfo: [CodingUserInfoKey: Any] = [:]
let parseParametersFromURL: (URL) throws -> [String: Any]
public init<Output>(_ regex: Regex<Output>) {
self.parseParametersFromURL = { url in
guard let match = try regex.firstMatch(in: url.absoluteString) else {
throw URLDecodingError.noMatch
}
return AnyRegexOutput(match).reduce(into: [String: Any]()) { sum, next in
if let name = next.name {
sum[name] = next.value
}
}
}
}
public init(_ parseParametersFromURL: @escaping (URL) throws -> [String: Any]) {
self.parseParametersFromURL = parseParametersFromURL
}
public init() {
self.parseParametersFromURL = { _ in [:] }
}
private(set) var dictionary: [String: Any] = [:]
public func decode<T>(_: T.Type = T.self, from url: URL) throws -> T where T: Decodable {
let old = dictionary
dictionary = try url.dictionary()
.merging(url.queryDictionary() as [String: Any], uniquingKeysWith: { $1 })
.merging(parseParametersFromURL(url.sanitized()), uniquingKeysWith: { $1 })
defer { dictionary = old }
return try T(from: self)
}
func convert<T>(_ any: Any, to: T.Type) throws -> Any? {
switch (any, T.self) {
case (let value as T, _):
return value
case (let string as String, is Bool.Type):
switch string.lowercased() {
case "1", "yes", "true":
return true
case "0", "no", "false":
return false
default:
break
}
fallthrough
case (let string as String, is UUID.Type):
return UUID(uuidString: string)
case (let timeInterval as TimeInterval, is Date.Type):
return Date(timeIntervalSince1970: timeInterval)
case (let string as CustomStringConvertible, is String.Type):
return string.description
case (let string as any StringProtocol, _):
switch Witness<T>.self {
case let convertible as URLDecoderLosslessStringConvertible.Type:
return convertible.value(from: String(string))
default:
break
}
fallthrough
default:
switch Witness<T>.self {
case let rawRepresentable as URLDecoderRawRepresentable.Type:
return rawRepresentable.value(from: any, using: self)
default:
return nil
}
}
}
}
enum URLDecodingError: Swift.Error {
case noMatch
case unsupportedContainer(Any.Type)
case unsupportedOperaton(String)
case conversionFailed(String, to: Any.Type)
}
extension URLDecoder {
public func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key: CodingKey {
try KeyedDecodingContainer(KeyedContainer<Key>(decoder: self))
}
}
extension URLDecoder {
public struct KeyedContainer<Key> where Key: CodingKey {
let decoder: URLDecoder
let dictionary: [String: Any]
public var codingPath: [CodingKey] { decoder.codingPath }
public var userInfo: [CodingUserInfoKey: Any] { decoder.userInfo }
public init(decoder: URLDecoder) throws {
self.decoder = decoder
self.dictionary = decoder.dictionary
}
func value(for key: Key) throws -> Any {
guard let value = dictionary[key.stringValue] else {
throw DecodingError.valueNotFound(
String.self,
DecodingError.Context(
codingPath: codingPath,
debugDescription: "No value found for key '\(key.stringValue)'"
)
)
}
return value
}
}
}
extension URLDecoder.KeyedContainer: KeyedDecodingContainerProtocol {
public var allKeys: [Key] {
dictionary.keys.compactMap(Key.init)
}
public func contains(_ key: Key) -> Bool {
dictionary[key.stringValue] != nil
}
public func decodeNil(forKey key: Key) throws -> Bool {
dictionary[key.stringValue] == nil
}
public func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable {
let value = try value(for: key)
decoder.codingPath.append(key)
defer { decoder.codingPath.removeLast() }
guard let result = try decoder.convert(value, to: T.self) as? T else {
throw DecodingError.typeMismatch(
T.self,
DecodingError.Context(
codingPath: codingPath,
debugDescription: "Unable to convert \(value) to \(T.self)"
)
)
}
return result
}
public func decodeIfPresent<T>(_ type: T.Type, forKey key: Key) throws -> T? where T: Decodable {
try? decode(type, forKey: key)
}
}
private protocol URLDecoderRawRepresentable {
static func value(from any: Any, using decoder: URLDecoder) -> Any?
}
private protocol URLDecoderLosslessStringConvertible {
static func value(from string: String) -> Any?
}
private enum Witness<T> {}
extension Witness: URLDecoderRawRepresentable where T: RawRepresentable, T.RawValue: Decodable {
static func value(from any: Any, using decoder: URLDecoder) -> Any? {
guard let value = any as? T.RawValue ?? (try? decoder.convert(any, to: T.RawValue.self) as? T.RawValue) else { return nil }
return T(rawValue: value)
}
}
extension Witness: URLDecoderLosslessStringConvertible where T: LosslessStringConvertible {
static func value(from string: String) -> Any? { T(string) }
}
private extension URL {
func dictionary() -> [String: Any] {
let components = URLComponents(url: self, resolvingAgainstBaseURL: false)
return [
"fragment": components?.fragment as Any,
"host": components?.host as Any,
"password": components?.password as Any,
"path": components?.path as Any,
"port": components?.port as Any,
"query": components?.query as Any,
"scheme": components?.scheme as Any,
"user": components?.user as Any,
]
}
func sanitized() -> URL {
var components = URLComponents(url: self, resolvingAgainstBaseURL: false)!
components.query = nil
return components.url!
}
func queryDictionary() -> [String: String] {
guard let items = URLComponents(url: self, resolvingAgainstBaseURL: false)?.queryItems else {
return [:]
}
return items.reduce(into: [String: String]()) { query, item in
guard let value = item.value else { return }
query[item.name] = value
}
}
}
// Unsupported stuff
extension URLDecoder {
public func unkeyedContainer() throws -> UnkeyedDecodingContainer { throw URLDecodingError.unsupportedContainer(UnkeyedDecodingContainer.self) }
public func singleValueContainer() throws -> SingleValueDecodingContainer { throw URLDecodingError.unsupportedContainer(SingleValueDecodingContainer.self) }
}
extension URLDecoder.KeyedContainer {
public func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey: CodingKey { throw URLDecodingError.unsupportedOperaton(#function) }
public func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { throw URLDecodingError.unsupportedOperaton(#function) }
public func superDecoder() throws -> Decoder { throw URLDecodingError.unsupportedOperaton(#function) }
public func superDecoder(forKey key: Key) throws -> Decoder { throw URLDecodingError.unsupportedOperaton(#function) }
}
@ollieatkinson
Copy link
Author

func test_url_decode_with_query_parameters() throws {
    struct Model: Decodable {
        let id: UUID
        let type: String
        let name: String
        let age: Int?
    }

    let url = "https://example.com/path/to/resource/34?id=E621E1F8-C36C-495A-93FC-0C247A3E6E5F&name=Dorothy"

    let decoder = URLDecoder(#/example.com/(?<type>.+)/(?<age>\d+)/#)
    let model = try decoder.decode(Model.self, from: XCTUnwrap(URL(string: url)))

    XCTAssertEqual(model.id, UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F"))
    XCTAssertEqual(model.type, "path/to/resource")
    XCTAssertEqual(model.name, "Dorothy")
    XCTAssertEqual(model.age, 34)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment