Skip to content

Commit 506953d

Browse files
authored
Support base64-encoded data (#55)
### Motivation OpenAPI supports base64-encoded data but to this point OpenAPI Generator has not (apple/swift-openapi-generator#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`
1 parent d873514 commit 506953d

File tree

4 files changed

+125
-1
lines changed

4 files changed

+125
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
17+
/// Provides a route to encode or decode base64-encoded data
18+
///
19+
/// This type holds raw, unencoded, data as a slice of bytes. It can be used to encode that
20+
/// data to a provided `Encoder` as base64-encoded data or to decode from base64 encoding when
21+
/// initialized from a decoder.
22+
///
23+
/// There is a convenience initializer to create an instance backed by provided data in the form
24+
/// of a slice of bytes:
25+
/// ```swift
26+
/// let bytes: ArraySlice<UInt8> = ...
27+
/// let base64EncodedData = Base64EncodedData(data: bytes)
28+
/// ```
29+
///
30+
/// To decode base64-encoded data it is possible to call the initializer directly, providing a decoder:
31+
/// ```swift
32+
/// let base64EncodedData = Base64EncodedData(from: decoder)
33+
///```
34+
///
35+
/// However more commonly the decoding initializer would be called by a decoder, for example:
36+
/// ```swift
37+
/// let encodedData: Data = ...
38+
/// let decoded = try JSONDecoder().decode(Base64EncodedData.self, from: encodedData)
39+
///```
40+
///
41+
/// Once an instance is holding data, it may be base64 encoded to a provided encoder:
42+
/// ```swift
43+
/// let bytes: ArraySlice<UInt8> = ...
44+
/// let base64EncodedData = Base64EncodedData(data: bytes)
45+
/// base64EncodedData.encode(to: encoder)
46+
/// ```
47+
///
48+
/// However more commonly it would be called by an encoder, for example:
49+
/// ```swift
50+
/// let bytes: ArraySlice<UInt8> = ...
51+
/// let encodedData = JSONEncoder().encode(encodedBytes)
52+
/// ```
53+
public struct Base64EncodedData: Sendable, Hashable {
54+
/// A container of the raw bytes.
55+
public var data: ArraySlice<UInt8>
56+
57+
/// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes.
58+
/// - Parameter data: The underlying bytes to wrap.
59+
public init(data: ArraySlice<UInt8>) {
60+
self.data = data
61+
}
62+
}
63+
64+
extension Base64EncodedData: Codable {
65+
public init(from decoder: any Decoder) throws {
66+
let container = try decoder.singleValueContainer()
67+
let base64EncodedString = try container.decode(String.self)
68+
69+
// permissive decoding
70+
let options = Data.Base64DecodingOptions.ignoreUnknownCharacters
71+
72+
guard let data = Data(base64Encoded: base64EncodedString, options: options) else {
73+
throw RuntimeError.invalidBase64String(base64EncodedString)
74+
}
75+
self.init(data: ArraySlice(data))
76+
}
77+
78+
public func encode(to encoder: any Encoder) throws {
79+
var container = encoder.singleValueContainer()
80+
81+
// https://datatracker.ietf.org/doc/html/rfc4648#section-3.1
82+
// "Implementations MUST NOT add line feeds to base-encoded data unless
83+
// the specification referring to this document explicitly directs base
84+
// encoders to add line feeds after a specific number of characters."
85+
let options = Data.Base64EncodingOptions()
86+
87+
let base64String = Data(data).base64EncodedString(options: options)
88+
try container.encode(base64String)
89+
}
90+
}

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
2121
case invalidServerURL(String)
2222
case invalidExpectedContentType(String)
2323
case invalidHeaderFieldName(String)
24+
case invalidBase64String(String)
2425

2526
// Data conversion
2627
case failedToDecodeStringConvertibleValue(type: String)
@@ -73,6 +74,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
7374
return "Invalid expected content type: '\(string)'"
7475
case .invalidHeaderFieldName(let name):
7576
return "Invalid header field name: '\(name)'"
77+
case .invalidBase64String(let string):
78+
return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'"
7679
case .failedToDecodeStringConvertibleValue(let string):
7780
return "Failed to decode a value of type '\(string)'."
7881
case .unsupportedParameterStyle(name: let name, location: let location, style: let style, explode: let explode):

Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift

+28-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414
import XCTest
15-
@_spi(Generated) import OpenAPIRuntime
15+
@_spi(Generated)@testable import OpenAPIRuntime
1616

1717
final class Test_OpenAPIValue: Test_Runtime {
1818

@@ -266,4 +266,31 @@ final class Test_OpenAPIValue: Test_Runtime {
266266
let nestedValue = try XCTUnwrap(nestedDict["nested"] as? Int)
267267
XCTAssertEqual(nestedValue, 2)
268268
}
269+
270+
func testEncoding_base64_success() throws {
271+
let encodedData = Base64EncodedData(data: ArraySlice(testStructData))
272+
273+
let JSONEncoded = try JSONEncoder().encode(encodedData)
274+
XCTAssertEqual(String(data: JSONEncoded, encoding: .utf8)!, testStructBase64EncodedString)
275+
}
276+
277+
func testDecoding_base64_success() throws {
278+
let encodedData = Base64EncodedData(data: ArraySlice(testStructData))
279+
280+
// `testStructBase64EncodedString` quoted and base64-encoded again
281+
let JSONEncoded = Data(base64Encoded: "ImV5SnVZVzFsSWpvaVJteDFabVo2SW4wPSI=")!
282+
283+
XCTAssertEqual(
284+
try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoded),
285+
encodedData
286+
)
287+
}
288+
289+
func testEncodingDecodingRoundtrip_base64_success() throws {
290+
let encodedData = Base64EncodedData(data: ArraySlice(testStructData))
291+
XCTAssertEqual(
292+
try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoder().encode(encodedData)),
293+
encodedData
294+
)
295+
}
269296
}

Tests/OpenAPIRuntimeTests/Test_Runtime.swift

+4
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ class Test_Runtime: XCTestCase {
107107
"age=3&name=Rover%21&type=Golden+Retriever"
108108
}
109109

110+
var testStructBase64EncodedString: String {
111+
#""eyJuYW1lIjoiRmx1ZmZ6In0=""# // {"name":"Fluffz"}
112+
}
113+
110114
var testEnum: TestHabitat {
111115
.water
112116
}

0 commit comments

Comments
 (0)