From 506953dcd18a3f9bb7a2a981fcc23a208f6a7769 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 10 Oct 2023 08:55:13 +0100 Subject: [PATCH] Support base64-encoded data (#55) ### 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 | 90 +++++++++++++++++++ .../OpenAPIRuntime/Errors/RuntimeError.swift | 3 + .../Base/Test_OpenAPIValue.swift | 29 +++++- Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 4 + 4 files changed, 125 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..8dbb13cf --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// 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, for example: +/// ```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, for example: +/// ```swift +/// let bytes: ArraySlice = ... +/// let encodedData = JSONEncoder().encode(encodedBytes) +/// ``` +public struct Base64EncodedData: Sendable, Hashable { + /// A container of the raw bytes. + 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 { + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let base64EncodedString = try container.decode(String.self) + + // permissive decoding + let options = Data.Base64DecodingOptions.ignoreUnknownCharacters + + guard let data = Data(base64Encoded: base64EncodedString, options: options) else { + throw RuntimeError.invalidBase64String(base64EncodedString) + } + self.init(data: ArraySlice(data)) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + // 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/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 9c1fb0be..37069661 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 (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): diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index fbe62e08..e513e26e 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,31 @@ final class Test_OpenAPIValue: Test_Runtime { let nestedValue = try XCTUnwrap(nestedDict["nested"] as? Int) XCTAssertEqual(nestedValue, 2) } + + 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..f99506e7 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 }