From 35ed5c466da2e3eab3bb88ca328b6f4ba60715c8 Mon Sep 17 00:00:00 2001 From: dankinsoid <30962149+dankinsoid@users.noreply.github.com> Date: Sat, 23 Sep 2023 02:18:30 +0400 Subject: [PATCH] add value encoding strategies --- .../AnyCodable/AnyCodableEncoder.swift | 68 +++++----------- .../AnyCodable/DataEncodingStrategies.swift | 25 ++++++ .../AnyCodable/DateEncodingFormat.swift | 77 ------------------- .../AnyCodable/DateEncodingStrategies.swift | 73 ++++++++++++++++++ .../DecimalEncodingStrategies.swift | 28 +++++++ .../AnyCodable/URLEncodingStrategies.swift | 20 +++++ .../AnyCodable/ValueEncodingStrategy.swift | 19 +++++ Tests/AnyCodableTests/AnyCodableTests.swift | 76 +++++++++++++++--- 8 files changed, 252 insertions(+), 134 deletions(-) create mode 100644 Sources/OpenAPIKit/AnyCodable/DataEncodingStrategies.swift delete mode 100644 Sources/OpenAPIKit/AnyCodable/DateEncodingFormat.swift create mode 100644 Sources/OpenAPIKit/AnyCodable/DateEncodingStrategies.swift create mode 100644 Sources/OpenAPIKit/AnyCodable/DecimalEncodingStrategies.swift create mode 100644 Sources/OpenAPIKit/AnyCodable/URLEncodingStrategies.swift create mode 100644 Sources/OpenAPIKit/AnyCodable/ValueEncodingStrategy.swift diff --git a/Sources/OpenAPIKit/AnyCodable/AnyCodableEncoder.swift b/Sources/OpenAPIKit/AnyCodable/AnyCodableEncoder.swift index 7852ae3ae..c3894ea87 100644 --- a/Sources/OpenAPIKit/AnyCodable/AnyCodableEncoder.swift +++ b/Sources/OpenAPIKit/AnyCodable/AnyCodableEncoder.swift @@ -5,37 +5,17 @@ public extension AnyCodable { /// /// - Parameters: /// - value: The value to encode. - /// - dateFormat: The date encoding format to use. + /// - valueEncodingStrategies: Value encoding strategies to use. /// - keyEncodingStrategy: The key encoding strategy to use. /// - Returns: A new instance of `AnyCodable` or `nil` if the given value cannot be encoded. @_disfavoredOverload - init?( - _ value: Encodable, - dateFormat: DateEncodingFormat = .default, - keyEncodingStrategy: KeyEncodingStrategy = .default - ) { - do { - self = try .encodable(value, dateFormat: dateFormat, keyEncodingStrategy: keyEncodingStrategy) - } catch { - return nil - } - } - - /// Creates a new instance from the given `Encodable` value. - /// - /// - Parameters: - /// - value: The value to encode. - /// - dateFormat: The date encoding format to use. - /// - keyEncodingStrategy: The key encoding strategy to use. - /// - Returns: A new instance of `AnyCodable` or `nil` if the given value cannot be encoded. - /// - Throws: An error if any value throws an error during encoding. - static func encodable( + init( _ value: Encodable, - dateFormat: DateEncodingFormat = .default, + valueEncodingStrategies: [ValueEncodingStrategy] = [.Decimal.number, .URL.uri, .Data.base64], keyEncodingStrategy: KeyEncodingStrategy = .default - ) throws -> AnyCodable { - let newEncoder = AnyCodableEncoder(dateFormat: dateFormat, keyEncodingStrategy: keyEncodingStrategy) - return try newEncoder.encode(value) + ) throws { + let newEncoder = AnyCodableEncoder(strategies: valueEncodingStrategies, keyEncodingStrategy: keyEncodingStrategy) + self = try newEncoder.encode(value) } } @@ -43,17 +23,17 @@ private final class AnyCodableEncoder: Encoder { let codingPath: [CodingKey] let userInfo: [CodingUserInfoKey: Any] private var result: AnyCodable - let dateFormat: DateEncodingFormat + let strategies: [ValueEncodingStrategy] let keyEncodingStrategy: KeyEncodingStrategy init( codingPath: [CodingKey] = [], - dateFormat: DateEncodingFormat, + strategies: [ValueEncodingStrategy], keyEncodingStrategy: KeyEncodingStrategy ) { self.codingPath = codingPath userInfo = [:] - self.dateFormat = dateFormat + self.strategies = strategies self.keyEncodingStrategy = keyEncodingStrategy result = .object([:]) } @@ -101,23 +81,15 @@ private final class AnyCodableEncoder: Encoder { func encode(_ value: Encodable) throws -> AnyCodable { switch value { - case let date as Date: - var container = singleValueContainer() - try dateFormat.encode(date, &container) - - case let data as Data: - try data.base64EncodedString().encode(to: self) - - case let url as URL: - try url.absoluteString.encode(to: self) - - case let decimal as Decimal: - try decimal.description.encode(to: self) - case nil as Any?: result = .null default: + for format in strategies { + if try format.encode(value, self) { + return result + } + } try value.encode(to: self) } return result @@ -188,7 +160,7 @@ private struct AnyCodableSingleValueEncodingContainer: SingleValueEncodingContai } mutating func encode(_ value: T) throws { - let newEncoder = AnyCodableEncoder(codingPath: codingPath, dateFormat: encoder.dateFormat, keyEncodingStrategy: encoder.keyEncodingStrategy) + let newEncoder = AnyCodableEncoder(codingPath: codingPath, strategies: encoder.strategies, keyEncodingStrategy: encoder.keyEncodingStrategy) result = try newEncoder.encode(value) } } @@ -264,7 +236,7 @@ private struct AnyCodableKeyedEncodingContainer: KeyedEncodingCo } mutating func encode(_ value: T, forKey key: Key) throws { - let newEncoder = AnyCodableEncoder(codingPath: nestedPath(for: key), dateFormat: encoder.dateFormat, keyEncodingStrategy: encoder.keyEncodingStrategy) + let newEncoder = AnyCodableEncoder(codingPath: nestedPath(for: key), strategies: encoder.strategies, keyEncodingStrategy: encoder.keyEncodingStrategy) result[str(key)] = try newEncoder.encode(value) } @@ -309,12 +281,12 @@ private struct AnyCodableKeyedEncodingContainer: KeyedEncodingCo } mutating func superEncoder() -> Encoder { - AnyCodableEncoder(codingPath: codingPath, dateFormat: encoder.dateFormat, keyEncodingStrategy: encoder.keyEncodingStrategy) + AnyCodableEncoder(codingPath: codingPath, strategies: encoder.strategies, keyEncodingStrategy: encoder.keyEncodingStrategy) } mutating func superEncoder(forKey key: Key) -> Encoder { result[str(key)] = .object([:]) - return AnyCodableEncoder(codingPath: nestedPath(for: key), dateFormat: encoder.dateFormat, keyEncodingStrategy: encoder.keyEncodingStrategy) + return AnyCodableEncoder(codingPath: nestedPath(for: key), strategies: encoder.strategies, keyEncodingStrategy: encoder.keyEncodingStrategy) } private func nestedPath(for key: Key) -> [CodingKey] { @@ -383,7 +355,7 @@ private struct AnyCodableUnkeyedEncodingContainer: UnkeyedEncodingContainer { mutating func encodeNil() throws {} mutating func superEncoder() -> Encoder { - AnyCodableEncoder(codingPath: codingPath, dateFormat: encoder.dateFormat, keyEncodingStrategy: encoder.keyEncodingStrategy) + AnyCodableEncoder(codingPath: codingPath, strategies: encoder.strategies, keyEncodingStrategy: encoder.keyEncodingStrategy) } mutating func encode(_ value: Bool) throws { @@ -445,7 +417,7 @@ private struct AnyCodableUnkeyedEncodingContainer: UnkeyedEncodingContainer { mutating func encode(_ value: T) throws { let newEncoder = AnyCodableEncoder( codingPath: nestedPath, - dateFormat: encoder.dateFormat, + strategies: encoder.strategies, keyEncodingStrategy: encoder.keyEncodingStrategy ) try result.append(newEncoder.encode(value)) diff --git a/Sources/OpenAPIKit/AnyCodable/DataEncodingStrategies.swift b/Sources/OpenAPIKit/AnyCodable/DataEncodingStrategies.swift new file mode 100644 index 000000000..38db3fb57 --- /dev/null +++ b/Sources/OpenAPIKit/AnyCodable/DataEncodingStrategies.swift @@ -0,0 +1,25 @@ +import Foundation + +public extension ValueEncodingStrategy { + + /// Data encoding strategy to use when encoding `AnyCodable` values. + enum Data { + } +} + +public extension ValueEncodingStrategy.Data { + static var `default`: ValueEncodingStrategy = .Data.base64 + + /// Base64 string, schema: .string(format: .byte) + static var base64: ValueEncodingStrategy { + .Data.base64(options: []) + } + + /// Base64 string, schema: .string(format: .byte) + static func base64(options: Data.Base64EncodingOptions) -> ValueEncodingStrategy { + ValueEncodingStrategy(Data.self) { data, encoder in + var container = encoder.singleValueContainer() + try container.encode(data.base64EncodedString(options: options)) + } + } +} diff --git a/Sources/OpenAPIKit/AnyCodable/DateEncodingFormat.swift b/Sources/OpenAPIKit/AnyCodable/DateEncodingFormat.swift deleted file mode 100644 index de709942e..000000000 --- a/Sources/OpenAPIKit/AnyCodable/DateEncodingFormat.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation - -/// Date encoding strategy to use when encoding `AnyCodable` values. -public struct DateEncodingFormat { - public let schema: JSONSchema - public let encode: (Date, inout SingleValueEncodingContainer) throws -> Void -} - -public extension DateEncodingFormat { - static var `default`: DateEncodingFormat = .dateTime - - /// full-date notation as defined by RFC 3339, section 5.6, for example, 2017-07-21 - static var date: DateEncodingFormat { - DateEncodingFormat(schema: .string(format: .date)) { date, encoder in - try encoder.encode(DateEncodingFormat.date(date)) - } - } - - /// the date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z - static var dateTime: DateEncodingFormat { - DateEncodingFormat(schema: .string(format: .dateTime)) { date, encoder in - try encoder.encode(DateEncodingFormat.dateTime(date)) - } - } - - /// the interval between the date value and 00:00:00 UTC on 1 January 1970. - static var timestamp: DateEncodingFormat { - DateEncodingFormat(schema: .number(format: .other("timestamp"))) { date, encoder in - try encoder.encode(date.timeIntervalSince1970) - } - } - - static func custom(_ format: String) -> DateEncodingFormat { - DateEncodingFormat(schema: .string(format: .other(format))) { date, encoder in - dateFormatter.dateFormat = format - try encoder.encode(dateFormatter.string(from: date)) - } - } - - /// Custom date encoding strategy - static func custom( - _ schema: JSONSchema, - encode: @escaping (Date, inout SingleValueEncodingContainer) throws -> Void - ) -> DateEncodingFormat { - DateEncodingFormat(schema: schema, encode: encode) - } - - /// Custom date encoding strategy - static func custom( - _ dataFormat: JSONTypeFormat.StringFormat, - formatter: DateFormatter - ) -> DateEncodingFormat { - DateEncodingFormat(schema: .string(format: dataFormat)) { date, encoder in - try encoder.encode(formatter.string(from: date)) - } - } -} - -extension DateEncodingFormat { - static func dateTime(_ date: Date) -> String { - isoFormatter.string(from: date) - } - - static func date(_ date: Date) -> String { - dateFormatter.dateFormat = "yyyy-MM-dd" - return dateFormatter.string(from: date) - } -} - -private let isoFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - formatter.timeZone = TimeZone(secondsFromGMT: 0) - return formatter -}() -private let dateFormatter = DateFormatter() diff --git a/Sources/OpenAPIKit/AnyCodable/DateEncodingStrategies.swift b/Sources/OpenAPIKit/AnyCodable/DateEncodingStrategies.swift new file mode 100644 index 000000000..24e64875c --- /dev/null +++ b/Sources/OpenAPIKit/AnyCodable/DateEncodingStrategies.swift @@ -0,0 +1,73 @@ +import Foundation + +public extension ValueEncodingStrategy { + + /// Date encoding strategy to use when encoding `AnyCodable` values. + enum Date { + } +} + +public extension ValueEncodingStrategy.Date { + static var `default`: ValueEncodingStrategy = .Date.dateTime + + /// full-date notation as defined by RFC 3339, section 5.6, for example, 2017-07-21, schema: .string(format: .date) + static var date: ValueEncodingStrategy { + .Date.custom { date, encoder in + try encoder.encode(ValueEncodingStrategy.Date.date(date)) + } + } + + /// the date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z, schema: .string(format: .dateTime) + static var dateTime: ValueEncodingStrategy { + .Date.custom { date, encoder in + try encoder.encode(ValueEncodingStrategy.Date.dateTime(date)) + } + } + + /// the interval between the date value and 00:00:00 UTC on 1 January 1970, schema: .number(format: .other("timestamp")) + static var timestamp: ValueEncodingStrategy { + .Date.custom { date, encoder in + try encoder.encode(date.timeIntervalSince1970) + } + } + + /// Custom date encoding strategy + static func custom( + encode: @escaping (Date, inout SingleValueEncodingContainer) throws -> Void + ) -> ValueEncodingStrategy { + ValueEncodingStrategy(Date.self) { + var container = $1.singleValueContainer() + try encode($0, &container) + } + } + + /// Custom date encoding strategy + static func custom( + _ dataFormat: JSONTypeFormat.StringFormat, + formatter: DateFormatter + ) -> ValueEncodingStrategy { + .Date.custom { date, encoder in + try encoder.encode(formatter.string(from: date)) + } + } +} + +extension ValueEncodingStrategy.Date { + static func dateTime(_ date: Date) -> String { + isoFormatter.string(from: date) + } + + static func date(_ date: Date) -> String { + dateFormatter.dateFormat = "yyyy-MM-dd" + return dateFormatter.string(from: date) + } +} + +private let isoFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter +}() +private let dateFormatter = DateFormatter() diff --git a/Sources/OpenAPIKit/AnyCodable/DecimalEncodingStrategies.swift b/Sources/OpenAPIKit/AnyCodable/DecimalEncodingStrategies.swift new file mode 100644 index 000000000..3d57d58b5 --- /dev/null +++ b/Sources/OpenAPIKit/AnyCodable/DecimalEncodingStrategies.swift @@ -0,0 +1,28 @@ +import Foundation + +public extension ValueEncodingStrategy { + + /// Decimal encoding strategy to use when encoding `AnyCodable` values. + enum Decimal { + } +} + +public extension ValueEncodingStrategy.Decimal { + static var `default`: ValueEncodingStrategy = .Decimal.number + + /// Quoted string + static var string: ValueEncodingStrategy { + ValueEncodingStrategy(Decimal.self) { decimal, encoder in + var container = encoder.singleValueContainer() + try container.encode(decimal.description) + } + } + + /// Number + static var number: ValueEncodingStrategy { + ValueEncodingStrategy(Decimal.self) { decimal, encoder in + var container = encoder.singleValueContainer() + try container.encode((decimal as NSDecimalNumber).doubleValue) + } + } +} diff --git a/Sources/OpenAPIKit/AnyCodable/URLEncodingStrategies.swift b/Sources/OpenAPIKit/AnyCodable/URLEncodingStrategies.swift new file mode 100644 index 000000000..53d1e5866 --- /dev/null +++ b/Sources/OpenAPIKit/AnyCodable/URLEncodingStrategies.swift @@ -0,0 +1,20 @@ +import Foundation + +public extension ValueEncodingStrategy { + + /// URL encoding strategy to use when encoding `AnyCodable` values. + enum URL { + } +} + +public extension ValueEncodingStrategy.URL { + static var `default`: ValueEncodingStrategy = .URL.uri + + /// URI string, schema: .string(format: .other("uri")) + static var uri: ValueEncodingStrategy { + ValueEncodingStrategy(URL.self) { url, encoder in + var container = encoder.singleValueContainer() + try container.encode(url.absoluteString) + } + } +} diff --git a/Sources/OpenAPIKit/AnyCodable/ValueEncodingStrategy.swift b/Sources/OpenAPIKit/AnyCodable/ValueEncodingStrategy.swift new file mode 100644 index 000000000..8868ed60c --- /dev/null +++ b/Sources/OpenAPIKit/AnyCodable/ValueEncodingStrategy.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct ValueEncodingStrategy { + + public let encode: (Encodable, Encoder) throws -> Bool + + public init( + _ type: T.Type, + encode: @escaping (T, Encoder) throws -> Void + ) { + self.encode = { + guard let value = $0 as? T else { + return false + } + try encode(value, $1) + return true + } + } +} diff --git a/Tests/AnyCodableTests/AnyCodableTests.swift b/Tests/AnyCodableTests/AnyCodableTests.swift index decc94faf..ffa33dbc6 100644 --- a/Tests/AnyCodableTests/AnyCodableTests.swift +++ b/Tests/AnyCodableTests/AnyCodableTests.swift @@ -219,7 +219,7 @@ class AnyCodableTests: XCTestCase { } func test_encodeURL() throws { - let data = try JSONEncoder().encode(Wrapper(value: .encodable(URL(string: "https://hello.com")!))) + let data = try JSONEncoder().encode(Wrapper(value: AnyCodable(URL(string: "https://hello.com")!))) let string = String(data: data, encoding: .utf8) @@ -227,13 +227,16 @@ class AnyCodableTests: XCTestCase { } func test_encodeEncodable() throws { - let anyCodable: AnyCodable = try .encodable(EncodableStruct()) + let anyCodable = try AnyCodable(EncodableStruct()) let expectedAnyCodable: AnyCodable = [ "int": 1, "string": "hello", "bool": true, "array": [1, 2, 3], "dictionary": ["a": 1, "b": 2, "c": 3], + "data": "aGVsbG8=", + "decimal": 10.0, + "url": "https://google.com" ] XCTAssertEqual(anyCodable, expectedAnyCodable) @@ -243,9 +246,8 @@ class AnyCodableTests: XCTestCase { } func test_dateEncodableInit() throws { - let anyCodable = AnyCodable( + let anyCodable = try AnyCodable( EncodableStruct(), - dateFormat: .default, keyEncodingStrategy: .default ) let expectedAnyCodable: AnyCodable = [ @@ -254,6 +256,9 @@ class AnyCodableTests: XCTestCase { "bool": true, "array": [1, 2, 3], "dictionary": ["a": 1, "b": 2, "c": 3], + "data": "aGVsbG8=", + "decimal": 10.0, + "url": "https://google.com" ] XCTAssertEqual(anyCodable, expectedAnyCodable) @@ -266,16 +271,16 @@ class AnyCodableTests: XCTestCase { let camelCaseString = "thisIsCamelCase" XCTAssertEqual(KeyEncodingStrategy.convertToSnakeCase.encode(camelCaseString), "this_is_camel_case") - let anyCodable = try AnyCodable.encodable(StructWithLargeNameProperty(), keyEncodingStrategy: .convertToSnakeCase) + let anyCodable = try AnyCodable(StructWithLargeNameProperty(), keyEncodingStrategy: .convertToSnakeCase) let expectedAnyCodable: AnyCodable = [ "this_is_a_very_long_property_name_that_will_be_encoded": "hello", ] XCTAssertEqual(anyCodable, expectedAnyCodable) } - func test_dateEncodingFormats() throws { + func test_dateEncodingStrategies() throws { let date = Date(timeIntervalSince1970: 0) - let anyCodable = try AnyCodable.encodable(date, dateFormat: .timestamp) + let anyCodable = try AnyCodable(date, valueEncodingStrategies: [.Date.timestamp]) let expectedAnyCodable: AnyCodable = 0.0 XCTAssertEqual(anyCodable, expectedAnyCodable) @@ -283,7 +288,7 @@ class AnyCodableTests: XCTestCase { let string = String(data: data, encoding: .utf8) XCTAssertEqual(string, "[0]") - let anyCodable3 = try AnyCodable.encodable(date, dateFormat: .dateTime) + let anyCodable3 = try AnyCodable(date, valueEncodingStrategies: [.Date.dateTime]) let expectedAnyCodable3: AnyCodable = "1970-01-01T00:00:00.000Z" XCTAssertEqual(anyCodable3, expectedAnyCodable3) @@ -291,7 +296,7 @@ class AnyCodableTests: XCTestCase { let string3 = String(data: data3, encoding: .utf8) XCTAssertEqual(string3, #"["1970-01-01T00:00:00.000Z"]"#) - let anyCodable4 = try AnyCodable.encodable(date, dateFormat: .date) + let anyCodable4 = try AnyCodable(date, valueEncodingStrategies: [.Date.date]) let expectedAnyCodable4: AnyCodable = "1970-01-01" XCTAssertEqual(anyCodable4, expectedAnyCodable4) @@ -300,6 +305,56 @@ class AnyCodableTests: XCTestCase { XCTAssertEqual(string4, #"["1970-01-01"]"#) } + func test_dataEncodingStrategies() throws { + let data = Data([0x01, 0x02, 0x03]) + let anyCodable1 = try AnyCodable(data, valueEncodingStrategies: [.Data.base64]) + let expectedAnyCodable1: AnyCodable = "AQID" + XCTAssertEqual(anyCodable1, expectedAnyCodable1) + + let data1 = try JSONEncoder().encode([anyCodable1]) // Use array for swift 5.1 support + let string1 = String(data: data1, encoding: .utf8) + XCTAssertEqual(string1, #"["AQID"]"#) + + let anyCodable2 = try AnyCodable(data, valueEncodingStrategies: [.Data.base64(options: .endLineWithCarriageReturn)]) + let expectedAnyCodable2: AnyCodable = "AQID" + XCTAssertEqual(anyCodable2, expectedAnyCodable2) + + let data2 = try JSONEncoder().encode([anyCodable2]) // Use array for swift 5.1 support + let string2 = String(data: data2, encoding: .utf8) + XCTAssertEqual(string2, #"["AQID"]"#) + } + + func test_urlEncodingStrategies() throws { + let url = URL(string: "https://google.com")! + let anyCodable = try AnyCodable(url, valueEncodingStrategies: [.URL.uri]) + let expectedAnyCodable: AnyCodable = "https://google.com" + XCTAssertEqual(anyCodable, expectedAnyCodable) + + let data = try JSONEncoder().encode([anyCodable]) // Use array for swift 5.1 support + let string = String(data: data, encoding: .utf8) + XCTAssertEqual(string, #"["https:\/\/google.com"]"#) + } + + func test_decimalEncodingStrategies() throws { + let decimal = Decimal(10) + + let anyCodable1 = try AnyCodable(decimal, valueEncodingStrategies: [.Decimal.number]) + let expectedAnyCodable1: AnyCodable = 10.0 + XCTAssertEqual(anyCodable1, expectedAnyCodable1) + + let data1 = try JSONEncoder().encode([anyCodable1]) // Use array for swift 5.1 support + let string1 = String(data: data1, encoding: .utf8) + XCTAssertEqual(string1, "[10]") + + let anyCodable2 = try AnyCodable(decimal, valueEncodingStrategies: [.Decimal.string]) + let expectedAnyCodable2: AnyCodable = "10" + XCTAssertEqual(anyCodable2, expectedAnyCodable2) + + let data2 = try JSONEncoder().encode([anyCodable2]) // Use array for swift 5.1 support + let string2 = String(data: data2, encoding: .utf8) + XCTAssertEqual(string2, #"["10"]"#) + } + func test_RefInit() { var value = 0 let ref = Ref(get: { @@ -360,6 +415,9 @@ private struct EncodableStruct: Codable, Equatable { var bool = true var array = [1, 2, 3] var dictionary = ["a": 1, "b": 2, "c": 3] + var data = "hello".data(using: .utf8) + var decimal = Decimal(10) + var url = URL(string: "https://google.com")! } private struct StructWithLargeNameProperty: Codable {