From 6ea58254a9e6d00ff10912181dfa784c3a6e45ac Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 26 Sep 2023 13:22:03 +0100 Subject: [PATCH 1/6] Supports base64-encoded data Motivation OpenAPI supports base64-encoded data but to this point OpenAPI Generator has not (https://github.com/apple/swift-openapi-generator/issues/11). Modifications Introduce the `Base64EncodedData` codable type to allow users in the generator to describe byte types which must be en/de-coded. Result Users will be able to describe base64-encoded data as `OpenAPIRuntime.Base64EncodedData` e.g. ``` public typealias MyData = OpenAPIRuntime.Base64EncodedData ``` Test Plan Added a round-trip encode/decode test `testEncodingDecodingRoundTrip_base64_success` --- .../Base/Base64EncodedData.swift | 38 +++++++++++++++++++ .../OpenAPIRuntime/Errors/RuntimeError.swift | 3 ++ .../Base/Test_OpenAPIValue.swift | 11 +++++- 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenAPIRuntime/Base/Base64EncodedData.swift diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift new file mode 100644 index 00000000..63d326c2 --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +public struct Base64EncodedData: Sendable, Codable, Hashable { + var data: Foundation.Data // or [UInt8] or ArraySlice + + public init(data: Foundation.Data) { + self.data = data + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let base64EncodedString = try container.decode(String.self) + guard let data = Data(base64Encoded: base64EncodedString) else { + throw RuntimeError.invalidBase64String(base64EncodedString) + } + self.init(data: data) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + let base64String = data.base64EncodedString() + try container.encode(base64String) + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 9c1fb0be..424cdf51 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -21,6 +21,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case invalidServerURL(String) case invalidExpectedContentType(String) case invalidHeaderFieldName(String) + case invalidBase64String(String) // Data conversion case failedToDecodeStringConvertibleValue(type: String) @@ -73,6 +74,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Invalid expected content type: '\(string)'" case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'" + case .invalidBase64String(let string): + return "Invalid base64-encoded string: '\(string)'" case .failedToDecodeStringConvertibleValue(let string): return "Failed to decode a value of type '\(string)'." case .unsupportedParameterStyle(name: let name, location: let location, style: let style, explode: let explode): diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index fbe62e08..4a7c736f 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@_spi(Generated) @testable import OpenAPIRuntime final class Test_OpenAPIValue: Test_Runtime { @@ -266,4 +266,13 @@ final class Test_OpenAPIValue: Test_Runtime { let nestedValue = try XCTUnwrap(nestedDict["nested"] as? Int) XCTAssertEqual(nestedValue, 2) } + + func testEncodingDecodingRoundTrip_base64_success() throws { + print(String(data: testStructData.base64EncodedData(), encoding: .utf8)!.utf8) + let encodedData = Base64EncodedData(data: testStructData) + XCTAssertEqual( + try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoder().encode(encodedData)), + encodedData + ) + } } From 8923171cd4fcfca5a9515e9c8e6b4fa033bab759 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Wed, 4 Oct 2023 16:10:14 +0100 Subject: [PATCH 2/6] review comments --- .../Base/Base64EncodedData.swift | 21 ++++++++++++---- .../Base/Test_OpenAPIValue.swift | 24 ++++++++++++++++--- Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 4 ++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index 63d326c2..f6700a90 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -15,24 +15,35 @@ import Foundation public struct Base64EncodedData: Sendable, Codable, Hashable { - var data: Foundation.Data // or [UInt8] or ArraySlice + var data: ArraySlice - public init(data: Foundation.Data) { + public init(data: ArraySlice) { self.data = data } public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let base64EncodedString = try container.decode(String.self) - guard let data = Data(base64Encoded: base64EncodedString) else { + + // permissive decoding + let options = Data.Base64DecodingOptions.ignoreUnknownCharacters + + guard let data = Data(base64Encoded: base64EncodedString, options: options) else { throw RuntimeError.invalidBase64String(base64EncodedString) } - self.init(data: data) + self.init(data: ArraySlice(data)) } public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() - let base64String = data.base64EncodedString() + + // https://datatracker.ietf.org/doc/html/rfc4648#section-3.1 + // "Implementations MUST NOT add line feeds to base-encoded data unless + // the specification referring to this document explicitly directs base + // encoders to add line feeds after a specific number of characters." + let options = Data.Base64EncodingOptions() + + let base64String = Data(data).base64EncodedString(options: options) try container.encode(base64String) } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 4a7c736f..5549e88a 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -267,9 +267,27 @@ final class Test_OpenAPIValue: Test_Runtime { XCTAssertEqual(nestedValue, 2) } - func testEncodingDecodingRoundTrip_base64_success() throws { - print(String(data: testStructData.base64EncodedData(), encoding: .utf8)!.utf8) - let encodedData = Base64EncodedData(data: testStructData) + func testEncoding_base64_success() throws { + let encodedData = Base64EncodedData(data: ArraySlice(testStructData)) + + let JSONEncoded = try JSONEncoder().encode(encodedData) + XCTAssertEqual(String(data: JSONEncoded, encoding: .utf8)!, testStructBase64EncodedString) + } + + func testDecoding_base64_success() throws { + let encodedData = Base64EncodedData(data: ArraySlice(testStructData)) + + // `testStructBase64EncodedString` quoted and base64-encoded again + let JSONEncoded = Data(base64Encoded: "ImV5SnVZVzFsSWpvaVJteDFabVo2SW4wPSI=")! + + XCTAssertEqual( + try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoded), + encodedData + ) + } + + func testEncodingDecodingRoundtrip_base64_success() throws { + let encodedData = Base64EncodedData(data: ArraySlice(testStructData)) XCTAssertEqual( try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoder().encode(encodedData)), encodedData diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 3b2f1e83..11b55f7b 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -107,6 +107,10 @@ class Test_Runtime: XCTestCase { "age=3&name=Rover%21&type=Golden+Retriever" } + var testStructBase64EncodedString: String { + #""eyJuYW1lIjoiRmx1ZmZ6In0=""# // {"name":"Fluffz"} + } + var testEnum: TestHabitat { .water } From dffa64c6e21edff15eec38ff6a27f5f9ea88451b Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 5 Oct 2023 07:54:51 +0100 Subject: [PATCH 3/6] whitespace --- Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift | 2 +- Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 5549e88a..e513e26e 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) @testable import OpenAPIRuntime +@_spi(Generated)@testable import OpenAPIRuntime final class Test_OpenAPIValue: Test_Runtime { diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 11b55f7b..f99506e7 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -108,7 +108,7 @@ class Test_Runtime: XCTestCase { } var testStructBase64EncodedString: String { - #""eyJuYW1lIjoiRmx1ZmZ6In0=""# // {"name":"Fluffz"} + #""eyJuYW1lIjoiRmx1ZmZ6In0=""# // {"name":"Fluffz"} } var testEnum: TestHabitat { From 1427a01bca65e4e939b3f80629964c727545a25b Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 5 Oct 2023 11:14:52 +0100 Subject: [PATCH 4/6] make Base64EncodedData.data public, docs --- .../Base/Base64EncodedData.swift | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index f6700a90..624c0b80 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -14,13 +14,60 @@ import Foundation -public struct Base64EncodedData: Sendable, Codable, Hashable { - var data: ArraySlice +/// Provides a route to encode or decode base64-encoded data +/// +/// This type holds raw, unencoded, data as a slice of bytes. It can be used to encode that +/// data to a provided `Encoder` as base64-encoded data or to decode from base64 encoding when +/// initialized from a decoder. +/// +/// There is a convenience initializer to create an instance backed by provided data in the form +/// of a slice of bytes: +/// ```swift +/// let bytes: ArraySlice = ... +/// let base64EncodedData = Base64EncodedData(data: bytes) +/// ``` +/// +/// To decode base64-encoded data it is possible to call the initializer directly, providing a decoder: +/// ```swift +/// let base64EncodedData = Base64EncodedData(from: decoder) +///``` +/// +/// However more commonly the decoding initializer would be called by a decoder e.g. +/// ```swift +/// let encodedData: Data = ... +/// let decoded = try JSONDecoder().decode(Base64EncodedData.self, from: encodedData) +///``` +/// +/// Once an instance is holding data, it may be base64 encoded to a provided encoder: +/// ```swift +/// let bytes: ArraySlice = ... +/// let base64EncodedData = Base64EncodedData(data: bytes) +/// base64EncodedData.encode(to: encoder) +/// ``` +/// +/// However more commonly it would be called by an encoder e.g. +/// ```swift +/// let bytes: ArraySlice = ... +/// let encodedData = JSONEncoder().encode(encodedBytes) +/// ``` +public struct Base64EncodedData: Sendable, Hashable { + /// data to be encoded + public var data: ArraySlice + /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. + /// - Parameter data: The underlying bytes to wrap. public init(data: ArraySlice) { self.data = data } +} +extension Base64EncodedData: Codable { + /// Creates a new instance by decoding from the given decoder. + /// + /// This initializer throws an error if reading from the decoder fails, or + /// if the data read is corrupted or otherwise invalid. + /// + /// - Parameter decoder: The decoder to read data from. public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let base64EncodedString = try container.decode(String.self) @@ -34,6 +81,16 @@ public struct Base64EncodedData: Sendable, Codable, Hashable { self.init(data: ArraySlice(data)) } + + /// Encodes this value into the given encoder. + /// + /// If the value fails to encode anything, `encoder` will encode an empty + /// keyed container in its place. + /// + /// This function throws an error if any values are invalid for the given + /// encoder's format. + /// + /// - Parameter encoder: The encoder to write data to. public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() From 43dd914131e78764ead5e670e8949ba6ae70c783 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 5 Oct 2023 13:29:24 +0100 Subject: [PATCH 5/6] whitespace --- Sources/OpenAPIRuntime/Base/Base64EncodedData.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index 624c0b80..0ddf138a 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -81,7 +81,6 @@ extension Base64EncodedData: Codable { self.init(data: ArraySlice(data)) } - /// Encodes this value into the given encoder. /// /// If the value fails to encode anything, `encoder` will encode an empty From 7548e96052ec5c08ddb7b045a2673ef57fc2390e Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 6 Oct 2023 10:11:09 +0100 Subject: [PATCH 6/6] minor review comments --- .../Base/Base64EncodedData.swift | 21 +++---------------- .../OpenAPIRuntime/Errors/RuntimeError.swift | 2 +- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index 0ddf138a..8dbb13cf 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -32,7 +32,7 @@ import Foundation /// let base64EncodedData = Base64EncodedData(from: decoder) ///``` /// -/// However more commonly the decoding initializer would be called by a decoder e.g. +/// However more commonly the decoding initializer would be called by a decoder, for example: /// ```swift /// let encodedData: Data = ... /// let decoded = try JSONDecoder().decode(Base64EncodedData.self, from: encodedData) @@ -45,13 +45,13 @@ import Foundation /// base64EncodedData.encode(to: encoder) /// ``` /// -/// However more commonly it would be called by an encoder e.g. +/// However more commonly it would be called by an encoder, for example: /// ```swift /// let bytes: ArraySlice = ... /// let encodedData = JSONEncoder().encode(encodedBytes) /// ``` public struct Base64EncodedData: Sendable, Hashable { - /// data to be encoded + /// A container of the raw bytes. public var data: ArraySlice /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. @@ -62,12 +62,6 @@ public struct Base64EncodedData: Sendable, Hashable { } extension Base64EncodedData: Codable { - /// Creates a new instance by decoding from the given decoder. - /// - /// This initializer throws an error if reading from the decoder fails, or - /// if the data read is corrupted or otherwise invalid. - /// - /// - Parameter decoder: The decoder to read data from. public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let base64EncodedString = try container.decode(String.self) @@ -81,15 +75,6 @@ extension Base64EncodedData: Codable { self.init(data: ArraySlice(data)) } - /// Encodes this value into the given encoder. - /// - /// If the value fails to encode anything, `encoder` will encode an empty - /// keyed container in its place. - /// - /// This function throws an error if any values are invalid for the given - /// encoder's format. - /// - /// - Parameter encoder: The encoder to write data to. public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 424cdf51..37069661 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -75,7 +75,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'" case .invalidBase64String(let string): - return "Invalid base64-encoded string: '\(string)'" + return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'" case .failedToDecodeStringConvertibleValue(let string): return "Failed to decode a value of type '\(string)'." case .unsupportedParameterStyle(name: let name, location: let location, style: let style, explode: let explode):