mirror of
https://github.com/whoeevee/EeveeSpotifyReborn.git
synced 2026-01-08 23:23:20 +00:00
406 lines
17 KiB
Swift
406 lines
17 KiB
Swift
// Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors
|
|
//
|
|
// This software is released under the MIT License.
|
|
// https://opensource.org/licenses/MIT
|
|
//
|
|
// Created by Shawn Moore on 11/20/17.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// MARK: - XML Decoder
|
|
|
|
/// `XMLDecoder` facilitates the decoding of XML into semantic `Decodable` types.
|
|
open class XMLDecoder {
|
|
// MARK: Options
|
|
|
|
/// The strategy to use for decoding `Date` values.
|
|
public enum DateDecodingStrategy {
|
|
/// Defer to `Date` for decoding. This is the default strategy.
|
|
case deferredToDate
|
|
|
|
/// Decode the `Date` as a UNIX timestamp from a XML number. This is the default strategy.
|
|
case secondsSince1970
|
|
|
|
/// Decode the `Date` as UNIX millisecond timestamp from a XML number.
|
|
case millisecondsSince1970
|
|
|
|
/// Decode the `Date` as an ISO-8601-formatted string (in RFC 3339 format).
|
|
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
|
|
case iso8601
|
|
|
|
/// Decode the `Date` as a string parsed by the given formatter.
|
|
case formatted(DateFormatter)
|
|
|
|
/// Decode the `Date` as a custom box decoded by the given closure.
|
|
case custom((_ decoder: Decoder) throws -> Date)
|
|
|
|
/// Decode the `Date` as a string parsed by the given formatter for the give key.
|
|
static func keyFormatted(
|
|
_ formatterForKey: @escaping (CodingKey) throws -> DateFormatter?
|
|
) -> XMLDecoder.DateDecodingStrategy {
|
|
return .custom { decoder -> Date in
|
|
guard let codingKey = decoder.codingPath.last else {
|
|
throw DecodingError.dataCorrupted(DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "No Coding Path Found"
|
|
))
|
|
}
|
|
|
|
guard let container = try? decoder.singleValueContainer(),
|
|
let text = try? container.decode(String.self)
|
|
else {
|
|
throw DecodingError.dataCorrupted(DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Could not decode date text"
|
|
))
|
|
}
|
|
|
|
guard let dateFormatter = try formatterForKey(codingKey) else {
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container,
|
|
debugDescription: "No date formatter for date text"
|
|
)
|
|
}
|
|
|
|
if let date = dateFormatter.date(from: text) {
|
|
return date
|
|
} else {
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container,
|
|
debugDescription: "Cannot decode date string \(text)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The strategy to use for decoding `Data` values.
|
|
public enum DataDecodingStrategy {
|
|
/// Defer to `Data` for decoding.
|
|
case deferredToData
|
|
|
|
/// Decode the `Data` from a Base64-encoded string. This is the default strategy.
|
|
case base64
|
|
|
|
/// Decode the `Data` as a custom box decoded by the given closure.
|
|
case custom((_ decoder: Decoder) throws -> Data)
|
|
|
|
/// Decode the `Data` as a custom box by the given closure for the give key.
|
|
static func keyFormatted(
|
|
_ formatterForKey: @escaping (CodingKey) throws -> Data?
|
|
) -> XMLDecoder.DataDecodingStrategy {
|
|
return .custom { decoder -> Data in
|
|
guard let codingKey = decoder.codingPath.last else {
|
|
throw DecodingError.dataCorrupted(DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "No Coding Path Found"
|
|
))
|
|
}
|
|
|
|
guard let container = try? decoder.singleValueContainer(),
|
|
let text = try? container.decode(String.self)
|
|
else {
|
|
throw DecodingError.dataCorrupted(DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Could not decode date text"
|
|
))
|
|
}
|
|
|
|
guard let data = try formatterForKey(codingKey) else {
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container,
|
|
debugDescription: "Cannot decode data string \(text)"
|
|
)
|
|
}
|
|
|
|
return data
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The strategy to use for non-XML-conforming floating-point values (IEEE 754 infinity and NaN).
|
|
public enum NonConformingFloatDecodingStrategy {
|
|
/// Throw upon encountering non-conforming values. This is the default strategy.
|
|
case `throw`
|
|
|
|
/// Decode the values from the given representation strings.
|
|
case convertFromString(positiveInfinity: String, negativeInfinity: String, nan: String)
|
|
}
|
|
|
|
/// The strategy to use for automatically changing the box of keys before decoding.
|
|
public enum KeyDecodingStrategy {
|
|
/// Use the keys specified by each type. This is the default strategy.
|
|
case useDefaultKeys
|
|
|
|
/// Convert from "snake_case_keys" to "camelCaseKeys" before attempting
|
|
/// to match a key with the one specified by each type.
|
|
///
|
|
/// The conversion to upper case uses `Locale.system`, also known as
|
|
/// the ICU "root" locale. This means the result is consistent
|
|
/// regardless of the current user's locale and language preferences.
|
|
///
|
|
/// Converting from snake case to camel case:
|
|
/// 1. Capitalizes the word starting after each `_`
|
|
/// 2. Removes all `_`
|
|
/// 3. Preserves starting and ending `_` (as these are often used to indicate private variables or other metadata).
|
|
/// For example, `one_two_three` becomes `oneTwoThree`. `_one_two_three_` becomes `_oneTwoThree_`.
|
|
///
|
|
/// - Note: Using a key decoding strategy has a nominal performance cost, as each string key has to be inspected for the `_` character.
|
|
case convertFromSnakeCase
|
|
|
|
/// Convert from "kebab-case" to "kebabCase" before attempting
|
|
/// to match a key with the one specified by each type.
|
|
case convertFromKebabCase
|
|
|
|
/// Convert from "CodingKey" to "codingKey"
|
|
case convertFromCapitalized
|
|
|
|
/// Convert from "CODING_KEY" to "codingKey"
|
|
case convertFromUppercase
|
|
|
|
/// Provide a custom conversion from the key in the encoded XML to the
|
|
/// keys specified by the decoded types.
|
|
/// The full path to the current decoding position is provided for
|
|
/// context (in case you need to locate this key within the payload).
|
|
/// The returned key is used in place of the last component in the
|
|
/// coding path before decoding.
|
|
/// If the result of the conversion is a duplicate key, then only one
|
|
/// box will be present in the container for the type to decode from.
|
|
case custom((_ codingPath: [CodingKey]) -> CodingKey)
|
|
|
|
static func _convertFromCapitalized(_ stringKey: String) -> String {
|
|
guard !stringKey.isEmpty else {
|
|
return stringKey
|
|
}
|
|
let firstLetter = stringKey.prefix(1).lowercased()
|
|
let result = firstLetter + stringKey.dropFirst()
|
|
return result
|
|
}
|
|
|
|
static func _convertFromUppercase(_ stringKey: String) -> String {
|
|
_convert(stringKey.lowercased(), usingSeparator: "_")
|
|
}
|
|
|
|
static func _convertFromSnakeCase(_ stringKey: String) -> String {
|
|
return _convert(stringKey, usingSeparator: "_")
|
|
}
|
|
|
|
static func _convertFromKebabCase(_ stringKey: String) -> String {
|
|
return _convert(stringKey, usingSeparator: "-")
|
|
}
|
|
|
|
static func _convert(_ stringKey: String, usingSeparator separator: Character) -> String {
|
|
guard !stringKey.isEmpty else {
|
|
return stringKey
|
|
}
|
|
|
|
// Find the first non-separator character
|
|
guard let firstNonSeparator = stringKey.firstIndex(where: { $0 != separator }) else {
|
|
// Reached the end without finding a separator character
|
|
return stringKey
|
|
}
|
|
|
|
// Find the last non-separator character
|
|
var lastNonSeparator = stringKey.index(before: stringKey.endIndex)
|
|
while lastNonSeparator > firstNonSeparator, stringKey[lastNonSeparator] == separator {
|
|
stringKey.formIndex(before: &lastNonSeparator)
|
|
}
|
|
|
|
let keyRange = firstNonSeparator...lastNonSeparator
|
|
let leadingSeparatorRange = stringKey.startIndex..<firstNonSeparator
|
|
let trailingSeparatorRange = stringKey.index(after: lastNonSeparator)..<stringKey.endIndex
|
|
|
|
let components = stringKey[keyRange].split(separator: separator)
|
|
let joinedString: String
|
|
if components.count == 1 {
|
|
// No separators in key, leave the word as is - maybe it is already good
|
|
joinedString = String(stringKey[keyRange])
|
|
} else {
|
|
joinedString = ([components[0].lowercased()] + components[1...].map { $0.capitalized }).joined()
|
|
}
|
|
|
|
// Do a cheap isEmpty check before creating and appending potentially empty strings
|
|
let result: String
|
|
if leadingSeparatorRange.isEmpty, trailingSeparatorRange.isEmpty {
|
|
result = joinedString
|
|
} else if !leadingSeparatorRange.isEmpty, !trailingSeparatorRange.isEmpty {
|
|
// Both leading and trailing underscores
|
|
result = String(stringKey[leadingSeparatorRange]) + joinedString + String(stringKey[trailingSeparatorRange])
|
|
} else if !leadingSeparatorRange.isEmpty {
|
|
// Just leading
|
|
result = String(stringKey[leadingSeparatorRange]) + joinedString
|
|
} else {
|
|
// Just trailing
|
|
result = joinedString + String(stringKey[trailingSeparatorRange])
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
/// The strategy to use in decoding dates. Defaults to `.secondsSince1970`.
|
|
open var dateDecodingStrategy: DateDecodingStrategy = .secondsSince1970
|
|
|
|
/// The strategy to use in decoding binary data. Defaults to `.base64`.
|
|
open var dataDecodingStrategy: DataDecodingStrategy = .base64
|
|
|
|
/// The strategy to use in decoding non-conforming numbers. Defaults to `.throw`.
|
|
open var nonConformingFloatDecodingStrategy: NonConformingFloatDecodingStrategy = .throw
|
|
|
|
/// The strategy to use for decoding keys. Defaults to `.useDefaultKeys`.
|
|
open var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys
|
|
|
|
/// A node's decoding type
|
|
public enum NodeDecoding {
|
|
/// Decodes a node from attributes of form `nodeName="value"`.
|
|
case attribute
|
|
/// Decodes a node from elements of form `<nodeName>value</nodeName>`.
|
|
case element
|
|
/// Decodes a node from either elements of form `<nodeName>value</nodeName>` or attributes
|
|
/// of form `nodeName="value"`, with elements taking priority.
|
|
case elementOrAttribute
|
|
}
|
|
|
|
/// The strategy to use in encoding encoding attributes. Defaults to `.deferredToEncoder`.
|
|
open var nodeDecodingStrategy: NodeDecodingStrategy = .deferredToDecoder
|
|
|
|
/// Set of strategies to use for encoding of nodes.
|
|
public enum NodeDecodingStrategy {
|
|
/// Defer to `Encoder` for choosing an encoding. This is the default strategy.
|
|
case deferredToDecoder
|
|
|
|
/// Return a closure computing the desired node encoding for the value by its coding key.
|
|
case custom((Decodable.Type, Decoder) -> ((CodingKey) -> NodeDecoding))
|
|
|
|
func nodeDecodings(
|
|
forType codableType: Decodable.Type,
|
|
with decoder: Decoder
|
|
) -> ((CodingKey) -> NodeDecoding?) {
|
|
switch self {
|
|
case .deferredToDecoder:
|
|
guard let dynamicType = codableType as? DynamicNodeDecoding.Type else {
|
|
return { _ in nil }
|
|
}
|
|
return dynamicType.nodeDecoding(for:)
|
|
case let .custom(closure):
|
|
return closure(codableType, decoder)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Contextual user-provided information for use during decoding.
|
|
open var userInfo: [CodingUserInfoKey: Any] = [:]
|
|
|
|
/// The error context length. Non-zero length makes an error thrown from
|
|
/// the XML parser with line/column location repackaged with a context
|
|
/// around that location of specified length. For example, if an error was
|
|
/// thrown indicating that there's an unexpected character at line 3, column
|
|
/// 15 with `errorContextLength` set to 10, a new error type is rethrown
|
|
/// containing 5 characters before column 15 and 5 characters after, all on
|
|
/// line 3. Line wrapping should be handled correctly too as the context can
|
|
/// span more than a few lines.
|
|
open var errorContextLength: UInt = 0
|
|
|
|
/** A boolean value that determines whether the parser reports the
|
|
namespaces and qualified names of elements. The default value is `false`.
|
|
*/
|
|
open var shouldProcessNamespaces: Bool = false
|
|
|
|
/** A boolean value that determines whether the parser trims whitespaces
|
|
and newlines from the end and the beginning of string values. The default
|
|
value is `true`.
|
|
*/
|
|
open var trimValueWhitespaces: Bool
|
|
|
|
/** A boolean value that determines whether to remove pure whitespace elements
|
|
that have sibling elements that aren't pure whitespace. The default value
|
|
is `false`.
|
|
*/
|
|
open var removeWhitespaceElements: Bool
|
|
|
|
/// Options set on the top-level encoder to pass down the decoding hierarchy.
|
|
struct Options {
|
|
let dateDecodingStrategy: DateDecodingStrategy
|
|
let dataDecodingStrategy: DataDecodingStrategy
|
|
let nonConformingFloatDecodingStrategy: NonConformingFloatDecodingStrategy
|
|
let keyDecodingStrategy: KeyDecodingStrategy
|
|
let nodeDecodingStrategy: NodeDecodingStrategy
|
|
let userInfo: [CodingUserInfoKey: Any]
|
|
}
|
|
|
|
/// The options set on the top-level decoder.
|
|
var options: Options {
|
|
return Options(
|
|
dateDecodingStrategy: dateDecodingStrategy,
|
|
dataDecodingStrategy: dataDecodingStrategy,
|
|
nonConformingFloatDecodingStrategy: nonConformingFloatDecodingStrategy,
|
|
keyDecodingStrategy: keyDecodingStrategy,
|
|
nodeDecodingStrategy: nodeDecodingStrategy,
|
|
userInfo: userInfo
|
|
)
|
|
}
|
|
|
|
// MARK: - Constructing a XML Decoder
|
|
|
|
/// Initializes `self` with default strategies.
|
|
public init(trimValueWhitespaces: Bool = true, removeWhitespaceElements: Bool = false) {
|
|
self.trimValueWhitespaces = trimValueWhitespaces
|
|
self.removeWhitespaceElements = removeWhitespaceElements
|
|
}
|
|
|
|
// MARK: - Decoding Values
|
|
|
|
/// Decodes a top-level box of the given type from the given XML representation.
|
|
///
|
|
/// - parameter type: The type of the box to decode.
|
|
/// - parameter data: The data to decode from.
|
|
/// - returns: A box of the requested type.
|
|
/// - throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not valid XML.
|
|
/// - throws: An error if any box throws an error during decoding.
|
|
open func decode<T: Decodable>(
|
|
_ type: T.Type,
|
|
from data: Data
|
|
) throws -> T {
|
|
let topLevel: Box = try XMLStackParser.parse(
|
|
with: data,
|
|
errorContextLength: errorContextLength,
|
|
shouldProcessNamespaces: shouldProcessNamespaces,
|
|
trimValueWhitespaces: trimValueWhitespaces,
|
|
removeWhitespaceElements: removeWhitespaceElements
|
|
)
|
|
|
|
let decoder = XMLDecoderImplementation(
|
|
referencing: topLevel,
|
|
options: options,
|
|
nodeDecodings: []
|
|
)
|
|
decoder.nodeDecodings = [
|
|
options.nodeDecodingStrategy.nodeDecodings(
|
|
forType: T.self,
|
|
with: decoder
|
|
),
|
|
]
|
|
|
|
defer {
|
|
_ = decoder.nodeDecodings.removeLast()
|
|
}
|
|
|
|
return try decoder.unbox(topLevel)
|
|
}
|
|
}
|
|
|
|
// MARK: TopLevelDecoder
|
|
|
|
#if canImport(Combine)
|
|
import protocol Combine.TopLevelDecoder
|
|
import protocol Combine.TopLevelEncoder
|
|
#elseif canImport(OpenCombine)
|
|
import protocol OpenCombine.TopLevelDecoder
|
|
import protocol OpenCombine.TopLevelEncoder
|
|
#endif
|
|
|
|
#if canImport(Combine) || canImport(OpenCombine)
|
|
extension XMLDecoder: TopLevelDecoder {}
|
|
extension XMLEncoder: TopLevelEncoder {}
|
|
#endif
|