From 95307ba1c0b0184c97e6baf1c0f8ea3d5fd45733 Mon Sep 17 00:00:00 2001 From: Lars Peters <54734714+LarsPetersHH@users.noreply.github.com> Date: Thu, 18 Jan 2024 13:47:50 +0100 Subject: [PATCH 01/50] Bug/502 crash thread safety fix (#95) ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/502 - Ensure thread safety of `HTTPBody.collect(upTo)`. - `makeAsyncIterator()`: Instead of crashing, return AsyncSequence which throws `TooManyIterationsError` thereby honoring the contract for `IterationBehavior.single` (HTTPBody, MultipartBody) ### Modifications - HTTPBody, MultipartBody: `makeAsyncIterator()`: removed `try!`, catch error and create a sequence which throws the error on iteration. - This removed the need for `try checkIfCanCreateIterator()` in `HTTPBody.collect(upTo)`. **Note**: This creates a small change in behavior: There may be a `TooManyBytesError` thrown before the check for `iterationBehavior`. This approach uses the simplest code, IMO. If we want to keep that `iterationBehavior` is checked first and only after that for the length, then the code needs to be more complex. - Removed `try checkIfCanCreateIterator()` in both classes (only used in `HTTPBody`). ### Result - No intentional crash in `makeAsyncIterator()` anymore. - Tests supplied as example in https://github.com/apple/swift-openapi-generator/issues/502 succeed. ### Test Plan - Added check in `Test_Body.testIterationBehavior_single()` to ensure that using `makeAsyncIterator()` directly yields the expected error. - Added tests to check iteration behavior of `MultipartBody`. --------- Co-authored-by: Lars Peters --- .../OpenAPIRuntime/Interface/HTTPBody.swift | 25 +++------- .../Multipart/MultipartPublicTypes.swift | 22 ++++----- .../Interface/Test_HTTPBody.swift | 5 ++ .../Interface/Test_MultipartBody.swift | 49 +++++++++++++++++++ 4 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 Tests/OpenAPIRuntimeTests/Interface/Test_MultipartBody.swift diff --git a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index 648c504a..59292ec9 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -159,16 +159,6 @@ public final class HTTPBody: @unchecked Sendable { return locked_iteratorCreated } - /// Verifying that creating another iterator is allowed based on - /// the values of `iterationBehavior` and `locked_iteratorCreated`. - /// - Throws: If another iterator is not allowed to be created. - private func checkIfCanCreateIterator() throws { - lock.lock() - defer { lock.unlock() } - guard iterationBehavior == .single else { return } - if locked_iteratorCreated { throw TooManyIterationsError() } - } - /// Tries to mark an iterator as created, verifying that it is allowed /// based on the values of `iterationBehavior` and `locked_iteratorCreated`. /// - Throws: If another iterator is not allowed to be created. @@ -341,10 +331,12 @@ extension HTTPBody: AsyncSequence { /// Creates and returns an asynchronous iterator /// /// - Returns: An asynchronous iterator for byte chunks. + /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. public func makeAsyncIterator() -> AsyncIterator { - // The crash on error is intentional here. - try! tryToMarkIteratorCreated() - return .init(sequence.makeAsyncIterator()) + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } } } @@ -381,10 +373,6 @@ extension HTTPBody { /// than `maxBytes`. /// - Returns: A byte chunk containing all the accumulated bytes. fileprivate func collect(upTo maxBytes: Int) async throws -> ByteChunk { - - // Check that we're allowed to iterate again. - try checkIfCanCreateIterator() - // If the length is known, verify it's within the limit. if case .known(let knownBytes) = length { guard knownBytes <= maxBytes else { throw TooManyBytesError(maxBytes: maxBytes) } @@ -563,6 +551,9 @@ extension HTTPBody { var iterator = iterator self.produceNext = { try await iterator.next() } } + /// Creates an iterator throwing the given error when iterated. + /// - Parameter error: The error to throw on iteration. + fileprivate init(throwing error: any Error) { self.produceNext = { throw error } } /// Advances the iterator to the next element and returns it asynchronously. /// diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift index 2e4234e6..6db356c3 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -209,16 +209,6 @@ public final class MultipartBody: @unchecked Sendable { var errorDescription: String? { description } } - /// Verifying that creating another iterator is allowed based on the values of `iterationBehavior` - /// and `locked_iteratorCreated`. - /// - Throws: If another iterator is not allowed to be created. - internal func checkIfCanCreateIterator() throws { - lock.lock() - defer { lock.unlock() } - guard iterationBehavior == .single else { return } - if locked_iteratorCreated { throw TooManyIterationsError() } - } - /// Tries to mark an iterator as created, verifying that it is allowed based on the values /// of `iterationBehavior` and `locked_iteratorCreated`. /// - Throws: If another iterator is not allowed to be created. @@ -331,10 +321,12 @@ extension MultipartBody: AsyncSequence { /// Creates and returns an asynchronous iterator /// /// - Returns: An asynchronous iterator for parts. + /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. public func makeAsyncIterator() -> AsyncIterator { - // The crash on error is intentional here. - try! tryToMarkIteratorCreated() - return .init(sequence.makeAsyncIterator()) + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } } } @@ -355,6 +347,10 @@ extension MultipartBody { self.produceNext = { try await iterator.next() } } + /// Creates an iterator throwing the given error when iterated. + /// - Parameter error: The error to throw on iteration. + fileprivate init(throwing error: any Error) { self.produceNext = { throw error } } + /// Advances the iterator to the next element and returns it asynchronously. /// /// - Returns: The next element in the sequence, or `nil` if there are no more elements. diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift index 16a684c1..ede72366 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift @@ -173,6 +173,11 @@ final class Test_Body: Test_Runtime { _ = try await String(collecting: body, upTo: .max) XCTFail("Expected an error to be thrown") } catch {} + + do { + for try await _ in body {} + XCTFail("Expected an error to be thrown") + } catch {} } func testIterationBehavior_multiple() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_MultipartBody.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_MultipartBody.swift new file mode 100644 index 00000000..dfd7eab8 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_MultipartBody.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// 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 XCTest +@_spi(Generated) @testable import OpenAPIRuntime +import Foundation + +final class Test_MultipartBody: XCTestCase { + + func testIterationBehavior_single() async throws { + let sourceSequence = (0.. Date: Thu, 18 Jan 2024 18:35:32 +0100 Subject: [PATCH 02/50] Set content length in request/response bodies (#96) ### Motivation A surprising oversight, we were never setting the `content-length` header when sending out a known-length body. Some transports might be already doing this, but this change makes things more consistent. ### Modifications Add the `content-length` header when setting a body and we know the length from the `HTTPBody`. ### Result More consistent experience. ### Test Plan Adapted unit tests. --- .../Conversion/CurrencyExtensions.swift | 8 ++++++-- .../Conversion/Test_Converter+Client.swift | 14 +++++++------- .../Conversion/Test_Converter+Server.swift | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index df6caf04..38a17115 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -435,8 +435,10 @@ extension Converter { contentType: String, convert: (T) throws -> HTTPBody ) rethrows -> HTTPBody { + let body = try convert(value) headerFields[.contentType] = contentType - return try convert(value) + if case let .known(length) = body.length { headerFields[.contentLength] = String(length) } + return body } /// Sets the provided request body and the appropriate content type. @@ -597,8 +599,10 @@ extension Converter { contentType: String, convert: (T) throws -> HTTPBody ) rethrows -> HTTPBody { + let body = try convert(value) headerFields[.contentType] = contentType - return try convert(value) + if case let .known(length) = body.length { headerFields[.contentLength] = String(length) } + return body } /// Returns a decoded value for the provided path parameter. diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 57c11580..4a7b669c 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -95,7 +95,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/json" ) try await XCTAssertEqualStringifiedData(body, testStructPrettyString) - XCTAssertEqual(headerFields, [.contentType: "application/json"]) + XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "23"]) } func test_setOptionalRequestBodyAsJSON_codable_string() async throws { @@ -106,7 +106,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/json" ) try await XCTAssertEqualStringifiedData(body, testQuotedString) - XCTAssertEqual(headerFields, [.contentType: "application/json"]) + XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "7"]) } // | client | set | request body | JSON | required | setRequiredRequestBodyAsJSON | @@ -118,7 +118,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/json" ) try await XCTAssertEqualStringifiedData(body, testStructPrettyString) - XCTAssertEqual(headerFields, [.contentType: "application/json"]) + XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "23"]) } // | client | set | request body | urlEncodedForm | codable | optional | setRequiredRequestBodyAsURLEncodedForm | @@ -136,7 +136,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { } try await XCTAssertEqualStringifiedData(body, testStructURLFormString) - XCTAssertEqual(headerFields, [.contentType: "application/x-www-form-urlencoded"]) + XCTAssertEqual(headerFields, [.contentType: "application/x-www-form-urlencoded", .contentLength: "41"]) } // | client | set | request body | urlEncodedForm | codable | required | setRequiredRequestBodyAsURLEncodedForm | @@ -148,7 +148,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/x-www-form-urlencoded" ) try await XCTAssertEqualStringifiedData(body, testStructURLFormString) - XCTAssertEqual(headerFields, [.contentType: "application/x-www-form-urlencoded"]) + XCTAssertEqual(headerFields, [.contentType: "application/x-www-form-urlencoded", .contentLength: "41"]) } // | client | set | request body | binary | optional | setOptionalRequestBodyAsBinary | @@ -160,7 +160,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(body, testString) - XCTAssertEqual(headerFields, [.contentType: "application/octet-stream"]) + XCTAssertEqual(headerFields, [.contentType: "application/octet-stream", .contentLength: "5"]) } // | client | set | request body | binary | required | setRequiredRequestBodyAsBinary | @@ -172,7 +172,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(body, testString) - XCTAssertEqual(headerFields, [.contentType: "application/octet-stream"]) + XCTAssertEqual(headerFields, [.contentType: "application/octet-stream", .contentLength: "5"]) } // | client | set | request body | multipart | required | setRequiredRequestBodyAsMultipart | diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index d70a58d7..632116b2 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -316,7 +316,7 @@ final class Test_ServerConverterExtensions: Test_Runtime { contentType: "application/json" ) try await XCTAssertEqualStringifiedData(data, testStructPrettyString) - XCTAssertEqual(headers, [.contentType: "application/json"]) + XCTAssertEqual(headers, [.contentType: "application/json", .contentLength: "23"]) } // | server | set | response body | binary | required | setResponseBodyAsBinary | @@ -328,7 +328,7 @@ final class Test_ServerConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(data, testString) - XCTAssertEqual(headers, [.contentType: "application/octet-stream"]) + XCTAssertEqual(headers, [.contentType: "application/octet-stream", .contentLength: "5"]) } // | server | set | response body | multipart | required | setResponseBodyAsMultipart | From a875c2d45477be7a6d193ed3201603d05175d164 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 11 Mar 2024 08:05:15 +0000 Subject: [PATCH 03/50] Update CI to use release 5.10 (#101) --- docker/docker-compose.2204.510.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.2204.510.yaml b/docker/docker-compose.2204.510.yaml index 02e5d46e..079f4339 100644 --- a/docker/docker-compose.2204.510.yaml +++ b/docker/docker-compose.2204.510.yaml @@ -5,7 +5,8 @@ services: image: &image swift-openapi-runtime:22.04-5.10 build: args: - base_image: "swiftlang/swift:nightly-5.10-jammy" + ubuntu_version: "jammy" + swift_version: "5.10" test: image: *image From 634b7ebb1cd2e131a8400e3176b9f53205f8fff8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 3 Apr 2024 10:43:08 +0200 Subject: [PATCH 04/50] Fix empty additionalProperties dictionary encoding (#103) ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/525. Turns out that the mere act of creating a decoding container is meaningful and we skipped it as an optimization, causing JSONDecoder to fail for empty dictionaries when used in additional properties. ### Modifications Remove the extra guards that skipped creating a container, even when we already know there are no elements. ### Result No more failures when encoding empty dictionaries in additionalProperties. ### Test Plan Tested manually as this requirement seems to be coming out of JSONDecoder. --- Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift index 5aa893bf..6e9f5edc 100644 --- a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift @@ -102,7 +102,6 @@ /// - Parameter additionalProperties: A container of additional properties. /// - Throws: An error if there are issues with encoding the additional properties. public func encodeAdditionalProperties(_ additionalProperties: OpenAPIObjectContainer) throws { - guard !additionalProperties.value.isEmpty else { return } var container = container(keyedBy: StringKey.self) for (key, value) in additionalProperties.value { try container.encode(OpenAPIValueContainer(unvalidatedValue: value), forKey: .init(key)) @@ -116,7 +115,6 @@ /// - Parameter additionalProperties: A container of additional properties. /// - Throws: An error if there are issues with encoding the additional properties. public func encodeAdditionalProperties(_ additionalProperties: [String: T]) throws { - guard !additionalProperties.isEmpty else { return } var container = container(keyedBy: StringKey.self) for (key, value) in additionalProperties { try container.encode(value, forKey: .init(key)) } } From 6e2f9b9787a2ba597fd97960bfa65ff101bf79b8 Mon Sep 17 00:00:00 2001 From: Ugo Cottin Date: Fri, 5 Apr 2024 08:31:34 +0200 Subject: [PATCH 05/50] Add support for application/xml body (#102) ### Motivation See [apple/swift-openapi-generator#556](https://github.com/apple/swift-openapi-generator/issues/556) for more. ### Modifications Add converter methods for encoding and decoding XML request and response body. Add `CustomCoder` protocol, allows to use other `Encoder` and `Decoder` for other `content-type` body. User can define custom coder, and assign a custom coder to a specific content-type within `Configuration.customCoders` dictionary. ### Result It's now possible to define custom encoder and decoder for supported content-type. ### Test Plan Added converter methods are tested with a mock custom coder for `application/xml` content-type. To avoid adding a dependency to a XMLCoder like [CoreOffice/XMLCoder](https://github.com/CoreOffice/XMLCoder), mock custom coder uses JSONEncoder and JSONDecoder. Encoding and decoding to XML are out of scope of the tests, because encoding and decoding logic must be provided by user through custom coder implementation. --------- Signed-off-by: Ugo Cottin Co-authored-by: Honza Dvorsky --- .../OpenAPIRuntime/Base/OpenAPIMIMEType.swift | 3 + .../Conversion/Configuration.swift | 29 +++++++- .../Conversion/Converter+Client.swift | 67 +++++++++++++++++++ .../Conversion/Converter+Server.swift | 59 ++++++++++++++++ .../Conversion/CurrencyExtensions.swift | 26 +++++++ .../Deprecated/Deprecated.swift | 16 +++++ .../OpenAPIRuntime/Errors/RuntimeError.swift | 3 + .../Conversion/Test_Converter+Client.swift | 31 +++++++++ .../Conversion/Test_Converter+Server.swift | 29 ++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 10 ++- 10 files changed, 271 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 6dc2a730..d7092b1d 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -16,6 +16,9 @@ import Foundation /// A container for a parsed, valid MIME type. @_spi(Generated) public struct OpenAPIMIMEType: Equatable { + /// XML MIME type + public static let xml: OpenAPIMIMEType = .init(kind: .concrete(type: "application", subtype: "xml")) + /// The kind of the MIME type. public enum Kind: Equatable { diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index e0b593a5..f5ca02be 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -96,6 +96,27 @@ extension JSONDecoder.DateDecodingStrategy { } } +/// A type that allows custom content type encoding and decoding. +public protocol CustomCoder: Sendable { + + /// Encodes the given value and returns its custom encoded representation. + /// + /// - Parameter value: The value to encode. + /// - Returns: A new `Data` value containing the custom encoded data. + /// - Throws: An error if encoding fails. + func customEncode(_ value: T) throws -> Data + + /// Decodes a value of the given type from the given custom representation. + /// + /// - Parameters: + /// - type: The type of the value to decode. + /// - data: The data to decode from. + /// - Returns: A value of the requested type. + /// - Throws: An error if decoding fails. + func customDecode(_ type: T.Type, from data: Data) throws -> T + +} + /// A set of configuration values used by the generated client and server types. public struct Configuration: Sendable { @@ -105,17 +126,23 @@ public struct Configuration: Sendable { /// The generator to use when creating mutlipart bodies. public var multipartBoundaryGenerator: any MultipartBoundaryGenerator + /// Custom XML coder for encoding and decoding xml bodies. + public var xmlCoder: (any CustomCoder)? + /// 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. public init( dateTranscoder: any DateTranscoder = .iso8601, - multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, + xmlCoder: (any CustomCoder)? = nil ) { self.dateTranscoder = dateTranscoder self.multipartBoundaryGenerator = multipartBoundaryGenerator + self.xmlCoder = xmlCoder } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index ea575002..28abbdb2 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -127,6 +127,50 @@ extension Converter { convert: convertBodyCodableToJSON ) } + /// Sets an optional request body as XML in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The optional value to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// + /// - Returns: An `HTTPBody` representing the XML-encoded request body, or `nil` if the `value` is `nil`. + /// + /// - Throws: An error if setting the request body as XML fails. + public func setOptionalRequestBodyAsXML( + _ value: T?, + headerFields: inout HTTPFields, + contentType: String + ) throws -> HTTPBody? { + try setOptionalRequestBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } + /// Sets a required request body as XML in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The value to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// + /// - Returns: An `HTTPBody` representing the XML-encoded request body. + /// + /// - Throws: An error if setting the request body as XML fails. + public func setRequiredRequestBodyAsXML( + _ value: T, + headerFields: inout HTTPFields, + contentType: String + ) throws -> HTTPBody { + try setRequiredRequestBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } /// Sets an optional request body as binary in the specified header fields and returns an `HTTPBody`. /// @@ -275,6 +319,29 @@ extension Converter { convert: convertJSONToBodyCodable ) } + /// Retrieves the response body as XML and transforms it into a specified type. + /// + /// - Parameters: + /// - type: The type to decode the XML into. + /// - data: The HTTP body data containing the XML. + /// - transform: A transformation function to apply to the decoded XML. + /// + /// - Returns: The transformed result of type `C`. + /// + /// - Throws: An error if retrieving or transforming the response body fails. + public func getResponseBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C { + guard let data else { throw RuntimeError.missingRequiredResponseBody } + return try await getBufferingResponseBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } /// Retrieves the response body as binary data and transforms it into a specified type. /// diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index e8f36306..75b0f521 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -214,6 +214,47 @@ extension Converter { ) } + /// Retrieves and decodes an optional XML-encoded request body and transforms it to a different type. + /// + /// - Parameters: + /// - type: The type to decode the request body into. + /// - data: The HTTP request body to decode, or `nil` if the body is not present. + /// - transform: A closure that transforms the decoded value to a different type. + /// - Returns: The transformed value, or `nil` if the request body is not present or if decoding fails. + /// - Throws: An error if there are issues decoding or transforming the request body. + public func getOptionalRequestBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C? { + try await getOptionalBufferingRequestBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } + /// Retrieves and decodes a required XML-encoded request body and transforms it to a different type. + /// + /// - Parameters: + /// - type: The type to decode the request body into. + /// - data: The HTTP request body to decode, or `nil` if the body is not present. + /// - transform: A closure that transforms the decoded value to a different type. + /// - Returns: The transformed value. + /// - Throws: An error if the request body is not present, if decoding fails, or if there are issues transforming the request body. + public func getRequiredRequestBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C { + try await getRequiredBufferingRequestBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } + /// Retrieves and transforms an optional binary request body. /// /// - Parameters: @@ -347,6 +388,24 @@ extension Converter { convert: convertBodyCodableToJSON ) } + /// Sets the response body as XML data, serializing the provided value. + /// + /// - Parameters: + /// - value: The value to be serialized into the response body. + /// - headerFields: The HTTP header fields to update with the new `contentType`. + /// - contentType: The content type to set in the HTTP header fields. + /// - Returns: An `HTTPBody` with the response body set as XML data. + /// - Throws: An error if serialization or setting the response body fails. + public func setResponseBodyAsXML(_ value: T, headerFields: inout HTTPFields, contentType: String) + throws -> HTTPBody + { + try setResponseBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } /// Sets the response body as binary data. /// diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 38a17115..ea07d217 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -144,6 +144,32 @@ extension Converter { return HTTPBody(data) } + /// Returns a value decoded from a XML body. + /// - Parameter body: The body containing the raw XML bytes. + /// - Returns: A decoded value. + /// - Throws: An error if decoding from the body fails. + /// - Throws: An error if no custom coder is present for XML coding. + func convertXMLToBodyCodable(_ body: HTTPBody) async throws -> T { + guard let coder = configuration.xmlCoder else { + throw RuntimeError.missingCoderForCustomContentType(contentType: OpenAPIMIMEType.xml.description) + } + let data = try await Data(collecting: body, upTo: .max) + return try coder.customDecode(T.self, from: data) + } + + /// Returns a XML body for the provided encodable value. + /// - Parameter value: The value to encode as XML. + /// - Returns: The raw XML body. + /// - Throws: An error if encoding to XML fails. + /// - Throws: An error if no custom coder is present for XML coding. + func convertBodyCodableToXML(_ value: T) throws -> HTTPBody { + guard let coder = configuration.xmlCoder else { + throw RuntimeError.missingCoderForCustomContentType(contentType: OpenAPIMIMEType.xml.description) + } + let data = try coder.customEncode(value) + return HTTPBody(data) + } + /// Returns a value decoded from a URL-encoded form body. /// - Parameter body: The body containing the raw URL-encoded form bytes. /// - Returns: A decoded value. diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index bf030d1c..1cfa5c99 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -22,3 +22,19 @@ extension UndocumentedPayload { self.init(headerFields: [:], body: nil) } } + +extension Configuration { + /// 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. + @available(*, deprecated, renamed: "init(dateTranscoder:multipartBoundaryGenerator:xmlCoder:)") @_disfavoredOverload + public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + ) { + self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: multipartBoundaryGenerator, xmlCoder: nil) + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 150b804c..f3c21db9 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -26,6 +26,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Data conversion case failedToDecodeStringConvertibleValue(type: String) + case missingCoderForCustomContentType(contentType: String) enum ParameterLocation: String, CustomStringConvertible { case query @@ -88,6 +89,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret 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 .missingCoderForCustomContentType(let contentType): + return "Missing custom coder for content type '\(contentType)'." case .unsupportedParameterStyle(name: let name, location: let location, style: let style, explode: let explode): return "Unsupported parameter style, parameter name: '\(name)', kind: \(location), style: \(style), explode: \(explode)" diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 4a7b669c..0f3bf066 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -120,6 +120,28 @@ final class Test_ClientConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(body, testStructPrettyString) XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "23"]) } + // | client | set | request body | XML | optional | setOptionalRequestBodyAsXML | + func test_setOptionalRequestBodyAsXML_codable() async throws { + var headerFields: HTTPFields = [:] + let body = try converter.setOptionalRequestBodyAsXML( + testStruct, + headerFields: &headerFields, + contentType: "application/xml" + ) + try await XCTAssertEqualStringifiedData(body, testStructString) + XCTAssertEqual(headerFields, [.contentType: "application/xml", .contentLength: "17"]) + } + // | client | set | request body | XML | required | setRequiredRequestBodyAsXML | + func test_setRequiredRequestBodyAsXML_codable() async throws { + var headerFields: HTTPFields = [:] + let body = try converter.setRequiredRequestBodyAsXML( + testStruct, + headerFields: &headerFields, + contentType: "application/xml" + ) + try await XCTAssertEqualStringifiedData(body, testStructString) + XCTAssertEqual(headerFields, [.contentType: "application/xml", .contentLength: "17"]) + } // | client | set | request body | urlEncodedForm | codable | optional | setRequiredRequestBodyAsURLEncodedForm | func test_setOptionalRequestBodyAsURLEncodedForm_codable() async throws { @@ -206,6 +228,15 @@ final class Test_ClientConverterExtensions: Test_Runtime { ) XCTAssertEqual(value, testStruct) } + // | client | get | response body | XML | required | getResponseBodyAsXML | + func test_getResponseBodyAsXML_codable() async throws { + let value: TestPet = try await converter.getResponseBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(value, testStruct) + } // | client | get | response body | binary | required | getResponseBodyAsBinary | func test_getResponseBodyAsBinary_data() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 632116b2..b2305a08 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -247,6 +247,24 @@ final class Test_ServerConverterExtensions: Test_Runtime { ) XCTAssertEqual(body, testStruct) } + // | server | get | request body | XML | optional | getOptionalRequestBodyAsXML | + func test_getOptionalRequestBodyAsXML_codable() async throws { + let body: TestPet? = try await converter.getOptionalRequestBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(body, testStruct) + } + // | server | get | request body | XML | required | getRequiredRequestBodyAsXML | + func test_getRequiredRequestBodyAsXML_codable() async throws { + let body: TestPet = try await converter.getRequiredRequestBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(body, testStruct) + } // | server | get | request body | urlEncodedForm | optional | getOptionalRequestBodyAsURLEncodedForm | func test_getOptionalRequestBodyAsURLEncodedForm_codable() async throws { @@ -318,6 +336,17 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(data, testStructPrettyString) XCTAssertEqual(headers, [.contentType: "application/json", .contentLength: "23"]) } + // | server | set | response body | XML | required | setResponseBodyAsXML | + func test_setResponseBodyAsXML_codable() async throws { + var headers: HTTPFields = [:] + let data = try converter.setResponseBodyAsXML( + testStruct, + headerFields: &headers, + contentType: "application/xml" + ) + try await XCTAssertEqualStringifiedData(data, testStructString) + XCTAssertEqual(headers, [.contentType: "application/xml", .contentLength: "17"]) + } // | server | set | response body | binary | required | setResponseBodyAsBinary | func test_setResponseBodyAsBinary_data() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 9de89902..fe31067e 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -26,7 +26,8 @@ class Test_Runtime: XCTestCase { var serverURL: URL { get throws { try URL(validatingOpenAPIServerURL: "/api") } } - var configuration: Configuration { .init(multipartBoundaryGenerator: .constant) } + var customCoder: any CustomCoder { MockCustomCoder() } + var configuration: Configuration { .init(multipartBoundaryGenerator: .constant, xmlCoder: customCoder) } var converter: Converter { .init(configuration: configuration) } @@ -222,6 +223,13 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware { } } +struct MockCustomCoder: CustomCoder { + func customEncode(_ value: T) throws -> Data where T: Encodable { try JSONEncoder().encode(value) } + func customDecode(_ type: T.Type, from data: Data) throws -> T where T: Decodable { + try JSONDecoder().decode(T.self, from: data) + } +} + /// Asserts that a given URL's absolute string representation is equal to an expected string. /// /// - Parameters: From 9d4a2ffb98c1bb6a2a037b267a4ef0eec0654059 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Tue, 9 Apr 2024 05:37:15 -0400 Subject: [PATCH 06/50] Improve error message when encountering unexpected content-type headers (#104) ### Motivation When the server response has a content-type header that does not conform to the OpenAPI document, we currently throw an error. However, this error presents itself in a very confusing manner: it prints `Unexpected Content-Type header: application/json`, where `application/json is the _expected_ content-type. At best, this is ambiguous and potentially misleading. ### Modifications - Extend `case RuntimeError.unexpectedContentTypeHeader` with _both_ the expected and received content-type associated values. - Update the printed description to include both the expected and received content-type header values. ### Result When an unexpected content-type is received, the error message is clearer. ### Test Plan Updated the existing test that expects an error to check the error and that the error values are provided in the correct order. --- .../Conversion/Converter+Common.swift | 7 ++++++- Sources/OpenAPIRuntime/Errors/RuntimeError.swift | 5 +++-- .../Conversion/Test_Converter+Common.swift | 13 +++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index dc908e75..73f8fecb 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -61,7 +61,12 @@ extension Converter { // The force unwrap is safe, we only get here if the array is not empty. let bestOption = evaluatedOptions.max { a, b in a.match.score < b.match.score }! let bestContentType = bestOption.contentType - if case .incompatible = bestOption.match { throw RuntimeError.unexpectedContentTypeHeader(bestContentType) } + if case .incompatible = bestOption.match { + throw RuntimeError.unexpectedContentTypeHeader( + expected: bestContentType, + received: String(describing: received) + ) + } return bestContentType } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index f3c21db9..b0c776ed 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -37,7 +37,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Headers case missingRequiredHeaderField(String) - case unexpectedContentTypeHeader(String) + case unexpectedContentTypeHeader(expected: String, received: String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) case missingOrMalformedContentDispositionName @@ -95,7 +95,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Unsupported parameter style, parameter name: '\(name)', kind: \(location), style: \(style), explode: \(explode)" case .missingRequiredHeaderField(let name): return "The required header field named '\(name)' is missing." - case .unexpectedContentTypeHeader(let contentType): return "Unexpected Content-Type header: \(contentType)" + case .unexpectedContentTypeHeader(expected: let expected, received: let received): + return "Unexpected content type, expected: \(expected), received: \(received)" case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" case .malformedAcceptHeader(let accept): return "Malformed Accept header: \(accept)" case .missingOrMalformedContentDispositionName: diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 925ebf4f..e82b12ab 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@testable @_spi(Generated) import OpenAPIRuntime import HTTPTypes extension HTTPField.Name { static var foo: Self { Self("foo")! } } @@ -84,7 +84,16 @@ final class Test_CommonConverterExtensions: Test_Runtime { try testCase(received: "image/png", options: ["image/*", "*/*"], expected: "image/*") XCTAssertThrowsError( try testCase(received: "text/csv", options: ["text/html", "application/json"], expected: "-") - ) + ) { error in + XCTAssert(error is RuntimeError) + guard let error = error as? RuntimeError, + case .unexpectedContentTypeHeader(expected: let expected, received: let received) = error, + expected == "text/html", received == "text/csv" + else { + XCTFail("Unexpected error: \(error)") + return + } + } } func testVerifyContentTypeIfPresent() throws { From 9a8291fa2f90cc7296f2393a99bb4824ee34f869 Mon Sep 17 00:00:00 2001 From: kostis stefanou Date: Tue, 16 Apr 2024 12:51:05 +0300 Subject: [PATCH 07/50] [Runtime] Add support of deepObject style in query params (#100) ### Motivation The runtime changes for: https://github.com/apple/swift-openapi-generator/issues/259 ### Modifications Added `deepObject` style to serializer & parser in order to support nested keys on query parameters. ### Result Support nested keys on query parameters. ### Test Plan These are just the runtime changes, tested together with generated changes. --------- Co-authored-by: Honza Dvorsky --- .../Conversion/CurrencyExtensions.swift | 4 +- .../Conversion/ParameterStyles.swift | 5 + .../Common/URICoderConfiguration.swift | 2 + .../URICoder/Parsing/URIParser.swift | 43 ++++++- .../Serialization/URISerializer.swift | 26 +++- .../URICoder/Encoding/Test_URIEncoder.swift | 7 ++ .../URICoder/Parsing/Test_URIParser.swift | 65 +++++++--- .../Serialization/Test_URISerializer.swift | 97 ++++++++++---- .../URICoder/Test_URICodingRoundtrip.swift | 118 +++++++++++++----- .../URICoder/URICoderTestUtils.swift | 6 + 10 files changed, 300 insertions(+), 73 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index ea07d217..fc50b2a1 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -29,7 +29,9 @@ extension ParameterStyle { ) { let resolvedStyle = style ?? .defaultForQueryItems let resolvedExplode = explode ?? ParameterStyle.defaultExplodeFor(forStyle: resolvedStyle) - guard resolvedStyle == .form else { + switch resolvedStyle { + case .form, .deepObject: break + default: throw RuntimeError.unsupportedParameterStyle( name: name, location: .query, diff --git a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift index fb95bce7..07aa6092 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -26,6 +26,10 @@ /// /// Details: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2 case simple + /// The deepObject style. + /// + /// Details: https://spec.openapis.org/oas/v3.1.0.html#style-values + case deepObject } extension ParameterStyle { @@ -53,6 +57,7 @@ extension URICoderConfiguration.Style { switch style { case .form: self = .form case .simple: self = .simple + case .deepObject: self = .deepObject } } } diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift index bfb42c48..ccbdb8c5 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift @@ -25,6 +25,8 @@ struct URICoderConfiguration { /// A style for form-based URI expansion. case form + /// A style for nested variable expansion + case deepObject } /// A character used to escape the space character. diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index 3be75420..c1cb5940 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -36,13 +36,15 @@ struct URIParser: Sendable { } /// A typealias for the underlying raw string storage. -private typealias Raw = String.SubSequence +typealias Raw = String.SubSequence /// A parser error. -private enum ParsingError: Swift.Error { +enum ParsingError: Swift.Error, Hashable { /// A malformed key-value pair was detected. case malformedKeyValuePair(Raw) + /// An invalid configuration was detected. + case invalidConfiguration(String) } // MARK: - Parser implementations @@ -61,6 +63,7 @@ extension URIParser { switch configuration.style { case .form: return [:] case .simple: return ["": [""]] + case .deepObject: return [:] } } switch (configuration.style, configuration.explode) { @@ -68,6 +71,10 @@ extension URIParser { case (.form, false): return try parseUnexplodedFormRoot() case (.simple, true): return try parseExplodedSimpleRoot() case (.simple, false): return try parseUnexplodedSimpleRoot() + case (.deepObject, true): return try parseExplodedDeepObjectRoot() + case (.deepObject, false): + let reason = "Deep object style is only valid with explode set to true" + throw ParsingError.invalidConfiguration(reason) } } @@ -205,6 +212,38 @@ extension URIParser { } } } + /// Parses the root node assuming the raw string uses the deepObject style + /// and the explode parameter is enabled. + /// - Returns: The parsed root node. + /// - Throws: An error if parsing fails. + private mutating func parseExplodedDeepObjectRoot() throws -> URIParsedNode { + let parseNode = try parseGenericRoot { data, appendPair in + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let nestedKeyStartingCharacter: Character = "[" + let nestedKeyEndingCharacter: Character = "]" + func nestedKey(from deepObjectKey: String.SubSequence) -> Raw { + var unescapedDeepObjectKey = Substring(deepObjectKey.removingPercentEncoding ?? "") + let topLevelKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyStartingCharacter) + let nestedKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyEndingCharacter) + return nestedKey.isEmpty ? topLevelKey : nestedKey + } + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + guard case .foundFirst = firstResult else { throw ParsingError.malformedKeyValuePair(firstValue) } + // Hit the key/value separator, so a value will follow. + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = nestedKey(from: firstValue) + let value = secondValue + appendPair(key, [value]) + } + } + for (key, value) in parseNode where value.count > 1 { throw ParsingError.malformedKeyValuePair(key) } + return parseNode + } } // MARK: - URIParser utilities diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index 26071f85..45d3b0da 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -65,10 +65,16 @@ extension CharacterSet { extension URISerializer { /// A serializer error. - private enum SerializationError: Swift.Error { + enum SerializationError: Swift.Error, Hashable { /// Nested containers are not supported. case nestedContainersNotSupported + /// Deep object arrays are not supported. + case deepObjectsArrayNotSupported + /// Deep object with primitive values are not supported. + case deepObjectsWithPrimitiveValuesNotSupported + /// An invalid configuration was detected. + case invalidConfiguration(String) } /// Computes an escaped version of the provided string. @@ -117,6 +123,7 @@ extension URISerializer { switch configuration.style { case .form: keyAndValueSeparator = "=" case .simple: keyAndValueSeparator = nil + case .deepObject: throw SerializationError.deepObjectsWithPrimitiveValuesNotSupported } try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator) case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key) @@ -180,6 +187,7 @@ extension URISerializer { case (.simple, _): keyAndValueSeparator = nil pairSeparator = "," + case (.deepObject, _): throw SerializationError.deepObjectsArrayNotSupported } func serializeNext(_ element: URIEncodedNode.Primitive) throws { if let keyAndValueSeparator { @@ -228,8 +236,18 @@ extension URISerializer { case (.simple, false): keyAndValueSeparator = "," pairSeparator = "," + case (.deepObject, true): + keyAndValueSeparator = "=" + pairSeparator = "&" + case (.deepObject, false): + let reason = "Deep object style is only valid with explode set to true" + throw SerializationError.invalidConfiguration(reason) } + func serializeNestedKey(_ elementKey: String, forKey rootKey: String) -> String { + guard case .deepObject = configuration.style else { return elementKey } + return rootKey + "[" + elementKey + "]" + } func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws { try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator) } @@ -238,10 +256,12 @@ extension URISerializer { data.append(containerKeyAndValue) } for (elementKey, element) in sortedDictionary.dropLast() { - try serializeNext(element, forKey: elementKey) + try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key)) data.append(pairSeparator) } - if let (elementKey, element) = sortedDictionary.last { try serializeNext(element, forKey: elementKey) } + if let (elementKey, element) = sortedDictionary.last { + try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key)) + } } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift index fe9d445e..fd5cdd20 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift @@ -23,4 +23,11 @@ final class Test_URIEncoder: Test_Runtime { let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") XCTAssertEqual(encodedString, "bar=hello+world") } + func testNestedEncoding() throws { + struct Foo: Encodable { var bar: String } + let serializer = URISerializer(configuration: .deepObjectExplode) + let encoder = URIEncoder(serializer: serializer) + let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") + XCTAssertEqual(encodedString, "root%5Bbar%5D=hello%20world") + } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 9bd8f3e8..86c962e1 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -18,6 +18,7 @@ final class Test_URIParser: Test_Runtime { let testedVariants: [URICoderConfiguration] = [ .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, + .deepObjectExplode, ] func testParsing() throws { @@ -29,7 +30,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("", value: ["": [""]]), simpleUnexplode: .custom("", value: ["": [""]]), formDataExplode: "empty=", - formDataUnexplode: "empty=" + formDataUnexplode: "empty=", + deepObjectExplode: "object%5Bempty%5D=" ), value: ["empty": [""]] ), @@ -40,7 +42,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("", value: ["": [""]]), simpleUnexplode: .custom("", value: ["": [""]]), formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ), value: [:] ), @@ -51,7 +54,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("fred", value: ["": ["fred"]]), simpleUnexplode: .custom("fred", value: ["": ["fred"]]), formDataExplode: "who=fred", - formDataUnexplode: "who=fred" + formDataUnexplode: "who=fred", + deepObjectExplode: "object%5Bwho%5D=fred" ), value: ["who": ["fred"]] ), @@ -62,7 +66,8 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("Hello%20World", value: ["": ["Hello World"]]), simpleUnexplode: .custom("Hello%20World", value: ["": ["Hello World"]]), formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World" + formDataUnexplode: "hello=Hello+World", + deepObjectExplode: "object%5Bhello%5D=Hello%20World" ), value: ["hello": ["Hello World"]] ), @@ -73,7 +78,11 @@ final class Test_URIParser: Test_Runtime { simpleExplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue" + formDataUnexplode: "list=red,green,blue", + deepObjectExplode: .custom( + "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue", + expectedError: .malformedKeyValuePair("list") + ) ), value: ["list": ["red", "green", "blue"]] ), @@ -93,7 +102,8 @@ final class Test_URIParser: Test_Runtime { formDataUnexplode: .custom( "keys=comma,%2C,dot,.,semi,%3B", value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] - ) + ), + deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B" ), value: ["semi": [";"], "dot": ["."], "comma": [","]] ), @@ -101,14 +111,28 @@ final class Test_URIParser: Test_Runtime { for testCase in cases { func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws { var parser = URIParser(configuration: variant.config, data: input.string[...]) - let parsedNode = try parser.parseRoot() - XCTAssertEqual( - parsedNode, - input.valueOverride ?? testCase.value, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line - ) + do { + let parsedNode = try parser.parseRoot() + XCTAssertEqual( + parsedNode, + input.valueOverride ?? testCase.value, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } catch { + guard let expectedError = input.expectedError, let parsingError = error as? ParsingError else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: testCase.file, line: testCase.line) + return + } + XCTAssertEqual( + expectedError, + parsingError, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } } let variants = testCase.variants try testVariant(.formExplode, variants.formExplode) @@ -117,6 +141,7 @@ final class Test_URIParser: Test_Runtime { try testVariant(.simpleUnexplode, variants.simpleUnexplode) try testVariant(.formDataExplode, variants.formDataExplode) try testVariant(.formDataUnexplode, variants.formDataUnexplode) + try testVariant(.deepObjectExplode, variants.deepObjectExplode) } } } @@ -133,25 +158,32 @@ extension Test_URIParser { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } struct Variants { struct Input: ExpressibleByStringLiteral { var string: String var valueOverride: URIParsedNode? + var expectedError: ParsingError? - init(string: String, valueOverride: URIParsedNode? = nil) { + init(string: String, valueOverride: URIParsedNode? = nil, expectedError: ParsingError? = nil) { self.string = string self.valueOverride = valueOverride + self.expectedError = expectedError } static func custom(_ string: String, value: URIParsedNode) -> Self { - .init(string: string, valueOverride: value) + .init(string: string, valueOverride: value, expectedError: nil) + } + static func custom(_ string: String, expectedError: ParsingError) -> Self { + .init(string: string, valueOverride: nil, expectedError: expectedError) } init(stringLiteral value: String) { self.string = value self.valueOverride = nil + self.expectedError = nil } } @@ -161,6 +193,7 @@ extension Test_URIParser { var simpleUnexplode: Input var formDataExplode: Input var formDataUnexplode: Input + var deepObjectExplode: Input } var variants: Variants var value: URIParsedNode diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index f93fabed..688c508a 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -31,7 +31,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "empty=", - formDataUnexplode: "empty=" + formDataUnexplode: "empty=", + deepObjectExplode: .custom("empty=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -43,7 +44,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "fred", simpleUnexplode: "fred", formDataExplode: "who=fred", - formDataUnexplode: "who=fred" + formDataUnexplode: "who=fred", + deepObjectExplode: .custom("who=fred", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -55,7 +57,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "1234", simpleUnexplode: "1234", formDataExplode: "x=1234", - formDataUnexplode: "x=1234" + formDataUnexplode: "x=1234", + deepObjectExplode: .custom("x=1234", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -67,7 +70,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "12.34", simpleUnexplode: "12.34", formDataExplode: "x=12.34", - formDataUnexplode: "x=12.34" + formDataUnexplode: "x=12.34", + deepObjectExplode: .custom("x=12.34", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -79,7 +83,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "true", simpleUnexplode: "true", formDataExplode: "enabled=true", - formDataUnexplode: "enabled=true" + formDataUnexplode: "enabled=true", + deepObjectExplode: .custom( + "enabled=true", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ), makeCase( @@ -91,7 +99,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "Hello%20World", simpleUnexplode: "Hello%20World", formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World" + formDataUnexplode: "hello=Hello+World", + deepObjectExplode: .custom( + "hello=Hello%20World", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ), makeCase( @@ -103,7 +115,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "red,green,blue", simpleUnexplode: "red,green,blue", formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue" + formDataUnexplode: "list=red,green,blue", + deepObjectExplode: .custom( + "list=red&list=green&list=blue", + expectedError: .deepObjectsArrayNotSupported + ) ) ), makeCase( @@ -118,21 +134,38 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "comma=%2C,dot=.,semi=%3B", simpleUnexplode: "comma,%2C,dot,.,semi,%3B", formDataExplode: "comma=%2C&dot=.&semi=%3B", - formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B" + formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B", + deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B" ) ), ] for testCase in cases { - func testVariant(_ variant: Case.Variant, _ expectedString: String) throws { + func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws { var serializer = URISerializer(configuration: variant.config) - let encodedString = try serializer.serializeNode(testCase.value, forKey: testCase.key) - XCTAssertEqual( - encodedString, - expectedString, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line - ) + do { + let encodedString = try serializer.serializeNode(testCase.value, forKey: testCase.key) + XCTAssertEqual( + encodedString, + input.string, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } catch { + guard let expectedError = input.expectedError, + let serializationError = error as? URISerializer.SerializationError + else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: testCase.file, line: testCase.line) + return + } + XCTAssertEqual( + expectedError, + serializationError, + "Failed for config: \(variant.name)", + file: testCase.file, + line: testCase.line + ) + } } try testVariant(.formExplode, testCase.variants.formExplode) try testVariant(.formUnexplode, testCase.variants.formUnexplode) @@ -140,6 +173,7 @@ final class Test_URISerializer: Test_Runtime { try testVariant(.simpleUnexplode, testCase.variants.simpleUnexplode) try testVariant(.formDataExplode, testCase.variants.formDataExplode) try testVariant(.formDataUnexplode, testCase.variants.formDataUnexplode) + try testVariant(.deepObjectExplode, testCase.variants.deepObjectExplode) } } } @@ -156,14 +190,31 @@ extension Test_URISerializer { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } struct Variants { - var formExplode: String - var formUnexplode: String - var simpleExplode: String - var simpleUnexplode: String - var formDataExplode: String - var formDataUnexplode: String + struct Input: ExpressibleByStringLiteral { + var string: String + var expectedError: URISerializer.SerializationError? + init(string: String, expectedError: URISerializer.SerializationError? = nil) { + self.string = string + self.expectedError = expectedError + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, expectedError: expectedError) + } + init(stringLiteral value: String) { + self.string = value + self.expectedError = nil + } + } + var formExplode: Input + var formUnexplode: Input + var simpleExplode: Input + var simpleUnexplode: Input + var formDataExplode: Input + var formDataUnexplode: Input + var deepObjectExplode: Input } var value: URIEncodedNode var key: String diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index ccfe52c4..cc0dc29c 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -96,7 +96,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "root=", - formDataUnexplode: "root=" + formDataUnexplode: "root=", + deepObjectExplode: .custom("root=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -110,7 +111,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "Hello%20World%21", simpleUnexplode: "Hello%20World%21", formDataExplode: "root=Hello+World%21", - formDataUnexplode: "root=Hello+World%21" + formDataUnexplode: "root=Hello+World%21", + deepObjectExplode: .custom( + "root=Hello%20World%21", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ) @@ -124,7 +129,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "red", simpleUnexplode: "red", formDataExplode: "root=red", - formDataUnexplode: "root=red" + formDataUnexplode: "root=red", + deepObjectExplode: .custom("root=red", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -138,7 +144,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "1234", simpleUnexplode: "1234", formDataExplode: "root=1234", - formDataUnexplode: "root=1234" + formDataUnexplode: "root=1234", + deepObjectExplode: .custom("root=1234", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -152,7 +159,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "12.34", simpleUnexplode: "12.34", formDataExplode: "root=12.34", - formDataUnexplode: "root=12.34" + formDataUnexplode: "root=12.34", + deepObjectExplode: .custom("root=12.34", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -166,7 +174,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "true", simpleUnexplode: "true", formDataExplode: "root=true", - formDataUnexplode: "root=true" + formDataUnexplode: "root=true", + deepObjectExplode: .custom("root=true", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -180,7 +189,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "2023-08-25T07%3A34%3A59Z", simpleUnexplode: "2023-08-25T07%3A34%3A59Z", formDataExplode: "root=2023-08-25T07%3A34%3A59Z", - formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z" + formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z", + deepObjectExplode: .custom( + "root=2023-08-25T07%3A34%3A59Z", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ) @@ -194,7 +207,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "a,b,c", simpleUnexplode: "a,b,c", formDataExplode: "list=a&list=b&list=c", - formDataUnexplode: "list=a,b,c" + formDataUnexplode: "list=a,b,c", + deepObjectExplode: .custom("list=a&list=b&list=c", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -208,7 +222,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", simpleUnexplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", formDataExplode: "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", - formDataUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z" + formDataUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", + deepObjectExplode: .custom( + "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", + expectedError: .deepObjectsArrayNotSupported + ) ) ) @@ -222,7 +240,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: .custom("", value: [""]), simpleUnexplode: .custom("", value: [""]), formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -236,7 +255,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "red,green,blue", simpleUnexplode: "red,green,blue", formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue" + formDataUnexplode: "list=red,green,blue", + deepObjectExplode: .custom( + "list=red&list=green&list=blue", + expectedError: .deepObjectsArrayNotSupported + ) ) ) @@ -250,7 +273,9 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "bar=24,color=red,date=2023-08-25T07%3A34%3A59Z,empty=,foo=hi%21", simpleUnexplode: "bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", formDataExplode: "bar=24&color=red&date=2023-08-25T07%3A34%3A59Z&empty=&foo=hi%21", - formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21" + formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", + deepObjectExplode: + "keys%5Bbar%5D=24&keys%5Bcolor%5D=red&keys%5Bdate%5D=2023-08-25T07%3A34%3A59Z&keys%5Bempty%5D=&keys%5Bfoo%5D=hi%21" ) ) @@ -265,7 +290,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "2023-01-18T10%3A04%3A11Z", simpleUnexplode: "2023-01-18T10%3A04%3A11Z", formDataExplode: "root=2023-01-18T10%3A04%3A11Z", - formDataUnexplode: "root=2023-01-18T10%3A04%3A11Z" + formDataUnexplode: "root=2023-01-18T10%3A04%3A11Z", + deepObjectExplode: .custom( + "root=2023-01-18T10%3A04%3A11Z", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ) try _test( @@ -277,7 +306,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "green", simpleUnexplode: "green", formDataExplode: "root=green", - formDataUnexplode: "root=green" + formDataUnexplode: "root=green", + deepObjectExplode: .custom("root=green", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( @@ -289,7 +319,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "foo=bar", simpleUnexplode: "foo,bar", formDataExplode: "foo=bar", - formDataUnexplode: "root=foo,bar" + formDataUnexplode: "root=foo,bar", + deepObjectExplode: "root%5Bfoo%5D=bar" ) ) @@ -304,7 +335,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) @@ -318,7 +350,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "bar=24,color=red,empty=,foo=hi%21", simpleUnexplode: "bar,24,color,red,empty,,foo,hi%21", formDataExplode: "bar=24&color=red&empty=&foo=hi%21", - formDataUnexplode: "keys=bar,24,color,red,empty,,foo,hi%21" + formDataUnexplode: "keys=bar,24,color,red,empty,,foo,hi%21", + deepObjectExplode: "keys%5Bbar%5D=24&keys%5Bcolor%5D=red&keys%5Bempty%5D=&keys%5Bfoo%5D=hi%21" ) ) @@ -332,7 +365,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: .custom("", value: ["": ""]), simpleUnexplode: .custom("", value: ["": ""]), formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) } @@ -347,21 +381,28 @@ final class Test_URICodingRoundtrip: Test_Runtime { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", configuration: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", configuration: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", configuration: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", configuration: .deepObjectExplode) } struct Variants { struct Input: ExpressibleByStringLiteral { var string: String var customValue: T? - - init(string: String, customValue: T?) { + var expectedError: URISerializer.SerializationError? + init(string: String, customValue: T?, expectedError: URISerializer.SerializationError?) { self.string = string self.customValue = customValue + self.expectedError = expectedError } - init(stringLiteral value: String) { self.init(string: value, customValue: nil) } + init(stringLiteral value: String) { self.init(string: value, customValue: nil, expectedError: nil) } - static func custom(_ string: String, value: T) -> Self { .init(string: string, customValue: value) } + static func custom(_ string: String, value: T) -> Self { + .init(string: string, customValue: value, expectedError: nil) + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, customValue: nil, expectedError: expectedError) + } } var formExplode: Input @@ -370,6 +411,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { var simpleUnexplode: Input var formDataExplode: Input var formDataUnexplode: Input + var deepObjectExplode: Input } func _test( @@ -381,11 +423,27 @@ final class Test_URICodingRoundtrip: Test_Runtime { ) throws { func testVariant(name: String, configuration: URICoderConfiguration, variant: Variants.Input) throws { let encoder = URIEncoder(configuration: configuration) - let encodedString = try encoder.encode(value, forKey: key) - XCTAssertEqual(encodedString, variant.string, "Variant: \(name)", file: file, line: line) - let decoder = URIDecoder(configuration: configuration) - let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) - XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) + do { + let encodedString = try encoder.encode(value, forKey: key) + XCTAssertEqual(encodedString, variant.string, "Variant: \(name)", file: file, line: line) + let decoder = URIDecoder(configuration: configuration) + let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) + XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) + } catch { + guard let expectedError = variant.expectedError, + let serializationError = error as? URISerializer.SerializationError + else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: file, line: line) + return + } + XCTAssertEqual( + expectedError, + serializationError, + "Failed for config: \(variant.string)", + file: file, + line: line + ) + } } try testVariant(name: "formExplode", configuration: .formExplode, variant: variants.formExplode) try testVariant(name: "formUnexplode", configuration: .formUnexplode, variant: variants.formUnexplode) @@ -397,6 +455,10 @@ final class Test_URICodingRoundtrip: Test_Runtime { configuration: .formDataUnexplode, variant: variants.formDataUnexplode ) + try testVariant( + name: "deepObjectExplode", + configuration: .deepObjectExplode, + variant: variants.deepObjectExplode + ) } - } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift index 375c266a..65235d82 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift @@ -59,4 +59,10 @@ extension URICoderConfiguration { spaceEscapingCharacter: .plus, dateTranscoder: defaultDateTranscoder ) + static let deepObjectExplode: Self = .init( + style: .deepObject, + explode: true, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: defaultDateTranscoder + ) } From acb1838dfe52caa1f68ca874bf98b7c79937e8fe Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 11 Jun 2024 17:03:44 +0200 Subject: [PATCH 08/50] Swift 6 prep - Part 1 (#106) ### Motivation Fixes to get closer to building in Swift 6 mode without diagnostics. ### Modifications - Made two public types Sendable - s/#file/#filePath in tests ### Result Builds with fewer warnings when trying Swift 6 mode (will open another PR for the remaining work). ### Test Plan Unit tests passed. --- .../OpenAPIRuntime/Base/OpenAPIMIMEType.swift | 4 ++-- .../Base/Test_ContentDisposition.swift | 2 +- .../Base/Test_OpenAPIMIMEType.swift | 4 ++-- .../Conversion/Test_Converter+Common.swift | 12 ++++++++---- .../Test_ServerSentEventsDecoding.swift | 8 +++++--- .../Test_ServerSentEventsEncoding.swift | 6 ++++-- .../Interface/Test_HTTPBody.swift | 18 ++++++++++++------ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 2 +- .../Decoder/Test_URIValueFromNodeDecoder.swift | 2 +- .../URICoder/Test_URICodingRoundtrip.swift | 2 +- 10 files changed, 37 insertions(+), 23 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index d7092b1d..3d7adef8 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -14,13 +14,13 @@ import Foundation /// A container for a parsed, valid MIME type. -@_spi(Generated) public struct OpenAPIMIMEType: Equatable { +@_spi(Generated) public struct OpenAPIMIMEType: Equatable, Sendable { /// XML MIME type public static let xml: OpenAPIMIMEType = .init(kind: .concrete(type: "application", subtype: "xml")) /// The kind of the MIME type. - public enum Kind: Equatable { + public enum Kind: Equatable, Sendable { /// Any, spelled as `*/*`. case any diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift index b820929d..121c5fdd 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -21,7 +21,7 @@ final class Test_ContentDisposition: Test_Runtime { input: String, parsed: ContentDisposition?, output: String?, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { let value = ContentDisposition(rawValue: input) diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift index 207d3920..a28ec47d 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -88,7 +88,7 @@ final class Test_OpenAPIMIMEType: Test_Runtime { receivedParameters: [String: String], against option: OpenAPIMIMEType, expected expectedMatch: OpenAPIMIMEType.Match, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { let result = OpenAPIMIMEType.evaluate( @@ -109,7 +109,7 @@ final class Test_OpenAPIMIMEType: Test_Runtime { func testJSONWith2Params( against option: OpenAPIMIMEType, expected expectedMatch: OpenAPIMIMEType.Match, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { testCase( diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index e82b12ab..85d04f25 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -26,7 +26,7 @@ final class Test_CommonConverterExtensions: Test_Runtime { received: String?, options: [String], expected expectedChoice: String, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) throws { let choice = try converter.bestContentType(received: received.map { .init($0)! }, options: options) @@ -109,9 +109,13 @@ final class Test_CommonConverterExtensions: Test_Runtime { } func testExtractContentDispositionNameAndFilename() throws { - func testCase(value: String?, name: String?, filename: String?, file: StaticString = #file, line: UInt = #line) - throws - { + func testCase( + value: String?, + name: String?, + filename: String?, + file: StaticString = #filePath, + line: UInt = #line + ) throws { let headerFields: HTTPFields if let value { headerFields = [.contentDisposition: value] } else { headerFields = [:] } let (actualName, actualFilename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift index be98e6f1..79d645a5 100644 --- a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift @@ -16,7 +16,9 @@ import XCTest import Foundation final class Test_ServerSentEventsDecoding: Test_Runtime { - func _test(input: String, output: [ServerSentEvent], file: StaticString = #file, line: UInt = #line) async throws { + func _test(input: String, output: [ServerSentEvent], file: StaticString = #filePath, line: UInt = #line) + async throws + { let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)).asDecodedServerSentEvents() let events = try await [ServerSentEvent](collecting: sequence) XCTAssertEqual(events.count, output.count, file: file, line: line) @@ -85,7 +87,7 @@ final class Test_ServerSentEventsDecoding: Test_Runtime { func _testJSONData( input: String, output: [ServerSentEventWithJSONData], - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)) @@ -123,7 +125,7 @@ final class Test_ServerSentEventsDecoding: Test_Runtime { } final class Test_ServerSentEventsDecoding_Lines: Test_Runtime { - func _test(input: String, output: [String], file: StaticString = #file, line: UInt = #line) async throws { + func _test(input: String, output: [String], file: StaticString = #filePath, line: UInt = #line) async throws { let upstream = asOneBytePerElementSequence(ArraySlice(input.utf8)) let sequence = ServerSentEventsLineDeserializationSequence(upstream: upstream) let lines = try await [ArraySlice](collecting: sequence) diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift index db88cd60..ac8922da 100644 --- a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift @@ -16,7 +16,9 @@ import XCTest import Foundation final class Test_ServerSentEventsEncoding: Test_Runtime { - func _test(input: [ServerSentEvent], output: String, file: StaticString = #file, line: UInt = #line) async throws { + func _test(input: [ServerSentEvent], output: String, file: StaticString = #filePath, line: UInt = #line) + async throws + { let sequence = WrappedSyncSequence(sequence: input).asEncodedServerSentEvents() try await XCTAssertEqualAsyncData(sequence, output.utf8, file: file, line: line) } @@ -73,7 +75,7 @@ final class Test_ServerSentEventsEncoding: Test_Runtime { func _testJSONData( input: [ServerSentEventWithJSONData], output: String, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let sequence = WrappedSyncSequence(sequence: input).asEncodedServerSentEventsWithJSONData() diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift index ede72366..1ae395f7 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift @@ -229,16 +229,22 @@ final class Test_Body: Test_Runtime { } extension Test_Body { - func _testConsume(_ body: HTTPBody, expected: HTTPBody.ByteChunk, file: StaticString = #file, line: UInt = #line) - async throws - { + func _testConsume( + _ body: HTTPBody, + expected: HTTPBody.ByteChunk, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { let output = try await ArraySlice(collecting: body, upTo: .max) XCTAssertEqual(output, expected, file: file, line: line) } - func _testConsume(_ body: HTTPBody, expected: some StringProtocol, file: StaticString = #file, line: UInt = #line) - async throws - { + func _testConsume( + _ body: HTTPBody, + expected: some StringProtocol, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { let output = try await String(collecting: body, upTo: .max) XCTAssertEqual(output, expected.description, file: file, line: line) } diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index fe31067e..5a691f33 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -237,7 +237,7 @@ struct MockCustomCoder: CustomCoder { /// - rhs: The expected absolute string representation. /// - file: The file name to include in the failure message (default is the source file where this function is called). /// - line: The line number to include in the failure message (default is the line where this function is called). -public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticString = #file, line: UInt = #line) { +public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticString = #filePath, line: UInt = #line) { guard let lhs else { XCTFail("URL is nil") return diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift index bbbf4dae..c805f3b6 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift @@ -81,7 +81,7 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { key: String, style: URICoderConfiguration.Style = .form, explode: Bool = true, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) throws { let decoder = URIValueFromNodeDecoder( diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index cc0dc29c..3f351768 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -418,7 +418,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { _ value: T, key: String, _ variants: Variants, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) throws { func testVariant(name: String, configuration: URICoderConfiguration, variant: Variants.Input) throws { From e80046bc806e27b9cc0b052eb325a04664de66ae Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 2 Jul 2024 11:33:17 -0400 Subject: [PATCH 09/50] Fixing Issues with Async Upstream (#108) ### Motivation Fixing Issues with Building in Swift 6 for Linux ### Modifications * Fix Issues with Upstream Iterator ### Result Compatability with Swift 6 on Linux ### Test Plan TBD --- .../MultipartFramesToRawPartsSequence.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift index c5823ec2..8734b6aa 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift @@ -342,8 +342,17 @@ extension MultipartFramesToRawPartsSequence { switch stateMachine.nextFromPartSequence() { case .returnNil: return nil case .fetchFrame: + let frame: Upstream.AsyncIterator.Element? var upstream = upstream - let frame = try await upstream.next() + #if compiler(>=6.0) + if #available(macOS 15, iOS 18.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) { + frame = try await upstream.next(isolation: self) + } else { + frame = try await upstream.next() + } + #else + frame = try await upstream.next() + #endif self.upstream = upstream switch stateMachine.partReceivedFrame(frame) { case .returnNil: return nil @@ -365,8 +374,17 @@ extension MultipartFramesToRawPartsSequence { switch stateMachine.nextFromBodySubsequence() { case .returnNil: return nil case .fetchFrame: + let frame: Upstream.AsyncIterator.Element? var upstream = upstream - let frame = try await upstream.next() + #if compiler(>=6.0) + if #available(macOS 15, iOS 18.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) { + frame = try await upstream.next(isolation: self) + } else { + frame = try await upstream.next() + } + #else + frame = try await upstream.next() + #endif self.upstream = upstream switch stateMachine.bodyReceivedFrame(frame) { case .returnNil: return nil From 39fa3ec57879bdeef185854bdc2ce356e1cb66b5 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 17 Jul 2024 16:43:40 +0200 Subject: [PATCH 10/50] Support NSNull in OpenAPIContainer types (#109) ### Motivation When receiving containers from adopter code, there might be NSNull values, which represent a nil value. Previously, this value would not be treated as nil, instead it'd throw an error as an unrecognized type. ### Modifications Handle NSNull and treat it as nil. ### Result You can provide a container with an NSNull nested value and it'll get encoded correctly. ### Test Plan Added a unit test. --- .../OpenAPIRuntime/Base/OpenAPIValue.swift | 17 +++++++++++++++ .../Base/Test_OpenAPIValue.swift | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index 0d39a6c4..2bb98f4c 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -12,6 +12,14 @@ // //===----------------------------------------------------------------------===// +#if canImport(Foundation) +#if canImport(Darwin) +import class Foundation.NSNull +#else +@preconcurrency import class Foundation.NSNull +#endif +#endif + /// A container for a value represented by JSON Schema. /// /// Contains an untyped JSON value. In some cases, the structure of the data @@ -62,6 +70,9 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Throws: When the value is not supported. static func tryCast(_ value: (any Sendable)?) throws -> (any Sendable)? { guard let value = value else { return nil } + #if canImport(Foundation) + if value is NSNull { return value } + #endif if let array = value as? [(any Sendable)?] { return try array.map(tryCast(_:)) } if let dictionary = value as? [String: (any Sendable)?] { return try dictionary.mapValues(tryCast(_:)) } if let value = tryCastPrimitiveType(value) { return value } @@ -123,6 +134,12 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { try container.encodeNil() return } + #if canImport(Foundation) + if value is NSNull { + try container.encodeNil() + return + } + #endif switch value { case let value as Bool: try container.encode(value) case let value as Int: try container.encode(value) diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index d95ee8c4..89a4a5a8 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -12,6 +12,13 @@ // //===----------------------------------------------------------------------===// import XCTest +#if canImport(Foundation) +#if canImport(Darwin) +import class Foundation.NSNull +#else +@preconcurrency import class Foundation.NSNull +#endif +#endif @_spi(Generated) @testable import OpenAPIRuntime final class Test_OpenAPIValue: Test_Runtime { @@ -22,6 +29,10 @@ final class Test_OpenAPIValue: Test_Runtime { _ = OpenAPIValueContainer(1) _ = OpenAPIValueContainer(4.5) + #if canImport(Foundation) + XCTAssertEqual(try OpenAPIValueContainer(unvalidatedValue: NSNull()).value as? NSNull, NSNull()) + #endif + _ = try OpenAPIValueContainer(unvalidatedValue: ["hello"]) _ = try OpenAPIValueContainer(unvalidatedValue: ["hello": "world"]) @@ -60,6 +71,16 @@ final class Test_OpenAPIValue: Test_Runtime { """# try _testPrettyEncoded(container, expectedJSON: expectedString) } + #if canImport(Foundation) + func testEncodingNSNull() throws { + let value = NSNull() + let container = try OpenAPIValueContainer(unvalidatedValue: value) + let expectedString = #""" + null + """# + try _testPrettyEncoded(container, expectedJSON: expectedString) + } + #endif func testEncoding_container_failure() throws { struct Foobar: Equatable {} From 71fcfa7691794054aa44538420acc281cc8e94e9 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 19 Jul 2024 13:31:59 +0200 Subject: [PATCH 11/50] Improved encoding of NSNumber in OpenAPIValueContainer (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved encoding of NSNumber in OpenAPIValueContainer ### Motivation When getting CoreFoundation/Foundation types, especially numbers, they automatically bridge to Swift types like Bool, Int, etc. That casting is pretty flexible, and allows e.g. casting a number into a boolean, which isn't desired when encoding into JSON, as `false` and `0` represent very different values. Previously, we relied on the automatic casting to know how to encode values, however that produced incorrect results in some cases. ### Modifications Add explicit handling of CF/NS types and try to encode using that new method before falling back to testing for native Swift types. This ensures that the original intention of the creator of the CF/NS types doesn't get lost in encoding. ### Result Correct encoding into JSON of types produced in the CF/NS world, like JSONSerialization. ### Test Plan Added unit tests. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (5.9.0) - Build finished. ✔︎ pull request validation (api breakage) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. https://github.com/apple/swift-openapi-runtime/pull/110 --- .gitignore | 1 + .../OpenAPIRuntime/Base/OpenAPIValue.swift | 42 +++++++++++++ .../Base/Test_OpenAPIValue.swift | 59 +++++++++++++++++-- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index f6f5465e..c01c56a8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ DerivedData/ /Package.resolved .ci/ .docc-build/ +.swiftpm diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index 2bb98f4c..a1be0397 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -18,6 +18,8 @@ import class Foundation.NSNull #else @preconcurrency import class Foundation.NSNull #endif +import class Foundation.NSNumber +import CoreFoundation #endif /// A container for a value represented by JSON Schema. @@ -139,6 +141,10 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { try container.encodeNil() return } + if let nsNumber = value as? NSNumber { + try encode(nsNumber, to: &container) + return + } #endif switch value { case let value as Bool: try container.encode(value) @@ -156,6 +162,42 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { ) } } + /// Encodes the provided NSNumber based on its internal representation. + /// - Parameters: + /// - value: The NSNumber that boxes one of possibly many different types of values. + /// - container: The container to encode the value in. + /// - Throws: An error if the encoding process encounters issues or if the value is invalid. + private func encode(_ value: NSNumber, to container: inout any SingleValueEncodingContainer) throws { + if value === kCFBooleanTrue { + try container.encode(true) + } else if value === kCFBooleanFalse { + try container.encode(false) + } else { + #if canImport(ObjectiveC) + let nsNumber = value as CFNumber + #else + let nsNumber = unsafeBitCast(value, to: CFNumber.self) + #endif + let type = CFNumberGetType(nsNumber) + switch type { + case .sInt8Type, .charType: try container.encode(value.int8Value) + case .sInt16Type, .shortType: try container.encode(value.int16Value) + case .sInt32Type, .intType: try container.encode(value.int32Value) + case .sInt64Type, .longLongType: try container.encode(value.int64Value) + case .float32Type, .floatType: try container.encode(value.floatValue) + case .float64Type, .doubleType, .cgFloatType: try container.encode(value.doubleValue) + case .nsIntegerType, .longType, .cfIndexType: try container.encode(value.intValue) + default: + throw EncodingError.invalidValue( + value, + .init( + codingPath: container.codingPath, + debugDescription: "OpenAPIValueContainer cannot encode NSNumber of the underlying type: \(type)" + ) + ) + } + } + } // MARK: Equatable diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 89a4a5a8..7049502e 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -13,11 +13,8 @@ //===----------------------------------------------------------------------===// import XCTest #if canImport(Foundation) -#if canImport(Darwin) -import class Foundation.NSNull -#else -@preconcurrency import class Foundation.NSNull -#endif +@preconcurrency import Foundation +import CoreFoundation #endif @_spi(Generated) @testable import OpenAPIRuntime @@ -80,8 +77,58 @@ final class Test_OpenAPIValue: Test_Runtime { """# try _testPrettyEncoded(container, expectedJSON: expectedString) } - #endif + func testEncodingNSNumber() throws { + func assertEncodedCF( + _ value: CFNumber, + as encodedValue: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + #if canImport(ObjectiveC) + let nsNumber = value as NSNumber + #else + let nsNumber = unsafeBitCast(self, to: NSNumber.self) + #endif + try assertEncoded(nsNumber, as: encodedValue, file: file, line: line) + } + func assertEncoded( + _ value: NSNumber, + as encodedValue: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let container = try OpenAPIValueContainer(unvalidatedValue: value) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(container) + XCTAssertEqual(String(decoding: data, as: UTF8.self), encodedValue, file: file, line: line) + } + try assertEncoded(NSNumber(value: true as Bool), as: "true") + try assertEncoded(NSNumber(value: false as Bool), as: "false") + try assertEncoded(NSNumber(value: 24 as Int8), as: "24") + try assertEncoded(NSNumber(value: 24 as Int16), as: "24") + try assertEncoded(NSNumber(value: 24 as Int32), as: "24") + try assertEncoded(NSNumber(value: 24 as Int64), as: "24") + try assertEncoded(NSNumber(value: 24 as Int), as: "24") + try assertEncoded(NSNumber(value: 24 as UInt8), as: "24") + try assertEncoded(NSNumber(value: 24 as UInt16), as: "24") + try assertEncoded(NSNumber(value: 24 as UInt32), as: "24") + try assertEncoded(NSNumber(value: 24 as UInt64), as: "24") + try assertEncoded(NSNumber(value: 24 as UInt), as: "24") + #if canImport(ObjectiveC) + try assertEncoded(NSNumber(value: 24 as NSInteger), as: "24") + #endif + try assertEncoded(NSNumber(value: 24 as CFIndex), as: "24") + try assertEncoded(NSNumber(value: 24.1 as Float32), as: "24.1") + try assertEncoded(NSNumber(value: 24.1 as Float64), as: "24.1") + try assertEncoded(NSNumber(value: 24.1 as Float), as: "24.1") + try assertEncoded(NSNumber(value: 24.1 as Double), as: "24.1") + XCTAssertThrowsError(try assertEncodedCF(kCFNumberNaN, as: "-")) + XCTAssertThrowsError(try assertEncodedCF(kCFNumberNegativeInfinity, as: "-")) + XCTAssertThrowsError(try assertEncodedCF(kCFNumberPositiveInfinity, as: "-")) + } + #endif func testEncoding_container_failure() throws { struct Foobar: Equatable {} XCTAssertThrowsError(try OpenAPIValueContainer(unvalidatedValue: Foobar())) { error in From 26e8ae3515d1ff3607e924ac96fc0094775f55e8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 29 Jul 2024 20:50:06 +0200 Subject: [PATCH 12/50] 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 f5ca02be..2ee7ab00 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 69223da5..606790f7 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 1cfa5c99..c9f538ef 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 6027c423..e4e3ff03 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 5a691f33..942c9df2 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 From f95974c7614696487f07c9f30f91495536dd4402 Mon Sep 17 00:00:00 2001 From: "LamTrinh.Dev" Date: Sat, 7 Sep 2024 23:20:29 +0700 Subject: [PATCH 13/50] Correct the link of sswg-security at SECURITY.md (#114) ### Motivation: Correct the link of sswg-security at SECURITY.md. Currently it is showing "Not Found". ### Modifications: + https://github.com/swift-server/sswg/blob/main/process/incubation.md#security-best-practices => https://www.swift.org/sswg/security/ ### Result: Correct the link of sswg-security --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index e0f13947..3bee8213 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,6 +38,6 @@ with the details usually included with bug reports. forums][swift-forums-sec]. [sswg]: https://github.com/swift-server/sswg -[sswg-security]: https://github.com/swift-server/sswg/blob/main/process/incubation.md#security-best-practices +[sswg-security]: https://www.swift.org/sswg/security/ [swift-forums-sec]: https://forums.swift.org/c/server/security-updates/ [mitre]: https://cveform.mitre.org/ From e1f390ae6a2e4ee8bc2c9d675ed07c19b5152076 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Wed, 2 Oct 2024 11:49:11 +0100 Subject: [PATCH 14/50] ci: Migrate to GitHub Actions and reusable workflows, part one (#116) ### Motivation Following on from the migration in Swift OpenAPI Generator, we need to migrate this repo to GitHub Actions based CI and the reusable workflows that are currently in the NIO repo. ### Modifications In order to bootstrap the migration, we need to merge the workflows in, otherwise we won't get any PR feedback on them while we get them ready. As a practical matter, they are all passing locally (verified) by `act` but it would be nice to stage these in so we can keep a green CI while we migrate and decommission the old CI. So this disables the soundness checks for now, so we can then use a follow up PR to do the cut over with testing in the PR. ### Result Old CI still working, new CI should start running in some capacity. --- .github/workflows/pull_request.yml | 45 ++++++++++++++++++++++ .github/workflows/scheduled.yml | 32 +++++++++++++++ .licenseignore | 9 +++++ .swiftformatignore | 1 + scripts/check-for-breaking-api-changes.sh | 2 +- scripts/check-for-broken-symlinks.sh | 2 +- scripts/check-for-docc-warnings.sh | 2 +- scripts/check-for-unacceptable-language.sh | 2 +- scripts/check-license-headers.sh | 6 ++- scripts/generate-contributors-list.sh | 2 +- scripts/run-integration-test.sh | 6 +-- scripts/run-swift-format.sh | 2 +- scripts/soundness.sh | 2 +- 13 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/pull_request.yml create mode 100644 .github/workflows/scheduled.yml create mode 100644 .licenseignore create mode 100644 .swiftformatignore mode change 100644 => 100755 scripts/run-integration-test.sh diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 00000000..0b6b0c61 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,45 @@ +name: PR + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + soundness: + name: Soundness + uses: apple/swift-nio/.github/workflows/soundness.yml@main + with: + # These are set to false to stage this in with the old CI. + # A follow-up PR will cut them over. + api_breakage_check_enabled: false + broken_symlink_check_enabled: false + docs_check_enabled: false + format_check_enabled: false + license_header_check_enabled: false + license_header_check_project_name: "SwiftOpenAPIGenerator" + shell_check_enabled: false + unacceptable_language_check_enabled: false + + unit-tests: + name: Unit tests + uses: apple/swift-nio/.github/workflows/unit_tests.yml@main + with: + linux_5_8_enabled: false + linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" + linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_main_enabled: false + + integration-test: + name: Integration test + uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + with: + name: "Integration test" + matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" + matrix_linux_5_8_enabled: false + matrix_linux_nightly_main_enabled: false + + swift-6-language-mode: + name: Swift 6 Language Mode + uses: apple/swift-nio/.github/workflows/swift_6_language_mode.yml@main + if: false # Disabled for now. diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml new file mode 100644 index 00000000..cb5f46df --- /dev/null +++ b/.github/workflows/scheduled.yml @@ -0,0 +1,32 @@ +name: Scheduled + +on: + schedule: + - cron: "0 8,20 * * *" + +jobs: + unit-tests: + name: Unit tests + uses: apple/swift-nio/.github/workflows/unit_tests.yml@main + with: + linux_5_8_enabled: false + linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" + linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + + integration-test: + name: Integration test + uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + with: + name: "Integration test" + matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" + matrix_linux_5_8_enabled: false + + example-packages: + name: Example packages + uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + with: + name: "Example packages" + matrix_linux_command: "./scripts/test-examples.sh" + matrix_linux_5_8_enabled: false diff --git a/.licenseignore b/.licenseignore new file mode 100644 index 00000000..9939e1f8 --- /dev/null +++ b/.licenseignore @@ -0,0 +1,9 @@ +.gitignore +.licenseignore +.swiftformatignore +.spi.yml +.swift-format +.github/ +**.md +**.txt +Package.swift diff --git a/.swiftformatignore b/.swiftformatignore new file mode 100644 index 00000000..4308420a --- /dev/null +++ b/.swiftformatignore @@ -0,0 +1 @@ +Package.swift diff --git a/scripts/check-for-breaking-api-changes.sh b/scripts/check-for-breaking-api-changes.sh index d2ce9812..b8b5b373 100755 --- a/scripts/check-for-breaking-api-changes.sh +++ b/scripts/check-for-breaking-api-changes.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/scripts/check-for-broken-symlinks.sh b/scripts/check-for-broken-symlinks.sh index 4df8c92b..82cadb49 100644 --- a/scripts/check-for-broken-symlinks.sh +++ b/scripts/check-for-broken-symlinks.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/scripts/check-for-docc-warnings.sh b/scripts/check-for-docc-warnings.sh index 88215d49..b9453355 100644 --- a/scripts/check-for-docc-warnings.sh +++ b/scripts/check-for-docc-warnings.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/scripts/check-for-unacceptable-language.sh b/scripts/check-for-unacceptable-language.sh index 94f79dfc..759afa66 100644 --- a/scripts/check-for-unacceptable-language.sh +++ b/scripts/check-for-unacceptable-language.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/scripts/check-license-headers.sh b/scripts/check-license-headers.sh index f4d2ae3d..308531f8 100644 --- a/scripts/check-license-headers.sh +++ b/scripts/check-license-headers.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project @@ -51,6 +51,8 @@ read -ra PATHS_TO_CHECK_FOR_LICENSE <<< "$( \ ":(exclude)Package.swift" \ ":(exclude)README.md" \ ":(exclude)SECURITY.md" \ + ":(exclude).licenseignore" \ + ":(exclude).swiftformatignore" \ ":(exclude)scripts/unacceptable-language.txt" \ ":(exclude)docker/*" \ ":(exclude)**/*.docc/*" \ @@ -64,7 +66,7 @@ for FILE_PATH in "${PATHS_TO_CHECK_FOR_LICENSE[@]}"; do case "${FILE_EXTENSION}" in swift) EXPECTED_FILE_HEADER=$(sed -e 's|@@|//|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}") ;; yml) EXPECTED_FILE_HEADER=$(sed -e 's|@@|##|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}") ;; - sh) EXPECTED_FILE_HEADER=$(cat <(echo '#!/usr/bin/env bash') <(sed -e 's|@@|##|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}")) ;; + sh) EXPECTED_FILE_HEADER=$(cat <(echo '#!/bin/bash') <(sed -e 's|@@|##|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}")) ;; *) fatal "Unsupported file extension for file (exclude or update this script): ${FILE_PATH}" ;; esac EXPECTED_FILE_HEADER_LINECOUNT=$(wc -l <<<"${EXPECTED_FILE_HEADER}") diff --git a/scripts/generate-contributors-list.sh b/scripts/generate-contributors-list.sh index 99f072e0..f9a33089 100644 --- a/scripts/generate-contributors-list.sh +++ b/scripts/generate-contributors-list.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/scripts/run-integration-test.sh b/scripts/run-integration-test.sh old mode 100644 new mode 100755 index 8042f9a8..61378588 --- a/scripts/run-integration-test.sh +++ b/scripts/run-integration-test.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project @@ -24,7 +24,7 @@ JQ_BIN=${JQ_BIN:-$(command -v jq)} || fatal "JQ_BIN unset and no jq on PATH" CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" -TMP_DIR=$(mktemp -d "${PWD}/tmp.$(basename "$0").XXXXXXXXXX.noindex") +TMP_DIR=$(/usr/bin/mktemp -d -p "${TMPDIR-/tmp}" "$(basename "$0").XXXXXXXXXX") PACKAGE_PATH=${PACKAGE_PATH:-${REPO_ROOT}} @@ -43,6 +43,6 @@ swift package --package-path "${INTEGRATION_TEST_PACKAGE_PATH}" \ edit "${PACKAGE_NAME}" --path "${PACKAGE_PATH}" log "Building integration test package: ${INTEGRATION_TEST_PACKAGE_PATH}" -swift build --package-path "${INTEGRATION_TEST_PACKAGE_PATH}" -Xswiftc -strict-concurrency=complete +swift build --package-path "${INTEGRATION_TEST_PACKAGE_PATH}" log "✅ Successfully built integration test package." diff --git a/scripts/run-swift-format.sh b/scripts/run-swift-format.sh index eefa5850..145b2403 100755 --- a/scripts/run-swift-format.sh +++ b/scripts/run-swift-format.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project diff --git a/scripts/soundness.sh b/scripts/soundness.sh index 45ee0f30..2d15984c 100755 --- a/scripts/soundness.sh +++ b/scripts/soundness.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash ##===----------------------------------------------------------------------===## ## ## This source file is part of the SwiftOpenAPIGenerator open source project From f795237d2dc8eef3a675e1cfa80b9aad0d6f0696 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Wed, 2 Oct 2024 12:08:49 +0100 Subject: [PATCH 15/50] ci: Migrate to GitHub Actions and reusable workflows, part two (#117) ### Motivation Following #116 we can now cut over to the soundness checks from the GithHub Actions CI and short-circuit the old CI checks. ### Modifications - Remove most scripts used by old CI - Short-circuit API checking script - Short-circuit the docker CI - Enable soundness tests in GitHub Actions workflow - Ignore docker/* for license check - Remove DocC plugin from package manifest - Update CONTRIBUTING.md with instructions for local run ### Result GitHub Actions CI is the one that we care about. We can then update the branch rules, disable the old webhook, and, finally, remove the vestigial stuff. --- .github/workflows/pull_request.yml | 16 ++-- .licenseignore | 1 + CONTRIBUTING.md | 40 ++++++---- Package.swift | 1 - docker/docker-compose.yaml | 8 +- scripts/check-for-breaking-api-changes.sh | 28 +------ scripts/check-for-broken-symlinks.sh | 37 --------- scripts/check-for-docc-warnings.sh | 40 ---------- scripts/check-for-unacceptable-language.sh | 37 --------- scripts/check-license-headers.sh | 92 ---------------------- scripts/generate-contributors-list.sh | 52 ------------ scripts/run-swift-format.sh | 48 ----------- scripts/soundness.sh | 55 ------------- scripts/unacceptable-language.txt | 15 ---- 14 files changed, 39 insertions(+), 431 deletions(-) delete mode 100644 scripts/check-for-broken-symlinks.sh delete mode 100644 scripts/check-for-docc-warnings.sh delete mode 100644 scripts/check-for-unacceptable-language.sh delete mode 100644 scripts/check-license-headers.sh delete mode 100644 scripts/generate-contributors-list.sh delete mode 100755 scripts/run-swift-format.sh delete mode 100755 scripts/soundness.sh delete mode 100644 scripts/unacceptable-language.txt diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0b6b0c61..eaf36ac0 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -9,16 +9,14 @@ jobs: name: Soundness uses: apple/swift-nio/.github/workflows/soundness.yml@main with: - # These are set to false to stage this in with the old CI. - # A follow-up PR will cut them over. - api_breakage_check_enabled: false - broken_symlink_check_enabled: false - docs_check_enabled: false - format_check_enabled: false - license_header_check_enabled: false + api_breakage_check_enabled: true + broken_symlink_check_enabled: true + docs_check_enabled: true + format_check_enabled: true + license_header_check_enabled: true license_header_check_project_name: "SwiftOpenAPIGenerator" - shell_check_enabled: false - unacceptable_language_check_enabled: false + shell_check_enabled: true + unacceptable_language_check_enabled: true unit-tests: name: Unit tests diff --git a/.licenseignore b/.licenseignore index 9939e1f8..c0e92649 100644 --- a/.licenseignore +++ b/.licenseignore @@ -7,3 +7,4 @@ **.md **.txt Package.swift +docker/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e767af1a..51fa3b6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,28 +56,40 @@ A good patch is: 3. Documented, adding API documentation as needed to cover new functions and properties. 4. Accompanied by a great commit message, using our commit message template. -### Run `./scripts/soundness.sh` +### Run CI checks locally -The scripts directory contains a [soundness.sh script](https://github.com/apple/swift-openapi-runtime/blob/main/scripts/soundness.sh) -that enforces additional checks, like license headers and formatting style. +You can run the Github Actions workflows locally using +[act](https://github.com/nektos/act). To run all the jobs that run on a pull +request, use the following command: -Please make sure to `./scripts/soundness.sh` before pushing a change upstream, otherwise it is likely the PR validation will fail -on minor changes such as a missing `self.` or similar formatting issues. +``` +% act pull_request +``` -For frequent contributors, we recommend adding the script as a [git pre-push hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks), which you can do via executing the following command in the project root directory: +To run just a single job, use `workflow_call -j `, and specify the inputs +the job expects. For example, to run just shellcheck: + +``` +% act workflow_call -j soundness --input shell_check_enabled=true +``` -```bash -cat << EOF > .git/hooks/pre-push +To bind-mount the working directory to the container, rather than a copy, use +`--bind`. For example, to run just the formatting, and have the results +reflected in your working directory: -if [[ -f "scripts/soundness.sh" ]]; then - scripts/soundness.sh -fi -EOF +``` +% act --bind workflow_call -j soundness --input format_check_enabled=true ``` -Which makes the script execute, and only allow the `git push` to complete if the check has passed. +If you'd like `act` to always run with certain flags, these can be be placed in +an `.actrc` file either in the current working directory or your home +directory, for example: -In the case of formatting issues, you can then `git add` the formatting changes, and attempt the push again. +``` +--container-architecture=linux/amd64 +--remote-name upstream +--action-offline-mode +``` ## How to contribute your work diff --git a/Package.swift b/Package.swift index fcdf464f..f225ecfc 100644 --- a/Package.swift +++ b/Package.swift @@ -34,7 +34,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-http-types", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ .target( diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index c63046bb..7de47bd7 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -26,11 +26,11 @@ services: soundness: <<: *common - command: /bin/bash -xcl "swift -version && uname -a && ./scripts/soundness.sh" + command: echo "skipping; moved to Github Actions" test: <<: *common - command: /bin/bash -xcl "swift $${SWIFT_TEST_VERB-test} $${WARN_AS_ERROR_ARG-} $${SANITIZER_ARG-} $${IMPORT_CHECK_ARG-} $${STRICT_CONCURRENCY_ARG-}" + command: echo "skipping; moved to Github Actions" shell: <<: *common @@ -38,10 +38,10 @@ services: integration-test: <<: *common - command: /bin/bash -xcl "swift -version && uname -a && bash ./scripts/run-integration-test.sh" + command: echo "skipping; moved to Github Actions" docc-test: <<: *common - command: /bin/bash -xcl "swift -version && uname -a && bash ./scripts/check-for-docc-warnings.sh" + command: echo "skipping; moved to Github Actions" environment: DOCC_TARGET: OpenAPIRuntime diff --git a/scripts/check-for-breaking-api-changes.sh b/scripts/check-for-breaking-api-changes.sh index b8b5b373..0d973aaa 100755 --- a/scripts/check-for-breaking-api-changes.sh +++ b/scripts/check-for-breaking-api-changes.sh @@ -13,30 +13,4 @@ ## ##===----------------------------------------------------------------------===## -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" - -log "Checking required environment variables..." -test -n "${BASELINE_REPO_URL:-}" || fatal "BASELINE_REPO_URL unset" -test -n "${BASELINE_TREEISH:-}" || fatal "BASELINE_TREEISH unset" - -log "Fetching baseline: ${BASELINE_REPO_URL}#${BASELINE_TREEISH}..." -git -C "${REPO_ROOT}" fetch "${BASELINE_REPO_URL}" "${BASELINE_TREEISH}" -BASELINE_COMMIT=$(git -C "${REPO_ROOT}" rev-parse FETCH_HEAD) - -log "Checking for API changes since ${BASELINE_REPO_URL}#${BASELINE_TREEISH} (${BASELINE_COMMIT})..." -swift package --package-path "${REPO_ROOT}" diagnose-api-breaking-changes \ - "${BASELINE_COMMIT}" \ - && RC=$? || RC=$? - -if [ "${RC}" -ne 0 ]; then - fatal "❌ Breaking API changes detected." - exit "${RC}" -fi -log "✅ No breaking API changes detected." +exit 0 diff --git a/scripts/check-for-broken-symlinks.sh b/scripts/check-for-broken-symlinks.sh deleted file mode 100644 index 82cadb49..00000000 --- a/scripts/check-for-broken-symlinks.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" - -log "Checking for broken symlinks..." -NUM_BROKEN_SYMLINKS=0 -while read -r -d '' file; do - if ! test -e "${REPO_ROOT}/${file}"; then - error "Broken symlink: ${file}" - ((NUM_BROKEN_SYMLINKS++)) - fi -done < <(git -C "${REPO_ROOT}" ls-files -z) - -if [ "${NUM_BROKEN_SYMLINKS}" -gt 0 ]; then - fatal "❌ Found ${NUM_BROKEN_SYMLINKS} symlinks." -fi - -log "✅ Found 0 symlinks." diff --git a/scripts/check-for-docc-warnings.sh b/scripts/check-for-docc-warnings.sh deleted file mode 100644 index b9453355..00000000 --- a/scripts/check-for-docc-warnings.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## - -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -log "Checking required environment variables..." -test -n "${DOCC_TARGET:-}" || fatal "DOCC_TARGET unset" - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" - -swift package --package-path "${REPO_ROOT}" plugin generate-documentation \ - --product "${DOCC_TARGET}" \ - --analyze \ - --level detailed \ - --warnings-as-errors \ - && DOCC_PLUGIN_RC=$? || DOCC_PLUGIN_RC=$? - -if [ "${DOCC_PLUGIN_RC}" -ne 0 ]; then - fatal "❌ Generating documentation produced warnings and/or errors." - exit "${DOCC_PLUGIN_RC}" -fi - -log "✅ Generated documentation with no warnings." diff --git a/scripts/check-for-unacceptable-language.sh b/scripts/check-for-unacceptable-language.sh deleted file mode 100644 index 759afa66..00000000 --- a/scripts/check-for-unacceptable-language.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" -UNACCEPTABLE_LANGUAGE_PATTERNS_PATH="${CURRENT_SCRIPT_DIR}/unacceptable-language.txt" - -log "Checking for unacceptable language..." -PATHS_WITH_UNACCEPTABLE_LANGUAGE=$(git -C "${REPO_ROOT}" grep \ - -l -F -w \ - -f "${UNACCEPTABLE_LANGUAGE_PATTERNS_PATH}" \ - -- \ - ":(exclude)${UNACCEPTABLE_LANGUAGE_PATTERNS_PATH}" \ -) || true | /usr/bin/paste -s -d " " - - -if [ -n "${PATHS_WITH_UNACCEPTABLE_LANGUAGE}" ]; then - fatal "❌ Found unacceptable language in files: ${PATHS_WITH_UNACCEPTABLE_LANGUAGE}." -fi - -log "✅ Found no unacceptable language." diff --git a/scripts/check-license-headers.sh b/scripts/check-license-headers.sh deleted file mode 100644 index 308531f8..00000000 --- a/scripts/check-license-headers.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" - -EXPECTED_FILE_HEADER_TEMPLATE="@@===----------------------------------------------------------------------===@@ -@@ -@@ This source file is part of the SwiftOpenAPIGenerator open source project -@@ -@@ Copyright (c) YEARS 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 -@@ -@@===----------------------------------------------------------------------===@@" - -PATHS_WITH_MISSING_LICENSE=( ) - -read -ra PATHS_TO_CHECK_FOR_LICENSE <<< "$( \ - git -C "${REPO_ROOT}" ls-files -z \ - ":(exclude).gitignore" \ - ":(exclude).spi.yml" \ - ":(exclude).swift-format" \ - ":(exclude).github/*" \ - ":(exclude)CODE_OF_CONDUCT.md" \ - ":(exclude)CONTRIBUTING.md" \ - ":(exclude)CONTRIBUTORS.txt" \ - ":(exclude)LICENSE.txt" \ - ":(exclude)NOTICE.txt" \ - ":(exclude)Package.swift" \ - ":(exclude)README.md" \ - ":(exclude)SECURITY.md" \ - ":(exclude).licenseignore" \ - ":(exclude).swiftformatignore" \ - ":(exclude)scripts/unacceptable-language.txt" \ - ":(exclude)docker/*" \ - ":(exclude)**/*.docc/*" \ - | xargs -0 \ -)" - -for FILE_PATH in "${PATHS_TO_CHECK_FOR_LICENSE[@]}"; do - FILE_BASENAME=$(basename -- "${FILE_PATH}") - FILE_EXTENSION="${FILE_BASENAME##*.}" - - case "${FILE_EXTENSION}" in - swift) EXPECTED_FILE_HEADER=$(sed -e 's|@@|//|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}") ;; - yml) EXPECTED_FILE_HEADER=$(sed -e 's|@@|##|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}") ;; - sh) EXPECTED_FILE_HEADER=$(cat <(echo '#!/bin/bash') <(sed -e 's|@@|##|g' <<<"${EXPECTED_FILE_HEADER_TEMPLATE}")) ;; - *) fatal "Unsupported file extension for file (exclude or update this script): ${FILE_PATH}" ;; - esac - EXPECTED_FILE_HEADER_LINECOUNT=$(wc -l <<<"${EXPECTED_FILE_HEADER}") - - FILE_HEADER=$(head -n "${EXPECTED_FILE_HEADER_LINECOUNT}" "${FILE_PATH}") - NORMALIZED_FILE_HEADER=$( - echo "${FILE_HEADER}" \ - | sed -e 's/202[3]-202[3]/YEARS/' -e 's/202[3]/YEARS/' \ - ) - - if ! diff -u \ - --label "Expected header" <(echo "${EXPECTED_FILE_HEADER}") \ - --label "${FILE_PATH}" <(echo "${NORMALIZED_FILE_HEADER}") - then - PATHS_WITH_MISSING_LICENSE+=("${FILE_PATH} ") - fi -done - -if [ "${#PATHS_WITH_MISSING_LICENSE[@]}" -gt 0 ]; then - fatal "❌ Found missing license header in files: ${PATHS_WITH_MISSING_LICENSE[*]}." -fi - -log "✅ Found no files with missing license header." diff --git a/scripts/generate-contributors-list.sh b/scripts/generate-contributors-list.sh deleted file mode 100644 index f9a33089..00000000 --- a/scripts/generate-contributors-list.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## -##===----------------------------------------------------------------------===## -## -## This source file is part of the SwiftNIO open source project -## -## Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors -## Licensed under Apache License v2.0 -## -## See LICENSE.txt for license information -## See CONTRIBUTORS.txt for the list of SwiftNIO project authors -## -## SPDX-License-Identifier: Apache-2.0 -## -##===----------------------------------------------------------------------===## - -set -eu -here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -contributors=$( cd "$here"/.. && git shortlog -es | cut -f2 | sed 's/^/- /' ) - -cat > "$here/../CONTRIBUTORS.txt" <<- EOF - For the purpose of tracking copyright, this is the list of individuals and - organizations who have contributed source code to SwiftOpenAPIGenerator. - - For employees of an organization/company where the copyright of work done - by employees of that company is held by the company itself, only the company - needs to be listed here. - - ## COPYRIGHT HOLDERS - - - Apple Inc. (all contributors with '@apple.com') - - ### Contributors - - $contributors - - **Updating this list** - - Please do not edit this file manually. It is generated using \`./scripts/generate-contributors-list.sh\`. If a name is misspelled or appearing multiple times: add an entry in \`./.mailmap\` -EOF diff --git a/scripts/run-swift-format.sh b/scripts/run-swift-format.sh deleted file mode 100755 index 145b2403..00000000 --- a/scripts/run-swift-format.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$(git -C "${CURRENT_SCRIPT_DIR}" rev-parse --show-toplevel)" - -FORMAT_COMMAND=(lint --strict) -for arg in "$@"; do - if [ "$arg" == "--fix" ]; then - FORMAT_COMMAND=(format --in-place) - fi -done - -SWIFTFORMAT_BIN=${SWIFTFORMAT_BIN:-$(command -v swift-format)} || fatal "❌ SWIFTFORMAT_BIN unset and no swift-format on PATH" - -"${SWIFTFORMAT_BIN}" "${FORMAT_COMMAND[@]}" \ - --parallel --recursive \ - "${REPO_ROOT}/Sources" "${REPO_ROOT}/Tests" \ - && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$? - -if [ "${SWIFT_FORMAT_RC}" -ne 0 ]; then - fatal "❌ Running swift-format produced errors. - - To fix, run the following command: - - % ./scripts/run-swift-format.sh --fix - " - exit "${SWIFT_FORMAT_RC}" -fi - -log "✅ Ran swift-format with no errors." diff --git a/scripts/soundness.sh b/scripts/soundness.sh deleted file mode 100755 index 2d15984c..00000000 --- a/scripts/soundness.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## -set -euo pipefail - -log() { printf -- "** %s\n" "$*" >&2; } -error() { printf -- "** ERROR: %s\n" "$*" >&2; } -fatal() { error "$@"; exit 1; } - -CURRENT_SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -NUM_CHECKS_FAILED=0 - -FIX_FORMAT="" -for arg in "$@"; do - if [ "$arg" == "--fix" ]; then - FIX_FORMAT="--fix" - fi -done - -SCRIPT_PATHS=( - "${CURRENT_SCRIPT_DIR}/check-for-broken-symlinks.sh" - "${CURRENT_SCRIPT_DIR}/check-for-unacceptable-language.sh" - "${CURRENT_SCRIPT_DIR}/check-license-headers.sh" -) - -for SCRIPT_PATH in "${SCRIPT_PATHS[@]}"; do - log "Running ${SCRIPT_PATH}..." - if ! bash "${SCRIPT_PATH}"; then - ((NUM_CHECKS_FAILED+=1)) - fi -done - -log "Running swift-format..." -bash "${CURRENT_SCRIPT_DIR}"/run-swift-format.sh $FIX_FORMAT > /dev/null -FORMAT_EXIT_CODE=$? -if [ $FORMAT_EXIT_CODE -ne 0 ]; then - ((NUM_CHECKS_FAILED+=1)) -fi - -if [ "${NUM_CHECKS_FAILED}" -gt 0 ]; then - fatal "❌ ${NUM_CHECKS_FAILED} soundness check(s) failed." -fi - -log "✅ All soundness check(s) passed." diff --git a/scripts/unacceptable-language.txt b/scripts/unacceptable-language.txt deleted file mode 100644 index 6ac4a985..00000000 --- a/scripts/unacceptable-language.txt +++ /dev/null @@ -1,15 +0,0 @@ -blacklist -whitelist -slave -master -sane -sanity -insane -insanity -kill -killed -killing -hang -hung -hanged -hanging From 654e2bb387eb5f2a6c663d06a415ddf5eb3ab03b Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Wed, 2 Oct 2024 12:17:56 +0100 Subject: [PATCH 16/50] ci: Migrate to GitHub Actions and reusable workflows, part three (final) (#118) ### Motivation Following on from #116 and #117, we have now disabled the webhook that was driving the old CI and updated the branch protection rules to require the new GitHub Actions based CI checks. We can now delete the final shims that were keeping the old CI green, since it is no longer running and complete the migration. ### Modifications - Delete all Docker bits and scripts used by old CI. ### Result Migration complete. ### Test Plan We should be seeing _only_ GitHub Actions checks on this PR and they should all be passing (apart from the ones that are explicitly disabled). --- docker/Dockerfile | 26 ------------- docker/docker-compose.2204.510.yaml | 21 ---------- docker/docker-compose.2204.59.yaml | 19 --------- docker/docker-compose.2204.590.yaml | 19 --------- docker/docker-compose.2204.main.yaml | 20 ---------- docker/docker-compose.yaml | 47 ----------------------- scripts/check-for-breaking-api-changes.sh | 16 -------- 7 files changed, 168 deletions(-) delete mode 100644 docker/Dockerfile delete mode 100644 docker/docker-compose.2204.510.yaml delete mode 100644 docker/docker-compose.2204.59.yaml delete mode 100644 docker/docker-compose.2204.590.yaml delete mode 100644 docker/docker-compose.2204.main.yaml delete mode 100644 docker/docker-compose.yaml delete mode 100755 scripts/check-for-breaking-api-changes.sh diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 5040ad9c..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -ARG swift_version=5.9 -ARG ubuntu_version=jammy -ARG base_image=swift:${swift_version}-${ubuntu_version} - -FROM ${base_image} -ARG swift_version -ARG ubuntu_version - -# set as UTF-8 -RUN apt-get update && apt-get install -y locales locales-all -ENV LC_ALL en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US.UTF-8 - -# tools -RUN mkdir -p $HOME/.tools -RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile - -# swift-format -ARG swiftformat_version=509.0.0 -RUN git clone --branch $swiftformat_version --depth 1 https://github.com/apple/swift-format $HOME/.tools/swift-format-source -RUN cd $HOME/.tools/swift-format-source && swift build -c release -RUN ln -s $HOME/.tools/swift-format-source/.build/release/swift-format $HOME/.tools/swift-format - -# jq -RUN apt-get install -y jq diff --git a/docker/docker-compose.2204.510.yaml b/docker/docker-compose.2204.510.yaml deleted file mode 100644 index 079f4339..00000000 --- a/docker/docker-compose.2204.510.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.10 - build: - args: - ubuntu_version: "jammy" - swift_version: "5.10" - - test: - image: *image - environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - # Disabled strict concurrency checking as currently it's not possible to iterate an async sequence - # from inside an actor without warnings. - # - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.2204.59.yaml b/docker/docker-compose.2204.59.yaml deleted file mode 100644 index 891fabef..00000000 --- a/docker/docker-compose.2204.59.yaml +++ /dev/null @@ -1,19 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.9 - build: - args: - ubuntu_version: "jammy" - swift_version: "5.9" - - test: - image: *image - environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.2204.590.yaml b/docker/docker-compose.2204.590.yaml deleted file mode 100644 index dacf9e3c..00000000 --- a/docker/docker-compose.2204.590.yaml +++ /dev/null @@ -1,19 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.9.0 - build: - args: - ubuntu_version: "jammy" - swift_version: "5.9.0" - - test: - image: *image - environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.2204.main.yaml b/docker/docker-compose.2204.main.yaml deleted file mode 100644 index 75b44630..00000000 --- a/docker/docker-compose.2204.main.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: "3" - -services: - - runtime-setup: - image: &image swift-openapi-runtime:22.04-main - build: - args: - base_image: "swiftlang/swift:nightly-main-jammy" - - test: - image: *image - environment: - # Disable warnings as errors on nightlies as they are still in-development. - # - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml deleted file mode 100644 index 7de47bd7..00000000 --- a/docker/docker-compose.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# NOTE: This file is not designed to be run independently. -# -# Instead, use it with a file for a specific OS and Swift version, for example: -# -# % docker-compose \ -# -f docker/docker-compose.yaml \ -# -f docker/docker-compose.2204.59.yaml \ -# run test -# -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:default - build: - context: . - dockerfile: Dockerfile - - common: &common - image: *image - depends_on: [runtime-setup] - volumes: - - ~/.ssh:/root/.ssh - - ..:/code:z - working_dir: /code - - soundness: - <<: *common - command: echo "skipping; moved to Github Actions" - - test: - <<: *common - command: echo "skipping; moved to Github Actions" - - shell: - <<: *common - entrypoint: /bin/bash - - integration-test: - <<: *common - command: echo "skipping; moved to Github Actions" - - docc-test: - <<: *common - command: echo "skipping; moved to Github Actions" - environment: - DOCC_TARGET: OpenAPIRuntime diff --git a/scripts/check-for-breaking-api-changes.sh b/scripts/check-for-breaking-api-changes.sh deleted file mode 100755 index 0d973aaa..00000000 --- a/scripts/check-for-breaking-api-changes.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## 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 -## -##===----------------------------------------------------------------------===## - -exit 0 From 82b474f0a3b73d0d94a75f149c816def6e520b86 Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Wed, 2 Oct 2024 15:07:48 +0100 Subject: [PATCH 17/50] benchmarks: Add subdirectory Benchmarks/ package (#119) ### Motivation We'd like to be able to benchmark various parts of the runtime library to make targeted and measurable performance improvements. ### Modifications - Add a subdirectory directory package with the package-benchmark plugin. - Add a benchmark for `ISO8601DateTranscoder.encode(_:)`. - Add temporary benchmarks of Foundation ISO8601 date encoding APIs to guide improvements to `ISO8601DateTranscoder`. ### Result - Can now run benchmarks using `swift package --package-path Benchmarks/ benchmark`. ### Test Plan There is no CI for this currently. That can follow, but we wanted the package plugin to unblock incoming updates to the date transcoder. --- .gitignore | 2 +- .licenseignore | 2 +- .swiftformatignore | 2 +- .../OpenAPIRuntimeBenchmarks/Benchmarks.swift | 61 +++++++++++++++++++ Benchmarks/Package.swift | 24 ++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 Benchmarks/Benchmarks/OpenAPIRuntimeBenchmarks/Benchmarks.swift create mode 100644 Benchmarks/Package.swift diff --git a/.gitignore b/.gitignore index c01c56a8..c4deea75 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ xcuserdata/ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .vscode -/Package.resolved +Package.resolved .ci/ .docc-build/ .swiftpm diff --git a/.licenseignore b/.licenseignore index c0e92649..3767d5e3 100644 --- a/.licenseignore +++ b/.licenseignore @@ -6,5 +6,5 @@ .github/ **.md **.txt -Package.swift +**Package.swift docker/* diff --git a/.swiftformatignore b/.swiftformatignore index 4308420a..ef0b696a 100644 --- a/.swiftformatignore +++ b/.swiftformatignore @@ -1 +1 @@ -Package.swift +**Package.swift diff --git a/Benchmarks/Benchmarks/OpenAPIRuntimeBenchmarks/Benchmarks.swift b/Benchmarks/Benchmarks/OpenAPIRuntimeBenchmarks/Benchmarks.swift new file mode 100644 index 00000000..634a0d65 --- /dev/null +++ b/Benchmarks/Benchmarks/OpenAPIRuntimeBenchmarks/Benchmarks.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2024 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 Benchmark +import OpenAPIRuntime +import Foundation + +let benchmarks = { + let defaultMetrics: [BenchmarkMetric] = [.mallocCountTotal, .cpuTotal] + + Benchmark( + "ISO8601DateTranscoder.encode(_:)", + configuration: Benchmark.Configuration( + metrics: defaultMetrics, + scalingFactor: .kilo, + maxDuration: .seconds(10_000_000), + maxIterations: 5 + ) + ) { benchmark in + let transcoder = ISO8601DateTranscoder() + benchmark.startMeasurement() + for _ in benchmark.scaledIterations { blackHole(try transcoder.encode(.distantFuture)) } + } + + Benchmark( + "ISO8601DateFormatter.string(from:)", + configuration: Benchmark.Configuration( + metrics: defaultMetrics, + scalingFactor: .kilo, + maxDuration: .seconds(10_000_000), + maxIterations: 5 + ) + ) { benchmark in + let formatter = ISO8601DateFormatter() + benchmark.startMeasurement() + for _ in benchmark.scaledIterations { blackHole(formatter.string(from: .distantFuture)) } + } + + Benchmark( + "Date.ISO8601Format(_:)", + configuration: Benchmark.Configuration( + metrics: defaultMetrics, + scalingFactor: .kilo, + maxDuration: .seconds(10_000_000), + maxIterations: 5 + ) + ) { benchmark in + benchmark.startMeasurement() + for _ in benchmark.scaledIterations { blackHole(Date.distantFuture.ISO8601Format()) } + } +} diff --git a/Benchmarks/Package.swift b/Benchmarks/Package.swift new file mode 100644 index 00000000..1294475e --- /dev/null +++ b/Benchmarks/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "swift-openapi-runtime-benchmarks", + platforms: [ .macOS("14") ], + dependencies: [ + .package(name: "swift-openapi-runtime", path: "../"), + .package(url: "https://github.com/ordo-one/package-benchmark.git", from: "1.22.0"), + ], + targets: [ + .executableTarget( + name: "OpenAPIRuntimeBenchmarks", + dependencies: [ + .product(name: "Benchmark", package: "package-benchmark"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ], + path: "Benchmarks/OpenAPIRuntimeBenchmarks", + plugins: [ + .plugin(name: "BenchmarkPlugin", package: "package-benchmark") + ] + ), + ] +) From da2e5b8f78651a6ab7ea091c3f8ae54ec2f7b9a9 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 3 Oct 2024 17:17:49 +0200 Subject: [PATCH 18/50] Support nested arrays of primitive values inside of objects (#120) ### Motivation It's a useful pattern to define a single JSON schema for all your (e.g. query) parameters, and handle them as a single object in your code. In OpenAPI, that'd be expressed like this, for example: ```yaml # parameter name: myParams in: query explode: true style: form schema: $ref: '#/components/schemas/QueryObject' # schema QueryObject: type: object properties: myString: type: string myList: type: array items: type: string ``` Until now, the `myList` property would not be allowed, and would fail to serialize and parse, as arrays within objects were not allowed for `form` style parameters (used by query items, by default). ### Modifications This PR extends the support of the `form` style to handle single nesting in the top level objects. It does _not_ add support for arbitrarily deep nesting. As part of this work, we also now allow the `deepObject` style to do the same - use arrays nested in an object. ### Result The useful pattern of having an array within a "params" object works correctly now. ### Test Plan Added unit tests for all 4 components: encoder, decoder, serializer, and parser. --- .../URICoder/Common/URIEncodedNode.swift | 10 ++++ .../URICoder/Parsing/URIParser.swift | 1 - .../Serialization/URISerializer.swift | 58 ++++++++++++++++--- .../Test_URIValueFromNodeDecoder.swift | 13 +++++ .../Encoding/Test_URIValueToNodeEncoder.swift | 16 +++++ .../URICoder/Parsing/Test_URIParser.swift | 28 +++++---- .../Serialization/Test_URISerializer.swift | 16 ++--- 7 files changed, 111 insertions(+), 31 deletions(-) diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift index 985b7715..4297f778 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift @@ -47,6 +47,16 @@ enum URIEncodedNode: Equatable { /// A date value. case date(Date) } + + /// A primitive value or an array of primitive values. + enum PrimitiveOrArrayOfPrimitives: Equatable { + + /// A primitive value. + case primitive(Primitive) + + /// An array of primitive values. + case arrayOfPrimitives([Primitive]) + } } extension URIEncodedNode { diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index c1cb5940..ff224621 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -241,7 +241,6 @@ extension URIParser { appendPair(key, [value]) } } - for (key, value) in parseNode where value.count > 1 { throw ParsingError.malformedKeyValuePair(key) } return parseNode } } diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index 45d3b0da..e7817720 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -65,8 +65,7 @@ extension CharacterSet { extension URISerializer { /// A serializer error. - enum SerializationError: Swift.Error, Hashable { - + enum SerializationError: Swift.Error, Hashable, CustomStringConvertible, LocalizedError { /// Nested containers are not supported. case nestedContainersNotSupported /// Deep object arrays are not supported. @@ -75,6 +74,28 @@ extension URISerializer { case deepObjectsWithPrimitiveValuesNotSupported /// An invalid configuration was detected. case invalidConfiguration(String) + + /// A human-readable description of the serialization error. + /// + /// This computed property returns a string that includes information about the serialization error. + /// + /// - Returns: A string describing the serialization error and its associated details. + var description: String { + switch self { + case .nestedContainersNotSupported: "URISerializer: Nested containers are not supported" + case .deepObjectsArrayNotSupported: "URISerializer: Deep object arrays are not supported" + case .deepObjectsWithPrimitiveValuesNotSupported: + "URISerializer: Deep object with primitive values are not supported" + case .invalidConfiguration(let string): "URISerializer: Invalid configuration: \(string)" + } + } + + /// A localized description of the serialization error. + /// + /// This computed property provides a localized human-readable description of the serialization error, which is suitable for displaying to users. + /// + /// - Returns: A localized string describing the serialization error. + var errorDescription: String? { description } } /// Computes an escaped version of the provided string. @@ -114,6 +135,16 @@ extension URISerializer { guard case let .primitive(primitive) = node else { throw SerializationError.nestedContainersNotSupported } return primitive } + func unwrapPrimitiveOrArrayOfPrimitives(_ node: URIEncodedNode) throws + -> URIEncodedNode.PrimitiveOrArrayOfPrimitives + { + if case let .primitive(primitive) = node { return .primitive(primitive) } + if case let .array(array) = node { + let primitives = try array.map(unwrapPrimitiveValue) + return .arrayOfPrimitives(primitives) + } + throw SerializationError.nestedContainersNotSupported + } switch value { case .unset: // Nothing to serialize. @@ -128,7 +159,7 @@ extension URISerializer { try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator) case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key) case .dictionary(let dictionary): - try serializeDictionary(dictionary.mapValues(unwrapPrimitiveValue), forKey: key) + try serializeDictionary(dictionary.mapValues(unwrapPrimitiveOrArrayOfPrimitives), forKey: key) } } @@ -213,9 +244,10 @@ extension URISerializer { /// - key: The key to serialize the value under (details depend on the /// style and explode parameters in the configuration). /// - Throws: An error if serialization of the dictionary fails. - private mutating func serializeDictionary(_ dictionary: [String: URIEncodedNode.Primitive], forKey key: String) - throws - { + private mutating func serializeDictionary( + _ dictionary: [String: URIEncodedNode.PrimitiveOrArrayOfPrimitives], + forKey key: String + ) throws { guard !dictionary.isEmpty else { return } let sortedDictionary = dictionary.sorted { a, b in a.key.localizedCaseInsensitiveCompare(b.key) == .orderedAscending @@ -248,8 +280,18 @@ extension URISerializer { guard case .deepObject = configuration.style else { return elementKey } return rootKey + "[" + elementKey + "]" } - func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws { - try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator) + func serializeNext(_ element: URIEncodedNode.PrimitiveOrArrayOfPrimitives, forKey elementKey: String) throws { + switch element { + case .primitive(let primitive): + try serializePrimitiveKeyValuePair(primitive, forKey: elementKey, separator: keyAndValueSeparator) + case .arrayOfPrimitives(let array): + guard !array.isEmpty else { return } + for item in array.dropLast() { + try serializePrimitiveKeyValuePair(item, forKey: elementKey, separator: keyAndValueSeparator) + data.append(pairSeparator) + } + try serializePrimitiveKeyValuePair(array.last!, forKey: elementKey, separator: keyAndValueSeparator) + } } if let containerKeyAndValue = configuration.containerKeyAndValueSeparator { data.append(try stringifiedKey(key)) diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift index c805f3b6..f1236cb9 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift @@ -23,6 +23,12 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { var color: SimpleEnum? } + struct StructWithArray: Decodable, Equatable { + var foo: String + var bar: [Int]? + var val: [String] + } + enum SimpleEnum: String, Decodable, Equatable { case red case green @@ -59,6 +65,13 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { // A struct. try test(["foo": ["bar"]], SimpleStruct(foo: "bar"), key: "root") + // A struct with an array property. + try test( + ["foo": ["bar"], "bar": ["1", "2"], "val": ["baz", "baq"]], + StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]), + key: "root" + ) + // A struct with a nested enum. try test(["foo": ["bar"], "color": ["blue"]], SimpleStruct(foo: "bar", color: .blue), key: "root") diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift index 913511b6..80759c62 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift @@ -41,6 +41,12 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { var val: SimpleEnum? } + struct StructWithArray: Encodable { + var foo: String + var bar: [Int]? + var val: [String] + } + struct NestedStruct: Encodable { var simple: SimpleStruct } let cases: [Case] = [ @@ -89,6 +95,16 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { .dictionary(["foo": .primitive(.string("bar")), "val": .primitive(.string("foo"))]) ), + // A struct with an array property. + makeCase( + StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]), + .dictionary([ + "foo": .primitive(.string("bar")), + "bar": .array([.primitive(.integer(1)), .primitive(.integer(2))]), + "val": .array([.primitive(.string("baz")), .primitive(.string("baq"))]), + ]) + ), + // A nested struct. makeCase( NestedStruct(simple: SimpleStruct(foo: "bar")), diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 86c962e1..16a6e02d 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -79,33 +79,31 @@ final class Test_URIParser: Test_Runtime { simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), formDataExplode: "list=red&list=green&list=blue", formDataUnexplode: "list=red,green,blue", - deepObjectExplode: .custom( - "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue", - expectedError: .malformedKeyValuePair("list") - ) + deepObjectExplode: "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue" ), value: ["list": ["red", "green", "blue"]] ), makeCase( .init( - formExplode: "comma=%2C&dot=.&semi=%3B", + formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", formUnexplode: .custom( - "keys=comma,%2C,dot,.,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] + "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", + value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] ), - simpleExplode: "comma=%2C,dot=.,semi=%3B", + simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B", simpleUnexplode: .custom( - "comma,%2C,dot,.,semi,%3B", - value: ["": ["comma", ",", "dot", ".", "semi", ";"]] + "comma,%2C,dot,.,list,one,list,two,semi,%3B", + value: ["": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] ), - formDataExplode: "comma=%2C&dot=.&semi=%3B", + formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", formDataUnexplode: .custom( - "keys=comma,%2C,dot,.,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] + "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", + value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] ), - deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B" + deepObjectExplode: + "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B" ), - value: ["semi": [";"], "dot": ["."], "comma": [","]] + value: ["semi": [";"], "dot": ["."], "comma": [","], "list": ["one", "two"]] ), ] for testCase in cases { diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index 688c508a..f198b6eb 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -126,16 +126,18 @@ final class Test_URISerializer: Test_Runtime { value: .dictionary([ "semi": .primitive(.string(";")), "dot": .primitive(.string(".")), "comma": .primitive(.string(",")), + "list": .array([.primitive(.string("one")), .primitive(.string("two"))]), ]), key: "keys", .init( - formExplode: "comma=%2C&dot=.&semi=%3B", - formUnexplode: "keys=comma,%2C,dot,.,semi,%3B", - simpleExplode: "comma=%2C,dot=.,semi=%3B", - simpleUnexplode: "comma,%2C,dot,.,semi,%3B", - formDataExplode: "comma=%2C&dot=.&semi=%3B", - formDataUnexplode: "keys=comma,%2C,dot,.,semi,%3B", - deepObjectExplode: "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Bsemi%5D=%3B" + formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", + formUnexplode: "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", + simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B", + simpleUnexplode: "comma,%2C,dot,.,list,one,list,two,semi,%3B", + formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", + formDataUnexplode: "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", + deepObjectExplode: + "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B" ) ), ] From d604dd0f79c8f62986b23f16bd0fe7b7c4fdcd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Heidekr=C3=BCger?= Date: Thu, 3 Oct 2024 10:22:09 -0700 Subject: [PATCH 19/50] EventStreams: Customisable Terminating Byte Sequence (#115) ### Motivation As discussed in https://github.com/apple/swift-openapi-generator/issues/622, some APIs, e.g., ChatGPT or Claude, may return a non-JSON byte sequence to terminate a stream of events. If not handled with a workaround (see below)such non-JSON terminating byte sequences cause a decoding error. ### Modifications This PR adds the ability to customise the terminating byte sequence by providing a closure to `asDecodedServerSentEvents()` as well as `asDecodedServerSentEventsWithJSONData()` that can match incoming data for the terminating byte sequence before it is decoded into JSON, for instance. ### Result Instead of having to decode and re-encode incoming events to filter out the terminating byte sequence - as seen in https://github.com/apple/swift-openapi-generator/issues/622#issuecomment-2346391088 - terminating byte sequences can now be cleanly caught by either providing a closure or providing the terminating byte sequence directly when calling `asDecodedServerSentEvents()` and `asDecodedServerSentEventsWithJSONData()`. ### Test Plan This PR includes unit tests that test the new function parameters as part of the existing tests for `asDecodedServerSentEvents()` as well as `asDecodedServerSentEventsWithJSONData()`. --------- Co-authored-by: Honza Dvorsky --- .../Deprecated/Deprecated.swift | 34 +++++++++ .../ServerSentEventsDecoding.swift | 71 +++++++++++++------ .../Test_ServerSentEventsDecoding.swift | 62 ++++++++++++++-- 3 files changed, 140 insertions(+), 27 deletions(-) diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index c9f538ef..2ce41750 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -59,3 +59,37 @@ extension Configuration { ) } } + +extension AsyncSequence where Element == ArraySlice, Self: Sendable { + /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. + /// + /// Use this method if the event's `data` field is not JSON, or if you don't want to parse it using `asDecodedServerSentEventsWithJSONData`. + /// - Returns: A sequence that provides the events. + @available(*, deprecated, renamed: "asDecodedServerSentEvents(while:)") @_disfavoredOverload + public func asDecodedServerSentEvents() -> ServerSentEventsDeserializationSequence< + ServerSentEventsLineDeserializationSequence + > { asDecodedServerSentEvents(while: { _ in true }) } + /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. + /// + /// Use this method if the event's `data` field is JSON. + /// - Parameters: + /// - dataType: The type to decode the JSON data into. + /// - decoder: The JSON decoder to use. + /// - Returns: A sequence that provides the events with the decoded JSON data. + @available(*, deprecated, renamed: "asDecodedServerSentEventsWithJSONData(of:decoder:while:)") @_disfavoredOverload + public func asDecodedServerSentEventsWithJSONData( + of dataType: JSONDataType.Type = JSONDataType.self, + decoder: JSONDecoder = .init() + ) -> AsyncThrowingMapSequence< + ServerSentEventsDeserializationSequence>, + ServerSentEventWithJSONData + > { asDecodedServerSentEventsWithJSONData(of: dataType, decoder: decoder, while: { _ in true }) } +} + +extension ServerSentEventsDeserializationSequence { + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. + @available(*, deprecated, renamed: "init(upstream:while:)") @_disfavoredOverload public init(upstream: Upstream) { + self.init(upstream: upstream, while: { _ in true }) + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift index 421e5319..ff374b39 100644 --- a/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift +++ b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift @@ -28,9 +28,19 @@ where Upstream.Element == ArraySlice { /// The upstream sequence. private let upstream: Upstream + /// A closure that determines whether the given byte chunk should be forwarded to the consumer. + /// - Parameter: A byte chunk. + /// - Returns: `true` if the byte chunk should be forwarded, `false` if this byte chunk is the terminating sequence. + private let predicate: @Sendable (ArraySlice) -> Bool + /// Creates a new sequence. - /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. - public init(upstream: Upstream) { self.upstream = upstream } + /// - Parameters: + /// - upstream: The upstream sequence of arbitrary byte chunks. + /// - predicate: A closure that determines whether the given byte chunk should be forwarded to the consumer. + public init(upstream: Upstream, while predicate: @escaping @Sendable (ArraySlice) -> Bool) { + self.upstream = upstream + self.predicate = predicate + } } extension ServerSentEventsDeserializationSequence: AsyncSequence { @@ -46,7 +56,16 @@ extension ServerSentEventsDeserializationSequence: AsyncSequence { var upstream: UpstreamIterator /// The state machine of the iterator. - var stateMachine: StateMachine = .init() + var stateMachine: StateMachine + + /// Creates a new sequence. + /// - Parameters: + /// - upstream: The upstream sequence of arbitrary byte chunks. + /// - predicate: A closure that determines whether the given byte chunk should be forwarded to the consumer. + init(upstream: UpstreamIterator, while predicate: @escaping ((ArraySlice) -> Bool)) { + self.upstream = upstream + self.stateMachine = .init(while: predicate) + } /// Asynchronously advances to the next element and returns it, or ends the /// sequence if there is no next element. @@ -70,7 +89,7 @@ extension ServerSentEventsDeserializationSequence: AsyncSequence { /// Creates the asynchronous iterator that produces elements of this /// asynchronous sequence. public func makeAsyncIterator() -> Iterator { - Iterator(upstream: upstream.makeAsyncIterator()) + Iterator(upstream: upstream.makeAsyncIterator(), while: predicate) } } @@ -79,26 +98,30 @@ extension AsyncSequence where Element == ArraySlice, Self: Sendable { /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. /// /// Use this method if the event's `data` field is not JSON, or if you don't want to parse it using `asDecodedServerSentEventsWithJSONData`. + /// - Parameter: A closure that determines whether the given byte chunk should be forwarded to the consumer. /// - Returns: A sequence that provides the events. - public func asDecodedServerSentEvents() -> ServerSentEventsDeserializationSequence< - ServerSentEventsLineDeserializationSequence - > { .init(upstream: ServerSentEventsLineDeserializationSequence(upstream: self)) } - + public func asDecodedServerSentEvents( + while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } + ) -> ServerSentEventsDeserializationSequence> { + .init(upstream: ServerSentEventsLineDeserializationSequence(upstream: self), while: predicate) + } /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. /// /// Use this method if the event's `data` field is JSON. /// - Parameters: /// - dataType: The type to decode the JSON data into. /// - decoder: The JSON decoder to use. + /// - predicate: A closure that determines whether the given byte sequence is the terminating byte sequence defined by the API. /// - Returns: A sequence that provides the events with the decoded JSON data. public func asDecodedServerSentEventsWithJSONData( of dataType: JSONDataType.Type = JSONDataType.self, - decoder: JSONDecoder = .init() + decoder: JSONDecoder = .init(), + while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } ) -> AsyncThrowingMapSequence< ServerSentEventsDeserializationSequence>, ServerSentEventWithJSONData > { - asDecodedServerSentEvents() + asDecodedServerSentEvents(while: predicate) .map { event in ServerSentEventWithJSONData( event: event.event, @@ -118,10 +141,10 @@ extension ServerSentEventsDeserializationSequence.Iterator { struct StateMachine { /// The possible states of the state machine. - enum State: Hashable { + enum State { /// Accumulating an event, which hasn't been emitted yet. - case accumulatingEvent(ServerSentEvent, buffer: [ArraySlice]) + case accumulatingEvent(ServerSentEvent, buffer: [ArraySlice], predicate: (ArraySlice) -> Bool) /// Finished, the terminal state. case finished @@ -134,7 +157,9 @@ extension ServerSentEventsDeserializationSequence.Iterator { private(set) var state: State /// Creates a new state machine. - init() { self.state = .accumulatingEvent(.init(), buffer: []) } + init(while predicate: @escaping (ArraySlice) -> Bool) { + self.state = .accumulatingEvent(.init(), buffer: [], predicate: predicate) + } /// An action returned by the `next` method. enum NextAction { @@ -156,20 +181,24 @@ extension ServerSentEventsDeserializationSequence.Iterator { /// - Returns: An action to perform. mutating func next() -> NextAction { switch state { - case .accumulatingEvent(var event, var buffer): + case .accumulatingEvent(var event, var buffer, let predicate): guard let line = buffer.first else { return .needsMore } state = .mutating buffer.removeFirst() if line.isEmpty { // Dispatch the accumulated event. - state = .accumulatingEvent(.init(), buffer: buffer) // If the last character of data is a newline, strip it. if event.data?.hasSuffix("\n") ?? false { event.data?.removeLast() } + if let data = event.data, !predicate(ArraySlice(data.utf8)) { + state = .finished + return .returnNil + } + state = .accumulatingEvent(.init(), buffer: buffer, predicate: predicate) return .emitEvent(event) } if line.first! == ASCII.colon { // A comment, skip this line. - state = .accumulatingEvent(event, buffer: buffer) + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) return .noop } // Parse the field name and value. @@ -193,7 +222,7 @@ extension ServerSentEventsDeserializationSequence.Iterator { } guard let value else { // An unknown type of event, skip. - state = .accumulatingEvent(event, buffer: buffer) + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) return .noop } // Process the field. @@ -214,11 +243,11 @@ extension ServerSentEventsDeserializationSequence.Iterator { } default: // An unknown or invalid field, skip. - state = .accumulatingEvent(event, buffer: buffer) + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) return .noop } // Processed the field, continue. - state = .accumulatingEvent(event, buffer: buffer) + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) return .noop case .finished: return .returnNil case .mutating: preconditionFailure("Invalid state") @@ -240,11 +269,11 @@ extension ServerSentEventsDeserializationSequence.Iterator { /// - Returns: An action to perform. mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { switch state { - case .accumulatingEvent(let event, var buffer): + case .accumulatingEvent(let event, var buffer, let predicate): if let value { state = .mutating buffer.append(value) - state = .accumulatingEvent(event, buffer: buffer) + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) return .noop } else { // If no value is received, drop the existing event on the floor. diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift index 79d645a5..2a15b932 100644 --- a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift @@ -16,10 +16,14 @@ import XCTest import Foundation final class Test_ServerSentEventsDecoding: Test_Runtime { - func _test(input: String, output: [ServerSentEvent], file: StaticString = #filePath, line: UInt = #line) - async throws - { - let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)).asDecodedServerSentEvents() + func _test( + input: String, + output: [ServerSentEvent], + file: StaticString = #filePath, + line: UInt = #line, + while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } + ) async throws { + let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)).asDecodedServerSentEvents(while: predicate) let events = try await [ServerSentEvent](collecting: sequence) XCTAssertEqual(events.count, output.count, file: file, line: line) for (index, linePair) in zip(events, output).enumerated() { @@ -27,6 +31,7 @@ final class Test_ServerSentEventsDecoding: Test_Runtime { XCTAssertEqual(actualEvent, expectedEvent, "Event: \(index)", file: file, line: line) } } + func test() async throws { // Simple event. try await _test( @@ -83,15 +88,32 @@ final class Test_ServerSentEventsDecoding: Test_Runtime { .init(id: "123", data: "This is a message with an ID."), ] ) + + try await _test( + input: #""" + data: hello + data: world + + data: [DONE] + + data: hello2 + data: world2 + + + """#, + output: [.init(data: "hello\nworld")], + while: { incomingData in incomingData != ArraySlice(Data("[DONE]".utf8)) } + ) } func _testJSONData( input: String, output: [ServerSentEventWithJSONData], file: StaticString = #filePath, - line: UInt = #line + line: UInt = #line, + while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } ) async throws { let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)) - .asDecodedServerSentEventsWithJSONData(of: JSONType.self) + .asDecodedServerSentEventsWithJSONData(of: JSONType.self, while: predicate) let events = try await [ServerSentEventWithJSONData](collecting: sequence) XCTAssertEqual(events.count, output.count, file: file, line: line) for (index, linePair) in zip(events, output).enumerated() { @@ -99,6 +121,7 @@ final class Test_ServerSentEventsDecoding: Test_Runtime { XCTAssertEqual(actualEvent, expectedEvent, "Event: \(index)", file: file, line: line) } } + struct TestEvent: Decodable, Hashable, Sendable { var index: Int } func testJSONData() async throws { // Simple event. @@ -121,6 +144,33 @@ final class Test_ServerSentEventsDecoding: Test_Runtime { .init(event: "event2", data: TestEvent(index: 2), id: "2"), ] ) + + try await _testJSONData( + input: #""" + event: event1 + id: 1 + data: {"index":1} + + event: event2 + id: 2 + data: { + data: "index": 2 + data: } + + data: [DONE] + + event: event3 + id: 1 + data: {"index":3} + + + """#, + output: [ + .init(event: "event1", data: TestEvent(index: 1), id: "1"), + .init(event: "event2", data: TestEvent(index: 2), id: "2"), + ], + while: { incomingData in incomingData != ArraySlice(Data("[DONE]".utf8)) } + ) } } From 26547e78b210424e74eb2cf1c02c2565809ca99f Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Fri, 4 Oct 2024 05:34:18 +0100 Subject: [PATCH 20/50] ci: Remove examples pipeline from scheduled workflow (#121) --- .github/workflows/scheduled.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index cb5f46df..7a345603 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -21,12 +21,4 @@ jobs: with: name: "Integration test" matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" - matrix_linux_5_8_enabled: false - - example-packages: - name: Example packages - uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main - with: - name: "Example packages" - matrix_linux_command: "./scripts/test-examples.sh" - matrix_linux_5_8_enabled: false + matrix_linux_5_8_enabled: false \ No newline at end of file From a79c21c1533aeb1c9dfb42c4cd39bb737a3dce34 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Wed, 16 Oct 2024 14:18:50 +0100 Subject: [PATCH 21/50] [CI] Update to Swift 6 CI (#122) # Motivation We just updated our CI matrix in NIO to only support 5.9, 5.10 and 6. # Modification This PR updates the trigger files in this repo. Since this repo was always 5.9+ this is easy. # Result Up to date CI --- .github/workflows/pull_request.yml | 62 ++++++++----------- .github/workflows/scheduled.yml | 35 +++++------ .spi.yml | 4 +- .../ServerSentEventsDecoding.swift | 7 ++- 4 files changed, 50 insertions(+), 58 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index eaf36ac0..46ba74df 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,43 +1,35 @@ name: PR on: - pull_request: - types: [opened, reopened, synchronize] + pull_request: + types: [opened, reopened, synchronize] jobs: - soundness: - name: Soundness - uses: apple/swift-nio/.github/workflows/soundness.yml@main - with: - api_breakage_check_enabled: true - broken_symlink_check_enabled: true - docs_check_enabled: true - format_check_enabled: true - license_header_check_enabled: true - license_header_check_project_name: "SwiftOpenAPIGenerator" - shell_check_enabled: true - unacceptable_language_check_enabled: true + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + license_header_check_project_name: "SwiftOpenAPIGenerator" - unit-tests: - name: Unit tests - uses: apple/swift-nio/.github/workflows/unit_tests.yml@main - with: - linux_5_8_enabled: false - linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" - linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_main_enabled: false + unit-tests: + name: Unit tests + uses: apple/swift-nio/.github/workflows/unit_tests.yml@main + with: + linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" + linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_main_enabled: false - integration-test: - name: Integration test - uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main - with: - name: "Integration test" - matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" - matrix_linux_5_8_enabled: false - matrix_linux_nightly_main_enabled: false + integration-test: + name: Integration test + uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + with: + name: "Integration test" + matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" + matrix_linux_nightly_main_enabled: false - swift-6-language-mode: - name: Swift 6 Language Mode - uses: apple/swift-nio/.github/workflows/swift_6_language_mode.yml@main - if: false # Disabled for now. + swift-6-language-mode: + name: Swift 6 Language Mode + uses: apple/swift-nio/.github/workflows/swift_6_language_mode.yml@main + if: false # Disabled for now. diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index 7a345603..cd177af7 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -1,24 +1,23 @@ name: Scheduled on: - schedule: - - cron: "0 8,20 * * *" + schedule: + - cron: "0 8,20 * * *" jobs: - unit-tests: - name: Unit tests - uses: apple/swift-nio/.github/workflows/unit_tests.yml@main - with: - linux_5_8_enabled: false - linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" - linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + unit-tests: + name: Unit tests + uses: apple/swift-nio/.github/workflows/unit_tests.yml@main + with: + linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" + linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" - integration-test: - name: Integration test - uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main - with: - name: "Integration test" - matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" - matrix_linux_5_8_enabled: false \ No newline at end of file + integration-test: + name: Integration test + uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + with: + name: "Integration test" + matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" diff --git a/.spi.yml b/.spi.yml index 58e64aec..97fc4286 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,5 +1,5 @@ version: 1 builder: configs: - - documentation_targets: - - OpenAPIRuntime + - documentation_targets: + - OpenAPIRuntime diff --git a/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift index ff374b39..34f51b21 100644 --- a/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift +++ b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift @@ -29,8 +29,9 @@ where Upstream.Element == ArraySlice { private let upstream: Upstream /// A closure that determines whether the given byte chunk should be forwarded to the consumer. - /// - Parameter: A byte chunk. - /// - Returns: `true` if the byte chunk should be forwarded, `false` if this byte chunk is the terminating sequence. + /// + /// The closure should return `true` if the byte chunk should be forwarded and + /// `false` if this byte chunk is the terminating sequence. private let predicate: @Sendable (ArraySlice) -> Bool /// Creates a new sequence. @@ -98,7 +99,7 @@ extension AsyncSequence where Element == ArraySlice, Self: Sendable { /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. /// /// Use this method if the event's `data` field is not JSON, or if you don't want to parse it using `asDecodedServerSentEventsWithJSONData`. - /// - Parameter: A closure that determines whether the given byte chunk should be forwarded to the consumer. + /// - Parameter predicate: A closure that determines whether the given byte chunk should be forwarded to the consumer. /// - Returns: A sequence that provides the events. public func asDecodedServerSentEvents( while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } From daa2fb54fe4a7f5187d7286047d5144c8cb97477 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Sat, 19 Oct 2024 19:36:12 +0200 Subject: [PATCH 22/50] Add Mac Catalyst support (#124) --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f225ecfc..2cae0bfc 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "swift-openapi-runtime", platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) + .macOS(.v10_15), .macCatalyst(.v13), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) ], products: [ .library( From 335cac0a9daad1461f45897edda6bdbf56632689 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 14 Nov 2024 11:06:49 +0000 Subject: [PATCH 23/50] unify scheduled and main yamls (#128) --- .github/workflows/{scheduled.yml => main.yml} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{scheduled.yml => main.yml} (95%) diff --git a/.github/workflows/scheduled.yml b/.github/workflows/main.yml similarity index 95% rename from .github/workflows/scheduled.yml rename to .github/workflows/main.yml index cd177af7..0e52f33a 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,8 @@ -name: Scheduled +name: Main on: + push: + branches: [main] schedule: - cron: "0 8,20 * * *" From 2cb09fb7a341c000b3abc2d9bfe046ca78bd5d66 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 15 Nov 2024 09:55:55 +0000 Subject: [PATCH 24/50] remove unused Swift 6 language mode workflow (#130) remove unused Swift 6 language mode workflow --- .github/workflows/pull_request.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 46ba74df..4b6971bb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -28,8 +28,3 @@ jobs: name: "Integration test" matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" matrix_linux_nightly_main_enabled: false - - swift-6-language-mode: - name: Swift 6 Language Mode - uses: apple/swift-nio/.github/workflows/swift_6_language_mode.yml@main - if: false # Disabled for now. From b820948dde4ca98c65994fc347c13e2275226ac6 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 19 Nov 2024 20:10:52 +0100 Subject: [PATCH 25/50] [Runtime] Improve parameter handling of MIME types in content types (#113) ### Motivation In service of an upcoming generator PR - to better handle parameters in MIME types. This is needed for APIs such as Kubernetes, that has both `application/json` and `application/json; watch=true` in a single operation. ### Modifications Use the more modern `OpenAPIMIMEType` type for parsing and comparing content types, rather than the old naive logic that ignored parameter (mis)matches. ### Result More accurate handling of content types. ### Test Plan Adapted unit tests. --- .../Conversion/Converter+Server.swift | 41 ++++++++++++++++--- .../OpenAPIRuntime/Errors/RuntimeError.swift | 2 + .../Conversion/Test_Converter+Server.swift | 12 ++++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index 75b0f521..a3088bd3 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -56,14 +56,21 @@ extension Converter { // Drop everything after the optional semicolon (q, extensions, ...) value.split(separator: ";")[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - if acceptValues.isEmpty { return } - if acceptValues.contains("*/*") { return } - if acceptValues.contains("\(substring.split(separator: "/")[0].lowercased())/*") { return } - if acceptValues.contains(where: { $0.localizedCaseInsensitiveContains(substring) }) { return } + guard let parsedSubstring = OpenAPIMIMEType(substring) else { + throw RuntimeError.invalidAcceptSubstring(substring) + } + // Look for the first match. + for acceptValue in acceptValues { + // Fast path. + if acceptValue == substring { return } + guard let parsedAcceptValue = OpenAPIMIMEType(acceptValue) else { + throw RuntimeError.invalidExpectedContentType(acceptValue) + } + if parsedSubstring.satisfies(acceptValue: parsedAcceptValue) { return } + } throw RuntimeError.unexpectedAcceptHeader(acceptHeader) } - /// Retrieves and decodes a path parameter as a URI-encoded value of the specified type. /// /// - Parameters: @@ -469,3 +476,27 @@ extension Converter { ) } } + +fileprivate extension OpenAPIMIMEType { + /// Checks if the type satisfies the provided Accept header value. + /// - Parameter acceptValue: A parsed Accept header MIME type. + /// - Returns: `true` if it satisfies the Accept header, `false` otherwise. + func satisfies(acceptValue: OpenAPIMIMEType) -> Bool { + switch (acceptValue.kind, self.kind) { + case (.concrete, .any), (.concrete, .anySubtype), (.anySubtype, .any): + // The response content-type must be at least as specific as the accept header. + return false + case (.any, _): + // Accept: */* -- Any content-type satisfies the accept header. + return true + case (.anySubtype(let acceptType), .anySubtype(let substringType)), + (.anySubtype(let acceptType), .concrete(let substringType, _)): + // Accept: type/* -- The content-type should match the partially-specified accept header. + return acceptType.lowercased() == substringType.lowercased() + case (.concrete(let acceptType, let acceptSubtype), .concrete(let substringType, let substringSubtype)): + // Accept: type/subtype -- The content-type should match the concrete type. + return acceptType.lowercased() == substringType.lowercased() + && acceptSubtype.lowercased() == substringSubtype.lowercased() + } + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index b0c776ed..549e3a13 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 invalidServerVariableValue(name: String, value: String, allowedValues: [String]) case invalidExpectedContentType(String) + case invalidAcceptSubstring(String) case invalidHeaderFieldName(String) case invalidBase64String(String) @@ -85,6 +86,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Invalid server variable named: '\(name)', which has the value: '\(value)', but the only allowed values are: \(allowedValues.map { "'\($0)'" }.joined(separator: ", "))" case .invalidExpectedContentType(let string): return "Invalid expected content type: '\(string)'" + case .invalidAcceptSubstring(let string): return "Invalid Accept header 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))'" diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index b2305a08..9e16fbfb 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -39,18 +39,20 @@ final class Test_ServerConverterExtensions: Test_Runtime { .accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8" ] let multiple: HTTPFields = [.accept: "text/plain, application/json"] + let params: HTTPFields = [.accept: "application/json; foo=bar"] let cases: [(HTTPFields, String, Bool)] = [ // No Accept header, any string validates successfully (emptyHeaders, "foobar", true), - // Accept: */*, any string validates successfully - (wildcard, "foobar", true), + // Accept: */*, any MIME type validates successfully + (wildcard, "foobaz/bar", true), // Accept: text/*, so text/plain succeeds, application/json fails (partialWildcard, "text/plain", true), (partialWildcard, "application/json", false), // Accept: text/plain, text/plain succeeds, application/json fails - (short, "text/plain", true), (short, "application/json", false), + (short, "text/plain", true), (short, "application/json", false), (short, "application/*", false), + (short, "*/*", false), // A bunch of acceptable content types (long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true), @@ -58,6 +60,10 @@ final class Test_ServerConverterExtensions: Test_Runtime { // Multiple values (multiple, "text/plain", true), (multiple, "application/json", true), (multiple, "application/xml", false), + + // Params + (params, "application/json; foo=bar", true), (params, "application/json; charset=utf-8; foo=bar", true), + (params, "application/json", true), (params, "text/plain", false), ] for (headers, contentType, success) in cases { if success { From 0155a70f22a121c918a4da2da0765059d1d6b921 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Wed, 20 Nov 2024 11:35:21 +0000 Subject: [PATCH 26/50] add .editorconfig file (#129) add .editorconfig file --- .editorconfig | 8 ++++++++ .licenseignore | 1 + 2 files changed, 9 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..08891d83 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.licenseignore b/.licenseignore index 3767d5e3..9869feaf 100644 --- a/.licenseignore +++ b/.licenseignore @@ -8,3 +8,4 @@ **.txt **Package.swift docker/* +.editorconfig From 3d5d9579b188f3ffa5a84b4fcc69a8e4279dcc34 Mon Sep 17 00:00:00 2001 From: gayathrisairam <168187165+gayathrisairam@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:26:25 +0000 Subject: [PATCH 27/50] Add error handling middleware (#126) ### Motivation Implementation of [Improved error handling proposal](https://forums.swift.org/t/proposal-soar-0011-improved-error-handling/74736) ### Modifications Added HTTPResponseConvertible protocol Added ErrorHandlingMiddleware that converts errors confirming to HTTPResponseConvertible to a HTTP response. ### Result The new middleware is an opt-in middleware. So there won't be any change to existing clients. Clients who wish to have OpenAPI error handling can include the new error middleware in their application. ### Test Plan Added E2E tests to test the new middleware. --------- Co-authored-by: Gayathri Sairamkrishnan Co-authored-by: Honza Dvorsky --- .../Interface/ErrorHandlingMiddleware.swift | 98 ++++++++++++ .../Test_ErrorHandlingMiddleware.swift | 144 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift create mode 100644 Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift diff --git a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift new file mode 100644 index 00000000..55113ce5 --- /dev/null +++ b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2024 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 HTTPTypes + +/// An opt-in error handling middleware that converts an error to an HTTP response. +/// +/// Inclusion of ``ErrorHandlingMiddleware`` should be accompanied by conforming errors to the ``HTTPResponseConvertible`` protocol. +/// Errors not conforming to ``HTTPResponseConvertible`` are converted to a response with the 500 status code. +/// +/// ## Example usage +/// +/// 1. Create an error type that conforms to the ``HTTPResponseConvertible`` protocol: +/// +/// ```swift +/// extension MyAppError: HTTPResponseConvertible { +/// var httpStatus: HTTPResponse.Status { +/// switch self { +/// case .invalidInputFormat: +/// .badRequest +/// case .authorizationError: +/// .forbidden +/// } +/// } +/// } +/// ``` +/// +/// 2. Opt into the ``ErrorHandlingMiddleware`` while registering the handler: +/// +/// ```swift +/// let handler = RequestHandler() +/// try handler.registerHandlers(on: transport, middlewares: [ErrorHandlingMiddleware()]) +/// ``` +/// - Note: The placement of ``ErrorHandlingMiddleware`` in the middleware chain is important. It should be determined based on the specific needs of each application. Consider the order of execution and dependencies between middlewares. +public struct ErrorHandlingMiddleware: ServerMiddleware { + /// Creates a new middleware. + public init() {} + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func intercept( + _ request: HTTPTypes.HTTPRequest, + body: OpenAPIRuntime.HTTPBody?, + metadata: OpenAPIRuntime.ServerRequestMetadata, + operationID: String, + next: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata) + async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) + ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { + do { return try await next(request, body, metadata) } catch { + if let serverError = error as? ServerError, + let appError = serverError.underlyingError as? (any HTTPResponseConvertible) + { + return ( + HTTPResponse(status: appError.httpStatus, headerFields: appError.httpHeaderFields), + appError.httpBody + ) + } else { + return (HTTPResponse(status: .internalServerError), nil) + } + } + } +} + +/// A value that can be converted to an HTTP response and body. +/// +/// Conform your error type to this protocol to convert it to an `HTTPResponse` and ``HTTPBody``. +/// +/// Used by ``ErrorHandlingMiddleware``. +public protocol HTTPResponseConvertible { + + /// An HTTP status to return in the response. + var httpStatus: HTTPResponse.Status { get } + + /// The HTTP header fields of the response. + /// This is optional as default values are provided in the extension. + var httpHeaderFields: HTTPTypes.HTTPFields { get } + + /// The body of the HTTP response. + var httpBody: OpenAPIRuntime.HTTPBody? { get } +} + +extension HTTPResponseConvertible { + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var httpHeaderFields: HTTPTypes.HTTPFields { [:] } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var httpBody: OpenAPIRuntime.HTTPBody? { nil } +} diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift new file mode 100644 index 00000000..91977a31 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2024 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 HTTPTypes + +import XCTest +@_spi(Generated) @testable import OpenAPIRuntime + +final class Test_ErrorHandlingMiddlewareTests: XCTestCase { + static let mockRequest: HTTPRequest = .init(soar_path: "http://abc.com", method: .get) + static let mockBody: HTTPBody = HTTPBody("hello") + static let errorHandlingMiddleware = ErrorHandlingMiddleware() + + func testSuccessfulRequest() async throws { + let response = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept( + Test_ErrorHandlingMiddlewareTests.mockRequest, + body: Test_ErrorHandlingMiddlewareTests.mockBody, + metadata: .init(), + operationID: "testop", + next: getNextMiddleware(failurePhase: .never) + ) + XCTAssertEqual(response.0.status, .ok) + } + + func testError_conformingToProtocol_convertedToResponse() async throws { + let (response, responseBody) = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept( + Test_ErrorHandlingMiddlewareTests.mockRequest, + body: Test_ErrorHandlingMiddlewareTests.mockBody, + metadata: .init(), + operationID: "testop", + next: getNextMiddleware(failurePhase: .convertibleError) + ) + XCTAssertEqual(response.status, .badGateway) + XCTAssertEqual(response.headerFields, [.contentType: "application/json"]) + XCTAssertEqual(responseBody, testHTTPBody) + } + + func testError_conformingToProtocolWithoutAllValues_convertedToResponse() async throws { + let (response, responseBody) = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept( + Test_ErrorHandlingMiddlewareTests.mockRequest, + body: Test_ErrorHandlingMiddlewareTests.mockBody, + metadata: .init(), + operationID: "testop", + next: getNextMiddleware(failurePhase: .partialConvertibleError) + ) + XCTAssertEqual(response.status, .badRequest) + XCTAssertEqual(response.headerFields, [:]) + XCTAssertEqual(responseBody, nil) + } + + func testError_notConformingToProtocol_returns500() async throws { + let (response, responseBody) = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept( + Test_ErrorHandlingMiddlewareTests.mockRequest, + body: Test_ErrorHandlingMiddlewareTests.mockBody, + metadata: .init(), + operationID: "testop", + next: getNextMiddleware(failurePhase: .nonConvertibleError) + ) + XCTAssertEqual(response.status, .internalServerError) + XCTAssertEqual(response.headerFields, [:]) + XCTAssertEqual(responseBody, nil) + } + + private func getNextMiddleware(failurePhase: MockErrorMiddleware_Next.FailurePhase) -> @Sendable ( + HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata + ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { + let mockNext: + @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata) + async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) = { request, body, metadata in + try await MockErrorMiddleware_Next(failurePhase: failurePhase) + .intercept( + request, + body: body, + metadata: metadata, + operationID: "testop", + next: { _, _, _ in (HTTPResponse.init(status: .ok), nil) } + ) + } + return mockNext + } +} + +struct MockErrorMiddleware_Next: ServerMiddleware { + enum FailurePhase { + case never + case convertibleError + case nonConvertibleError + case partialConvertibleError + } + var failurePhase: FailurePhase = .never + + @Sendable func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + metadata: ServerRequestMetadata, + operationID: String, + next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var error: (any Error)? + switch failurePhase { + case .never: break + case .convertibleError: error = ConvertibleError() + case .nonConvertibleError: error = NonConvertibleError() + case .partialConvertibleError: error = PartialConvertibleError() + } + if let underlyingError = error { + throw ServerError( + operationID: operationID, + request: request, + requestBody: body, + requestMetadata: metadata, + causeDescription: "", + underlyingError: underlyingError + ) + } + let (response, responseBody) = try await next(request, body, metadata) + return (response, responseBody) + } +} + +struct ConvertibleError: Error, HTTPResponseConvertible { + var httpStatus: HTTPTypes.HTTPResponse.Status = HTTPResponse.Status.badGateway + var httpHeaderFields: HTTPFields = [.contentType: "application/json"] + var httpBody: OpenAPIRuntime.HTTPBody? = testHTTPBody +} + +struct PartialConvertibleError: Error, HTTPResponseConvertible { + var httpStatus: HTTPTypes.HTTPResponse.Status = HTTPResponse.Status.badRequest +} + +struct NonConvertibleError: Error {} + +let testHTTPBody = HTTPBody(try! JSONEncoder().encode(["error", " test error"])) From 5e119a3d52dde0229312ed586be99c666c6b6f64 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 21 Nov 2024 18:19:20 +0100 Subject: [PATCH 28/50] Refactor URIDecoder/URIParser to improve handling of the deepObject style (#127) ### Motivation As part of https://github.com/apple/swift-openapi-generator/issues/259, adding deepObject parameter style support, the initial PR wasn't complete. And once we dug more into it, turns out the original implementation of the URIDecoder/URIParser didn't really lend themselves well for handling deepObject, and the recent additions of supporting arrays within dictionaries (https://github.com/apple/swift-openapi-runtime/pull/120) further confused the implementation. ### Modifications Refactored URIParser/URIDecoder with a clearer understanding of the current requirements. It's now much easier to follow and embraces the fact that each of the 7 variants of URI coding we support (form exploded, form unexploded, simple exploded, simple unexploded, form data exploded, form data unexploded, and now deepObject exploded) are similar, but still different in subtle ways. This new implementation doesn't try as hard to share code between the implementations, so might at first sight appear to duplicate code. The original implementation had many methods with many configuration parameters and utility methods with a high cyclomatic complexity, which made it very hard to reason about. We did away with that. While there, I also made some minor improvements to the serialization path, which allows cleaner round-tripping tests. ### Result A more maintainable and more correct URI decoder/parser implementation. ### Test Plan Added many more unit tests that test the full matrix of supported styles and inputs. --------- Co-authored-by: Si Beaumont --- .../Conversion/ParameterStyles.swift | 5 +- .../Common/URICoderConfiguration.swift | 11 +- .../URICoder/Common/URIEncodedNode.swift | 16 + .../URICoder/Common/URIParsedNode.swift | 27 - .../URICoder/Common/URIParsedTypes.swift | 63 ++ .../URICoder/Decoding/URIDecoder.swift | 94 +-- .../URIValueFromNodeDecoder+Keyed.swift | 11 +- .../URIValueFromNodeDecoder+Single.swift | 72 +- .../URIValueFromNodeDecoder+Unkeyed.swift | 29 +- .../Decoding/URIValueFromNodeDecoder.swift | 455 ++++++----- .../URICoder/Encoding/URIEncoder.swift | 9 +- .../URIValueToNodeEncoder+Unkeyed.swift | 7 + .../URICoder/Parsing/URIParser.swift | 371 +++++---- .../Serialization/URISerializer.swift | 2 +- .../Conversion/Test_Converter+Server.swift | 36 + .../URICoder/Decoder/Test_URIDecoder.swift | 138 +++- .../Test_URIValueFromNodeDecoder.swift | 47 +- .../URICoder/Parsing/Test_URIParser.swift | 747 ++++++++++++++---- .../Serialization/Test_URISerializer.swift | 4 - .../URICoder/Test_URICodingRoundtrip.swift | 47 +- .../URICoder/URICoderTestUtils.swift | 9 + 21 files changed, 1475 insertions(+), 725 deletions(-) delete mode 100644 Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift create mode 100644 Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift diff --git a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift index 07aa6092..31dda63c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -14,7 +14,7 @@ /// The serialization style used by a parameter. /// -/// Details: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields-10 +/// Details: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#fixed-fields-10 @_spi(Generated) public enum ParameterStyle: Sendable { /// The form style. @@ -26,9 +26,10 @@ /// /// Details: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2 case simple + /// The deepObject style. /// - /// Details: https://spec.openapis.org/oas/v3.1.0.html#style-values + /// Details: https://spec.openapis.org/oas/v3.0.4.html#style-values case deepObject } diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift index ccbdb8c5..3f7b380e 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift @@ -17,15 +17,22 @@ import Foundation /// A bag of configuration values used by the URI encoder and decoder. struct URICoderConfiguration { - /// A variable expansion style as described by RFC 6570 and OpenAPI 3.0.3. + /// A variable expansion style as described by RFC 6570 and OpenAPI 3.0.4. enum Style { /// A style for simple string variable expansion. + /// + /// The whole string always belongs to the root key. case simple /// A style for form-based URI expansion. + /// + /// Only some key/value pairs can belong to the root key, rest are ignored. case form + /// A style for nested variable expansion + /// + /// Only some key/value pairs can belong to the root key, rest are ignored. case deepObject } @@ -43,7 +50,7 @@ struct URICoderConfiguration { var style: Style /// A Boolean value indicating whether the key should be repeated with - /// each value, as described by RFC 6570 and OpenAPI 3.0.3. + /// each value, as described by RFC 6570 and OpenAPI 3.0.4. var explode: Bool /// The character used to escape the space character. diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift index 4297f778..d2f9edbb 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift @@ -73,6 +73,10 @@ extension URIEncodedNode { /// The encoder appended to a node that wasn't an array. case appendingToNonArrayContainer + /// The encoder is trying to mark a container as array, but it's already + /// marked as a container of another type. + case markingExistingNonArrayContainerAsArray + /// The encoder inserted a value for key into a node that wasn't /// a dictionary. case insertingChildValueIntoNonContainer @@ -128,6 +132,18 @@ extension URIEncodedNode { } } + /// Marks the node as an array, starting as empty. + /// - Throws: If the node is already set to be anything else but an array. + mutating func markAsArray() throws { + switch self { + case .array: + // Already an array. + break + case .unset: self = .array([]) + default: throw InsertionError.markingExistingNonArrayContainerAsArray + } + } + /// Appends a value to the array node. /// - Parameter childValue: The node to append to the underlying array. /// - Throws: If the node is already set to be anything else but an array. diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift deleted file mode 100644 index 51ecbe2a..00000000 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift +++ /dev/null @@ -1,27 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 - -/// The type used for keys by `URIParser`. -typealias URIParsedKey = String.SubSequence - -/// The type used for values by `URIParser`. -typealias URIParsedValue = String.SubSequence - -/// The type used for an array of values by `URIParser`. -typealias URIParsedValueArray = [URIParsedValue] - -/// The type used for a node and a dictionary by `URIParser`. -typealias URIParsedNode = [URIParsedKey: URIParsedValueArray] diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift new file mode 100644 index 00000000..23d54e65 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// A component of a `URIParsedKey`. +typealias URIParsedKeyComponent = String.SubSequence + +/// A parsed key for a parsed value. +/// +/// For example, `foo=bar` in a `form` string would parse the key as `foo` (single component). +/// In an unexploded `form` string `root=foo,bar`, the key would be `root/foo` (two components). +/// In a `simple` string `bar`, the key would be empty (0 components). +struct URIParsedKey: Hashable { + + /// The individual string components. + let components: [URIParsedKeyComponent] + + /// Creates a new parsed key. + /// - Parameter components: The key components. + init(_ components: [URIParsedKeyComponent]) { self.components = components } + + /// A new empty key. + static var empty: Self { .init([]) } +} + +/// A primitive value produced by `URIParser`. +typealias URIParsedValue = String.SubSequence + +/// A key-value produced by `URIParser`. +struct URIParsedPair: Equatable { + + /// The key of the pair. + /// + /// In `foo=bar`, `foo` is the key. + var key: URIParsedKey + + /// The value of the pair. + /// + /// In `foo=bar`, `bar` is the value. + var value: URIParsedValue +} + +// MARK: - Extensions + +extension URIParsedKey: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + var description: String { + if components.isEmpty { return "" } + return components.joined(separator: "/") + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift index 72cc077f..3be1dce1 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift @@ -15,7 +15,7 @@ import Foundation /// A type that decodes a `Decodable` value from an URI-encoded string -/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.3, depending on +/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.4, depending on /// the configuration. /// /// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) @@ -45,6 +45,13 @@ import Foundation /// | `{list\*}` | `red,green,blue` | /// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | /// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +/// +/// [OpenAPI 3.0.4 - Deep object expansion.](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#style-examples) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------------------------------| +/// | `{?keys\*}` | `?keys%5Bsemi%5D=%3B&keys%5Bdot%5D=.&keys%5Bcomma%5D=%2C` | +/// struct URIDecoder: Sendable { /// The configuration instructing the decoder how to interpret the raw @@ -60,10 +67,6 @@ extension URIDecoder { /// Attempt to decode an object from an URI string. /// - /// Under the hood, `URIDecoder` first parses the string into a - /// `URIParsedNode` using `URIParser`, and then uses - /// `URIValueFromNodeDecoder` to decode the `Decodable` value. - /// /// - Parameters: /// - type: The type to decode. /// - key: The key of the decoded value. Only used with certain styles @@ -72,15 +75,12 @@ extension URIDecoder { /// - Returns: The decoded value. /// - Throws: An error if decoding fails, for example, due to incompatible data or key. func decode(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws -> T { - try withCachedParser(from: data) { decoder in try decoder.decode(type, forKey: key) } + let decoder = URIValueFromNodeDecoder(data: data, rootKey: key[...], configuration: configuration) + return try decoder.decodeRoot(type) } /// Attempt to decode an object from an URI string, if present. /// - /// Under the hood, `URIDecoder` first parses the string into a - /// `URIParsedNode` using `URIParser`, and then uses - /// `URIValueFromNodeDecoder` to decode the `Decodable` value. - /// /// - Parameters: /// - type: The type to decode. /// - key: The key of the decoded value. Only used with certain styles @@ -90,76 +90,8 @@ extension URIDecoder { /// - Throws: An error if decoding fails, for example, due to incompatible data or key. func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws -> T? - { try withCachedParser(from: data) { decoder in try decoder.decodeIfPresent(type, forKey: key) } } - - /// Make multiple decode calls on the parsed URI. - /// - /// Use to avoid repeatedly reparsing the raw string. - /// - Parameters: - /// - data: The URI-encoded string. - /// - calls: The closure that contains 0 or more calls to - /// the `decode` method on `URICachedDecoder`. - /// - Returns: The result of the closure invocation. - /// - Throws: An error if parsing or decoding fails. - func withCachedParser(from data: Substring, calls: (URICachedDecoder) throws -> R) throws -> R { - var parser = URIParser(configuration: configuration, data: data) - let parsedNode = try parser.parseRoot() - let decoder = URICachedDecoder(configuration: configuration, node: parsedNode) - return try calls(decoder) - } -} - -struct URICachedDecoder { - - /// The configuration used by the decoder. - fileprivate let configuration: URICoderConfiguration - - /// The node from which to decode a value on demand. - fileprivate let node: URIParsedNode - - /// Attempt to decode an object from an URI-encoded string. - /// - /// Under the hood, `URICachedDecoder` already has a pre-parsed - /// `URIParsedNode` and uses `URIValueFromNodeDecoder` to decode - /// the `Decodable` value. - /// - /// - Parameters: - /// - type: The type to decode. - /// - key: The key of the decoded value. Only used with certain styles - /// and explode options, ignored otherwise. - /// - Returns: The decoded value. - /// - Throws: An error if decoding fails. - func decode(_ type: T.Type = T.self, forKey key: String = "") throws -> T { - let decoder = URIValueFromNodeDecoder( - node: node, - rootKey: key[...], - style: configuration.style, - explode: configuration.explode, - dateTranscoder: configuration.dateTranscoder - ) - return try decoder.decodeRoot() - } - - /// Attempt to decode an object from an URI-encoded string, if present. - /// - /// Under the hood, `URICachedDecoder` already has a pre-parsed - /// `URIParsedNode` and uses `URIValueFromNodeDecoder` to decode - /// the `Decodable` value. - /// - /// - Parameters: - /// - type: The type to decode. - /// - key: The key of the decoded value. Only used with certain styles - /// and explode options, ignored otherwise. - /// - Returns: The decoded value. - /// - Throws: An error if decoding fails. - func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "") throws -> T? { - let decoder = URIValueFromNodeDecoder( - node: node, - rootKey: key[...], - style: configuration.style, - explode: configuration.explode, - dateTranscoder: configuration.dateTranscoder - ) - return try decoder.decodeRootIfPresent() + { + let decoder = URIValueFromNodeDecoder(data: data, rootKey: key[...], configuration: configuration) + return try decoder.decodeRootIfPresent(type) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift index fd47d462..3e3990f6 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift @@ -19,9 +19,6 @@ struct URIKeyedDecodingContainer { /// The associated decoder. let decoder: URIValueFromNodeDecoder - - /// The underlying dictionary. - let values: URIParsedNode } extension URIKeyedDecodingContainer { @@ -32,7 +29,7 @@ extension URIKeyedDecodingContainer { /// - Returns: The value found for the provided key. /// - Throws: An error if no value for the key was found. private func _decodeValue(forKey key: Key) throws -> URIParsedValue { - guard let value = values[key.stringValue[...]]?.first else { + guard let value = try decoder.nestedElementInCurrentDictionary(forKey: key.stringValue) else { throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: "Key not found.")) } return value @@ -97,9 +94,9 @@ extension URIKeyedDecodingContainer { extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { - var allKeys: [Key] { values.keys.map { key in Key.init(stringValue: String(key))! } } + var allKeys: [Key] { decoder.elementKeysInCurrentDictionary().compactMap { .init(stringValue: $0) } } - func contains(_ key: Key) -> Bool { values[key.stringValue[...]] != nil } + func contains(_ key: Key) -> Bool { decoder.containsElementInCurrentDictionary(forKey: key.stringValue) } var codingPath: [any CodingKey] { decoder.codingPath } @@ -153,7 +150,7 @@ extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { case is UInt64.Type: return try decode(UInt64.self, forKey: key) as! T case is Date.Type: return try decoder.dateTranscoder.decode(String(_decodeValue(forKey: key))) as! T default: - try decoder.push(.init(key)) + decoder.push(.init(key)) defer { decoder.pop() } return try type.init(from: decoder) } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift index 3c829873..2207bd84 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift @@ -24,7 +24,12 @@ struct URISingleValueDecodingContainer { extension URISingleValueDecodingContainer { /// The underlying value as a single value. - var value: URIParsedValue { get throws { try decoder.currentElementAsSingleValue() } } + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + var value: URIParsedValue? { get throws { try decoder.currentElementAsSingleValue() } } /// Returns the value found in the underlying node converted to /// the provided type. @@ -33,7 +38,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeBinaryFloatingPoint(_: T.Type = T.self) throws -> T { - guard let double = try Double(value) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let double = Double(value) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to Double.") @@ -49,7 +64,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeFixedWidthInteger(_: T.Type = T.self) throws -> T { - guard let parsedValue = try T(value) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let parsedValue = T(value) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") @@ -65,7 +90,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeLosslessStringConvertible(_: T.Type = T.self) throws -> T { - guard let parsedValue = try T(String(value)) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let parsedValue = T(String(value)) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") @@ -79,11 +114,23 @@ extension URISingleValueDecodingContainer: SingleValueDecodingContainer { var codingPath: [any CodingKey] { decoder.codingPath } - func decodeNil() -> Bool { false } + func decodeNil() -> Bool { do { return try value == nil } catch { return false } } func decode(_ type: Bool.Type) throws -> Bool { try _decodeLosslessStringConvertible() } - func decode(_ type: String.Type) throws -> String { try String(value) } + func decode(_ type: String.Type) throws -> String { + guard let value = try value else { + throw DecodingError.valueNotFound( + String.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + return String(value) + } func decode(_ type: Double.Type) throws -> Double { try _decodeBinaryFloatingPoint() } @@ -125,7 +172,18 @@ extension URISingleValueDecodingContainer: SingleValueDecodingContainer { case is UInt16.Type: return try decode(UInt16.self) as! T case is UInt32.Type: return try decode(UInt32.self) as! T case is UInt64.Type: return try decode(UInt64.self) as! T - case is Date.Type: return try decoder.dateTranscoder.decode(String(value)) as! T + case is Date.Type: + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + return try decoder.dateTranscoder.decode(String(value)) as! T default: return try T.init(from: decoder) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift index 44a5cd28..72fe4739 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift @@ -16,24 +16,17 @@ import Foundation /// An unkeyed container used by `URIValueFromNodeDecoder`. struct URIUnkeyedDecodingContainer { - /// The associated decoder. let decoder: URIValueFromNodeDecoder - /// The underlying array. - let values: URIParsedValueArray - - /// The index of the item being currently decoded. - private var index: Int + /// The index of the next item to be decoded. + private(set) var currentIndex: Int /// Creates a new unkeyed container ready to decode the first key. - /// - Parameters: - /// - decoder: The underlying decoder. - /// - values: The underlying array. - init(decoder: URIValueFromNodeDecoder, values: URIParsedValueArray) { + /// - Parameter decoder: The underlying decoder. + init(decoder: URIValueFromNodeDecoder) { self.decoder = decoder - self.values = values - self.index = values.startIndex + self.currentIndex = 0 } } @@ -46,7 +39,7 @@ extension URIUnkeyedDecodingContainer { /// - Throws: An error if the container ran out of items. private mutating func _decodingNext(in work: () throws -> R) throws -> R { guard !isAtEnd else { throw URIValueFromNodeDecoder.GeneralError.reachedEndOfUnkeyedContainer } - defer { values.formIndex(after: &index) } + defer { currentIndex += 1 } return try work() } @@ -55,7 +48,7 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The next value found. /// - Throws: An error if the container ran out of items. private mutating func _decodeNext() throws -> URIParsedValue { - try _decodingNext { [values, index] in values[index] } + try _decodingNext { [decoder, currentIndex] in try decoder.nestedElementInCurrentArray(atIndex: currentIndex) } } /// Returns the next value converted to the provided type. @@ -111,11 +104,9 @@ extension URIUnkeyedDecodingContainer { extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { - var count: Int? { values.count } - - var isAtEnd: Bool { index == values.endIndex } + var count: Int? { try? decoder.countOfCurrentArray() } - var currentIndex: Int { index } + var isAtEnd: Bool { currentIndex == count } var codingPath: [any CodingKey] { decoder.codingPath } @@ -168,7 +159,7 @@ extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { case is Date.Type: return try decoder.dateTranscoder.decode(String(_decodeNext())) as! T default: return try _decodingNext { [decoder, currentIndex] in - try decoder.push(.init(intValue: currentIndex)) + decoder.push(.init(intValue: currentIndex)) defer { decoder.pop() } return try type.init(from: decoder) } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift index 55982755..83c77c90 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift @@ -14,46 +14,54 @@ import Foundation -/// A type that allows decoding `Decodable` values from a `URIParsedNode`. +/// A type that allows decoding `Decodable` values from a URI-encoded string. final class URIValueFromNodeDecoder { - /// The coder used for serializing Date values. + /// The key of the root object. + let rootKey: URIParsedKeyComponent + + /// The date transcoder used for decoding the `Date` type. let dateTranscoder: any DateTranscoder - /// The underlying root node. - private let node: URIParsedNode + /// The coder configuration. + private let configuration: URICoderConfiguration + + /// The URIParser used to parse the provided URI-encoded string into + /// an intermediate representation. + private let parser: URIParser + + /// The cached parsing state of the decoder. + private struct ParsingCache { + + /// The cached result of parsing the string as a primitive value. + var primitive: Result? - /// The key of the root value in the node. - private let rootKey: URIParsedKey + /// The cached result of parsing the string as an array. + var array: Result<[URIParsedValue], any Error>? - /// The variable expansion style. - private let style: URICoderConfiguration.Style + /// The cached result of parsing the string as a dictionary. + var dictionary: Result<[URIParsedKeyComponent: [URIParsedValue]], any Error>? + } - /// The explode parameter of the expansion style. - private let explode: Bool + /// A cache holding the parsed intermediate representation. + private var cache: ParsingCache - /// The stack of nested values within the root node. - private var codingStack: [CodingStackEntry] + /// The stack of nested keys within the root node. + /// + /// Represents the currently parsed container. + private var codingStack: [URICoderCodingKey] /// Creates a new decoder. /// - Parameters: - /// - node: The underlying root node. - /// - rootKey: The key of the root value in the node. - /// - style: The variable expansion style. - /// - explode: The explode parameter of the expansion style. - /// - dateTranscoder: The coder used for serializing Date values. - init( - node: URIParsedNode, - rootKey: URIParsedKey, - style: URICoderConfiguration.Style, - explode: Bool, - dateTranscoder: any DateTranscoder - ) { - self.node = node + /// - data: The data to parse. + /// - rootKey: The key of the root object. + /// - configuration: The configuration of the decoder. + init(data: Substring, rootKey: URIParsedKeyComponent, configuration: URICoderConfiguration) { self.rootKey = rootKey - self.style = style - self.explode = explode - self.dateTranscoder = dateTranscoder + self.dateTranscoder = configuration.dateTranscoder + self.configuration = configuration + self.parser = .init(configuration: configuration, data: data) + self.cache = .init() self.codingStack = [] } @@ -76,13 +84,22 @@ final class URIValueFromNodeDecoder { return value } - /// Decodes the provided type from the root node. + /// Decodes the provided type from the root node, if it's present. /// - Parameter type: The type to decode from the decoder. - /// - Returns: The decoded value. + /// - Returns: The decoded value, or nil if not found. /// - Throws: When a decoding error occurs. func decodeRootIfPresent(_ type: T.Type = T.self) throws -> T? { - // The root is only nil if the node is empty. - if try currentElementAsArray().isEmpty { return nil } + switch configuration.style { + case .simple: + // Root is never nil, empty data just means an element with an empty string. + break + case .form: + // Try to parse as an array, check the number of elements. + if try parsedRootAsArray().count == 0 { return nil } + case .deepObject: + // Try to parse as a dictionary, check the number of elements. + if try parsedRootAsDictionary().count == 0 { return nil } + } return try decodeRoot(type) } } @@ -98,62 +115,13 @@ extension URIValueFromNodeDecoder { /// The decoder was asked for more items, but it was already at the /// end of the unkeyed container. case reachedEndOfUnkeyedContainer - - /// The provided coding key does not have a valid integer value, but - /// it is being used for accessing items in an unkeyed container. - case codingKeyNotInt - - /// The provided coding key is out of bounds of the unkeyed container. - case codingKeyOutOfBounds - - /// The coding key is of a value not found in the keyed container. - case codingKeyNotFound } - /// A node materialized by the decoder. - private enum URIDecodedNode { - - /// A single value. - case single(URIParsedValue) - - /// An array of values. - case array(URIParsedValueArray) - - /// A dictionary of values. - case dictionary(URIParsedNode) - } - - /// An entry in the coding stack for `URIValueFromNodeDecoder`. - /// - /// This is used to keep track of where we are in the decode. - private struct CodingStackEntry { - - /// The key at which the entry was found. - var key: URICoderCodingKey - - /// The node at the key inside its parent. - var element: URIDecodedNode - } - - /// The element at the current head of the coding stack. - private var currentElement: URIDecodedNode { codingStack.last?.element ?? .dictionary(node) } - /// Pushes a new container on top of the current stack, nesting into the /// value at the provided key. /// - Parameter codingKey: The coding key for the value that is then put /// at the top of the stack. - /// - Throws: An error if an issue occurs during the container push operation. - func push(_ codingKey: URICoderCodingKey) throws { - let nextElement: URIDecodedNode - if let intValue = codingKey.intValue { - let value = try nestedValueInCurrentElementAsArray(at: intValue) - nextElement = .single(value) - } else { - let values = try nestedValuesInCurrentElementAsDictionary(forKey: codingKey.stringValue) - nextElement = .array(values) - } - codingStack.append(CodingStackEntry(key: codingKey, element: nextElement)) - } + func push(_ codingKey: URICoderCodingKey) { codingStack.append(codingKey) } /// Pops the top container from the stack and restores the previously top /// container to be the current top container. @@ -167,153 +135,238 @@ extension URIValueFromNodeDecoder { throw DecodingError.typeMismatch(String.self, .init(codingPath: codingPath, debugDescription: message)) } - /// Extracts the root value of the provided node using the root key. - /// - Parameter node: The node which to expect for the root key. - /// - Returns: The value found at the root key in the provided node. - /// - Throws: A `DecodingError` if the value is not found at the root key - private func rootValue(in node: URIParsedNode) throws -> URIParsedValueArray { - guard let value = node[rootKey] else { - if style == .simple, let valueForFallbackKey = node[""] { - // The simple style doesn't encode the key, so single values - // get encoded as a value only, and parsed under the empty - // string key. - return valueForFallbackKey + // MARK: - withParsed methods + + /// Parse the root parsed as a specific type, with automatic caching. + /// - Parameters: + /// - valueKeyPath: A key path to the parsing cache for storing the cached value. + /// - parsingClosure: Gets the value from the parser. + /// - Returns: The parsed value. + /// - Throws: If the parsing closure fails. + private func cachedRoot( + as valueKeyPath: WritableKeyPath?>, + parse parsingClosure: (URIParser) throws -> ValueType + ) throws -> ValueType { + let value: ValueType + if let cached = cache[keyPath: valueKeyPath] { + value = try cached.get() + } else { + let result: Result + do { + value = try parsingClosure(parser) + result = .success(value) + } catch { + result = .failure(error) + throw error } - return [] + cache[keyPath: valueKeyPath] = result } return value } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as a dictionary. - /// - Returns: The value if it can be treated as a dictionary. - /// - Throws: An error if the current element cannot be treated as a dictionary. - private func currentElementAsDictionary() throws -> URIParsedNode { try nodeAsDictionary(currentElement) } - - /// Checks if the provided node can be treated as a dictionary, and returns - /// it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as a dictionary. - /// - Throws: An error if the node cannot be treated as a valid dictionary. - private func nodeAsDictionary(_ node: URIDecodedNode) throws -> URIParsedNode { - // There are multiple ways a valid dictionary is represented in a node, - // depends on the explode parameter. - // 1. exploded: Key-value pairs in the node: ["R":["100"]] - // 2. unexploded form: Flattened key-value pairs in the only top level - // key's value array: ["":["R","100"]] - // To simplify the code, when asked for a keyed container here and explode - // is false, we convert (2) to (1), and then treat everything as (1). - // The conversion only works if the number of values is even, including 0. - if explode { - guard case let .dictionary(values) = node else { - try throwMismatch("Cannot treat a single value or an array as a dictionary.") + /// Parse the root as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsPrimitive() throws -> URIParsedValue? { + try cachedRoot(as: \.primitive, parse: { try $0.parseRootAsPrimitive(rootKey: rootKey)?.value }) + } + + /// Parse the root as an array. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsArray() throws -> [URIParsedValue] { + try cachedRoot(as: \.array, parse: { try $0.parseRootAsArray(rootKey: rootKey).map(\.value) }) + } + + /// Parse the root as a dictionary. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsDictionary() throws -> [URIParsedKeyComponent: [URIParsedValue]] { + try cachedRoot( + as: \.dictionary, + parse: { parser in + func normalizedDictionaryKey(_ key: URIParsedKey) throws -> Substring { + func validateComponentCount(_ count: Int) throws { + guard key.components.count == count else { + try throwMismatch( + "Decoding a dictionary key encountered an unexpected number of components (expected: \(count), got: \(key.components.count)." + ) + } + } + switch (configuration.style, configuration.explode) { + case (.form, true), (.simple, _): + try validateComponentCount(1) + return key.components[0] + case (.form, false), (.deepObject, true): + try validateComponentCount(2) + return key.components[1] + case (.deepObject, false): try throwMismatch("Decoding deepObject + unexplode is not supported.") + } + } + let tuples = try parser.parseRootAsDictionary(rootKey: rootKey) + let normalizedTuples: [(Substring, [URIParsedValue])] = try tuples.map { pair in + try (normalizedDictionaryKey(pair.key), [pair.value]) + } + return Dictionary(normalizedTuples, uniquingKeysWith: +) } - return values - } - let values = try nodeAsArray(node) - if values == [""] && style == .simple { - // An unexploded simple combination produces a ["":[""]] for an - // empty string. It should be parsed as an empty dictionary. - return ["": [""]] - } - guard values.count % 2 == 0 else { - try throwMismatch("Cannot parse an unexploded dictionary an odd number of elements.") - } - let pairs = stride(from: values.startIndex, to: values.endIndex, by: 2) - .map { firstIndex in (values[firstIndex], [values[firstIndex + 1]]) } - let convertedNode = Dictionary(pairs, uniquingKeysWith: { $0 + $1 }) - return convertedNode + ) } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as an array. - /// - Returns: The value if it can be treated as an array. - /// - Throws: An error if the node cannot be treated as an array. - private func currentElementAsArray() throws -> URIParsedValueArray { try nodeAsArray(currentElement) } - - /// Checks if the provided node can be treated as an array, and returns - /// it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as an array. - /// - Throws: An error if the node cannot be treated as a valid array. - private func nodeAsArray(_ node: URIDecodedNode) throws -> URIParsedValueArray { - switch node { - case .single(let value): return [value] - case .array(let values): return values - case .dictionary(let values): return try rootValue(in: values) + // MARK: - decoding utilities + + /// Returns a dictionary value. + /// - Parameters: + /// - key: The key for which to return the value. + /// - dictionary: The dictionary in which to find the value. + /// - Returns: The value in the dictionary, or nil if not found. + /// - Throws: When multiple values are found for the key. + func primitiveValue(forKey key: String, in dictionary: [URIParsedKeyComponent: [URIParsedValue]]) throws + -> URIParsedValue? + { + let values = dictionary[key[...], default: []] + if values.isEmpty { return nil } + if values.count > 1 { try throwMismatch("Dictionary value contains multiple values.") } + return values[0] + } + + // MARK: - withCurrent methods + + /// Use the current top of the stack as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails. + private func withCurrentPrimitiveElement(_ work: (URIParsedValue?) throws -> R) throws -> R { + if !codingStack.isEmpty { + // Nesting is involved. + // There are exactly three scenarios we support: + // - primitive in a top level array + // - primitive in a top level dictionary + // - primitive in a nested array inside a top level dictionary + if codingStack.count == 1 { + let key = codingStack[0] + if let intKey = key.intValue { + // Top level array. + return try work(parsedRootAsArray()[intKey]) + } else { + // Top level dictionary. + return try work(primitiveValue(forKey: key.stringValue, in: parsedRootAsDictionary())) + } + } else if codingStack.count == 2 { + // Nested array within a top level dictionary. + let dictionaryKey = codingStack[0].stringValue[...] + guard let nestedArrayKey = codingStack[1].intValue else { + try throwMismatch("Nested coding key is not an integer, hinting at unsupported nesting.") + } + return try work(parsedRootAsDictionary()[dictionaryKey, default: []][nestedArrayKey]) + } else { + try throwMismatch("Arbitrary nesting of containers is not supported.") + } + } else { + // Top level primitive. + return try work(parsedRootAsPrimitive()) } } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as a primitive value. - /// - Returns: The value if it can be treated as a primitive value. - /// - Throws: An error if the node cannot be treated as a primitive value. - func currentElementAsSingleValue() throws -> URIParsedValue { try nodeAsSingleValue(currentElement) } - - /// Checks if the provided node can be treated as a primitive value, and - /// returns it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as a primitive value. - /// - Throws: An error if the node cannot be treated as a primitive value. - private func nodeAsSingleValue(_ node: URIDecodedNode) throws -> URIParsedValue { - // A single value can be parsed from a node that: - // 1. Has a single key-value pair - // 2. The value array has a single element. - let array: URIParsedValueArray - switch node { - case .single(let value): return value - case .array(let values): array = values - case .dictionary(let values): array = try rootValue(in: values) + /// Use the current top of the stack as an array. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails. + private func withCurrentArrayElements(_ work: ([URIParsedValue]) throws -> R) throws -> R { + if let nestedArrayParentKey = codingStack.first { + // Top level is dictionary, first level nesting is array. + // Get all the elements that match this rootKey + nested key path. + return try work(parsedRootAsDictionary()[nestedArrayParentKey.stringValue[...]] ?? []) + } else { + // Top level array. + return try work(parsedRootAsArray()) } - guard array.count == 1 else { - if style == .simple { return Substring(array.joined(separator: ",")) } - let reason = array.isEmpty ? "an empty node" : "a node with multiple values" - try throwMismatch("Cannot parse a value from \(reason).") + } + + /// Use the current top of the stack as a dictionary. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails or if there is unsupported extra nesting of containers. + private func withCurrentDictionaryElements(_ work: ([URIParsedKeyComponent: [URIParsedValue]]) throws -> R) + throws -> R + { + if !codingStack.isEmpty { + try throwMismatch("Nesting a dictionary inside another container is not supported.") + } else { + // Top level dictionary. + return try work(parsedRootAsDictionary()) } - let value = array[0] - return value } - /// Returns the nested value at the provided index inside the node at the - /// top of the coding stack. - /// - Parameter index: The index of the nested value. - /// - Returns: The nested value. - /// - Throws: An error if the current node is not a valid array, or if the - /// index is out of bounds. - private func nestedValueInCurrentElementAsArray(at index: Int) throws -> URIParsedValue { - let values = try currentElementAsArray() - guard index < values.count else { throw GeneralError.codingKeyOutOfBounds } - return values[index] + // MARK: - metadata and data accessors + + /// Returns the current top-of-stack as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Returns: The primitive value, or nil if not found. + /// - Throws: When parsing the root fails. + func currentElementAsSingleValue() throws -> URIParsedValue? { try withCurrentPrimitiveElement { $0 } } + + /// Returns the count of elements in the current top-of-stack array. + /// - Returns: The number of elements. + /// - Throws: When parsing the root fails. + func countOfCurrentArray() throws -> Int { try withCurrentArrayElements { $0.count } } + + /// Returns an element from the current top-of-stack array. + /// - Parameter index: The position in the array to return. + /// - Returns: The primitive value from the array. + /// - Throws: When parsing the root fails. + func nestedElementInCurrentArray(atIndex index: Int) throws -> URIParsedValue { + try withCurrentArrayElements { $0[index] } } - /// Returns the nested value at the provided key inside the node at the - /// top of the coding stack. - /// - Parameter key: The key of the nested value. - /// - Returns: The nested value. - /// - Throws: An error if the current node is not a valid dictionary, or - /// if no value exists for the key. - private func nestedValuesInCurrentElementAsDictionary(forKey key: String) throws -> URIParsedValueArray { - let values = try currentElementAsDictionary() - guard let value = values[key[...]] else { throw GeneralError.codingKeyNotFound } - return value + /// Returns an element from the current top-of-stack dictionary. + /// - Parameter key: The key to find a value for. + /// - Returns: The value for the key, or nil if not found. + /// - Throws: When parsing the root fails. + func nestedElementInCurrentDictionary(forKey key: String) throws -> URIParsedValue? { + try withCurrentDictionaryElements { dictionary in try primitiveValue(forKey: key, in: dictionary) } + } + + /// Returns a Boolean value that represents whether the current top-of-stack dictionary + /// contains a value for the provided key. + /// - Parameter key: The key for which to look for a value. + /// - Returns: `true` if a value was found, `false` otherwise. + func containsElementInCurrentDictionary(forKey key: String) -> Bool { + (try? withCurrentDictionaryElements({ dictionary in dictionary[key[...]] != nil })) ?? false + } + + /// Returns a list of keys found in the current top-of-stack dictionary. + /// - Returns: A list of keys from the dictionary. + /// - Throws: When parsing the root fails. + func elementKeysInCurrentDictionary() -> [String] { + (try? withCurrentDictionaryElements { dictionary in dictionary.keys.map(String.init) }) ?? [] } } extension URIValueFromNodeDecoder: Decoder { - var codingPath: [any CodingKey] { codingStack.map(\.key) } + var codingPath: [any CodingKey] { codingStack } var userInfo: [CodingUserInfoKey: Any] { [:] } func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { - let values = try currentElementAsDictionary() - return .init(URIKeyedDecodingContainer(decoder: self, values: values)) + KeyedDecodingContainer(URIKeyedDecodingContainer(decoder: self)) } - func unkeyedContainer() throws -> any UnkeyedDecodingContainer { - let values = try currentElementAsArray() - return URIUnkeyedDecodingContainer(decoder: self, values: values) - } + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { URIUnkeyedDecodingContainer(decoder: self) } func singleValueContainer() throws -> any SingleValueDecodingContainer { URISingleValueDecodingContainer(decoder: self) diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift index 21028207..06103408 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift @@ -15,7 +15,7 @@ import Foundation /// A type that encodes an `Encodable` value to an URI-encoded string -/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.3, depending on +/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.4, depending on /// the configuration. /// /// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) @@ -45,6 +45,13 @@ import Foundation /// | `{list\*}` | `red,green,blue` | /// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | /// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +/// +/// [OpenAPI 3.0.4 - Deep object expansion.](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#style-examples) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------------------------------| +/// | `{?keys\*}` | `?keys%5Bsemi%5D=%3B&keys%5Bdot%5D=.&keys%5Bcomma%5D=%2C` | +/// struct URIEncoder: Sendable { /// The serializer used to turn `URIEncodedNode` values to a string. diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift index 35f71884..5d892151 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift @@ -19,6 +19,13 @@ struct URIUnkeyedEncodingContainer { /// The associated encoder. let encoder: URIValueToNodeEncoder + + /// Creates a new encoder. + /// - Parameter encoder: The associated encoder. + init(encoder: URIValueToNodeEncoder) { + self.encoder = encoder + try? encoder.currentStackEntry.storage.markAsArray() + } } extension URIUnkeyedEncodingContainer { diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index ff224621..9e1da427 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -14,24 +14,22 @@ import Foundation -/// A type that parses a `URIParsedNode` from a URI-encoded string. +/// A type that can parse a primitive, array, and a dictionary from a URI-encoded string. struct URIParser: Sendable { - /// The configuration instructing the parser how to interpret the raw - /// string. + /// The configuration of the parser. private let configuration: URICoderConfiguration - /// The underlying raw string storage. - private var data: Raw + /// The string to parse. + private let data: Raw /// Creates a new parser. /// - Parameters: - /// - configuration: The configuration instructing the parser how - /// to interpret the raw string. + /// - configuration: The configuration of the parser. /// - data: The string to parse. init(configuration: URICoderConfiguration, data: Substring) { self.configuration = configuration - self.data = data[...] + self.data = data } } @@ -43,6 +41,7 @@ enum ParsingError: Swift.Error, Hashable { /// A malformed key-value pair was detected. case malformedKeyValuePair(Raw) + /// An invalid configuration was detected. case invalidConfiguration(String) } @@ -50,198 +49,268 @@ enum ParsingError: Swift.Error, Hashable { // MARK: - Parser implementations extension URIParser { - - /// Parses the root node from the underlying string, selecting the logic - /// based on the configuration. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - mutating func parseRoot() throws -> URIParsedNode { - // A completely empty string should get parsed as a single - // empty key with a single element array with an empty string - // if the style is simple, otherwise it's an empty dictionary. - if data.isEmpty { - switch configuration.style { - case .form: return [:] - case .simple: return ["": [""]] - case .deepObject: return [:] - } - } + /// Parses the string as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form parser (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed primitive value, or nil if not found. + /// - Throws: When parsing the root fails. + func parseRootAsPrimitive(rootKey: URIParsedKeyComponent) throws -> URIParsedPair? { + var data = data switch (configuration.style, configuration.explode) { - case (.form, true): return try parseExplodedFormRoot() - case (.form, false): return try parseUnexplodedFormRoot() - case (.simple, true): return try parseExplodedSimpleRoot() - case (.simple, false): return try parseUnexplodedSimpleRoot() - case (.deepObject, true): return try parseExplodedDeepObjectRoot() + case (.form, _): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapedKey]) + return .init(key: key, value: unescapeValue(secondValue)) + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return nil + case (.simple, _): return .init(key: .empty, value: unescapeValue(data)) + case (.deepObject, true): + throw ParsingError.invalidConfiguration("deepObject does not support primitive values, only dictionaries") case (.deepObject, false): - let reason = "Deep object style is only valid with explode set to true" - throw ParsingError.invalidConfiguration(reason) + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } } - /// Parses the root node assuming the raw string uses the form style - /// and the explode parameter is enabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseExplodedFormRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + /// Parses the string as an array. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed array. + /// - Throws: When parsing the root fails. + func parseRootAsArray(rootKey: URIParsedKeyComponent) throws -> [URIParsedPair] { + var data = data + switch (configuration.style, configuration.explode) { + case (.form, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let value: Raw switch firstResult { case .foundFirst: - // Hit the key/value separator, so a value will follow. - let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - key = firstValue - value = secondValue - case .foundSecondOrEnd: - // No key/value separator, treat the string as the key. - key = firstValue - value = .init() + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapedKey]) + items.append(.init(key: key, value: unescapeValue(secondValue))) + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, [value]) } - } - } - - /// Parses the root node assuming the raw string uses the form style - /// and the explode parameter is disabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseUnexplodedFormRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + return items + case (.form, false): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - let valueSeparator: Character = "," - + let arrayElementSeparator: Character = "," + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let values: [Raw] switch firstResult { case .foundFirst: - // Hit the key/value separator, so one or more values will follow. - var accumulatedValues: [Raw] = [] - valueLoop: while !data.isEmpty { - let (secondResult, secondValue) = data.parseUpToEitherCharacterOrEnd( - first: valueSeparator, - second: pairSeparator - ) - accumulatedValues.append(secondValue) - switch secondResult { - case .foundFirst: - // Hit the value separator, so ended one value and - // another one is coming. - continue - case .foundSecondOrEnd: - // Hit the pair separator or the end, this is the - // last value. - break valueLoop + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let key = URIParsedKey([unescapedKey]) + elementScan: while !data.isEmpty { + let (secondResult, secondValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + items.append(.init(key: key, value: unescapeValue(secondValue))) + switch secondResult { + case .foundFirst: continue elementScan + case .foundSecondOrEnd: break elementScan + } } + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) } - if accumulatedValues.isEmpty { - // We hit the key/value separator, so always write - // at least one empty value. - accumulatedValues.append("") - } - key = firstValue - values = accumulatedValues case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, values) } + return items + case (.simple, _): + let pairSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let value = data.parseUpToCharacterOrEnd(pairSeparator) + items.append(.init(key: .empty, value: unescapeValue(value))) + } + return items + case (.deepObject, true): + throw ParsingError.invalidConfiguration("deepObject does not support array values, only dictionaries") + case (.deepObject, false): + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } } - /// Parses the root node assuming the raw string uses the simple style - /// and the explode parameter is enabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseExplodedSimpleRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + /// Parses the string as a dictionary. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed key/value pairs as an array. + /// - Throws: When parsing the root fails. + func parseRootAsDictionary(rootKey: URIParsedKeyComponent) throws -> [URIParsedPair] { + var data = data + switch (configuration.style, configuration.explode) { + case (.form, true): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapeValue(firstValue)]) + items.append(.init(key: key, value: unescapeValue(secondValue))) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.form, false): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let arrayElementSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + elementScan: while !data.isEmpty { + let (innerKeyResult, innerKeyValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + switch innerKeyResult { + case .foundFirst: + let (innerValueResult, innerValueValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + items.append( + .init( + key: URIParsedKey([unescapedKey, innerKeyValue]), + value: unescapeValue(innerValueValue) + ) + ) + switch innerValueResult { + case .foundFirst: continue elementScan + case .foundSecondOrEnd: break elementScan + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(innerKeyValue) + } + } + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.simple, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "," - + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let value: Raw + let key: URIParsedKey + let value: URIParsedValue switch firstResult { case .foundFirst: - // Hit the key/value separator, so a value will follow. let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - key = firstValue + key = URIParsedKey([unescapeValue(firstValue)]) value = secondValue - case .foundSecondOrEnd: - // No key/value separator, treat the string as the value. - key = .init() - value = firstValue + items.append(.init(key: key, value: unescapeValue(value))) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, [value]) } - } - } - - /// Parses the root node assuming the raw string uses the simple style - /// and the explode parameter is disabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseUnexplodedSimpleRoot() throws -> URIParsedNode { - // Unexploded simple dictionary cannot be told apart from - // an array, so we just accumulate all pairs as standalone - // values and add them to the array. It'll be the higher - // level decoder's responsibility to parse this properly. - - try parseGenericRoot { data, appendPair in + return items + case (.simple, false): let pairSeparator: Character = "," + var items: [URIParsedPair] = [] while !data.isEmpty { - let value = data.parseUpToCharacterOrEnd(pairSeparator) - appendPair(.init(), [value]) + let rawKey = data.parseUpToCharacterOrEnd(pairSeparator) + let value: URIParsedValue + if data.isEmpty { value = "" } else { value = data.parseUpToCharacterOrEnd(pairSeparator) } + let key = URIParsedKey([unescapeValue(rawKey)]) + items.append(.init(key: key, value: unescapeValue(value))) } - } - } - /// Parses the root node assuming the raw string uses the deepObject style - /// and the explode parameter is enabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseExplodedDeepObjectRoot() throws -> URIParsedNode { - let parseNode = try parseGenericRoot { data, appendPair in + return items + case (.deepObject, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - let nestedKeyStartingCharacter: Character = "[" - let nestedKeyEndingCharacter: Character = "]" - func nestedKey(from deepObjectKey: String.SubSequence) -> Raw { - var unescapedDeepObjectKey = Substring(deepObjectKey.removingPercentEncoding ?? "") - let topLevelKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyStartingCharacter) - let nestedKey = unescapedDeepObjectKey.parseUpToCharacterOrEnd(nestedKeyEndingCharacter) - return nestedKey.isEmpty ? topLevelKey : nestedKey - } + let nestedKeyStart: Character = "[" + let nestedKeyEnd: Character = "]" + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - guard case .foundFirst = firstResult else { throw ParsingError.malformedKeyValuePair(firstValue) } - // Hit the key/value separator, so a value will follow. - let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - let key = nestedKey(from: firstValue) - let value = secondValue - appendPair(key, [value]) + switch firstResult { + case .foundFirst: + var unescapedComposedKey = unescapeValue(firstValue) + if unescapedComposedKey.contains("[") && unescapedComposedKey.contains("]") { + // Do a quick check whether this is even a deepObject-encoded key, as + // we need to safely skip any unrelated keys, which might be formatted + // some other way. + let parentParsedKey = unescapedComposedKey.parseUpToCharacterOrEnd(nestedKeyStart) + let childParsedKey = unescapedComposedKey.parseUpToCharacterOrEnd(nestedKeyEnd) + if parentParsedKey == rootKey { + let key = URIParsedKey([parentParsedKey, childParsedKey]) + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + items.append(.init(key: key, value: unescapeValue(secondValue))) + continue + } + } + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } } + return items + case (.deepObject, false): + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } - return parseNode } } @@ -249,24 +318,6 @@ extension URIParser { extension URIParser { - /// Parses the underlying string using a parser closure. - /// - Parameter parser: A closure that accepts another closure, which should - /// be called 0 or more times, once for each parsed key-value pair. - /// - Returns: The accumulated node. - /// - Throws: An error if parsing using the provided parser closure fails, - private mutating func parseGenericRoot(_ parser: (inout Raw, (Raw, [Raw]) -> Void) throws -> Void) throws - -> URIParsedNode - { - var root = URIParsedNode() - let spaceEscapingCharacter = configuration.spaceEscapingCharacter - let unescapeValue: (Raw) -> Raw = { Self.unescapeValue($0, spaceEscapingCharacter: spaceEscapingCharacter) } - try parser(&data) { key, values in - let newItem = [unescapeValue(key): values.map(unescapeValue)] - root.merge(newItem) { $0 + $1 } - } - return root - } - /// Removes escaping from the provided string. /// - Parameter escapedValue: An escaped string. /// - Returns: The provided string with escaping removed. diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index e7817720..838ca9b1 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -205,7 +205,6 @@ extension URISerializer { /// style and explode parameters in the configuration). /// - Throws: An error if serialization of the array fails. private mutating func serializeArray(_ array: [URIEncodedNode.Primitive], forKey key: String) throws { - guard !array.isEmpty else { return } let keyAndValueSeparator: String? let pairSeparator: String switch (configuration.style, configuration.explode) { @@ -220,6 +219,7 @@ extension URISerializer { pairSeparator = "," case (.deepObject, _): throw SerializationError.deepObjectsArrayNotSupported } + guard !array.isEmpty else { return } func serializeNext(_ element: URIEncodedNode.Primitive) throws { if let keyAndValueSeparator { try serializePrimitiveKeyValuePair(element, forKey: key, separator: keyAndValueSeparator) diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 9e16fbfb..3d956bb2 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -177,6 +177,30 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, ["foo", "bar"]) } + func test_getOptionalQueryItemAsURI_deepObject_exploded() throws { + let query: Substring = "sort%5Bid%5D=ascending&sort%5Bname%5D=descending&unrelated=foo" + let value: [String: String]? = try converter.getOptionalQueryItemAsURI( + in: query, + style: .deepObject, + explode: true, + name: "sort", + as: [String: String].self + ) + XCTAssertEqual(value, ["id": "ascending", "name": "descending"]) + } + + func test_getOptionalQueryItemAsURI_deepObject_exploded_empty() throws { + let query: Substring = "foo=bar" + let value: [String: String]? = try converter.getOptionalQueryItemAsURI( + in: query, + style: .deepObject, + explode: true, + name: "sort", + as: [String: String].self + ) + XCTAssertNil(value) + } + func test_getRequiredQueryItemAsURI_arrayOfStrings() throws { let query: Substring = "search=foo&search=bar" let value: [String] = try converter.getRequiredQueryItemAsURI( @@ -201,6 +225,18 @@ final class Test_ServerConverterExtensions: Test_Runtime { XCTAssertEqual(value, ["foo", "bar"]) } + func test_getRequiredQueryItemAsURI_deepObject_exploded() throws { + let query: Substring = "sort%5Bid%5D=ascending&unrelated=foo&sort%5Bname%5D=descending" + let value: [String: String] = try converter.getRequiredQueryItemAsURI( + in: query, + style: .deepObject, + explode: true, + name: "sort", + as: [String: String].self + ) + XCTAssertEqual(value, ["id": "ascending", "name": "descending"]) + } + func test_getOptionalQueryItemAsURI_date() throws { let query: Substring = "search=\(testDateEscapedString)" let value: Date? = try converter.getOptionalQueryItemAsURI( diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift index c02c83c3..0e611789 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift @@ -16,11 +16,76 @@ import XCTest final class Test_URIDecoder: Test_Runtime { - func testDecoding() throws { + func testDecoding_string() throws { + _test( + "hello world", + forKey: "message", + from: .init( + formExplode: "message=hello%20world", + formUnexplode: "message=hello%20world", + simpleExplode: "hello%20world", + simpleUnexplode: "hello%20world", + formDataExplode: "message=hello+world", + formDataUnexplode: "message=hello+world", + deepObjectExplode: nil + ) + ) + } + + func testDecoding_maxNesting() throws { + struct Filter: Decodable, Equatable { + enum State: String, Decodable, Equatable { + case enabled + case disabled + } + var state: [State] + } + _test( + Filter(state: [.enabled, .disabled]), + forKey: "filter", + from: .init( + formExplode: "state=enabled&state=disabled", + formUnexplode: "filter=state,enabled,state,disabled", + simpleExplode: "state=enabled,state=disabled", + simpleUnexplode: "state,enabled,state,disabled", + formDataExplode: "state=enabled&state=disabled", + formDataUnexplode: "filter=state,enabled,state,disabled", + deepObjectExplode: "filter%5Bstate%5D=enabled&filter%5Bstate%5D=disabled" + ) + ) + } + + func testDecoding_array() throws { + _test( + ["hello world", "goodbye world"], + forKey: "message", + from: .init( + formExplode: "message=hello%20world&message=goodbye%20world", + formUnexplode: "message=hello%20world,goodbye%20world", + simpleExplode: "hello%20world,goodbye%20world", + simpleUnexplode: "hello%20world,goodbye%20world", + formDataExplode: "message=hello+world&message=goodbye+world", + formDataUnexplode: "message=hello+world,goodbye+world", + deepObjectExplode: nil + ) + ) + } + + func testDecoding_struct() throws { struct Foo: Decodable, Equatable { var bar: String } - let decoder = URIDecoder(configuration: .formDataExplode) - let decodedValue = try decoder.decode(Foo.self, forKey: "", from: "bar=hello+world") - XCTAssertEqual(decodedValue, Foo(bar: "hello world")) + _test( + Foo(bar: "hello world"), + forKey: "message", + from: .init( + formExplode: "bar=hello%20world", + formUnexplode: "message=bar,hello%20world", + simpleExplode: "bar=hello%20world", + simpleUnexplode: "bar,hello%20world", + formDataExplode: "bar=hello+world", + formDataUnexplode: "message=bar,hello+world", + deepObjectExplode: "message%5Bbar%5D=hello%20world" + ) + ) } func testDecoding_structWithOptionalProperty() throws { @@ -39,6 +104,21 @@ final class Test_URIDecoder: Test_Runtime { } } + func testDecoding_freeformObject() throws { + let decoder = URIDecoder(configuration: .formDataExplode) + do { + let decodedValue = try decoder.decode( + OpenAPIObjectContainer.self, + forKey: "", + from: "baz=1&bar=hello+world&bar=goodbye+world" + ) + XCTAssertEqual( + decodedValue, + try .init(unvalidatedValue: ["bar": ["hello world", "goodbye world"], "baz": 1]) + ) + } + } + func testDecoding_rootValue() throws { let decoder = URIDecoder(configuration: .formDataExplode) do { @@ -73,3 +153,53 @@ final class Test_URIDecoder: Test_Runtime { } } } + +extension Test_URIDecoder { + + struct Inputs { + var formExplode: Substring? + var formUnexplode: Substring? + var simpleExplode: Substring? + var simpleUnexplode: Substring? + var formDataExplode: Substring? + var formDataUnexplode: Substring? + var deepObjectExplode: Substring? + } + + func _test( + _ value: T, + forKey key: String, + from inputs: Inputs, + file: StaticString = #file, + line: UInt = #line + ) { + func _run(name: String, configuration: URICoderConfiguration, sourceString: Substring) { + let decoder = URIDecoder(configuration: configuration) + do { + let decodedValue = try decoder.decode(T.self, forKey: key, from: sourceString) + XCTAssertEqual(decodedValue, value, "Failed in \(name)", file: file, line: line) + } catch { XCTFail("Threw an error in \(name): \(error)", file: file, line: line) } + } + if let value = inputs.formExplode { + _run(name: "formExplode", configuration: .formExplode, sourceString: value) + } + if let value = inputs.formUnexplode { + _run(name: "formUnexplode", configuration: .formUnexplode, sourceString: value) + } + if let value = inputs.simpleExplode { + _run(name: "simpleExplode", configuration: .simpleExplode, sourceString: value) + } + if let value = inputs.simpleUnexplode { + _run(name: "simpleUnexplode", configuration: .simpleUnexplode, sourceString: value) + } + if let value = inputs.formDataExplode { + _run(name: "formDataExplode", configuration: .formDataExplode, sourceString: value) + } + if let value = inputs.formDataUnexplode { + _run(name: "formDataUnexplode", configuration: .formDataUnexplode, sourceString: value) + } + if let value = inputs.deepObjectExplode { + _run(name: "deepObjectExplode", configuration: .deepObjectExplode, sourceString: value) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift index f1236cb9..6ce7037b 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift @@ -36,60 +36,56 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { } // An empty string. - try test(["root": [""]], "", key: "root") + try test("root=", "", key: "root") // An empty string with a simple style. - try test(["root": [""]], "", key: "root", style: .simple) + try test("", "", key: "root", style: .simple) // A string with a space. - try test(["root": ["Hello World"]], "Hello World", key: "root") + try test("root=Hello%20world", "Hello world", key: "root") // An enum. - try test(["root": ["red"]], SimpleEnum.red, key: "root") + try test("root=red", SimpleEnum.red, key: "root") // An integer. - try test(["root": ["1234"]], 1234, key: "root") + try test("root=1234", 1234, key: "root") // A float. - try test(["root": ["12.34"]], 12.34, key: "root") + try test("root=12.34", 12.34, key: "root") // A bool. - try test(["root": ["true"]], true, key: "root") + try test("root=true", true, key: "root") // A simple array of strings. - try test(["root": ["a", "b", "c"]], ["a", "b", "c"], key: "root") + try test("root=a&root=b&root=c", ["a", "b", "c"], key: "root") // A simple array of enums. - try test(["root": ["red", "green", "blue"]], [.red, .green, .blue] as [SimpleEnum], key: "root") + try test("root=red&root=green&root=blue", [.red, .green, .blue] as [SimpleEnum], key: "root") // A struct. - try test(["foo": ["bar"]], SimpleStruct(foo: "bar"), key: "root") + try test("foo=bar", SimpleStruct(foo: "bar"), key: "root") // A struct with an array property. try test( - ["foo": ["bar"], "bar": ["1", "2"], "val": ["baz", "baq"]], + "foo=bar&bar=1&bar=2&val=baz&val=baq", StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]), key: "root" ) // A struct with a nested enum. - try test(["foo": ["bar"], "color": ["blue"]], SimpleStruct(foo: "bar", color: .blue), key: "root") + try test("foo=bar&color=blue", SimpleStruct(foo: "bar", color: .blue), key: "root") // A simple dictionary. - try test(["one": ["1"], "two": ["2"]], ["one": 1, "two": 2], key: "root") + try test("one=1&two=2", ["one": 1, "two": 2], key: "root") // A unexploded simple dictionary. - try test(["root": ["one", "1", "two", "2"]], ["one": 1, "two": 2], key: "root", explode: false) + try test("one,1,two,2", ["one": 1, "two": 2], key: "root", style: .simple, explode: false) // A dictionary of enums. - try test( - ["one": ["blue"], "two": ["green"]], - ["one": .blue, "two": .green] as [String: SimpleEnum], - key: "root" - ) + try test("one=blue&two=green", ["one": .blue, "two": .green] as [String: SimpleEnum], key: "root") func test( - _ node: URIParsedNode, + _ data: String, _ expectedValue: T, key: String, style: URICoderConfiguration.Style = .form, @@ -98,11 +94,14 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { line: UInt = #line ) throws { let decoder = URIValueFromNodeDecoder( - node: node, + data: data[...], rootKey: key[...], - style: style, - explode: explode, - dateTranscoder: .iso8601 + configuration: .init( + style: style, + explode: explode, + spaceEscapingCharacter: .percentEncoded, + dateTranscoder: .iso8601 + ) ) let decodedValue = try decoder.decodeRoot(T.self) XCTAssertEqual(decodedValue, expectedValue, file: file, line: line) diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 16a6e02d..a94805ac 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -14,137 +14,495 @@ import XCTest @testable import OpenAPIRuntime +/// Tests for URIParser. +/// +/// Guiding examples: +/// +/// rootKey: "color" +/// +/// form explode: +/// - nil: "" -> nil +/// - empty: "color=" -> ("color/0", "") +/// - primitive: "color=blue" -> ("color/0", "blue") +/// - array: "color=blue&color=black&color=brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary+array: "R=100&G=200&G=150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// form unexplode: +/// - nil: "" -> nil +/// - empty: "color=" -> ("color/0", "") +/// - primitive: "color=blue" -> ("color/0", "blue") +/// - array: "color=blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary: "color=R,100,G,200,G,150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// simple explode: +/// - nil: "" -> ("color/0", "") +/// - empty: "" -> ("color/0", "") +/// - primitive: "blue" -> ("color/0", "blue") +/// - array: "blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary+array: "R=100,G=200,G=150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// simple unexplode: +/// - nil: "" -> ("color/0", "") +/// - empty: "" -> ("color/0", "") +/// - primitive: "blue" -> ("color/0", "blue") +/// - array: "blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary: "R,100,G,200,G,150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// deepObject unexplode: unsupported +/// +/// deepObject explode: +/// - nil: -> unsupported +/// - empty: -> unsupported +/// - primitive: -> unsupported +/// - array: -> unsupported +/// - dictionary: "color%5BR%5D=100&color%5BG%5D=200&color%5BG%5D=150" +/// -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] final class Test_URIParser: Test_Runtime { - let testedVariants: [URICoderConfiguration] = [ - .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, - .deepObjectExplode, - ] - func testParsing() throws { - let cases: [Case] = [ - makeCase( - .init( - formExplode: "empty=", - formUnexplode: "empty=", - simpleExplode: .custom("", value: ["": [""]]), - simpleUnexplode: .custom("", value: ["": [""]]), - formDataExplode: "empty=", - formDataUnexplode: "empty=", - deepObjectExplode: "object%5Bempty%5D=" - ), - value: ["empty": [""]] - ), - makeCase( - .init( - formExplode: "", - formUnexplode: "", - simpleExplode: .custom("", value: ["": [""]]), - simpleUnexplode: .custom("", value: ["": [""]]), - formDataExplode: "", - formDataUnexplode: "", - deepObjectExplode: "" - ), - value: [:] - ), - makeCase( - .init( - formExplode: "who=fred", - formUnexplode: "who=fred", - simpleExplode: .custom("fred", value: ["": ["fred"]]), - simpleUnexplode: .custom("fred", value: ["": ["fred"]]), - formDataExplode: "who=fred", - formDataUnexplode: "who=fred", - deepObjectExplode: "object%5Bwho%5D=fred" - ), - value: ["who": ["fred"]] - ), - makeCase( - .init( - formExplode: "hello=Hello%20World", - formUnexplode: "hello=Hello%20World", - simpleExplode: .custom("Hello%20World", value: ["": ["Hello World"]]), - simpleUnexplode: .custom("Hello%20World", value: ["": ["Hello World"]]), - formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World", - deepObjectExplode: "object%5Bhello%5D=Hello%20World" - ), - value: ["hello": ["Hello World"]] - ), - makeCase( - .init( - formExplode: "list=red&list=green&list=blue", - formUnexplode: "list=red,green,blue", - simpleExplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), - simpleUnexplode: .custom("red,green,blue", value: ["": ["red", "green", "blue"]]), - formDataExplode: "list=red&list=green&list=blue", - formDataUnexplode: "list=red,green,blue", - deepObjectExplode: "object%5Blist%5D=red&object%5Blist%5D=green&object%5Blist%5D=blue" - ), - value: ["list": ["red", "green", "blue"]] - ), - makeCase( - .init( - formExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", - formUnexplode: .custom( - "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] - ), - simpleExplode: "comma=%2C,dot=.,list=one,list=two,semi=%3B", - simpleUnexplode: .custom( - "comma,%2C,dot,.,list,one,list,two,semi,%3B", - value: ["": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] - ), - formDataExplode: "comma=%2C&dot=.&list=one&list=two&semi=%3B", - formDataUnexplode: .custom( - "keys=comma,%2C,dot,.,list,one,list,two,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "list", "one", "list", "two", "semi", ";"]] - ), - deepObjectExplode: - "keys%5Bcomma%5D=%2C&keys%5Bdot%5D=.&keys%5Blist%5D=one&keys%5Blist%5D=two&keys%5Bsemi%5D=%3B" - ), - value: ["semi": [";"], "dot": ["."], "comma": [","], "list": ["one", "two"]] - ), - ] - for testCase in cases { - func testVariant(_ variant: Case.Variant, _ input: Case.Variants.Input) throws { - var parser = URIParser(configuration: variant.config, data: input.string[...]) - do { - let parsedNode = try parser.parseRoot() - XCTAssertEqual( - parsedNode, - input.valueOverride ?? testCase.value, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line - ) - } catch { - guard let expectedError = input.expectedError, let parsingError = error as? ParsingError else { - XCTAssert(false, "Unexpected error thrown: \(error)", file: testCase.file, line: testCase.line) - return - } - XCTAssertEqual( - expectedError, - parsingError, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line - ) - } - } - let variants = testCase.variants - try testVariant(.formExplode, variants.formExplode) - try testVariant(.formUnexplode, variants.formUnexplode) - try testVariant(.simpleExplode, variants.simpleExplode) - try testVariant(.simpleUnexplode, variants.simpleUnexplode) - try testVariant(.formDataExplode, variants.formDataExplode) - try testVariant(.formDataUnexplode, variants.formDataUnexplode) - try testVariant(.deepObjectExplode, variants.deepObjectExplode) - } + // Guiding examples, test filtering relevant keys for the rootKey + try testCase( + formExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue&color=black&color=brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "R=100&G=200&G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + formUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue,black,brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "prefix=bar&color=R,100,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: "100"), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + simpleExplode: .init( + rootKey: "color", + primitive: .assert("blue", equals: .init(key: .empty, value: "blue")), + array: .assert( + "blue,black,brown", + equals: [ + .init(key: .empty, value: "blue"), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R=100,G=200,G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + simpleUnexplode: .init( + rootKey: "color", + primitive: .assert("blue", equals: .init(key: .empty, value: "blue")), + array: .assert( + "blue,black,brown", + equals: [ + .init(key: .empty, value: "blue"), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R,100,G,200,G,150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + formDataExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue&color=black&color=brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "R=100&G=200&G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) + ), + formDataUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=blue&suffix=baz", equals: .init(key: "color", value: "blue")), + array: .assert( + "prefix=bar&color=blue,black,brown&suffix=baz", + equals: [ + .init(key: "color", value: "blue"), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "prefix=bar&color=R,100,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: "100"), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + deepObjectExplode: .init( + rootKey: "color", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert( + "prefix%5Bfoo%5D=1&color%5BR%5D=100&color%5BG%5D=200&color%5BG%5D=150&suffix%5Bbaz%5D=2", + equals: [ + .init(key: "color/R", value: "100"), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ) + ) + + // Test escaping + try testCase( + formExplode: .init( + rootKey: "message", + primitive: .assert("message=Hello%20world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello%20world&message=%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "R=Hello%20world&G=%24%24%24&G=%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + formUnexplode: .init( + rootKey: "message", + primitive: .assert("message=Hello%20world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello%20world,%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "message=R,Hello%20world,G,%24%24%24,G,%40%40%40", + equals: [ + .init(key: "message/R", value: "Hello world"), .init(key: "message/G", value: "$$$"), + .init(key: "message/G", value: "@@@"), + ] + ) + ), + simpleExplode: .init( + rootKey: "message", + primitive: .assert("Hello%20world", equals: .init(key: .empty, value: "Hello world")), + array: .assert( + "Hello%20world,%24%24%24,%40%40%40", + equals: [ + .init(key: .empty, value: "Hello world"), .init(key: .empty, value: "$$$"), + .init(key: .empty, value: "@@@"), + ] + ), + dictionary: .assert( + "R=Hello%20world,G=%24%24%24,G=%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + simpleUnexplode: .init( + rootKey: "message", + primitive: .assert("Hello%20world", equals: .init(key: .empty, value: "Hello world")), + array: .assert( + "Hello%20world,%24%24%24,%40%40%40", + equals: [ + .init(key: .empty, value: "Hello world"), .init(key: .empty, value: "$$$"), + .init(key: .empty, value: "@@@"), + ] + ), + dictionary: .assert( + "R,Hello%20world,G,%24%24%24,G,%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + formDataExplode: .init( + rootKey: "message", + primitive: .assert("message=Hello+world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello+world&message=%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "R=Hello+world&G=%24%24%24&G=%40%40%40", + equals: [ + .init(key: "R", value: "Hello world"), .init(key: "G", value: "$$$"), + .init(key: "G", value: "@@@"), + ] + ) + ), + formDataUnexplode: .init( + rootKey: "message", + primitive: .assert("message=Hello+world", equals: .init(key: "message", value: "Hello world")), + array: .assert( + "message=Hello+world,%240", + equals: [.init(key: "message", value: "Hello world"), .init(key: "message", value: "$0")] + ), + dictionary: .assert( + "message=R,Hello+world,G,%24%24%24,G,%40%40%40", + equals: [ + .init(key: "message/R", value: "Hello world"), .init(key: "message/G", value: "$$$"), + .init(key: "message/G", value: "@@@"), + ] + ) + ), + deepObjectExplode: .init( + rootKey: "message", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert( + "message%5BR%5D=Hello%20world&message%5BG%5D=%24%24%24&message%5BG%5D=%40%40%40", + equals: [ + .init(key: "message/R", value: "Hello world"), .init(key: "message/G", value: "$$$"), + .init(key: "message/G", value: "@@@"), + ] + ) + ) + ) + + // Missing/nil + try testCase( + formExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("", equals: []) + ), + formUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("prefix=bar&suffix=baz", equals: []) + ), + simpleExplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert("", equals: []), + dictionary: .assert("", equals: []) + ), + simpleUnexplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert("", equals: []), + dictionary: .assert("", equals: []) + ), + formDataExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("", equals: []) + ), + formDataUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&suffix=baz", equals: nil), + array: .assert("prefix=bar&suffix=baz", equals: []), + dictionary: .assert("prefix=bar&suffix=baz", equals: []) + ), + deepObjectExplode: .init( + rootKey: "color", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert("prefix%5Bfoo%5D=1&suffix%5Bbaz%5D=2", equals: []) + ) + ) + + // Empty value (distinct from missing/nil, but some cases overlap) + try testCase( + formExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert("prefix=bar&color=&suffix=baz", equals: [.init(key: "color", value: "")]), + dictionary: .assert( + "R=&G=200&G=150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + formUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert("prefix=bar&color=&suffix=baz", equals: [.init(key: "color", value: "")]), + dictionary: .assert( + "prefix=bar&color=R,,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: ""), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + simpleExplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert( + ",black,brown", + equals: [ + .init(key: .empty, value: ""), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R=,G=200,G=150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + simpleUnexplode: .init( + rootKey: "color", + primitive: .assert("", equals: .init(key: .empty, value: "")), + array: .assert( + ",black,brown", + equals: [ + .init(key: .empty, value: ""), .init(key: .empty, value: "black"), + .init(key: .empty, value: "brown"), + ] + ), + dictionary: .assert( + "R,,G,200,G,150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + formDataExplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert( + "prefix=bar&color=&color=black&color=brown&suffix=baz", + equals: [ + .init(key: "color", value: ""), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "R=&G=200&G=150", + equals: [.init(key: "R", value: ""), .init(key: "G", value: "200"), .init(key: "G", value: "150")] + ) + ), + formDataUnexplode: .init( + rootKey: "color", + primitive: .assert("prefix=bar&color=&suffix=baz", equals: .init(key: "color", value: "")), + array: .assert( + "prefix=bar&color=,black,brown&suffix=baz", + equals: [ + .init(key: "color", value: ""), .init(key: "color", value: "black"), + .init(key: "color", value: "brown"), + ] + ), + dictionary: .assert( + "prefix=bar&color=R,,G,200,G,150&suffix=baz", + equals: [ + .init(key: "color/R", value: ""), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ), + deepObjectExplode: .init( + rootKey: "color", + primitive: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + array: .init( + string: "", + result: .failure({ error in + guard case .invalidConfiguration = error else { + XCTFail("Unexpected error: \(error)") + return + } + }) + ), + dictionary: .assert( + "prefix%5Bfoo%5D=1&color%5BR%5D=&color%5BG%5D=200&color%5BG%5D=150&suffix%5Bbaz%5D=2", + equals: [ + .init(key: "color/R", value: ""), .init(key: "color/G", value: "200"), + .init(key: "color/G", value: "150"), + ] + ) + ) + ) } -} -extension Test_URIParser { struct Case { struct Variant { var name: String @@ -158,33 +516,33 @@ extension Test_URIParser { static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } - struct Variants { - - struct Input: ExpressibleByStringLiteral { - var string: String - var valueOverride: URIParsedNode? - var expectedError: ParsingError? - - init(string: String, valueOverride: URIParsedNode? = nil, expectedError: ParsingError? = nil) { - self.string = string - self.valueOverride = valueOverride - self.expectedError = expectedError - } - - static func custom(_ string: String, value: URIParsedNode) -> Self { - .init(string: string, valueOverride: value, expectedError: nil) - } - static func custom(_ string: String, expectedError: ParsingError) -> Self { - .init(string: string, valueOverride: nil, expectedError: expectedError) - } + struct RootInput { + var string: String + enum ExpectedResult { + case success(RootType) + case failure((ParsingError) -> Void) + } + var result: ExpectedResult - init(stringLiteral value: String) { - self.string = value - self.valueOverride = nil - self.expectedError = nil - } + init(string: String, result: ExpectedResult) { + self.string = string + self.result = result } + static func assert(_ string: String, equals value: RootType) -> Self { + .init(string: string, result: .success(value)) + } + static func assert(_ string: String, validateError: @escaping (ParsingError) -> Void) -> Self { + .init(string: string, result: .failure(validateError)) + } + } + struct Input { + var rootKey: URIParsedKeyComponent + var primitive: RootInput + var array: RootInput<[URIParsedPair]> + var dictionary: RootInput<[URIParsedPair]> + } + struct Variants { var formExplode: Input var formUnexplode: Input var simpleExplode: Input @@ -194,11 +552,92 @@ extension Test_URIParser { var deepObjectExplode: Input } var variants: Variants - var value: URIParsedNode var file: StaticString = #file var line: UInt = #line } - func makeCase(_ variants: Case.Variants, value: URIParsedNode, file: StaticString = #file, line: UInt = #line) - -> Case - { .init(variants: variants, value: value, file: file, line: line) } + + func testCase(_ variants: Case.Variants, file: StaticString = #file, line: UInt = #line) throws { + let caseValue = Case(variants: variants, file: file, line: line) + func testVariant(_ variant: Case.Variant, _ input: Case.Input) throws { + func testRoot( + rootName: String, + _ root: Case.RootInput, + parse: (URIParser) throws -> RootType + ) throws { + let parser = URIParser(configuration: variant.config, data: root.string[...]) + switch root.result { + case .success(let expectedValue): + let parsedValue = try parse(parser) + XCTAssertEqual( + parsedValue, + expectedValue, + "Failed for config: \(variant.name), root: \(rootName)", + file: caseValue.file, + line: caseValue.line + ) + case .failure(let validateError): + do { + _ = try parse(parser) + XCTFail("Should have thrown an error", file: caseValue.file, line: caseValue.line) + } catch { + guard let parsingError = error as? ParsingError else { + XCTAssert( + false, + "Unexpected error thrown: \(error)", + file: caseValue.file, + line: caseValue.line + ) + return + } + validateError(parsingError) + } + } + } + try testRoot( + rootName: "primitive", + input.primitive, + parse: { try $0.parseRootAsPrimitive(rootKey: input.rootKey) } + ) + try testRoot(rootName: "array", input.array, parse: { try $0.parseRootAsArray(rootKey: input.rootKey) }) + try testRoot( + rootName: "dictionary", + input.dictionary, + parse: { try $0.parseRootAsDictionary(rootKey: input.rootKey) } + ) + } + let variants = caseValue.variants + try testVariant(.formExplode, variants.formExplode) + try testVariant(.formUnexplode, variants.formUnexplode) + try testVariant(.simpleExplode, variants.simpleExplode) + try testVariant(.simpleUnexplode, variants.simpleUnexplode) + try testVariant(.formDataExplode, variants.formDataExplode) + try testVariant(.formDataUnexplode, variants.formDataUnexplode) + try testVariant(.deepObjectExplode, variants.deepObjectExplode) + } + + func testCase( + formExplode: Case.Input, + formUnexplode: Case.Input, + simpleExplode: Case.Input, + simpleUnexplode: Case.Input, + formDataExplode: Case.Input, + formDataUnexplode: Case.Input, + deepObjectExplode: Case.Input, + file: StaticString = #file, + line: UInt = #line + ) throws { + try testCase( + .init( + formExplode: formExplode, + formUnexplode: formUnexplode, + simpleExplode: simpleExplode, + simpleUnexplode: simpleUnexplode, + formDataExplode: formDataExplode, + formDataUnexplode: formDataUnexplode, + deepObjectExplode: deepObjectExplode + ), + file: file, + line: line + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index f198b6eb..a42c9913 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -16,10 +16,6 @@ import XCTest final class Test_URISerializer: Test_Runtime { - let testedVariants: [URICoderConfiguration] = [ - .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, - ] - func testSerializing() throws { let cases: [Case] = [ makeCase( diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index 3f351768..ac1fc00f 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -97,7 +97,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "", formDataExplode: "root=", formDataUnexplode: "root=", - deepObjectExplode: .custom("root=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -112,10 +112,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "Hello%20World%21", formDataExplode: "root=Hello+World%21", formDataUnexplode: "root=Hello+World%21", - deepObjectExplode: .custom( - "root=Hello%20World%21", - expectedError: .deepObjectsWithPrimitiveValuesNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -130,7 +127,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "red", formDataExplode: "root=red", formDataUnexplode: "root=red", - deepObjectExplode: .custom("root=red", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -145,7 +142,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "1234", formDataExplode: "root=1234", formDataUnexplode: "root=1234", - deepObjectExplode: .custom("root=1234", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -160,7 +157,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "12.34", formDataExplode: "root=12.34", formDataUnexplode: "root=12.34", - deepObjectExplode: .custom("root=12.34", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -175,7 +172,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "true", formDataExplode: "root=true", formDataUnexplode: "root=true", - deepObjectExplode: .custom("root=true", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -190,10 +187,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "2023-08-25T07%3A34%3A59Z", formDataExplode: "root=2023-08-25T07%3A34%3A59Z", formDataUnexplode: "root=2023-08-25T07%3A34%3A59Z", - deepObjectExplode: .custom( - "root=2023-08-25T07%3A34%3A59Z", - expectedError: .deepObjectsWithPrimitiveValuesNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -208,7 +202,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "a,b,c", formDataExplode: "list=a&list=b&list=c", formDataUnexplode: "list=a,b,c", - deepObjectExplode: .custom("list=a&list=b&list=c", expectedError: .deepObjectsArrayNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -223,10 +217,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", formDataExplode: "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", formDataUnexplode: "list=2023-08-25T07%3A34%3A59Z,2023-08-25T07%3A35%3A01Z", - deepObjectExplode: .custom( - "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", - expectedError: .deepObjectsArrayNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -237,8 +228,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: [""]), - simpleUnexplode: .custom("", value: [""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", formDataUnexplode: "", deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) @@ -256,10 +247,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "red,green,blue", formDataExplode: "list=red&list=green&list=blue", formDataUnexplode: "list=red,green,blue", - deepObjectExplode: .custom( - "list=red&list=green&list=blue", - expectedError: .deepObjectsArrayNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -291,10 +279,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "2023-01-18T10%3A04%3A11Z", formDataExplode: "root=2023-01-18T10%3A04%3A11Z", formDataUnexplode: "root=2023-01-18T10%3A04%3A11Z", - deepObjectExplode: .custom( - "root=2023-01-18T10%3A04%3A11Z", - expectedError: .deepObjectsWithPrimitiveValuesNotSupported - ) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( @@ -307,7 +292,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleUnexplode: "green", formDataExplode: "root=green", formDataUnexplode: "root=green", - deepObjectExplode: .custom("root=green", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( @@ -362,8 +347,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: ["": ""]), - simpleUnexplode: .custom("", value: ["": ""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", formDataUnexplode: "", deepObjectExplode: "" diff --git a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift index 65235d82..38462a6a 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift @@ -59,6 +59,7 @@ extension URICoderConfiguration { spaceEscapingCharacter: .plus, dateTranscoder: defaultDateTranscoder ) + static let deepObjectExplode: Self = .init( style: .deepObject, explode: true, @@ -66,3 +67,11 @@ extension URICoderConfiguration { dateTranscoder: defaultDateTranscoder ) } + +extension URIParsedKey: ExpressibleByStringLiteral { + + /// Creates an instance initialized to the given string value. + /// + /// - Parameter value: The value of the new instance. + public init(stringLiteral value: StringLiteralType) { self.init(value.split(separator: "/").map { $0[...] }) } +} From 7177b0cd4d24d51192b5b753ac72da367683d314 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Thu, 28 Nov 2024 15:23:10 +0100 Subject: [PATCH 29/50] Add GitHub action to check for Semantic Version label (#132) --- .github/workflows/pull_request_label.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/pull_request_label.yml diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml new file mode 100644 index 00000000..8fd47c13 --- /dev/null +++ b/.github/workflows/pull_request_label.yml @@ -0,0 +1,18 @@ +name: PR label + +on: + pull_request: + types: [labeled, unlabeled, opened, reopened, synchronize] + +jobs: + semver-label-check: + name: Semantic version label check + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Check for Semantic Version label + uses: apple/swift-nio/.github/actions/pull_request_semver_label_checker@main From 0946588933cf59e85e6c348c242ce00acb203238 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Wed, 11 Dec 2024 14:50:06 +0000 Subject: [PATCH 30/50] Enable MemberImportVisibility check on all targets (#133) Enable MemberImportVisibility check on all targets. Use a standard string header and footer to bracket the new block for ease of updating in the future with scripts. --- Package.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Package.swift b/Package.swift index 2cae0bfc..0d6222aa 100644 --- a/Package.swift +++ b/Package.swift @@ -50,3 +50,14 @@ let package = Package( ), ] ) + +// --- STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // +for target in package.targets { + if target.type != .plugin { + var settings = target.swiftSettings ?? [] + // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md + settings.append(.enableUpcomingFeature("MemberImportVisibility")) + target.swiftSettings = settings + } +} +// --- END: STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // From 56e9fea787e0e079fdea9a83d068c50484c028c4 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Wed, 18 Dec 2024 14:20:45 +0100 Subject: [PATCH 31/50] Update release.yml (#136) Update the release.yml file with the latest label changes --- .github/release.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/release.yml b/.github/release.yml index 4d18da56..e29eb846 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -2,14 +2,13 @@ changelog: categories: - title: SemVer Major labels: - - semver/major + - ⚠️ semver/major - title: SemVer Minor labels: - - semver/minor + - 🆕 semver/minor - title: SemVer Patch labels: - - semver/patch + - 🔨 semver/patch - title: Other Changes labels: - semver/none - - "*" From 7e80669c9be3e137d990f9707f99fd00af8d51e8 Mon Sep 17 00:00:00 2001 From: gayathrisairam <168187165+gayathrisairam@users.noreply.github.com> Date: Fri, 20 Dec 2024 08:11:29 +0000 Subject: [PATCH 32/50] Conform RuntimeError to HTTPResponseConvertible (#135) ### Motivation https://github.com/apple/swift-openapi-generator/issues/609#issuecomment-2522503704 ### Modifications Confirm `RuntimeError` to `HTTPResponseConvertible` and provide granular status codes. ### Result Response codes for bad user input will be 4xx (instead of 500) ### Test Plan Unit tests. --------- Co-authored-by: Gayathri Sairamkrishnan --- .../OpenAPIRuntime/Errors/RuntimeError.swift | 23 ++++++ .../Errors/Test_RuntimeError.swift | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 549e3a13..6cc82d33 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import protocol Foundation.LocalizedError import struct Foundation.Data +import HTTPTypes /// Error thrown by generated code. internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, PrettyStringConvertible { @@ -141,3 +142,25 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret @_spi(Generated) public func throwUnexpectedResponseBody(expectedContent: String, body: any Sendable) throws -> Never { throw RuntimeError.unexpectedResponseBody(expectedContent: expectedContent, body: body) } + +/// HTTP Response status definition for ``RuntimeError``. +extension RuntimeError: HTTPResponseConvertible { + /// HTTP Status code corresponding to each error case + public var httpStatus: HTTPTypes.HTTPResponse.Status { + switch self { + case .invalidServerURL, .invalidServerVariableValue, .pathUnset: .notFound + case .invalidExpectedContentType, .unexpectedContentTypeHeader: .unsupportedMediaType + case .missingCoderForCustomContentType: .unprocessableContent + case .unexpectedAcceptHeader: .notAcceptable + case .failedToDecodeStringConvertibleValue, .invalidAcceptSubstring, .invalidBase64String, + .invalidHeaderFieldName, .malformedAcceptHeader, .missingMultipartBoundaryContentTypeParameter, + .missingOrMalformedContentDispositionName, .missingRequiredHeaderField, + .missingRequiredMultipartFormDataContentType, .missingRequiredQueryParameter, .missingRequiredPathParameter, + .missingRequiredRequestBody, .unsupportedParameterStyle: + .badRequest + case .handlerFailed, .middlewareFailed, .missingRequiredResponseBody, .transportFailed, + .unexpectedResponseStatus, .unexpectedResponseBody: + .internalServerError + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift new file mode 100644 index 00000000..341f1b5b --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2024 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 HTTPTypes +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +struct MockRuntimeErrorHandler: Sendable { + var failWithError: (any Error)? = nil + func greet(_ input: String) async throws -> String { + if let failWithError { throw failWithError } + guard input == "hello" else { throw TestError() } + return "bye" + } + + static let requestBody: HTTPBody = HTTPBody("hello") + static let responseBody: HTTPBody = HTTPBody("bye") +} + +final class Test_RuntimeError: XCTestCase { + func testRuntimeError_withUnderlyingErrorNotConforming_returns500() async throws { + let server = UniversalServer( + handler: MockRuntimeErrorHandler(failWithError: RuntimeError.transportFailed(TestError())), + middlewares: [ErrorHandlingMiddleware()] + ) + let response = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockRuntimeErrorHandler.greet($0) }, + deserializer: { request, body, metadata in + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in fatalError() } + ) + XCTAssertEqual(response.0.status, .internalServerError) + } + + func testRuntimeError_withUnderlyingErrorConforming_returnsCorrectStatusCode() async throws { + let server = UniversalServer( + handler: MockRuntimeErrorHandler(failWithError: TestErrorConvertible.testError("Test Error")), + middlewares: [ErrorHandlingMiddleware()] + ) + let response = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockRuntimeErrorHandler.greet($0) }, + deserializer: { request, body, metadata in + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in fatalError() } + ) + XCTAssertEqual(response.0.status, .badGateway) + } +} + +enum TestErrorConvertible: Error, HTTPResponseConvertible { + case testError(String) + /// HTTP status code for error cases + public var httpStatus: HTTPTypes.HTTPResponse.Status { .badGateway } +} From 23146bc8710ac5e57abb693113f02dc274cf39b6 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 2 Jan 2025 13:12:25 +0100 Subject: [PATCH 33/50] [CI] Enable Linux nightly main on PRs (#137) ### Motivation PR CI was missing testing with nighly Linux toolchains from main. ### Modifications Enable nightly toolchain CI as well. ### Result More test coverage on PRs. ### Test Plan See this PR's CI if this works or if we need more changes. --- .github/workflows/pull_request.yml | 2 +- Sources/OpenAPIRuntime/Base/Acceptable.swift | 1 + Sources/OpenAPIRuntime/Base/ContentDisposition.swift | 1 + Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift | 1 + .../OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift | 1 + Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift | 4 ++-- .../Multipart/Test_MultipartBytesToFramesSequence.swift | 1 + .../Multipart/Test_MultipartFramesToBytesSequence.swift | 1 + .../Multipart/Test_MultipartFramesToRawPartsSequence.swift | 1 + .../Multipart/Test_MultipartRawPartsToFramesSequence.swift | 1 + .../Multipart/Test_MultipartValidationSequence.swift | 1 + 11 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 4b6971bb..6f244b2a 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -19,7 +19,7 @@ jobs: linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_main_enabled: false + linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: name: Integration test diff --git a/Sources/OpenAPIRuntime/Base/Acceptable.swift b/Sources/OpenAPIRuntime/Base/Acceptable.swift index fb19799f..2f1b5e21 100644 --- a/Sources/OpenAPIRuntime/Base/Acceptable.swift +++ b/Sources/OpenAPIRuntime/Base/Acceptable.swift @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import Foundation /// The protocol that all generated `AcceptableContentType` enums conform to. public protocol AcceptableProtocol: RawRepresentable, Sendable, Hashable, CaseIterable where RawValue == String {} diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index c0b25074..11a43dc0 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import Foundation /// A parsed representation of the `content-disposition` header described by RFC 6266 containing only /// the features relevant to OpenAPI multipart bodies. diff --git a/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift index 7093bd75..9e3e4542 100644 --- a/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift +++ b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes +import Foundation /// A container for request metadata already parsed and validated /// by the server transport. diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift index 39bc9d21..c3397ba2 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import Foundation /// A generator of a new boundary string used by multipart messages to separate parts. public protocol MultipartBoundaryGenerator: Sendable { diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 7049502e..277044b0 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -12,8 +12,8 @@ // //===----------------------------------------------------------------------===// import XCTest -#if canImport(Foundation) -@preconcurrency import Foundation +import Foundation +#if canImport(CoreFoundation) import CoreFoundation #endif @_spi(Generated) @testable import OpenAPIRuntime diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift index acdee3f4..36eb2301 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift @@ -14,6 +14,7 @@ import XCTest @_spi(Generated) @testable import OpenAPIRuntime import Foundation +import HTTPTypes final class Test_MultipartBytesToFramesSequence: Test_Runtime { func test() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift index de487ed6..da87af47 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift @@ -14,6 +14,7 @@ import XCTest @_spi(Generated) @testable import OpenAPIRuntime import Foundation +import HTTPTypes final class Test_MultipartFramesToBytesSequence: Test_Runtime { func test() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift index 4a75b727..143993a7 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift @@ -14,6 +14,7 @@ import XCTest @_spi(Generated) @testable import OpenAPIRuntime import Foundation +import HTTPTypes final class Test_MultipartFramesToRawPartsSequence: Test_Runtime { func test() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift index 5017e532..826ef34f 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift @@ -14,6 +14,7 @@ import XCTest @_spi(Generated) @testable import OpenAPIRuntime import Foundation +import HTTPTypes final class Test_MultipartRawPartsToFramesSequence: Test_Runtime { func test() async throws { diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift index 3951e864..0343966a 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -14,6 +14,7 @@ import XCTest @_spi(Generated) @testable import OpenAPIRuntime import Foundation +import HTTPTypes final class Test_MultipartValidationSequence: Test_Runtime { func test() async throws { From c118c193ea9c4bbded4d61b01e6829681a3aed51 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 31 Jan 2025 09:58:56 +0000 Subject: [PATCH 34/50] CI use 6.1 nightlies (#139) CI use 6.1 nightlies now that Swift development is happening in the 6.1 branch --- .github/workflows/main.yml | 2 +- .github/workflows/pull_request.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0e52f33a..6acede64 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 6f244b2a..a1494b51 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -18,7 +18,7 @@ jobs: linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: From acb942520db779ff7abb6f6fd59ff7b45db0714d Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 24 Feb 2025 12:30:55 +0100 Subject: [PATCH 35/50] Fix error description forwarding (#143) ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/730. We were not correctly keeping `CustomStringConvertible` and `LocalizedError` conformances separate for wrapper errors like `ClientError` and `ServerError`. This lead to some user-thrown errors (in handlers, transports, and middlewares) to print less information than the error was actually providing (using a different method). ### Modifications Properly untangle the two printing codepaths, and only call `localizedDescription` from the wrapper error's `errorDescription`. Also made the `localizedDescription` strings a bit more user-friendly and less detailed, as in some apps these errors might get directly rendered by a UI component that calls `localizedDescription`. ### Result Error logging should now match adopter expectations. ### Test Plan Added unit tests for `{Client,Server}Error` printing methods. --- .../Conversion/ErrorExtensions.swift | 10 ++++- .../OpenAPIRuntime/Errors/ClientError.swift | 8 ++-- .../OpenAPIRuntime/Errors/CodingErrors.swift | 8 ++-- .../OpenAPIRuntime/Errors/RuntimeError.swift | 4 ++ .../OpenAPIRuntime/Errors/ServerError.swift | 8 ++-- .../Interface/ServerTransport.swift | 2 +- .../Conversion/Test_Converter+Client.swift | 2 +- .../Errors/Test_ClientError.swift | 39 +++++++++++++++++++ .../Errors/Test_RuntimeError.swift | 6 +++ .../Errors/Test_ServerError.swift | 37 ++++++++++++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 11 ++++-- 11 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift create mode 100644 Tests/OpenAPIRuntimeTests/Errors/Test_ServerError.swift diff --git a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift index 9d21513c..1b938ffb 100644 --- a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift @@ -114,12 +114,18 @@ struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible { var description: String { let combinedDescription = errors.map { error in - guard let error = error as? (any PrettyStringConvertible) else { return error.localizedDescription } + guard let error = error as? (any PrettyStringConvertible) else { return "\(error)" } return error.prettyDescription } .enumerated().map { ($0.offset + 1, $0.element) }.map { "Error \($0.0): [\($0.1)]" }.joined(separator: ", ") return "MultiError (contains \(errors.count) error\(errors.count == 1 ? "" : "s")): \(combinedDescription)" } - var errorDescription: String? { description } + var errorDescription: String? { + if let first = errors.first { + return "Mutliple errors encountered, first one: \(first.localizedDescription)." + } else { + return "No errors" + } + } } diff --git a/Sources/OpenAPIRuntime/Errors/ClientError.swift b/Sources/OpenAPIRuntime/Errors/ClientError.swift index 90481bff..eb0c8005 100644 --- a/Sources/OpenAPIRuntime/Errors/ClientError.swift +++ b/Sources/OpenAPIRuntime/Errors/ClientError.swift @@ -109,9 +109,7 @@ public struct ClientError: Error { // MARK: Private fileprivate var underlyingErrorDescription: String { - guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { - return underlyingError.localizedDescription - } + guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { return "\(underlyingError)" } return prettyError.prettyDescription } } @@ -133,5 +131,7 @@ extension ClientError: LocalizedError { /// This computed property provides a localized human-readable description of the client error, which is suitable for displaying to users. /// /// - Returns: A localized string describing the client error. - public var errorDescription: String? { description } + public var errorDescription: String? { + "Client encountered an error invoking the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." + } } diff --git a/Sources/OpenAPIRuntime/Errors/CodingErrors.swift b/Sources/OpenAPIRuntime/Errors/CodingErrors.swift index 30a04c4c..12bdb42c 100644 --- a/Sources/OpenAPIRuntime/Errors/CodingErrors.swift +++ b/Sources/OpenAPIRuntime/Errors/CodingErrors.swift @@ -21,7 +21,7 @@ extension DecodingError: PrettyStringConvertible { case .keyNotFound(let key, let context): output = "keyNotFound \(key) - \(context.prettyDescription)" case .typeMismatch(let type, let context): output = "typeMismatch \(type) - \(context.prettyDescription)" case .valueNotFound(let type, let context): output = "valueNotFound \(type) - \(context.prettyDescription)" - @unknown default: output = "unknown: \(localizedDescription)" + @unknown default: output = "unknown: \(self)" } return "DecodingError: \(output)" } @@ -30,7 +30,7 @@ extension DecodingError: PrettyStringConvertible { extension DecodingError.Context: PrettyStringConvertible { var prettyDescription: String { let path = codingPath.map(\.description).joined(separator: "/") - return "at \(path): \(debugDescription) (underlying error: \(underlyingError?.localizedDescription ?? ""))" + return "at \(path): \(debugDescription) (underlying error: \(underlyingError.map { "\($0)" } ?? ""))" } } @@ -39,7 +39,7 @@ extension EncodingError: PrettyStringConvertible { let output: String switch self { case .invalidValue(let value, let context): output = "invalidValue \(value) - \(context.prettyDescription)" - @unknown default: output = "unknown: \(localizedDescription)" + @unknown default: output = "unknown: \(self)" } return "EncodingError: \(output)" } @@ -48,6 +48,6 @@ extension EncodingError: PrettyStringConvertible { extension EncodingError.Context: PrettyStringConvertible { var prettyDescription: String { let path = codingPath.map(\.description).joined(separator: "/") - return "at \(path): \(debugDescription) (underlying error: \(underlyingError?.localizedDescription ?? ""))" + return "at \(path): \(debugDescription) (underlying error: \(underlyingError.map { "\($0)" } ?? ""))" } } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 6cc82d33..2c3260ac 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -121,6 +121,10 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Unexpected response body, expected content type: \(expectedContentType), body: \(body)" } } + + // MARK: - LocalizedError + + var errorDescription: String? { description } } /// Throws an error to indicate an unexpected HTTP response status. diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 92d0552e..13288a9c 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -82,9 +82,7 @@ public struct ServerError: Error { // MARK: Private fileprivate var underlyingErrorDescription: String { - guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { - return underlyingError.localizedDescription - } + guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { return "\(underlyingError)" } return prettyError.prettyDescription } } @@ -106,5 +104,7 @@ extension ServerError: LocalizedError { /// This computed property provides a localized human-readable description of the server error, which is suitable for displaying to users. /// /// - Returns: A localized string describing the server error. - public var errorDescription: String? { description } + public var errorDescription: String? { + "Server encountered an error handling the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." + } } diff --git a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift index 40e16e8f..96e01dc4 100644 --- a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift @@ -197,7 +197,7 @@ public protocol ServerTransport { /// print("<<<: \(response.status.code)") /// return (response, responseBody) /// } catch { -/// print("!!!: \(error.localizedDescription)") +/// print("!!!: \(error)") /// throw error /// } /// } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 0f3bf066..e223ea53 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -285,5 +285,5 @@ public func XCTAssertEqualStringifiedData( do { let actualString = String(decoding: try expression1(), as: UTF8.self) XCTAssertEqual(actualString, try expression2(), file: file, line: line) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } + } catch { XCTFail("\(error)", file: file, line: line) } } diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift new file mode 100644 index 00000000..f7198fc9 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 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 HTTPTypes +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +final class Test_ServerError: XCTestCase { + func testPrinting() throws { + let upstreamError = RuntimeError.handlerFailed(PrintableError()) + let error: any Error = ServerError( + operationID: "op", + request: .init(soar_path: "/test", method: .get), + requestBody: nil, + requestMetadata: .init(), + causeDescription: upstreamError.prettyDescription, + underlyingError: upstreamError.underlyingError ?? upstreamError + ) + XCTAssertEqual( + "\(error)", + "Server error - cause description: 'User handler threw an error.', underlying error: Just description, operationID: op, request: GET /test [], requestBody: , metadata: Path parameters: [:], operationInput: , operationOutput: " + ) + XCTAssertEqual( + error.localizedDescription, + "Server encountered an error handling the operation \"op\", caused by \"User handler threw an error.\", underlying error: Just errorDescription." + ) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift index 341f1b5b..ec76c0e0 100644 --- a/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift @@ -68,6 +68,12 @@ final class Test_RuntimeError: XCTestCase { ) XCTAssertEqual(response.0.status, .badGateway) } + + func testDescriptions() async throws { + let error: any Error = RuntimeError.transportFailed(PrintableError()) + XCTAssertEqual("\(error)", "Transport threw an error.") + XCTAssertEqual(error.localizedDescription, "Transport threw an error.") + } } enum TestErrorConvertible: Error, HTTPResponseConvertible { diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_ServerError.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_ServerError.swift new file mode 100644 index 00000000..40236b4c --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_ServerError.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 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 HTTPTypes +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +final class Test_ClientError: XCTestCase { + func testPrinting() throws { + let upstreamError = RuntimeError.transportFailed(PrintableError()) + let error: any Error = ClientError( + operationID: "op", + operationInput: "test", + causeDescription: upstreamError.prettyDescription, + underlyingError: upstreamError.underlyingError ?? upstreamError + ) + XCTAssertEqual( + "\(error)", + "Client error - cause description: 'Transport threw an error.', underlying error: Just description, operationID: op, operationInput: test, request: , requestBody: , baseURL: , response: , responseBody: " + ) + XCTAssertEqual( + error.localizedDescription, + "Client encountered an error invoking the operation \"op\", caused by \"Transport threw an error.\", underlying error: Just errorDescription." + ) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 942c9df2..37184d58 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -210,6 +210,11 @@ extension ArraySlice { struct TestError: Error, Equatable {} +struct PrintableError: Error, CustomStringConvertible, LocalizedError { + var description: String { "Just description" } + var errorDescription: String? { "Just errorDescription" } +} + struct MockMiddleware: ClientMiddleware, ServerMiddleware { enum FailurePhase { case never @@ -345,7 +350,7 @@ struct PrintingMiddleware: ClientMiddleware { print("Received: \(response.status)") return (response, responseBody) } catch { - print("Failed with error: \(error.localizedDescription)") + print("Failed with error: \(error)") throw error } } @@ -373,7 +378,7 @@ public func XCTAssertEqualStringifiedData( } let actualString = String(decoding: Array(value1), as: UTF8.self) XCTAssertEqual(actualString, try expression2(), file: file, line: line) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } + } catch { XCTFail("\(error)", file: file, line: line) } } /// Asserts that the string representation of binary data in an HTTP body is equal to an expected string. @@ -454,7 +459,7 @@ public func XCTAssertEqualData( file: file, line: line ) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } + } catch { XCTFail("\(error)", file: file, line: line) } } /// Asserts that the data matches the expected value. From ae880024a86a631e48b86eea23f449ed7ef537bf Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 4 Mar 2025 10:36:43 +0000 Subject: [PATCH 36/50] Rename nightly_6_1 params to nightly_next (#144) Rename nightly_6_1 params to nightly_next; see https://github.com/apple/swift-nio/pull/3122 --- .github/workflows/main.yml | 2 +- .github/workflows/pull_request.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6acede64..8cd8c5df 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a1494b51..d22364e1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -18,7 +18,7 @@ jobs: linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" - linux_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: From 81c309c7b43cd56b2d2b90ca0170f17ff3d0c433 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 7 Mar 2025 16:25:43 +0000 Subject: [PATCH 37/50] Only apply standard swift settings on valid targets (#145) Only apply standard swift settings on valid targets. The current check ignores plugins but that is not comprehensive enough. --- Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 0d6222aa..58fc1d4d 100644 --- a/Package.swift +++ b/Package.swift @@ -53,11 +53,14 @@ let package = Package( // --- STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // for target in package.targets { - if target.type != .plugin { + switch target.type { + case .regular, .test, .executable: var settings = target.swiftSettings ?? [] // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md settings.append(.enableUpcomingFeature("MemberImportVisibility")) target.swiftSettings = settings + case .macro, .plugin, .system, .binary: () // not applicable + @unknown default: () // we don't know what to do here, do nothing } } // --- END: STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // From e1c9d542d82ca6546dcbb44a3402e425cfae0c66 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Thu, 13 Mar 2025 10:36:18 +0000 Subject: [PATCH 38/50] Add static SDK CI workflow (#146) Add static SDK CI workflow which runs on commits to PRs, merges to main and daily on main. --- .github/workflows/main.yml | 5 +++++ .github/workflows/pull_request.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8cd8c5df..899874fb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,3 +23,8 @@ jobs: with: name: "Integration test" matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" + + static-sdk: + name: Static SDK + # Workaround https://github.com/nektos/act/issues/1875 + uses: apple/swift-nio/.github/workflows/static_sdk.yml@main diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d22364e1..d145602d 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -28,3 +28,8 @@ jobs: name: "Integration test" matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" matrix_linux_nightly_main_enabled: false + + static-sdk: + name: Static SDK + # Workaround https://github.com/nektos/act/issues/1875 + uses: apple/swift-nio/.github/workflows/static_sdk.yml@main From e8bc5eda143e48d35b5a38f363b57f5c510e1c6c Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 28 Mar 2025 14:18:20 +0000 Subject: [PATCH 39/50] Enable macOS CI on merge to main and daily timer (#147) Motivation: * Improve test coverage * Check test pass/fail status * Monitor CI throughput Modifications: Enable macOS CI to be run on all merges to main and on a daily timer. Result: Improved test coverage run out-of-band at the moment so we can get a feeling for if any changes need to be made in the repo or in the CI pipelines to ensure timely and stable checks. --- .github/workflows/main.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 899874fb..13039111 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,3 +28,9 @@ jobs: name: Static SDK # Workaround https://github.com/nektos/act/issues/1875 uses: apple/swift-nio/.github/workflows/static_sdk.yml@main + + macos-tests: + name: macOS tests + uses: apple/swift-nio/.github/workflows/macos_tests.yml@main + with: + build_scheme: swift-openapi-runtime From a6027e2a752f10e609c13a9869b20bda2d84131a Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 1 Apr 2025 11:23:09 +0100 Subject: [PATCH 40/50] Enable macOS CI on pull requests (#149) Motivation: * Improve test coverage Modifications: Enable macOS CI to be run on pull request commits and make the use of the nightly runner pool for main.yml jobs explicit. Result: Improved test coverage. --- .github/workflows/main.yml | 1 + .github/workflows/pull_request.yml | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 13039111..70807072 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,4 +33,5 @@ jobs: name: macOS tests uses: apple/swift-nio/.github/workflows/macos_tests.yml@main with: + runner_pool: nightly build_scheme: swift-openapi-runtime diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d145602d..8ba6aa26 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -33,3 +33,10 @@ jobs: name: Static SDK # Workaround https://github.com/nektos/act/issues/1875 uses: apple/swift-nio/.github/workflows/static_sdk.yml@main + + macos-tests: + name: macOS tests + uses: apple/swift-nio/.github/workflows/macos_tests.yml@main + with: + runner_pool: general + build_scheme: swift-openapi-runtime From 8f33cc5dfe81169fb167da73584b9c72c3e8bc23 Mon Sep 17 00:00:00 2001 From: Jake Petroules Date: Tue, 1 Apr 2025 12:15:59 -0700 Subject: [PATCH 41/50] Add support for Windows (#148) CoreFoundation isn't API on Windows, so compile it out in that case. All tests pass with these changes. Co-authored-by: Honza Dvorsky --- .github/workflows/main.yml | 6 ++++++ .github/workflows/pull_request.yml | 6 ++++++ Sources/OpenAPIRuntime/Base/OpenAPIValue.swift | 6 ++++++ Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift | 2 ++ 4 files changed, 20 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 70807072..72bb8cbd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,12 @@ jobs: linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + windows_6_0_enabled: true + windows_nightly_6_1_enabled: true + windows_nightly_main_enabled: true + windows_6_0_arguments_override: "--explicit-target-dependency-import-check error" + windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" + windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: name: Integration test diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8ba6aa26..e186a31a 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -20,6 +20,12 @@ jobs: linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + windows_6_0_enabled: true + windows_nightly_6_1_enabled: true + windows_nightly_main_enabled: true + windows_6_0_arguments_override: "--explicit-target-dependency-import-check error" + windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" + windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" integration-test: name: Integration test diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index a1be0397..ed14a00a 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -19,8 +19,10 @@ import class Foundation.NSNull @preconcurrency import class Foundation.NSNull #endif import class Foundation.NSNumber +#if canImport(CoreFoundation) import CoreFoundation #endif +#endif /// A container for a value represented by JSON Schema. /// @@ -141,11 +143,13 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { try container.encodeNil() return } + #if canImport(CoreFoundation) if let nsNumber = value as? NSNumber { try encode(nsNumber, to: &container) return } #endif + #endif switch value { case let value as Bool: try container.encode(value) case let value as Int: try container.encode(value) @@ -162,6 +166,7 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { ) } } + #if canImport(CoreFoundation) /// Encodes the provided NSNumber based on its internal representation. /// - Parameters: /// - value: The NSNumber that boxes one of possibly many different types of values. @@ -198,6 +203,7 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { } } } + #endif // MARK: Equatable diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 277044b0..7146a267 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -78,6 +78,7 @@ final class Test_OpenAPIValue: Test_Runtime { try _testPrettyEncoded(container, expectedJSON: expectedString) } + #if canImport(CoreFoundation) func testEncodingNSNumber() throws { func assertEncodedCF( _ value: CFNumber, @@ -129,6 +130,7 @@ final class Test_OpenAPIValue: Test_Runtime { XCTAssertThrowsError(try assertEncodedCF(kCFNumberPositiveInfinity, as: "-")) } #endif + #endif func testEncoding_container_failure() throws { struct Foobar: Equatable {} XCTAssertThrowsError(try OpenAPIValueContainer(unvalidatedValue: Foobar())) { error in From e2395f4de23c708d18a9d533d6aa0aa28a7f0c30 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 8 Apr 2025 22:06:46 +0100 Subject: [PATCH 42/50] Switch integration tests to newer Swift test matrix (#150) --- .github/workflows/main.yml | 22 ++++++++++++++++++++-- .github/workflows/pull_request.yml | 23 ++++++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 72bb8cbd..bd7f1802 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,12 +23,30 @@ jobs: windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + construct-integration-test-matrix: + name: Construct integration matrix + runs-on: ubuntu-latest + outputs: + integration-test-matrix: '${{ steps.generate-matrix.outputs.integration-test-matrix }}' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - id: generate-matrix + run: echo "integration-test-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT" + env: + MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq + MATRIX_LINUX_COMMAND: ./scripts/run-integration-test.sh + integration-test: name: Integration test - uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + needs: construct-integration-test-matrix + uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main with: name: "Integration test" - matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" + matrix_string: '${{ needs.construct-integration-test-matrix.outputs.integration-test-matrix }}' + matrix_linux_nightly_main_enabled: false static-sdk: name: Static SDK diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index e186a31a..eb5aa92e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -27,13 +27,30 @@ jobs: windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" + construct-integration-test-matrix: + name: Construct integration matrix + runs-on: ubuntu-latest + outputs: + integration-test-matrix: '${{ steps.generate-matrix.outputs.integration-test-matrix }}' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - id: generate-matrix + run: echo "integration-test-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT" + env: + MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq && git config --global --add safe.directory /swift-openapi-runtime + MATRIX_LINUX_COMMAND: ./scripts/run-integration-test.sh + MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false + integration-test: name: Integration test - uses: apple/swift-nio/.github/workflows/swift_matrix.yml@main + needs: construct-integration-test-matrix + uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main with: name: "Integration test" - matrix_linux_command: "apt-get update -yq && apt-get install -yq jq && ./scripts/run-integration-test.sh" - matrix_linux_nightly_main_enabled: false + matrix_string: '${{ needs.construct-integration-test-matrix.outputs.integration-test-matrix }}' static-sdk: name: Static SDK From 46f9260e05a7c290d7b4bd664ba8264f8cd78e32 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Mon, 14 Apr 2025 11:14:55 +0100 Subject: [PATCH 43/50] Enable Swift 6.1 jobs in CI (#151) Motivation: Swift 6.1 has been released, we should add it to our CI coverage. Modifications: Add additional Swift 6.1 jobs where appropriate in main.yml, pull_request.yml Result: Improved test coverage. --- .github/workflows/main.yml | 3 +++ .github/workflows/pull_request.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bd7f1802..126cd0e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,12 +14,15 @@ jobs: linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_1_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" windows_6_0_enabled: true + windows_6_1_enabled: true windows_nightly_6_1_enabled: true windows_nightly_main_enabled: true windows_6_0_arguments_override: "--explicit-target-dependency-import-check error" + windows_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index eb5aa92e..0f85fca1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -18,12 +18,15 @@ jobs: linux_5_9_arguments_override: "--explicit-target-dependency-import-check error" linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_1_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" windows_6_0_enabled: true + windows_6_1_enabled: true windows_nightly_6_1_enabled: true windows_nightly_main_enabled: true windows_6_0_arguments_override: "--explicit-target-dependency-import-check error" + windows_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" From cd3d9dcf055693f5284cd2a5e89f760387afb813 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 22 Apr 2025 09:45:23 +0100 Subject: [PATCH 44/50] move nightly disable parameter in main.yml (#152) Move nightly disable parameter in main.yml - the matrix workflow doesn't accept this parameter, it's in the wrong place. It's already in the correct place in the pull request yaml. --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 126cd0e0..6fad32b0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,6 +41,7 @@ jobs: env: MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq MATRIX_LINUX_COMMAND: ./scripts/run-integration-test.sh + MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false integration-test: name: Integration test @@ -49,7 +50,6 @@ jobs: with: name: "Integration test" matrix_string: '${{ needs.construct-integration-test-matrix.outputs.integration-test-matrix }}' - matrix_linux_nightly_main_enabled: false static-sdk: name: Static SDK From e5b0de7221dd1a0c9b9a6de9afdbe06f3e46a24f Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 22 Apr 2025 14:36:32 +0100 Subject: [PATCH 45/50] main.yml - add /swift-openapi-runtime as safe directory (#153) Add /swift-openapi-runtime as safe directory. This is already set in the pull request yaml but is missing here --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6fad32b0..06fa2fdb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,7 +39,7 @@ jobs: - id: generate-matrix run: echo "integration-test-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT" env: - MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq + MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq && git config --global --add safe.directory /swift-openapi-runtime MATRIX_LINUX_COMMAND: ./scripts/run-integration-test.sh MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false From 7cf0cf2bc44a09dbdba493f8761f25f4758a76a0 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 13 May 2025 19:24:23 +0200 Subject: [PATCH 46/50] Fix typo in MultiError (#154) ### Motivation Don't have typos in error messages. ### Modifications Fixed the typo. ### Result Typo no more. ### Test Plan N/A --- Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift index 1b938ffb..b8e32edc 100644 --- a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift @@ -123,7 +123,7 @@ struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible { var errorDescription: String? { if let first = errors.first { - return "Mutliple errors encountered, first one: \(first.localizedDescription)." + return "Multiple errors encountered, first one: \(first.localizedDescription)." } else { return "No errors" } From e535c55ad4a6ef031e4d97ee59f0620d6484580c Mon Sep 17 00:00:00 2001 From: Raphael Date: Tue, 29 Jul 2025 22:51:56 +0100 Subject: [PATCH 47/50] Enable release mode builds (#155) --- .github/workflows/main.yml | 9 +++++++++ .github/workflows/pull_request.yml | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 06fa2fdb..1e5be061 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,3 +62,12 @@ jobs: with: runner_pool: nightly build_scheme: swift-openapi-runtime + + release-builds: + name: Release builds + uses: apple/swift-nio/.github/workflows/release_builds.yml@main + with: + windows_6_0_enabled: true + windows_6_1_enabled: true + windows_nightly_next_enabled: true + windows_nightly_main_enabled: true diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0f85fca1..14d8aad4 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -66,3 +66,12 @@ jobs: with: runner_pool: general build_scheme: swift-openapi-runtime + + release-builds: + name: Release builds + uses: apple/swift-nio/.github/workflows/release_builds.yml@main + with: + windows_6_0_enabled: true + windows_6_1_enabled: true + windows_nightly_next_enabled: true + windows_nightly_main_enabled: true From 7722cf8eac05c1f1b5b05895b04cfcc29576d9be Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 25 Aug 2025 17:06:26 +0200 Subject: [PATCH 48/50] Fix encoding of OpenAPI{Object,Value}Container to allow multiple encodings in anyOf/allOf (#156) ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/808. More background: We used to use `singleValueContainer()`, provided by Codable, to encode not just primitive values, but also composite values like objects and arrays. This works in _most_ cases, but not when you have an `anyOf` (or an `allOf`), which contains more than 1 of these types (`OpenAPIValueContainer` or `OpenAPIObjectContainer`). When it hits that case, it crashes at runtime, because you can't encode multiple composite types into a single encoder using `singleValueContainer()`. However, you can do that when using the proper `container(keyedBy:)` and `unkeyedContainer()` APIs, as that way you're not overwriting the value that's already there, but amending it. ### Modifications Improve the encoding and decoding logic of `OpenAPIValueContainer` and `OpenAPIObjectContainer` to use the more appropriate Codable APIs when coding composite types, like objects and arrays. ### Result Now we can have an anyOf/allOf with multiple of these types, and they encode/decode correctly. I also verified that the original example reported by the user works with this patch. ### Test Plan Added more unit tests and ensured all still pass, also verified that Swift OpenAPI Generator's tests pass when pointed at this patched version of Runtime. --- .../OpenAPIRuntime/Base/OpenAPIValue.swift | 106 ++++++++------ .../Conversion/CodableExtensions.swift | 2 +- .../Base/Test_OpenAPIValue.swift | 136 ++++++++++++++++++ 3 files changed, 203 insertions(+), 41 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index ed14a00a..83da78c1 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -114,10 +114,20 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { self.init(validatedValue: item) } else if let item = try? container.decode(String.self) { self.init(validatedValue: item) - } else if let item = try? container.decode([OpenAPIValueContainer].self) { - self.init(validatedValue: item.map(\.value)) - } else if let item = try? container.decode([String: OpenAPIValueContainer].self) { - self.init(validatedValue: item.mapValues(\.value)) + } else if var container = try? decoder.unkeyedContainer() { + var items: [(any Sendable)?] = [] + if let count = container.count { items.reserveCapacity(count) } + while !container.isAtEnd { + let item = try container.decode(OpenAPIValueContainer.self) + items.append(item.value) + } + self.init(validatedValue: items) + } else if let container = try? decoder.container(keyedBy: StringKey.self) { + let keyValuePairs = try container.allKeys.map { key -> (String, (any Sendable)?) in + let item = try container.decode(OpenAPIValueContainer.self, forKey: key) + return (key.stringValue, item.value) + } + self.init(validatedValue: Dictionary(uniqueKeysWithValues: keyValuePairs)) } else { throw DecodingError.dataCorruptedError( in: container, @@ -133,36 +143,53 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Parameter encoder: The encoder to which the value should be encoded. /// - Throws: An error if the encoding process encounters issues or if the value is invalid. public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() guard let value = value else { + var container = encoder.singleValueContainer() try container.encodeNil() return } #if canImport(Foundation) if value is NSNull { + var container = encoder.singleValueContainer() try container.encodeNil() return } #if canImport(CoreFoundation) if let nsNumber = value as? NSNumber { + var container = encoder.singleValueContainer() try encode(nsNumber, to: &container) return } #endif #endif switch value { - case let value as Bool: try container.encode(value) - case let value as Int: try container.encode(value) - case let value as Double: try container.encode(value) - case let value as String: try container.encode(value) + case let value as Bool: + var container = encoder.singleValueContainer() + try container.encode(value) + case let value as Int: + var container = encoder.singleValueContainer() + try container.encode(value) + case let value as Double: + var container = encoder.singleValueContainer() + try container.encode(value) + case let value as String: + var container = encoder.singleValueContainer() + try container.encode(value) case let value as [(any Sendable)?]: - try container.encode(value.map(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.unkeyedContainer() + for item in value { + let containerItem = OpenAPIValueContainer(validatedValue: item) + try container.encode(containerItem) + } case let value as [String: (any Sendable)?]: - try container.encode(value.mapValues(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.container(keyedBy: StringKey.self) + for (itemKey, itemValue) in value { + try container.encode(OpenAPIValueContainer(validatedValue: itemValue), forKey: StringKey(itemKey)) + } default: throw EncodingError.invalidValue( value, - .init(codingPath: container.codingPath, debugDescription: "OpenAPIValueContainer cannot be encoded") + .init(codingPath: encoder.codingPath, debugDescription: "OpenAPIValueContainer cannot be encoded") ) } } @@ -357,36 +384,29 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { // MARK: Decodable - /// Creates an `OpenAPIValueContainer` by decoding it from a single-value container in a given decoder. - /// - /// - Parameter decoder: The decoder used to decode the container. - /// - Throws: An error if the decoding process encounters an issue or if the data does not match the expected format. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let item = try container.decode([String: OpenAPIValueContainer].self) - self.init(validatedValue: item.mapValues(\.value)) + let container = try decoder.container(keyedBy: StringKey.self) + let keyValuePairs = try container.allKeys.map { key -> (String, (any Sendable)?) in + let item = try container.decode(OpenAPIValueContainer.self, forKey: key) + return (key.stringValue, item.value) + } + self.init(validatedValue: Dictionary(uniqueKeysWithValues: keyValuePairs)) } // MARK: Encodable - /// Encodes the `OpenAPIValueContainer` into a format that can be stored or transmitted via the given encoder. - /// - /// - Parameter encoder: The encoder used to perform the encoding. - /// - Throws: An error if the encoding process encounters an issue or if the data does not match the expected format. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(value.mapValues(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.container(keyedBy: StringKey.self) + for (itemKey, itemValue) in value { + try container.encode(OpenAPIValueContainer(validatedValue: itemValue), forKey: StringKey(itemKey)) + } } // MARK: Equatable - /// Compares two `OpenAPIObjectContainer` instances for equality by comparing their inner key-value dictionaries. - /// - /// - Parameters: - /// - lhs: The left-hand side `OpenAPIObjectContainer` to compare. - /// - rhs: The right-hand side `OpenAPIObjectContainer` to compare. - /// - /// - Returns: `true` if the `OpenAPIObjectContainer` instances are equal, `false` otherwise. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public static func == (lhs: OpenAPIObjectContainer, rhs: OpenAPIObjectContainer) -> Bool { let lv = lhs.value let rv = rhs.value @@ -401,9 +421,7 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { // MARK: Hashable - /// Hashes the `OpenAPIObjectContainer` instance into the provided `Hasher`. - /// - /// - Parameter hasher: The `Hasher` into which the hash value is combined. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public func hash(into hasher: inout Hasher) { for (key, itemValue) in value { hasher.combine(key) @@ -474,9 +492,14 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { /// - Parameter decoder: The decoder to use for decoding the array of values. /// - Throws: An error if the decoding process fails or if the decoded values cannot be validated. public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let item = try container.decode([OpenAPIValueContainer].self) - self.init(validatedValue: item.map(\.value)) + var container = try decoder.unkeyedContainer() + var items: [(any Sendable)?] = [] + if let count = container.count { items.reserveCapacity(count) } + while !container.isAtEnd { + let item = try container.decode(OpenAPIValueContainer.self) + items.append(item.value) + } + self.init(validatedValue: items) } // MARK: Encodable @@ -486,8 +509,11 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { /// - Parameter encoder: The encoder to use for encoding the array of values. /// - Throws: An error if the encoding process fails. public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(value.map(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.unkeyedContainer() + for item in value { + let containerItem = OpenAPIValueContainer(validatedValue: item) + try container.encode(containerItem) + } } // MARK: Equatable diff --git a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift index 6e9f5edc..e91954a2 100644 --- a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift @@ -142,7 +142,7 @@ } /// A freeform String coding key for decoding undocumented values. -private struct StringKey: CodingKey, Hashable, Comparable { +internal struct StringKey: CodingKey, Hashable, Comparable { var stringValue: String var intValue: Int? { Int(stringValue) } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 7146a267..f2591ffe 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -216,6 +216,82 @@ final class Test_OpenAPIValue: Test_Runtime { XCTAssertEqual(value["keyMore"] as? [Bool], [true]) } + func testEncoding_anyOfObjects_success() throws { + let values1: [String: (any Sendable)?] = ["key": "value"] + let values2: [String: (any Sendable)?] = ["keyMore": [true]] + let container = MyAnyOf2( + value1: try OpenAPIObjectContainer(unvalidatedValue: values1), + value2: try OpenAPIObjectContainer(unvalidatedValue: values2) + ) + let expectedString = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + try _testPrettyEncoded(container, expectedJSON: expectedString) + } + + func testDecoding_anyOfObjects_success() throws { + let json = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + let container: MyAnyOf2 = try _getDecoded(json: json) + let value1 = container.value1?.value + XCTAssertEqual(value1?.count, 2) + XCTAssertEqual(value1?["key"] as? String, "value") + XCTAssertEqual(value1?["keyMore"] as? [Bool], [true]) + let value2 = container.value2?.value + XCTAssertEqual(value2?.count, 2) + XCTAssertEqual(value2?["key"] as? String, "value") + XCTAssertEqual(value2?["keyMore"] as? [Bool], [true]) + } + + func testEncoding_anyOfValues_success() throws { + let values1: [String: (any Sendable)?] = ["key": "value"] + let values2: [String: (any Sendable)?] = ["keyMore": [true]] + let container = MyAnyOf2( + value1: try OpenAPIValueContainer(unvalidatedValue: values1), + value2: try OpenAPIValueContainer(unvalidatedValue: values2) + ) + let expectedString = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + try _testPrettyEncoded(container, expectedJSON: expectedString) + } + + func testDecoding_anyOfValues_success() throws { + let json = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + let container: MyAnyOf2 = try _getDecoded(json: json) + let value1 = try XCTUnwrap(container.value1?.value as? [String: (any Sendable)?]) + XCTAssertEqual(value1.count, 2) + XCTAssertEqual(value1["key"] as? String, "value") + XCTAssertEqual(value1["keyMore"] as? [Bool], [true]) + let value2 = try XCTUnwrap(container.value2?.value as? [String: (any Sendable)?]) + XCTAssertEqual(value2.count, 2) + XCTAssertEqual(value2["key"] as? String, "value") + XCTAssertEqual(value2["keyMore"] as? [Bool], [true]) + } + func testEncoding_array_success() throws { let values: [(any Sendable)?] = ["one", ["two": 2]] let container = try OpenAPIArrayContainer(unvalidatedValue: values) @@ -246,6 +322,40 @@ final class Test_OpenAPIValue: Test_Runtime { XCTAssertEqual(value[1] as? [String: Int], ["two": 2]) } + func testEncoding_arrayOfObjects_success() throws { + let values: [(any Sendable)?] = [["one": 1], ["two": 2]] + let container = try OpenAPIArrayContainer(unvalidatedValue: values) + let expectedString = #""" + [ + { + "one" : 1 + }, + { + "two" : 2 + } + ] + """# + try _testPrettyEncoded(container, expectedJSON: expectedString) + } + + func testDecoding_arrayOfObjects_success() throws { + let json = #""" + [ + { + "one" : 1 + }, + { + "two" : 2 + } + ] + """# + let container: OpenAPIArrayContainer = try _getDecoded(json: json) + let value = container.value + XCTAssertEqual(value.count, 2) + XCTAssertEqual(value[0] as? [String: Int], ["one": 1]) + XCTAssertEqual(value[1] as? [String: Int], ["two": 2]) + } + func testEncoding_objectNested_success() throws { struct Foo: Encodable { var bar: String @@ -334,3 +444,29 @@ final class Test_OpenAPIValue: Test_Runtime { ) } } + +struct MyAnyOf2: Codable, Hashable, + Sendable +{ + var value1: Value1? + var value2: Value2? + init(value1: Value1? = nil, value2: Value2? = nil) { + self.value1 = value1 + self.value2 = value2 + } + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { self.value1 = try .init(from: decoder) } catch { errors.append(error) } + do { self.value2 = try .init(from: decoder) } catch { errors.append(error) } + try Swift.DecodingError.verifyAtLeastOneSchemaIsNotNil( + [self.value1, self.value2], + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + try self.value1?.encode(to: encoder) + try self.value2?.encode(to: encoder) + } +} From 05d0d320cb67dfa81e292c0e8997891b19c2eb45 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 26 Sep 2025 16:34:19 +0100 Subject: [PATCH 49/50] Fix formatting in swift-format 6.2 (#159) ### Motivation Formatter got upgraded upstream, so we need to update formatting in our project. ### Modifications Regenerated using swift-format from Swift 6.2 and made a few manual fixes. ### Result Soundness format is clean again. ### Test Plan Ran locally. --- .../OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift | 3 ++- Sources/OpenAPIRuntime/Interface/ServerTransport.swift | 7 ++++--- Sources/OpenAPIRuntime/Interface/UniversalServer.swift | 4 ++-- Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift | 4 ++-- .../Interface/Test_ErrorHandlingMiddleware.swift | 7 ++++--- .../URICoder/Parsing/Test_URIParser.swift | 5 ----- 6 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift index 55113ce5..48c3cabc 100644 --- a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift +++ b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift @@ -52,7 +52,8 @@ public struct ErrorHandlingMiddleware: ServerMiddleware { body: OpenAPIRuntime.HTTPBody?, metadata: OpenAPIRuntime.ServerRequestMetadata, operationID: String, - next: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata) + next: + @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { do { return try await next(request, body, metadata) } catch { diff --git a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift index 96e01dc4..0984ed21 100644 --- a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift @@ -114,9 +114,10 @@ public protocol ServerTransport { /// - Important: The `path` can have mixed components, such /// as `/file/{name}.zip`. func register( - _ handler: @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> ( - HTTPResponse, HTTPBody? - ), + _ handler: + @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> ( + HTTPResponse, HTTPBody? + ), method: HTTPRequest.Method, path: String ) throws diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 4608dafe..4fb6bc82 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -91,8 +91,8 @@ import struct Foundation.URLComponents metadata: ServerRequestMetadata, forOperation operationID: String, using handlerMethod: @Sendable @escaping (APIHandler) -> ((OperationInput) async throws -> OperationOutput), - deserializer: @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> - OperationInput, + deserializer: + @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> OperationInput, serializer: @Sendable @escaping (OperationOutput, HTTPRequest) throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) where OperationInput: Sendable, OperationOutput: Sendable { @Sendable func wrappingErrors(work: () async throws -> R, mapError: (any Error) -> any Error) async throws diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index f2591ffe..419c1d7d 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -454,7 +454,7 @@ struct MyAnyOf2 @Sendable ( - HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata - ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { + private func getNextMiddleware(failurePhase: MockErrorMiddleware_Next.FailurePhase) + -> @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata) + async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) + { let mockNext: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) = { request, body, metadata in diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index a94805ac..678cf794 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -524,11 +524,6 @@ final class Test_URIParser: Test_Runtime { } var result: ExpectedResult - init(string: String, result: ExpectedResult) { - self.string = string - self.result = result - } - static func assert(_ string: String, equals value: RootType) -> Self { .init(string: string, result: .success(value)) } From 16e7f0de96c2293438b6ae6a71b2a46b332e0b04 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 26 Sep 2025 17:06:58 +0100 Subject: [PATCH 50/50] Enable Swift 6.2 jobs in CI (#158) Motivation: Swift 6.2 has been released, we should add it to our CI coverage. Modifications: Add additional Swift 6.2 jobs where appropriate in main.yml, pull_request.yml Result: Improved test coverage. Co-authored-by: Honza Dvorsky --- .github/workflows/main.yml | 4 ++++ .github/workflows/pull_request.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e5be061..6db1fc5d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,14 +15,17 @@ jobs: linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" linux_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" windows_6_0_enabled: true windows_6_1_enabled: true + windows_6_2_enabled: true windows_nightly_6_1_enabled: true windows_nightly_main_enabled: true windows_6_0_arguments_override: "--explicit-target-dependency-import-check error" windows_6_1_arguments_override: "--explicit-target-dependency-import-check error" + windows_6_2_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" @@ -69,5 +72,6 @@ jobs: with: windows_6_0_enabled: true windows_6_1_enabled: true + windows_6_2_enabled: true windows_nightly_next_enabled: true windows_nightly_main_enabled: true diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 14d8aad4..f3ab7a89 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -19,14 +19,17 @@ jobs: linux_5_10_arguments_override: "--explicit-target-dependency-import-check error" linux_6_0_arguments_override: "--explicit-target-dependency-import-check error" linux_6_1_arguments_override: "--explicit-target-dependency-import-check error" + linux_6_2_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" linux_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" windows_6_0_enabled: true windows_6_1_enabled: true + windows_6_2_enabled: true windows_nightly_6_1_enabled: true windows_nightly_main_enabled: true windows_6_0_arguments_override: "--explicit-target-dependency-import-check error" windows_6_1_arguments_override: "--explicit-target-dependency-import-check error" + windows_6_2_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_6_1_arguments_override: "--explicit-target-dependency-import-check error" windows_nightly_main_arguments_override: "--explicit-target-dependency-import-check error" @@ -73,5 +76,6 @@ jobs: with: windows_6_0_enabled: true windows_6_1_enabled: true + windows_6_2_enabled: true windows_nightly_next_enabled: true windows_nightly_main_enabled: true