Skip to content

Commit 26e8ae3

Browse files
authored
Custom JSON encoding options (#112)
1 parent 71fcfa7 commit 26e8ae3

File tree

5 files changed

+120
-1
lines changed

5 files changed

+120
-1
lines changed

Sources/OpenAPIRuntime/Conversion/Configuration.swift

+26
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,27 @@ public protocol CustomCoder: Sendable {
114114
/// - Returns: A value of the requested type.
115115
/// - Throws: An error if decoding fails.
116116
func customDecode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T
117+
}
118+
119+
/// The options that control the encoded JSON data.
120+
public struct JSONEncodingOptions: OptionSet, Sendable {
121+
122+
/// The format's default value.
123+
public let rawValue: UInt
124+
125+
/// Creates a JSONEncodingOptions value with the given raw value.
126+
public init(rawValue: UInt) { self.rawValue = rawValue }
117127

128+
/// Include newlines and indentation to make the output more human-readable.
129+
public static let prettyPrinted: JSONEncodingOptions = .init(rawValue: 1 << 0)
130+
131+
/// Serialize JSON objects with field keys sorted in lexicographic order.
132+
public static let sortedKeys: JSONEncodingOptions = .init(rawValue: 1 << 1)
133+
134+
/// Omit escaping forward slashes with backslashes.
135+
///
136+
/// Important: Only use this option when the output is not embedded in HTML/XML.
137+
public static let withoutEscapingSlashes: JSONEncodingOptions = .init(rawValue: 1 << 2)
118138
}
119139

120140
/// A set of configuration values used by the generated client and server types.
@@ -123,6 +143,9 @@ public struct Configuration: Sendable {
123143
/// The transcoder used when converting between date and string values.
124144
public var dateTranscoder: any DateTranscoder
125145

146+
/// The options for the underlying JSON encoder.
147+
public var jsonEncodingOptions: JSONEncodingOptions
148+
126149
/// The generator to use when creating mutlipart bodies.
127150
public var multipartBoundaryGenerator: any MultipartBoundaryGenerator
128151

@@ -134,14 +157,17 @@ public struct Configuration: Sendable {
134157
/// - Parameters:
135158
/// - dateTranscoder: The transcoder to use when converting between date
136159
/// and string values.
160+
/// - jsonEncodingOptions: The options for the underlying JSON encoder.
137161
/// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies.
138162
/// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads.
139163
public init(
140164
dateTranscoder: any DateTranscoder = .iso8601,
165+
jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted],
141166
multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random,
142167
xmlCoder: (any CustomCoder)? = nil
143168
) {
144169
self.dateTranscoder = dateTranscoder
170+
self.jsonEncodingOptions = jsonEncodingOptions
145171
self.multipartBoundaryGenerator = multipartBoundaryGenerator
146172
self.xmlCoder = xmlCoder
147173
}

Sources/OpenAPIRuntime/Conversion/Converter.swift

+12-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import class Foundation.JSONDecoder
3838
self.configuration = configuration
3939

4040
self.encoder = JSONEncoder()
41-
self.encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
41+
self.encoder.outputFormatting = .init(configuration.jsonEncodingOptions)
4242
self.encoder.dateEncodingStrategy = .from(dateTranscoder: configuration.dateTranscoder)
4343

4444
self.headerFieldEncoder = JSONEncoder()
@@ -49,3 +49,14 @@ import class Foundation.JSONDecoder
4949
self.decoder.dateDecodingStrategy = .from(dateTranscoder: configuration.dateTranscoder)
5050
}
5151
}
52+
53+
extension JSONEncoder.OutputFormatting {
54+
/// Creates a new value.
55+
/// - Parameter options: The JSON encoding options to represent.
56+
init(_ options: JSONEncodingOptions) {
57+
self.init()
58+
if options.contains(.prettyPrinted) { formUnion(.prettyPrinted) }
59+
if options.contains(.sortedKeys) { formUnion(.sortedKeys) }
60+
if options.contains(.withoutEscapingSlashes) { formUnion(.withoutEscapingSlashes) }
61+
}
62+
}

Sources/OpenAPIRuntime/Deprecated/Deprecated.swift

+21
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,25 @@ extension Configuration {
3737
) {
3838
self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: multipartBoundaryGenerator, xmlCoder: nil)
3939
}
40+
41+
/// Creates a new configuration with the specified values.
42+
///
43+
/// - Parameters:
44+
/// - dateTranscoder: The transcoder to use when converting between date
45+
/// and string values.
46+
/// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies.
47+
/// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads.
48+
@available(*, deprecated, renamed: "init(dateTranscoder:jsonEncodingOptions:multipartBoundaryGenerator:xmlCoder:)")
49+
@_disfavoredOverload public init(
50+
dateTranscoder: any DateTranscoder = .iso8601,
51+
multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random,
52+
xmlCoder: (any CustomCoder)? = nil
53+
) {
54+
self.init(
55+
dateTranscoder: dateTranscoder,
56+
jsonEncodingOptions: [.sortedKeys, .prettyPrinted],
57+
multipartBoundaryGenerator: multipartBoundaryGenerator,
58+
xmlCoder: xmlCoder
59+
)
60+
}
4061
}

Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift

+34
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414
import XCTest
15+
import HTTPTypes
16+
import Foundation
1517
@_spi(Generated) import OpenAPIRuntime
1618

1719
final class Test_Configuration: Test_Runtime {
@@ -27,4 +29,36 @@ final class Test_Configuration: Test_Runtime {
2729
XCTAssertEqual(try transcoder.encode(testDateWithFractionalSeconds), testDateWithFractionalSecondsString)
2830
XCTAssertEqual(testDateWithFractionalSeconds, try transcoder.decode(testDateWithFractionalSecondsString))
2931
}
32+
33+
func _testJSON(configuration: Configuration, expected: String) async throws {
34+
let converter = Converter(configuration: configuration)
35+
var headerFields: HTTPFields = [:]
36+
let body = try converter.setResponseBodyAsJSON(
37+
testPetWithPath,
38+
headerFields: &headerFields,
39+
contentType: "application/json"
40+
)
41+
let data = try await Data(collecting: body, upTo: 1024)
42+
XCTAssertEqualStringifiedData(data, expected)
43+
}
44+
45+
func testJSONEncodingOptions_default() async throws {
46+
try await _testJSON(configuration: Configuration(), expected: testPetWithPathPrettifiedWithEscapingSlashes)
47+
}
48+
49+
func testJSONEncodingOptions_empty() async throws {
50+
try await _testJSON(
51+
configuration: Configuration(jsonEncodingOptions: [
52+
.sortedKeys // without sorted keys, this test would be unreliable
53+
]),
54+
expected: testPetWithPathMinifiedWithEscapingSlashes
55+
)
56+
}
57+
58+
func testJSONEncodingOptions_prettyWithoutEscapingSlashes() async throws {
59+
try await _testJSON(
60+
configuration: Configuration(jsonEncodingOptions: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]),
61+
expected: testPetWithPathPrettifiedWithoutEscapingSlashes
62+
)
63+
}
3064
}

Tests/OpenAPIRuntimeTests/Test_Runtime.swift

+27
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ class Test_Runtime: XCTestCase {
117117

118118
var testStructPrettyData: Data { Data(testStructPrettyString.utf8) }
119119

120+
var testPetWithPath: TestPetWithPath { .init(name: "Fluffz", path: URL(string: "/land/forest")!) }
121+
122+
var testPetWithPathMinifiedWithEscapingSlashes: String { #"{"name":"Fluffz","path":"\/land\/forest"}"# }
123+
124+
var testPetWithPathPrettifiedWithEscapingSlashes: String {
125+
#"""
126+
{
127+
"name" : "Fluffz",
128+
"path" : "\/land\/forest"
129+
}
130+
"""#
131+
}
132+
133+
var testPetWithPathPrettifiedWithoutEscapingSlashes: String {
134+
#"""
135+
{
136+
"name" : "Fluffz",
137+
"path" : "/land/forest"
138+
}
139+
"""#
140+
}
141+
120142
var testStructURLFormData: Data { Data(testStructURLFormString.utf8) }
121143

122144
var testEvents: [TestPet] { [.init(name: "Rover"), .init(name: "Pancake")] }
@@ -247,6 +269,11 @@ public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticStri
247269

248270
struct TestPet: Codable, Equatable { var name: String }
249271

272+
struct TestPetWithPath: Codable, Equatable {
273+
var name: String
274+
var path: URL
275+
}
276+
250277
struct TestPetDetailed: Codable, Equatable {
251278
var name: String
252279
var type: String

0 commit comments

Comments
 (0)