Skip to content

Commit

Permalink
Support base64-encoded data (#55)
Browse files Browse the repository at this point in the history
### 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`
  • Loading branch information
rnro authored Oct 10, 2023
1 parent d873514 commit 506953d
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 1 deletion.
90 changes: 90 additions & 0 deletions Sources/OpenAPIRuntime/Base/Base64EncodedData.swift
Original file line number Diff line number Diff line change
@@ -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<UInt8> = ...
/// 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<UInt8> = ...
/// 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<UInt8> = ...
/// let encodedData = JSONEncoder().encode(encodedBytes)
/// ```
public struct Base64EncodedData: Sendable, Hashable {
/// A container of the raw bytes.
public var data: ArraySlice<UInt8>

/// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes.
/// - Parameter data: The underlying bytes to wrap.
public init(data: ArraySlice<UInt8>) {
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)
}
}
3 changes: 3 additions & 0 deletions Sources/OpenAPIRuntime/Errors/RuntimeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
29 changes: 28 additions & 1 deletion Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//
//===----------------------------------------------------------------------===//
import XCTest
@_spi(Generated) import OpenAPIRuntime
@_spi(Generated)@testable import OpenAPIRuntime

final class Test_OpenAPIValue: Test_Runtime {

Expand Down Expand Up @@ -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
)
}
}
4 changes: 4 additions & 0 deletions Tests/OpenAPIRuntimeTests/Test_Runtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down

0 comments on commit 506953d

Please sign in to comment.