From 26e8ae3515d1ff3607e924ac96fc0094775f55e8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 29 Jul 2024 20:50:06 +0200 Subject: [PATCH] Custom JSON encoding options (#112) --- .../Conversion/Configuration.swift | 26 ++++++++++++++ .../OpenAPIRuntime/Conversion/Converter.swift | 13 ++++++- .../Deprecated/Deprecated.swift | 21 ++++++++++++ .../Conversion/Test_Configuration.swift | 34 +++++++++++++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 27 +++++++++++++++ 5 files changed, 120 insertions(+), 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index f5ca02b..2ee7ab0 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -114,7 +114,27 @@ public protocol CustomCoder: Sendable { /// - Returns: A value of the requested type. /// - Throws: An error if decoding fails. func customDecode(_ type: T.Type, from data: Data) throws -> T +} + +/// The options that control the encoded JSON data. +public struct JSONEncodingOptions: OptionSet, Sendable { + + /// The format's default value. + public let rawValue: UInt + + /// Creates a JSONEncodingOptions value with the given raw value. + public init(rawValue: UInt) { self.rawValue = rawValue } + /// Include newlines and indentation to make the output more human-readable. + public static let prettyPrinted: JSONEncodingOptions = .init(rawValue: 1 << 0) + + /// Serialize JSON objects with field keys sorted in lexicographic order. + public static let sortedKeys: JSONEncodingOptions = .init(rawValue: 1 << 1) + + /// Omit escaping forward slashes with backslashes. + /// + /// Important: Only use this option when the output is not embedded in HTML/XML. + public static let withoutEscapingSlashes: JSONEncodingOptions = .init(rawValue: 1 << 2) } /// A set of configuration values used by the generated client and server types. @@ -123,6 +143,9 @@ public struct Configuration: Sendable { /// The transcoder used when converting between date and string values. public var dateTranscoder: any DateTranscoder + /// The options for the underlying JSON encoder. + public var jsonEncodingOptions: JSONEncodingOptions + /// The generator to use when creating mutlipart bodies. public var multipartBoundaryGenerator: any MultipartBoundaryGenerator @@ -134,14 +157,17 @@ public struct Configuration: Sendable { /// - Parameters: /// - dateTranscoder: The transcoder to use when converting between date /// and string values. + /// - jsonEncodingOptions: The options for the underlying JSON encoder. /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. public init( dateTranscoder: any DateTranscoder = .iso8601, + jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted], multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, xmlCoder: (any CustomCoder)? = nil ) { self.dateTranscoder = dateTranscoder + self.jsonEncodingOptions = jsonEncodingOptions self.multipartBoundaryGenerator = multipartBoundaryGenerator self.xmlCoder = xmlCoder } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter.swift b/Sources/OpenAPIRuntime/Conversion/Converter.swift index 69223da..606790f 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter.swift @@ -38,7 +38,7 @@ import class Foundation.JSONDecoder self.configuration = configuration self.encoder = JSONEncoder() - self.encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + self.encoder.outputFormatting = .init(configuration.jsonEncodingOptions) self.encoder.dateEncodingStrategy = .from(dateTranscoder: configuration.dateTranscoder) self.headerFieldEncoder = JSONEncoder() @@ -49,3 +49,14 @@ import class Foundation.JSONDecoder self.decoder.dateDecodingStrategy = .from(dateTranscoder: configuration.dateTranscoder) } } + +extension JSONEncoder.OutputFormatting { + /// Creates a new value. + /// - Parameter options: The JSON encoding options to represent. + init(_ options: JSONEncodingOptions) { + self.init() + if options.contains(.prettyPrinted) { formUnion(.prettyPrinted) } + if options.contains(.sortedKeys) { formUnion(.sortedKeys) } + if options.contains(.withoutEscapingSlashes) { formUnion(.withoutEscapingSlashes) } + } +} diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 1cfa5c9..c9f538e 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -37,4 +37,25 @@ extension Configuration { ) { self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: multipartBoundaryGenerator, xmlCoder: nil) } + + /// Creates a new configuration with the specified values. + /// + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date + /// and string values. + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. + @available(*, deprecated, renamed: "init(dateTranscoder:jsonEncodingOptions:multipartBoundaryGenerator:xmlCoder:)") + @_disfavoredOverload public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, + xmlCoder: (any CustomCoder)? = nil + ) { + self.init( + dateTranscoder: dateTranscoder, + jsonEncodingOptions: [.sortedKeys, .prettyPrinted], + multipartBoundaryGenerator: multipartBoundaryGenerator, + xmlCoder: xmlCoder + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift index 6027c42..e4e3ff0 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift @@ -12,6 +12,8 @@ // //===----------------------------------------------------------------------===// import XCTest +import HTTPTypes +import Foundation @_spi(Generated) import OpenAPIRuntime final class Test_Configuration: Test_Runtime { @@ -27,4 +29,36 @@ final class Test_Configuration: Test_Runtime { XCTAssertEqual(try transcoder.encode(testDateWithFractionalSeconds), testDateWithFractionalSecondsString) XCTAssertEqual(testDateWithFractionalSeconds, try transcoder.decode(testDateWithFractionalSecondsString)) } + + func _testJSON(configuration: Configuration, expected: String) async throws { + let converter = Converter(configuration: configuration) + var headerFields: HTTPFields = [:] + let body = try converter.setResponseBodyAsJSON( + testPetWithPath, + headerFields: &headerFields, + contentType: "application/json" + ) + let data = try await Data(collecting: body, upTo: 1024) + XCTAssertEqualStringifiedData(data, expected) + } + + func testJSONEncodingOptions_default() async throws { + try await _testJSON(configuration: Configuration(), expected: testPetWithPathPrettifiedWithEscapingSlashes) + } + + func testJSONEncodingOptions_empty() async throws { + try await _testJSON( + configuration: Configuration(jsonEncodingOptions: [ + .sortedKeys // without sorted keys, this test would be unreliable + ]), + expected: testPetWithPathMinifiedWithEscapingSlashes + ) + } + + func testJSONEncodingOptions_prettyWithoutEscapingSlashes() async throws { + try await _testJSON( + configuration: Configuration(jsonEncodingOptions: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), + expected: testPetWithPathPrettifiedWithoutEscapingSlashes + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 5a691f3..942c9df 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -117,6 +117,28 @@ class Test_Runtime: XCTestCase { var testStructPrettyData: Data { Data(testStructPrettyString.utf8) } + var testPetWithPath: TestPetWithPath { .init(name: "Fluffz", path: URL(string: "/land/forest")!) } + + var testPetWithPathMinifiedWithEscapingSlashes: String { #"{"name":"Fluffz","path":"\/land\/forest"}"# } + + var testPetWithPathPrettifiedWithEscapingSlashes: String { + #""" + { + "name" : "Fluffz", + "path" : "\/land\/forest" + } + """# + } + + var testPetWithPathPrettifiedWithoutEscapingSlashes: String { + #""" + { + "name" : "Fluffz", + "path" : "/land/forest" + } + """# + } + var testStructURLFormData: Data { Data(testStructURLFormString.utf8) } var testEvents: [TestPet] { [.init(name: "Rover"), .init(name: "Pancake")] } @@ -247,6 +269,11 @@ public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticStri struct TestPet: Codable, Equatable { var name: String } +struct TestPetWithPath: Codable, Equatable { + var name: String + var path: URL +} + struct TestPetDetailed: Codable, Equatable { var name: String var type: String