From 82edf0e8a973d1493ab287933d3df1b1fea86805 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 25 Oct 2023 12:31:07 +0200 Subject: [PATCH 01/79] Add a soundness --fix flag (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a soundness --fix flag ### Motivation When running `./scripts/soundness.sh` produces swift-format warnings, we ask adopters to manually copy/paste a call to swift format to fix the warnings up. This is tedious and unnecessary. ### Modifications Add a `--fix` option on the `soundness.sh` script to actually apply the fixes as well, avoiding the need to copy/paste long commands. ### Result Easier fixing up of formatting warnings. ### Test Plan Manually tested the workflow locally. Reviewed by: glbrntt Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/62 --- scripts/run-swift-format.sh | 13 ++++++++++--- scripts/soundness.sh | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) mode change 100644 => 100755 scripts/run-swift-format.sh diff --git a/scripts/run-swift-format.sh b/scripts/run-swift-format.sh old mode 100644 new mode 100755 index e2011cfe..eefa5850 --- a/scripts/run-swift-format.sh +++ b/scripts/run-swift-format.sh @@ -21,10 +21,17 @@ 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}" lint \ - --parallel --recursive --strict \ +"${SWIFTFORMAT_BIN}" "${FORMAT_COMMAND[@]}" \ + --parallel --recursive \ "${REPO_ROOT}/Sources" "${REPO_ROOT}/Tests" \ && SWIFT_FORMAT_RC=$? || SWIFT_FORMAT_RC=$? @@ -33,7 +40,7 @@ if [ "${SWIFT_FORMAT_RC}" -ne 0 ]; then To fix, run the following command: - % swift-format format --parallel --recursive --in-place Sources Tests + % ./scripts/run-swift-format.sh --fix " exit "${SWIFT_FORMAT_RC}" fi diff --git a/scripts/soundness.sh b/scripts/soundness.sh index f8ae7050..45ee0f30 100755 --- a/scripts/soundness.sh +++ b/scripts/soundness.sh @@ -21,11 +21,17 @@ 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" - "${CURRENT_SCRIPT_DIR}/run-swift-format.sh" ) for SCRIPT_PATH in "${SCRIPT_PATHS[@]}"; do @@ -35,6 +41,13 @@ for SCRIPT_PATH in "${SCRIPT_PATHS[@]}"; do 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 From 51bdb07b52e60c764c8ee6328d84ba7dc0e849b4 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 26 Oct 2023 14:25:33 +0200 Subject: [PATCH 02/79] [Runtime] Generate server variables (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Runtime] Generate server variables ### Motivation The runtime side of https://github.com/apple/swift-openapi-generator/issues/24. ### Modifications Added an SPI `ServerVariable` type and a variant of the `URL.init(validatingOpenAPIServerURL:variables:)` method, which takes the template and variables and returns a fully formed concrete URL. ### Result Unblocked the generator half of supporting server variables. ### Test Plan Added unit tests. Reviewed by: glbrntt Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/64 --- .../Conversion/ServerVariable.swift | 77 +++++++++++++++++ .../OpenAPIRuntime/Errors/RuntimeError.swift | 4 + .../Conversion/Test_ServerVariable.swift | 83 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 Sources/OpenAPIRuntime/Conversion/ServerVariable.swift create mode 100644 Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift diff --git a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift new file mode 100644 index 00000000..4a22853c --- /dev/null +++ b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +extension URL { + /// Returns a validated server URL created from the URL template, or + /// throws an error. + /// - Parameter + /// - string: A URL string. + /// - variables: A map of variable values to substitute into the URL + /// template. + /// - Throws: If the provided string doesn't convert to URL. + @_spi(Generated) + public init( + validatingOpenAPIServerURL string: String, + variables: [ServerVariable] + ) throws { + var urlString = string + for variable in variables { + let name = variable.name + let value = variable.value + if let allowedValues = variable.allowedValues { + guard allowedValues.contains(value) else { + throw RuntimeError.invalidServerVariableValue( + name: name, + value: value, + allowedValues: allowedValues + ) + } + } + urlString = urlString.replacingOccurrences(of: "{\(name)}", with: value) + } + guard let url = Self(string: urlString) else { + throw RuntimeError.invalidServerURL(urlString) + } + self = url + } +} + +/// A variable of a server URL template in the OpenAPI document. +@_spi(Generated) +public struct ServerVariable: Sendable, Hashable { + + /// The name of the variable. + public var name: String + + /// The value to be substituted into the URL template. + public var value: String + + /// A list of allowed values from the OpenAPI document. + /// + /// Nil means that any value is allowed. + public var allowedValues: [String]? + + /// Creates a new server variable. + /// - Parameters: + /// - name: The name of the variable. + /// - value: The value to be substituted into the URL template. + /// - allowedValues: A list of allowed values from the OpenAPI document. + public init(name: String, value: String, allowedValues: [String]? = nil) { + self.name = name + self.value = value + self.allowedValues = allowedValues + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 74eb1ef3..f7b4e93f 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -19,6 +19,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Miscs case invalidServerURL(String) + case invalidServerVariableValue(name: String, value: String, allowedValues: [String]) case invalidExpectedContentType(String) case invalidHeaderFieldName(String) case invalidBase64String(String) @@ -70,6 +71,9 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret switch self { case .invalidServerURL(let string): return "Invalid server URL: \(string)" + case .invalidServerVariableValue(name: let name, value: let value, allowedValues: let allowedValues): + 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 .invalidHeaderFieldName(let name): diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift new file mode 100644 index 00000000..0b134b2e --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +final class Test_ServerVariable: Test_Runtime { + + func testOnlyConstants() throws { + XCTAssertEqual( + try URL( + validatingOpenAPIServerURL: "https://example.com", + variables: [] + ) + .absoluteString, + "https://example.com" + ) + XCTAssertEqual( + try URL( + validatingOpenAPIServerURL: "https://example.com/api", + variables: [] + ) + .absoluteString, + "https://example.com/api" + ) + XCTAssertEqual( + try URL( + validatingOpenAPIServerURL: "/api", + variables: [] + ) + .absoluteString, + "/api" + ) + } + + func testVariables() throws { + XCTAssertEqual( + try URL( + validatingOpenAPIServerURL: "https://{subdomain}.example.com:{port}/{baseURL}", + variables: [ + .init(name: "subdomain", value: "test"), + .init(name: "port", value: "443", allowedValues: ["443", "8443"]), + .init(name: "baseURL", value: "v1"), + ] + ) + .absoluteString, + "https://test.example.com:443/v1" + ) + XCTAssertThrowsError( + try URL( + validatingOpenAPIServerURL: "https://{subdomain}.example.com:{port}/{baseURL}", + variables: [ + .init(name: "subdomain", value: "test"), + .init(name: "port", value: "foo", allowedValues: ["443", "8443"]), + .init(name: "baseURL", value: "v1"), + ] + ), + "Should have thrown an error", + { error in + guard + case let .invalidServerVariableValue(name: name, value: value, allowedValues: allowedValues) = error + as? RuntimeError + else { + XCTFail("Expected error, but not this: \(error)") + return + } + XCTAssertEqual(name, "port") + XCTAssertEqual(value, "foo") + XCTAssertEqual(allowedValues, ["443", "8443"]) + } + ) + } +} From ad8bf04ca5706eb71a34059992be78d08645b8e7 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 26 Oct 2023 16:37:57 +0200 Subject: [PATCH 03/79] Improved enriched error propagation from the transport and middlewares (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved enriched error propagation from the transport and middlewares ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/302 and https://github.com/apple/swift-openapi-generator/issues/17. The issue was that we hid away errors thrown in transports and middlewares, and the adopter would get `ClientError` where the `underlyingError` wasn't the error thrown by the underlying transport/middleware, but instead a private wrapper of ours. ### Modifications Make sure `{Client,Server}Error.underlyingError` contains the error thrown from the underlying transport/middleware when that was the cause of the error, otherwise keep `RuntimeError` there as was the behavior until now. Also added a `causeDescription` property on both public error types to allow communicating the context for the underlying error. Also made sure middleware errors are now wrapped in Client/ServerError, they weren't before so didn't contain the context necessary to debug issues well. ### Result Adopters can now extract the errors thrown e.g. by URLSession from our public error types using the `underlyingError` property and understand the context of where it was thrown by checking the user-facing `causeDescription`. Also, adopters now get enriched errors thrown from middlewares. ### Test Plan Wrote unit tests for both UniversalClient and UniversalServer, inevitably found some minor bugs there as well, fixed them all, plus the unit tests now verify the behavior new in this PR. Reviewed by: glbrntt Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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. ✖︎ pull request validation (api breakage) - Build finished. https://github.com/apple/swift-openapi-runtime/pull/63 --- .../Deprecated/Deprecated.swift | 84 +++++++ .../OpenAPIRuntime/Errors/ClientError.swift | 10 +- .../OpenAPIRuntime/Errors/RuntimeError.swift | 23 +- .../OpenAPIRuntime/Errors/ServerError.swift | 12 +- .../Interface/UniversalClient.swift | 90 +++++-- .../Interface/UniversalServer.swift | 58 +++-- .../Interface/Test_UniversalClient.swift | 229 ++++++++++++++++++ .../Interface/Test_UniversalServer.swift | 196 ++++++++++++++- Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 45 ++++ 9 files changed, 700 insertions(+), 47 deletions(-) create mode 100644 Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index cb20c7e6..bf454112 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -12,5 +12,89 @@ // //===----------------------------------------------------------------------===// import Foundation +import HTTPTypes // MARK: - Functionality to be removed in the future + +extension ClientError { + /// Creates a new error. + /// - Parameters: + /// - operationID: The OpenAPI operation identifier. + /// - operationInput: The operation-specific Input value. + /// - request: The HTTP request created during the operation. + /// - requestBody: The HTTP request body created during the operation. + /// - baseURL: The base URL for HTTP requests. + /// - response: The HTTP response received during the operation. + /// - responseBody: The HTTP response body received during the operation. + /// - underlyingError: The underlying error that caused the operation + /// to fail. + @available( + *, + deprecated, + renamed: + "ClientError.init(operationID:operationInput:request:requestBody:baseURL:response:responseBody:causeDescription:underlyingError:)", + message: "Use the initializer with a causeDescription parameter." + ) + public init( + operationID: String, + operationInput: any Sendable, + request: HTTPRequest? = nil, + requestBody: HTTPBody? = nil, + baseURL: URL? = nil, + response: HTTPResponse? = nil, + responseBody: HTTPBody? = nil, + underlyingError: any Error + ) { + self.init( + operationID: operationID, + operationInput: operationInput, + request: request, + requestBody: requestBody, + baseURL: baseURL, + response: response, + responseBody: responseBody, + causeDescription: "Legacy error without a causeDescription.", + underlyingError: underlyingError + ) + } +} + +extension ServerError { + /// Creates a new error. + /// - Parameters: + /// - operationID: The OpenAPI operation identifier. + /// - request: The HTTP request provided to the server. + /// - requestBody: The HTTP request body provided to the server. + /// - requestMetadata: The request metadata extracted by the server. + /// - operationInput: An operation-specific Input value. + /// - operationOutput: An operation-specific Output value. + /// - underlyingError: The underlying error that caused the operation + /// to fail. + @available( + *, + deprecated, + renamed: + "ServerError.init(operationID:request:requestBody:requestMetadata:operationInput:operationOutput:causeDescription:underlyingError:)", + message: "Use the initializer with a causeDescription parameter." + ) + public init( + operationID: String, + request: HTTPRequest, + requestBody: HTTPBody?, + requestMetadata: ServerRequestMetadata, + operationInput: (any Sendable)? = nil, + operationOutput: (any Sendable)? = nil, + underlyingError: any Error + ) { + self.init( + operationID: operationID, + request: request, + requestBody: requestBody, + requestMetadata: requestMetadata, + operationInput: operationInput, + operationOutput: operationOutput, + causeDescription: "Legacy error without a causeDescription.", + underlyingError: underlyingError + ) + } +} diff --git a/Sources/OpenAPIRuntime/Errors/ClientError.swift b/Sources/OpenAPIRuntime/Errors/ClientError.swift index 53df18f3..b820bd4a 100644 --- a/Sources/OpenAPIRuntime/Errors/ClientError.swift +++ b/Sources/OpenAPIRuntime/Errors/ClientError.swift @@ -64,6 +64,10 @@ public struct ClientError: Error { /// Will be nil if the error resulted before the response was received. public var responseBody: HTTPBody? + /// A user-facing description of what caused the underlying error + /// to be thrown. + public var causeDescription: String + /// The underlying error that caused the operation to fail. public var underlyingError: any Error @@ -76,6 +80,8 @@ public struct ClientError: Error { /// - baseURL: The base URL for HTTP requests. /// - response: The HTTP response received during the operation. /// - responseBody: The HTTP response body received during the operation. + /// - causeDescription: A user-facing description of what caused + /// the underlying error to be thrown. /// - underlyingError: The underlying error that caused the operation /// to fail. public init( @@ -86,6 +92,7 @@ public struct ClientError: Error { baseURL: URL? = nil, response: HTTPResponse? = nil, responseBody: HTTPBody? = nil, + causeDescription: String, underlyingError: any Error ) { self.operationID = operationID @@ -95,6 +102,7 @@ public struct ClientError: Error { self.baseURL = baseURL self.response = response self.responseBody = responseBody + self.causeDescription = causeDescription self.underlyingError = underlyingError } @@ -115,7 +123,7 @@ extension ClientError: CustomStringConvertible { /// /// - Returns: A string describing the client error and its associated details. public var description: String { - "Client error - operationID: \(operationID), operationInput: \(String(describing: operationInput)), request: \(request?.prettyDescription ?? ""), requestBody: \(requestBody?.prettyDescription ?? ""), baseURL: \(baseURL?.absoluteString ?? ""), response: \(response?.prettyDescription ?? ""), responseBody: \(responseBody?.prettyDescription ?? "") , underlying error: \(underlyingErrorDescription)" + "Client error - cause description: '\(causeDescription)', underlying error: \(underlyingErrorDescription), operationID: \(operationID), operationInput: \(String(describing: operationInput)), request: \(request?.prettyDescription ?? ""), requestBody: \(requestBody?.prettyDescription ?? ""), baseURL: \(baseURL?.absoluteString ?? ""), response: \(response?.prettyDescription ?? ""), responseBody: \(responseBody?.prettyDescription ?? "")" } } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index f7b4e93f..4fbab419 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -55,12 +55,25 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Transport/Handler case transportFailed(any Error) + case middlewareFailed(middlewareType: Any.Type, any Error) case handlerFailed(any Error) // Unexpected response (thrown by shorthand APIs) case unexpectedResponseStatus(expectedStatus: String, response: any Sendable) case unexpectedResponseBody(expectedContent: String, body: any Sendable) + /// A wrapped root cause error, if one was thrown by other code. + var underlyingError: (any Error)? { + switch self { + case .transportFailed(let error), + .handlerFailed(let error), + .middlewareFailed(_, let error): + return error + default: + return nil + } + } + // MARK: CustomStringConvertible var description: String { @@ -103,10 +116,12 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Missing required request body" case .missingRequiredResponseBody: return "Missing required response body" - case .transportFailed(let underlyingError): - return "Transport failed with error: \(underlyingError.localizedDescription)" - case .handlerFailed(let underlyingError): - return "User handler failed with error: \(underlyingError.localizedDescription)" + case .transportFailed: + return "Transport threw an error." + case .middlewareFailed(middlewareType: let type, _): + return "Middleware of type '\(type)' threw an error." + case .handlerFailed: + return "User handler threw an error." case .unexpectedResponseStatus(let expectedStatus, let response): return "Unexpected response, expected status code: \(expectedStatus), response: \(response)" case .unexpectedResponseBody(let expectedContentType, let body): diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 1fee3a96..7595a890 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -40,6 +40,10 @@ public struct ServerError: Error { /// Is nil if error was thrown before/during Output -> response conversion. public var operationOutput: (any Sendable)? + /// A user-facing description of what caused the underlying error + /// to be thrown. + public var causeDescription: String + /// The underlying error that caused the operation to fail. public var underlyingError: any Error @@ -51,6 +55,8 @@ public struct ServerError: Error { /// - requestMetadata: The request metadata extracted by the server. /// - operationInput: An operation-specific Input value. /// - operationOutput: An operation-specific Output value. + /// - causeDescription: A user-facing description of what caused + /// the underlying error to be thrown. /// - underlyingError: The underlying error that caused the operation /// to fail. public init( @@ -60,7 +66,8 @@ public struct ServerError: Error { requestMetadata: ServerRequestMetadata, operationInput: (any Sendable)? = nil, operationOutput: (any Sendable)? = nil, - underlyingError: (any Error) + causeDescription: String, + underlyingError: any Error ) { self.operationID = operationID self.request = request @@ -68,6 +75,7 @@ public struct ServerError: Error { self.requestMetadata = requestMetadata self.operationInput = operationInput self.operationOutput = operationOutput + self.causeDescription = causeDescription self.underlyingError = underlyingError } @@ -88,7 +96,7 @@ extension ServerError: CustomStringConvertible { /// /// - Returns: A string describing the server error and its associated details. public var description: String { - "Server error - operationID: \(operationID), request: \(request.prettyDescription), requestBody: \(requestBody?.prettyDescription ?? ""), metadata: \(requestMetadata.description), operationInput: \(operationInput.map { String(describing: $0) } ?? ""), operationOutput: \(operationOutput.map { String(describing: $0) } ?? ""), underlying error: \(underlyingErrorDescription)" + "Server error - cause description: '\(causeDescription)', underlying error: \(underlyingErrorDescription), operationID: \(operationID), request: \(request.prettyDescription), requestBody: \(requestBody?.prettyDescription ?? ""), metadata: \(requestMetadata.description), operationInput: \(operationInput.map { String(describing: $0) } ?? ""), operationOutput: \(operationOutput.map { String(describing: $0) } ?? "")" } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 06eaaf79..6c75a3ce 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift @@ -90,19 +90,20 @@ import Foundation serializer: @Sendable (OperationInput) throws -> (HTTPRequest, HTTPBody?), deserializer: @Sendable (HTTPResponse, HTTPBody?) async throws -> OperationOutput ) async throws -> OperationOutput where OperationInput: Sendable, OperationOutput: Sendable { - @Sendable - func wrappingErrors( + @Sendable func wrappingErrors( work: () async throws -> R, mapError: (any Error) -> any Error ) async throws -> R { do { return try await work() + } catch let error as ClientError { + throw error } catch { throw mapError(error) } } let baseURL = serverURL - func makeError( + @Sendable func makeError( request: HTTPRequest? = nil, requestBody: HTTPBody? = nil, baseURL: URL? = nil, @@ -110,7 +111,24 @@ import Foundation responseBody: HTTPBody? = nil, error: any Error ) -> any Error { - ClientError( + if var error = error as? ClientError { + error.request = error.request ?? request + error.requestBody = error.requestBody ?? requestBody + error.baseURL = error.baseURL ?? baseURL + error.response = error.response ?? response + error.responseBody = error.responseBody ?? responseBody + return error + } + let causeDescription: String + let underlyingError: any Error + if let runtimeError = error as? RuntimeError { + causeDescription = runtimeError.prettyDescription + underlyingError = runtimeError.underlyingError ?? error + } else { + causeDescription = "Unknown" + underlyingError = error + } + return ClientError( operationID: operationID, operationInput: input, request: request, @@ -118,7 +136,8 @@ import Foundation baseURL: baseURL, response: response, responseBody: responseBody, - underlyingError: error + causeDescription: causeDescription, + underlyingError: underlyingError ) } let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors { @@ -126,40 +145,61 @@ import Foundation } mapError: { error in makeError(error: error) } - let (response, responseBody): (HTTPResponse, HTTPBody?) = try await wrappingErrors { - var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + (_request, _body, _url) in + try await wrappingErrors { + try await transport.send( + _request, + body: _body, + baseURL: _url, + operationID: operationID + ) + } mapError: { error in + makeError( + request: request, + requestBody: requestBody, + baseURL: baseURL, + error: RuntimeError.transportFailed(error) + ) + } + } + for middleware in middlewares.reversed() { + let tmp = next + next = { (_request, _body, _url) in try await wrappingErrors { - try await transport.send( + try await middleware.intercept( _request, body: _body, baseURL: _url, - operationID: operationID - ) - } mapError: { error in - RuntimeError.transportFailed(error) - } - } - for middleware in middlewares.reversed() { - let tmp = next - next = { - try await middleware.intercept( - $0, - body: $1, - baseURL: $2, operationID: operationID, next: tmp ) + } mapError: { error in + makeError( + request: request, + requestBody: requestBody, + baseURL: baseURL, + error: RuntimeError.middlewareFailed( + middlewareType: type(of: middleware), + error + ) + ) } } - return try await next(request, requestBody, baseURL) - } mapError: { error in - makeError(request: request, baseURL: baseURL, error: error) } + let (response, responseBody): (HTTPResponse, HTTPBody?) = try await next(request, requestBody, baseURL) return try await wrappingErrors { try await deserializer(response, responseBody) } mapError: { error in - makeError(request: request, baseURL: baseURL, response: response, error: error) + makeError( + request: request, + requestBody: requestBody, + baseURL: baseURL, + response: response, + responseBody: responseBody, + error: error + ) } } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 6fba52a2..e523560f 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -102,31 +102,46 @@ import struct Foundation.URLComponents OperationInput, serializer: @Sendable @escaping (OperationOutput, HTTPRequest) throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) where OperationInput: Sendable, OperationOutput: Sendable { - @Sendable - func wrappingErrors( + @Sendable func wrappingErrors( work: () async throws -> R, mapError: (any Error) -> any Error ) async throws -> R { do { return try await work() + } catch let error as ServerError { + throw error } catch { throw mapError(error) } } - @Sendable - func makeError( + @Sendable func makeError( input: OperationInput? = nil, output: OperationOutput? = nil, error: any Error ) -> any Error { - ServerError( + if var error = error as? ServerError { + error.operationInput = error.operationInput ?? input + error.operationOutput = error.operationOutput ?? output + return error + } + let causeDescription: String + let underlyingError: any Error + if let runtimeError = error as? RuntimeError { + causeDescription = runtimeError.prettyDescription + underlyingError = runtimeError.underlyingError ?? error + } else { + causeDescription = "Unknown" + underlyingError = error + } + return ServerError( operationID: operationID, request: request, requestBody: requestBody, requestMetadata: metadata, operationInput: input, operationOutput: output, - underlyingError: error + causeDescription: causeDescription, + underlyingError: underlyingError ) } var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) = @@ -144,7 +159,10 @@ import struct Foundation.URLComponents return try await wrappingErrors { try await method(input) } mapError: { error in - RuntimeError.handlerFailed(error) + makeError( + input: input, + error: RuntimeError.handlerFailed(error) + ) } } mapError: { error in makeError(input: input, error: error) @@ -158,13 +176,25 @@ import struct Foundation.URLComponents for middleware in middlewares.reversed() { let tmp = next next = { - try await middleware.intercept( - $0, - body: $1, - metadata: $2, - operationID: operationID, - next: tmp - ) + _request, + _requestBody, + _metadata in + try await wrappingErrors { + try await middleware.intercept( + _request, + body: _requestBody, + metadata: _metadata, + operationID: operationID, + next: tmp + ) + } mapError: { error in + makeError( + error: RuntimeError.middlewareFailed( + middlewareType: type(of: middleware), + error + ) + ) + } } } return try await next(request, requestBody, metadata) diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift new file mode 100644 index 00000000..64e38c86 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift @@ -0,0 +1,229 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes +import Foundation +@_spi(Generated) @testable import OpenAPIRuntime + +struct MockClientTransport: ClientTransport { + var sendBlock: @Sendable (HTTPRequest, HTTPBody?, URL, String) async throws -> (HTTPResponse, HTTPBody?) + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPResponse, HTTPBody?) { + try await sendBlock(request, body, baseURL, operationID) + } + + static let requestBody: HTTPBody = HTTPBody("hello") + static let responseBody: HTTPBody = HTTPBody("bye") + + static var successful: Self { + MockClientTransport { _, _, _, _ in + (HTTPResponse(status: .ok), responseBody) + } + } + + static var failing: Self { + MockClientTransport { _, _, _, _ in + throw TestError() + } + } +} + +final class Test_UniversalClient: Test_Runtime { + + func testSuccess() async throws { + let client = UniversalClient(transport: MockClientTransport.successful) + let output = try await client.send( + input: "input", + forOperation: "op", + serializer: { input in + ( + HTTPRequest(soar_path: "/", method: .post), + MockClientTransport.requestBody + ) + }, + deserializer: { response, body in + let body = try XCTUnwrap(body) + let string = try await String(collecting: body, upTo: 10) + return string + } + ) + XCTAssertEqual(output, "bye") + } + + func testErrorPropagation_serializer() async throws { + do { + let client = UniversalClient(transport: MockClientTransport.successful) + try await client.send( + input: "input", + forOperation: "op", + serializer: { input in + throw TestError() + }, + deserializer: { response, body in + fatalError() + } + ) + } catch { + let clientError = try XCTUnwrap(error as? ClientError) + XCTAssertEqual(clientError.operationID, "op") + XCTAssertEqual(clientError.operationInput as? String, "input") + XCTAssertEqual(clientError.causeDescription, "Unknown") + XCTAssertEqual(clientError.underlyingError as? TestError, TestError()) + XCTAssertNil(clientError.request) + XCTAssertNil(clientError.requestBody) + XCTAssertNil(clientError.baseURL) + XCTAssertNil(clientError.response) + XCTAssertNil(clientError.responseBody) + } + } + + func testErrorPropagation_middlewareOnRequest() async throws { + do { + let client = UniversalClient( + transport: MockClientTransport.successful, + middlewares: [ + MockMiddleware(failurePhase: .onRequest) + ] + ) + try await client.send( + input: "input", + forOperation: "op", + serializer: { input in + ( + HTTPRequest(soar_path: "/", method: .post), + MockClientTransport.requestBody + ) + }, + deserializer: { response, body in + fatalError() + } + ) + } catch { + let clientError = try XCTUnwrap(error as? ClientError) + XCTAssertEqual(clientError.operationID, "op") + XCTAssertEqual(clientError.operationInput as? String, "input") + XCTAssertEqual(clientError.causeDescription, "Middleware of type 'MockMiddleware' threw an error.") + XCTAssertEqual(clientError.underlyingError as? TestError, TestError()) + XCTAssertEqual(clientError.request, HTTPRequest(soar_path: "/", method: .post)) + XCTAssertEqual(clientError.requestBody, MockClientTransport.requestBody) + XCTAssertEqual(clientError.baseURL, URL(string: "/")) + XCTAssertNil(clientError.response) + XCTAssertNil(clientError.responseBody) + } + } + + func testErrorPropagation_transport() async throws { + do { + let client = UniversalClient( + transport: MockClientTransport.failing, + middlewares: [ + MockMiddleware() + ] + ) + try await client.send( + input: "input", + forOperation: "op", + serializer: { input in + ( + HTTPRequest(soar_path: "/", method: .post), + MockClientTransport.requestBody + ) + }, + deserializer: { response, body in + fatalError() + } + ) + } catch { + let clientError = try XCTUnwrap(error as? ClientError) + XCTAssertEqual(clientError.operationID, "op") + XCTAssertEqual(clientError.operationInput as? String, "input") + XCTAssertEqual(clientError.causeDescription, "Transport threw an error.") + XCTAssertEqual(clientError.underlyingError as? TestError, TestError()) + XCTAssertEqual(clientError.request, HTTPRequest(soar_path: "/", method: .post)) + XCTAssertEqual(clientError.requestBody, MockClientTransport.requestBody) + XCTAssertEqual(clientError.baseURL, URL(string: "/")) + XCTAssertNil(clientError.response) + XCTAssertNil(clientError.responseBody) + } + } + + func testErrorPropagation_middlewareOnResponse() async throws { + do { + let client = UniversalClient( + transport: MockClientTransport.successful, + middlewares: [ + MockMiddleware(failurePhase: .onResponse) + ] + ) + try await client.send( + input: "input", + forOperation: "op", + serializer: { input in + ( + HTTPRequest(soar_path: "/", method: .post), + MockClientTransport.requestBody + ) + }, + deserializer: { response, body in + fatalError() + } + ) + } catch { + let clientError = try XCTUnwrap(error as? ClientError) + XCTAssertEqual(clientError.operationID, "op") + XCTAssertEqual(clientError.operationInput as? String, "input") + XCTAssertEqual(clientError.causeDescription, "Middleware of type 'MockMiddleware' threw an error.") + XCTAssertEqual(clientError.underlyingError as? TestError, TestError()) + XCTAssertEqual(clientError.request, HTTPRequest(soar_path: "/", method: .post)) + XCTAssertEqual(clientError.requestBody, MockClientTransport.requestBody) + XCTAssertEqual(clientError.baseURL, URL(string: "/")) + XCTAssertNil(clientError.response) + XCTAssertNil(clientError.responseBody) + } + } + + func testErrorPropagation_deserializer() async throws { + do { + let client = UniversalClient(transport: MockClientTransport.successful) + try await client.send( + input: "input", + forOperation: "op", + serializer: { input in + ( + HTTPRequest(soar_path: "/", method: .post), + MockClientTransport.requestBody + ) + }, + deserializer: { response, body in + throw TestError() + } + ) + } catch { + let clientError = try XCTUnwrap(error as? ClientError) + XCTAssertEqual(clientError.operationID, "op") + XCTAssertEqual(clientError.operationInput as? String, "input") + XCTAssertEqual(clientError.causeDescription, "Unknown") + XCTAssertEqual(clientError.underlyingError as? TestError, TestError()) + XCTAssertEqual(clientError.request, HTTPRequest(soar_path: "/", method: .post)) + XCTAssertEqual(clientError.requestBody, MockClientTransport.requestBody) + XCTAssertEqual(clientError.baseURL, URL(string: "/")) + XCTAssertEqual(clientError.response, HTTPResponse(status: .ok)) + XCTAssertEqual(clientError.responseBody, MockClientTransport.responseBody) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift index 61ef88f3..88b2ae96 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift @@ -12,11 +12,205 @@ // //===----------------------------------------------------------------------===// import XCTest +import HTTPTypes +import Foundation @_spi(Generated) @testable import OpenAPIRuntime +struct MockHandler: Sendable { + var shouldFail: Bool = false + func greet(_ input: String) async throws -> String { + if shouldFail { + throw TestError() + } + guard input == "hello" else { + throw TestError() + } + return "bye" + } + + static let requestBody: HTTPBody = HTTPBody("hello") + static let responseBody: HTTPBody = HTTPBody("bye") +} + final class Test_UniversalServer: Test_Runtime { - struct MockHandler: Sendable {} + func testSuccess() async throws { + let server = UniversalServer(handler: MockHandler()) + let (response, responseBody) = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: .init("hello"), + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in + (HTTPResponse(status: .ok), MockHandler.responseBody) + } + ) + XCTAssertEqual(response, HTTPResponse(status: .ok)) + XCTAssertEqual(responseBody, MockHandler.responseBody) + } + + func testErrorPropagation_middlewareOnRequest() async throws { + do { + let server = UniversalServer( + handler: MockHandler(), + middlewares: [ + MockMiddleware(failurePhase: .onRequest) + ] + ) + _ = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in + fatalError() + }, + serializer: { output, _ in + fatalError() + } + ) + } catch { + let serverError = try XCTUnwrap(error as? ServerError) + XCTAssertEqual(serverError.operationID, "op") + XCTAssertEqual(serverError.causeDescription, "Middleware of type 'MockMiddleware' threw an error.") + XCTAssertEqual(serverError.underlyingError as? TestError, TestError()) + XCTAssertEqual(serverError.request, .init(soar_path: "/", method: .post)) + XCTAssertEqual(serverError.requestBody, MockHandler.requestBody) + XCTAssertEqual(serverError.requestMetadata, .init()) + XCTAssertNil(serverError.operationInput) + XCTAssertNil(serverError.operationOutput) + } + } + + func testErrorPropagation_deserializer() async throws { + do { + let server = UniversalServer(handler: MockHandler()) + _ = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in + throw TestError() + }, + serializer: { output, _ in + fatalError() + } + ) + } catch { + let serverError = try XCTUnwrap(error as? ServerError) + XCTAssertEqual(serverError.operationID, "op") + XCTAssertEqual(serverError.causeDescription, "Unknown") + XCTAssertEqual(serverError.underlyingError as? TestError, TestError()) + XCTAssertEqual(serverError.request, .init(soar_path: "/", method: .post)) + XCTAssertEqual(serverError.requestBody, MockHandler.requestBody) + XCTAssertEqual(serverError.requestMetadata, .init()) + XCTAssertNil(serverError.operationInput) + XCTAssertNil(serverError.operationOutput) + } + } + + func testErrorPropagation_handler() async throws { + do { + let server = UniversalServer(handler: MockHandler(shouldFail: true)) + _ = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in + fatalError() + } + ) + } catch { + let serverError = try XCTUnwrap(error as? ServerError) + XCTAssertEqual(serverError.operationID, "op") + XCTAssertEqual(serverError.causeDescription, "User handler threw an error.") + XCTAssertEqual(serverError.underlyingError as? TestError, TestError()) + XCTAssertEqual(serverError.request, .init(soar_path: "/", method: .post)) + XCTAssertEqual(serverError.requestBody, MockHandler.requestBody) + XCTAssertEqual(serverError.requestMetadata, .init()) + XCTAssertEqual(serverError.operationInput as? String, "hello") + XCTAssertNil(serverError.operationOutput) + } + } + + func testErrorPropagation_serializer() async throws { + do { + let server = UniversalServer(handler: MockHandler()) + _ = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in + throw TestError() + } + ) + } catch { + let serverError = try XCTUnwrap(error as? ServerError) + XCTAssertEqual(serverError.operationID, "op") + XCTAssertEqual(serverError.causeDescription, "Unknown") + XCTAssertEqual(serverError.underlyingError as? TestError, TestError()) + XCTAssertEqual(serverError.request, .init(soar_path: "/", method: .post)) + XCTAssertEqual(serverError.requestBody, MockHandler.requestBody) + XCTAssertEqual(serverError.requestMetadata, .init()) + XCTAssertEqual(serverError.operationInput as? String, "hello") + XCTAssertEqual(serverError.operationOutput as? String, "bye") + } + } + + func testErrorPropagation_middlewareOnResponse() async throws { + do { + let server = UniversalServer( + handler: MockHandler(), + middlewares: [ + MockMiddleware(failurePhase: .onResponse) + ] + ) + _ = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in + (HTTPResponse(status: .ok), MockHandler.responseBody) + } + ) + } catch { + let serverError = try XCTUnwrap(error as? ServerError) + XCTAssertEqual(serverError.operationID, "op") + XCTAssertEqual(serverError.causeDescription, "Middleware of type 'MockMiddleware' threw an error.") + XCTAssertEqual(serverError.underlyingError as? TestError, TestError()) + XCTAssertEqual(serverError.request, .init(soar_path: "/", method: .post)) + XCTAssertEqual(serverError.requestBody, MockHandler.requestBody) + XCTAssertEqual(serverError.requestMetadata, .init()) + XCTAssertNil(serverError.operationInput) + XCTAssertNil(serverError.operationOutput) + } + } func testApiPathComponentsWithServerPrefix_noPrefix() throws { let server = UniversalServer( diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 4ab91a9f..704b1ef6 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -155,6 +155,51 @@ class Test_Runtime: XCTestCase { } } +struct TestError: Error, Equatable {} + +struct MockMiddleware: ClientMiddleware, ServerMiddleware { + enum FailurePhase { + case never + case onRequest + case onResponse + } + var failurePhase: FailurePhase = .never + + func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + if failurePhase == .onRequest { + throw TestError() + } + let (response, responseBody) = try await next(request, body, baseURL) + if failurePhase == .onResponse { + throw TestError() + } + return (response, responseBody) + } + + func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + metadata: ServerRequestMetadata, + operationID: String, + next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + if failurePhase == .onRequest { + throw TestError() + } + let (response, responseBody) = try await next(request, body, metadata) + if failurePhase == .onResponse { + throw TestError() + } + return (response, responseBody) + } +} + /// Asserts that a given URL's absolute string representation is equal to an expected string. /// /// - Parameters: From 91b16beb1fee44beefd01ce0c67570e4cb0fe75c Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 30 Oct 2023 16:11:04 +0100 Subject: [PATCH 04/79] Disable warnings-as-errors for nightlies (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disable warnings-as-errors for nightlies ### Motivation Same as https://github.com/apple/swift-openapi-generator/pull/353 but for the runtime package. ### Modifications Disable warnings as errors on CI for nightlies. ### Result _[After your change, what will change.]_ ### Test Plan CI should pass again. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/67 --- docker/docker-compose.2204.main.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.2204.main.yaml b/docker/docker-compose.2204.main.yaml index c40ac1ac..75b44630 100644 --- a/docker/docker-compose.2204.main.yaml +++ b/docker/docker-compose.2204.main.yaml @@ -11,7 +11,8 @@ services: test: image: *image environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors + # 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 From 333d73abf86112267e614921214c80f7fcd30413 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 30 Oct 2023 16:15:19 +0100 Subject: [PATCH 05/79] [Runtime] Improved content type matching (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Runtime] Improved content type matching ### Motivation The runtime changes for https://github.com/apple/swift-openapi-generator/issues/315. ### Modifications - Introduces a new SPI method `Converter.bestContentType` that takes a received content type value and from a provided list of other content types, picks the most appropriate one. This actually follows the specification now, by going from most specific (including parameter matching) to least specific (most wildcard-y). - Deprecates the previously used methods `Converter.makeUnexpectedContentTypeError` and `Converter.isMatchingContentType`. ### Result SPI methods that the generated code can use to correctly match content types. ### Test Plan Added unit tests. Reviewed by: gjcairo Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/65 --- .../OpenAPIRuntime/Base/OpenAPIMIMEType.swift | 112 +++++++++++++ .../Conversion/Converter+Common.swift | 76 ++++----- .../Deprecated/Deprecated.swift | 45 ++++++ .../Base/Test_OpenAPIMIMEType.swift | 92 ++++++++++- .../Conversion/Test_Converter+Common.swift | 148 ++++++++++++++++++ 5 files changed, 436 insertions(+), 37 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 7766aa2f..95390a7f 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -187,3 +187,115 @@ extension OpenAPIMIMEType: LosslessStringConvertible { .joined(separator: "; ") } } + +// MARK: - Internals + +extension OpenAPIMIMEType { + + /// The result of a match evaluation between two MIME types. + enum Match: Hashable { + + /// The reason why two types are incompatible. + enum IncompatibilityReason: Hashable { + + /// The types don't match. + case type + + /// The subtypes don't match. + case subtype + + /// The parameter of the provided name is missing or doesn't match. + case parameter(name: String) + } + + /// The types are incompatible for the provided reason. + case incompatible(IncompatibilityReason) + + /// The types match based on a full wildcard `*/*`. + case wildcard + + /// The types match based on a subtype wildcard, such as `image/*`. + case subtypeWildcard + + /// The types match across the type, subtype, and the provided number + /// of parameters. + case typeAndSubtype(matchedParameterCount: Int) + + /// A numeric representation of the quality of the match, the higher + /// the closer the types are. + var score: Int { + switch self { + case .incompatible: + return 0 + case .wildcard: + return 1 + case .subtypeWildcard: + return 2 + case .typeAndSubtype(let matchedParameterCount): + return 3 + matchedParameterCount + } + } + } + + /// Computes whether two MIME types match. + /// - Parameters: + /// - receivedType: The type component of the received MIME type. + /// - receivedSubtype: The subtype component of the received MIME type. + /// - receivedParameters: The parameters of the received MIME type. + /// - option: The MIME type to match against. + /// - Returns: The match result. + static func evaluate( + receivedType: String, + receivedSubtype: String, + receivedParameters: [String: String], + against option: OpenAPIMIMEType + ) -> Match { + switch option.kind { + case .any: + return .wildcard + case .anySubtype(let expectedType): + guard receivedType.lowercased() == expectedType.lowercased() else { + return .incompatible(.type) + } + return .subtypeWildcard + case .concrete(let expectedType, let expectedSubtype): + guard + receivedType.lowercased() == expectedType.lowercased() + && receivedSubtype.lowercased() == expectedSubtype.lowercased() + else { + return .incompatible(.subtype) + } + + // A full concrete match, so also check parameters. + // The rule is: + // 1. If a received parameter is not found in the option, + // that's okay and gets ignored. + // 2. If an option parameter is not received, this is an + // incompatible content type match. + // This means we can just iterate over option parameters and + // check them against the received parameters, but we can + // ignore any received parameters that didn't appear in the + // option parameters. + + // According to RFC 2045: https://www.rfc-editor.org/rfc/rfc2045#section-5.1 + // "Type, subtype, and parameter names are case-insensitive." + // Inferred: Parameter values are case-sensitive. + + let receivedNormalizedParameters = Dictionary( + uniqueKeysWithValues: receivedParameters.map { ($0.key.lowercased(), $0.value) } + ) + var matchedParameterCount = 0 + for optionParameter in option.parameters { + let normalizedParameterName = optionParameter.key.lowercased() + guard + let receivedValue = receivedNormalizedParameters[normalizedParameterName], + receivedValue == optionParameter.value + else { + return .incompatible(.parameter(name: normalizedParameterName)) + } + matchedParameterCount += 1 + } + return .typeAndSubtype(matchedParameterCount: matchedParameterCount) + } + } +} diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index 1123d1ff..a9c27f9c 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -29,45 +29,51 @@ extension Converter { return OpenAPIMIMEType(rawValue) } - /// Checks whether a concrete content type matches an expected content type. - /// - /// The concrete content type can contain parameters, such as `charset`, but - /// they are ignored in the equality comparison. - /// - /// The expected content type can contain wildcards, such as */* and text/*. + /// Chooses the most appropriate content type for the provided received + /// content type and a list of options. /// - Parameters: - /// - received: The concrete content type to validate against the other. - /// - expectedRaw: The expected content type, can contain wildcards. - /// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type. - /// - Returns: A Boolean value representing whether the concrete content - /// type matches the expected one. - public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool { - guard let received else { - return false - } - guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else { - return false + /// - received: The received content type. + /// - options: The options to match against. + /// - Returns: The most appropriate option. + /// - Throws: If none of the options match the received content type. + /// - Precondition: `options` must not be empty. + public func bestContentType( + received: OpenAPIMIMEType?, + options: [String] + ) throws -> String { + precondition(!options.isEmpty, "bestContentType options must not be empty.") + guard + let received, + case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind + else { + // If none received or if we received a wildcard, use the first one. + // This behavior isn't well defined by the OpenAPI specification. + // Note: We treat a partial wildcard, like `image/*` as a full + // wildcard `*/*`, but that's okay because for a concrete received + // content type the behavior of a wildcard is not clearly defined + // either. + return options[0] } - guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else { - throw RuntimeError.invalidExpectedContentType(expectedRaw) + let evaluatedOptions = try options.map { stringOption in + guard let parsedOption = OpenAPIMIMEType(stringOption) else { + throw RuntimeError.invalidExpectedContentType(stringOption) + } + let match = OpenAPIMIMEType.evaluate( + receivedType: receivedType, + receivedSubtype: receivedSubtype, + receivedParameters: received.parameters, + against: parsedOption + ) + return (contentType: stringOption, match: match) } - switch expectedContentType.kind { - case .any: - return true - case .anySubtype(let expectedType): - return receivedType.lowercased() == expectedType.lowercased() - case .concrete(let expectedType, let expectedSubtype): - return receivedType.lowercased() == expectedType.lowercased() - && receivedSubtype.lowercased() == expectedSubtype.lowercased() + let bestOption = evaluatedOptions.max { a, b in + a.match.score < b.match.score + }! // Safe, we only get here if the array is not empty. + let bestContentType = bestOption.contentType + if case .incompatible = bestOption.match { + throw RuntimeError.unexpectedContentTypeHeader(bestContentType) } - } - - /// Returns an error to be thrown when an unexpected content type is - /// received. - /// - Parameter contentType: The content type that was received. - /// - Returns: An error representing an unexpected content type. - public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error { - RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "") + return bestContentType } // MARK: - Converter helper methods diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index bf454112..c6d1d1af 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -98,3 +98,48 @@ extension ServerError { ) } } + +extension Converter { + /// Returns an error to be thrown when an unexpected content type is + /// received. + /// - Parameter contentType: The content type that was received. + /// - Returns: An error representing an unexpected content type. + @available(*, deprecated) + public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error { + RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "") + } + + /// Checks whether a concrete content type matches an expected content type. + /// + /// The concrete content type can contain parameters, such as `charset`, but + /// they are ignored in the equality comparison. + /// + /// The expected content type can contain wildcards, such as */* and text/*. + /// - Parameters: + /// - received: The concrete content type to validate against the other. + /// - expectedRaw: The expected content type, can contain wildcards. + /// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type. + /// - Returns: A Boolean value representing whether the concrete content + /// type matches the expected one. + @available(*, deprecated) + public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool { + guard let received else { + return false + } + guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else { + return false + } + guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else { + throw RuntimeError.invalidExpectedContentType(expectedRaw) + } + switch expectedContentType.kind { + case .any: + return true + case .anySubtype(let expectedType): + return receivedType.lowercased() == expectedType.lowercased() + case .concrete(let expectedType, let expectedSubtype): + return receivedType.lowercased() == expectedType.lowercased() + && receivedSubtype.lowercased() == expectedSubtype.lowercased() + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift index 5aacd455..3fbbf97d 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -12,10 +12,10 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@_spi(Generated) @testable import OpenAPIRuntime final class Test_OpenAPIMIMEType: Test_Runtime { - func test() throws { + func testParsing() throws { let cases: [(String, OpenAPIMIMEType?, String?)] = [ // Common @@ -87,4 +87,92 @@ final class Test_OpenAPIMIMEType: Test_Runtime { XCTAssertEqual(mime?.description, outputString) } } + + func testScore() throws { + let cases: [(OpenAPIMIMEType.Match, Int)] = [ + + (.incompatible(.type), 0), + (.incompatible(.subtype), 0), + (.incompatible(.parameter(name: "foo")), 0), + + (.wildcard, 1), + + (.subtypeWildcard, 2), + + (.typeAndSubtype(matchedParameterCount: 0), 3), + (.typeAndSubtype(matchedParameterCount: 2), 5), + ] + for (match, score) in cases { + XCTAssertEqual(match.score, score, "Mismatch for match: \(match)") + } + } + + func testEvaluate() throws { + func testCase( + receivedType: String, + receivedSubtype: String, + receivedParameters: [String: String], + against option: OpenAPIMIMEType, + expected expectedMatch: OpenAPIMIMEType.Match, + file: StaticString = #file, + line: UInt = #line + ) { + let result = OpenAPIMIMEType.evaluate( + receivedType: receivedType, + receivedSubtype: receivedSubtype, + receivedParameters: receivedParameters, + against: option + ) + XCTAssertEqual(result, expectedMatch, file: file, line: line) + } + + let jsonWith2Params = OpenAPIMIMEType("application/json; charset=utf-8; version=1")! + let jsonWith1Param = OpenAPIMIMEType("application/json; charset=utf-8")! + let json = OpenAPIMIMEType("application/json")! + let fullWildcard = OpenAPIMIMEType("*/*")! + let subtypeWildcard = OpenAPIMIMEType("application/*")! + + func testJSONWith2Params( + against option: OpenAPIMIMEType, + expected expectedMatch: OpenAPIMIMEType.Match, + file: StaticString = #file, + line: UInt = #line + ) { + testCase( + receivedType: "application", + receivedSubtype: "json", + receivedParameters: [ + "charset": "utf-8", + "version": "1", + ], + against: option, + expected: expectedMatch, + file: file, + line: line + ) + } + + // Actual test cases start here. + + testJSONWith2Params( + against: jsonWith2Params, + expected: .typeAndSubtype(matchedParameterCount: 2) + ) + testJSONWith2Params( + against: jsonWith1Param, + expected: .typeAndSubtype(matchedParameterCount: 1) + ) + testJSONWith2Params( + against: json, + expected: .typeAndSubtype(matchedParameterCount: 0) + ) + testJSONWith2Params( + against: subtypeWildcard, + expected: .subtypeWildcard + ) + testJSONWith2Params( + against: fullWildcard, + expected: .wildcard + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index cb5bd055..4819e912 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -25,6 +25,7 @@ final class Test_CommonConverterExtensions: Test_Runtime { // MARK: Miscs + @available(*, deprecated) func testContentTypeMatching() throws { let cases: [(received: String, expected: String, isMatch: Bool)] = [ ("application/json", "application/json", true), @@ -54,6 +55,153 @@ final class Test_CommonConverterExtensions: Test_Runtime { } } + func testBestContentType() throws { + func testCase( + received: String?, + options: [String], + expected expectedChoice: String, + file: StaticString = #file, + line: UInt = #line + ) throws { + let choice = try converter.bestContentType( + received: received.map { .init($0)! }, + options: options + ) + XCTAssertEqual(choice, expectedChoice, file: file, line: line) + } + + try testCase( + received: nil, + options: [ + "application/json", + "*/*", + ], + expected: "application/json" + ) + try testCase( + received: "*/*", + options: [ + "application/json", + "*/*", + ], + expected: "application/json" + ) + try testCase( + received: "application/*", + options: [ + "application/json", + "*/*", + ], + expected: "application/json" + ) + XCTAssertThrowsError( + try testCase( + received: "application/json", + options: [ + "whoops" + ], + expected: "-" + ) + ) + XCTAssertThrowsError( + try testCase( + received: "application/json", + options: [ + "text/plain", + "image/*", + ], + expected: "-" + ) + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*", + "application/*", + "application/json", + "application/json; charset=utf-8", + "application/json; charset=utf-8; version=1", + ], + expected: "application/json; charset=utf-8; version=1" + ) + try testCase( + received: "application/json; version=1; CHARSET=utf-8", + options: [ + "*/*", + "application/*", + "application/json", + "application/json; charset=utf-8", + "application/json; charset=utf-8; version=1", + ], + expected: "application/json; charset=utf-8; version=1" + ) + try testCase( + received: "application/json", + options: [ + "application/json; charset=utf-8", + "application/json; charset=utf-8; version=1", + "*/*", + "application/*", + "application/json", + ], + expected: "application/json" + ) + try testCase( + received: "application/json; charset=utf-8", + options: [ + "application/json; charset=utf-8; version=1", + "*/*", + "application/*", + "application/json", + ], + expected: "application/json" + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*", + "application/*", + "application/json; charset=utf-8", + "application/json", + ], + expected: "application/json; charset=utf-8" + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*", + "application/*", + ], + expected: "application/*" + ) + try testCase( + received: "application/json; charset=utf-8; version=1", + options: [ + "*/*" + ], + expected: "*/*" + ) + + try testCase( + received: "image/png", + options: [ + "image/*", + "*/*", + ], + expected: "image/*" + ) + XCTAssertThrowsError( + try testCase( + received: "text/csv", + options: [ + "text/html", + "application/json", + ], + expected: "-" + ) + ) + } + // MARK: Converter helper methods // | common | set | header field | URI | both | setHeaderFieldAsURI | From a51b3bd6f2151e9a6f792ca6937a7242c4758768 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 30 Oct 2023 16:44:04 +0100 Subject: [PATCH 06/79] [Runtime] Include partial errors in oneOf/anyOf decoding errors (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Runtime] Include partial errors in oneOf/anyOf decoding errors ### Motivation The runtime changes to address https://github.com/apple/swift-openapi-generator/issues/275. This makes debugging of decoding of oneOf/anyOf much easier, as the individual errors aren't dropped on the floor anymore. ### Modifications Added SPI that allows the generated code to collect and report partial errors when a oneOf/anyOf fails to decode (that includes trying multiple subschemas, which themselves emit errors when they're not the right match). ### Result Easier debugging of oneOf/anyOf decoding issues. ### Test Plan Tested manually as part of the generator changes, we don't generally test exact error strings. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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. ✖︎ pull request validation (api breakage) - Build finished. https://github.com/apple/swift-openapi-runtime/pull/66 --- .../Conversion/ErrorExtensions.swift | 76 ++++++++++++++++--- .../Deprecated/Deprecated.swift | 71 +++++++++++++++++ .../URICoder/Test_URICodingRoundtrip.swift | 16 +++- 3 files changed, 150 insertions(+), 13 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift index ff41be62..20b7a76a 100644 --- a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift @@ -22,16 +22,19 @@ extension DecodingError { /// occurred. /// - codingPath: The coding path to the decoder that attempted to decode /// the type. + /// - errors: The errors encountered when decoding individual cases. /// - Returns: A decoding error. static func failedToDecodeAnySchema( type: Any.Type, - codingPath: [any CodingKey] + codingPath: [any CodingKey], + errors: [any Error] ) -> Self { DecodingError.valueNotFound( type, DecodingError.Context.init( codingPath: codingPath, - debugDescription: "The anyOf structure did not decode into any child schema." + debugDescription: "The anyOf structure did not decode into any child schema.", + underlyingError: MultiError(errors: errors) ) ) } @@ -43,24 +46,47 @@ extension DecodingError { /// occurred. /// - codingPath: The coding path to the decoder that attempted to decode /// the type. + /// - errors: The errors encountered when decoding individual cases. /// - Returns: A decoding error. @_spi(Generated) public static func failedToDecodeOneOfSchema( type: Any.Type, - codingPath: [any CodingKey] + codingPath: [any CodingKey], + errors: [any Error] ) -> Self { DecodingError.valueNotFound( type, DecodingError.Context.init( codingPath: codingPath, - debugDescription: "The oneOf structure did not decode into any child schema." + debugDescription: "The oneOf structure did not decode into any child schema.", + underlyingError: MultiError(errors: errors) ) ) } -} -@_spi(Generated) -extension DecodingError { + /// Returns a decoding error used by the oneOf decoder when + /// the discriminator property contains an unknown schema name. + /// - Parameters: + /// - discriminatorKey: The discriminator coding key. + /// - discriminatorValue: The unknown value of the discriminator. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type, with the discriminator value as the last component. + /// - Returns: A decoding error. + @_spi(Generated) + public static func unknownOneOfDiscriminator( + discriminatorKey: any CodingKey, + discriminatorValue: String, + codingPath: [any CodingKey] + ) -> Self { + return DecodingError.keyNotFound( + discriminatorKey, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: + "The oneOf structure does not contain the provided discriminator value '\(discriminatorValue)'." + ) + ) + } /// Verifies that the anyOf decoder successfully decoded at least one /// child schema, and throws an error otherwise. @@ -70,17 +96,49 @@ extension DecodingError { /// occurred. /// - codingPath: The coding path to the decoder that attempted to decode /// the type. + /// - errors: The errors encountered when decoding individual cases. /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. + @_spi(Generated) public static func verifyAtLeastOneSchemaIsNotNil( _ values: [Any?], type: Any.Type, - codingPath: [any CodingKey] + codingPath: [any CodingKey], + errors: [any Error] ) throws { guard values.contains(where: { $0 != nil }) else { throw DecodingError.failedToDecodeAnySchema( type: type, - codingPath: codingPath + codingPath: codingPath, + errors: errors ) } } } + +/// A wrapper of multiple errors, for example collected during a parallelized +/// operation from the individual subtasks. +struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible { + + /// The multiple underlying errors. + var errors: [any Error] + + var description: String { + let combinedDescription = + errors + .map { error in + guard let error = error as? (any PrettyStringConvertible) else { + return error.localizedDescription + } + 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 + } +} diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index c6d1d1af..5de87792 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -143,3 +143,74 @@ extension Converter { } } } + +extension DecodingError { + /// Returns a decoding error used by the oneOf decoder when not a single + /// child schema decodes the received payload. + /// - Parameters: + /// - type: The type representing the oneOf schema in which the decoding + /// occurred. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type. + /// - Returns: A decoding error. + @_spi(Generated) + @available(*, deprecated) + public static func failedToDecodeOneOfSchema( + type: Any.Type, + codingPath: [any CodingKey] + ) -> Self { + DecodingError.valueNotFound( + type, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "The oneOf structure did not decode into any child schema." + ) + ) + } + + /// Returns a decoding error used by the anyOf decoder when not a single + /// child schema decodes the received payload. + /// - Parameters: + /// - type: The type representing the anyOf schema in which the decoding + /// occurred. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type. + /// - Returns: A decoding error. + @available(*, deprecated) + static func failedToDecodeAnySchema( + type: Any.Type, + codingPath: [any CodingKey] + ) -> Self { + DecodingError.valueNotFound( + type, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "The anyOf structure did not decode into any child schema." + ) + ) + } + + /// Verifies that the anyOf decoder successfully decoded at least one + /// child schema, and throws an error otherwise. + /// - Parameters: + /// - values: An array of optional values to check. + /// - type: The type representing the anyOf schema in which the decoding + /// occurred. + /// - codingPath: The coding path to the decoder that attempted to decode + /// the type. + /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. + @_spi(Generated) + @available(*, deprecated) + public static func verifyAtLeastOneSchemaIsNotNil( + _ values: [Any?], + type: Any.Type, + codingPath: [any CodingKey] + ) throws { + guard values.contains(where: { $0 != nil }) else { + throw DecodingError.failedToDecodeAnySchema( + type: type, + codingPath: codingPath + ) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index fcce3775..44c62520 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -50,22 +50,30 @@ final class Test_URICodingRoundtrip: Test_Runtime { self.value3 = value3 } init(from decoder: any Decoder) throws { + var errors: [any Error] = [] do { let container = try decoder.singleValueContainer() - value1 = try? container.decode(Foundation.Date.self) + value1 = try container.decode(Foundation.Date.self) + } catch { + errors.append(error) } do { let container = try decoder.singleValueContainer() - value2 = try? container.decode(SimpleEnum.self) + value2 = try container.decode(SimpleEnum.self) + } catch { + errors.append(error) } do { let container = try decoder.singleValueContainer() - value3 = try? container.decode(TrivialStruct.self) + value3 = try container.decode(TrivialStruct.self) + } catch { + errors.append(error) } try DecodingError.verifyAtLeastOneSchemaIsNotNil( [value1, value2, value3], type: Self.self, - codingPath: decoder.codingPath + codingPath: decoder.codingPath, + errors: errors ) } func encode(to encoder: any Encoder) throws { From f6085a3b33dc5267becb13b7217f23f1c891091d Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 31 Oct 2023 15:55:52 +0100 Subject: [PATCH 07/79] Apply the same .swift-format as the generator repo (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same .swift-format as the generator repo ### Motivation Bring the runtime library formatting in line with the generator repo. Also fixes https://github.com/apple/swift-openapi-generator/issues/250. ### Modifications Copied the `.swift-format` file, reran swift-format. Only formatting changes. ### Result Consistent style. ### Test Plan CI. Reviewed by: gjcairo Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/68 --- .swift-format | 9 +- Sources/OpenAPIRuntime/Base/Acceptable.swift | 38 +-- .../Base/Base64EncodedData.swift | 4 +- .../OpenAPIRuntime/Base/CopyOnWriteBox.swift | 62 +--- .../OpenAPIRuntime/Base/OpenAPIMIMEType.swift | 127 ++------ .../OpenAPIRuntime/Base/OpenAPIValue.swift | 165 +++------- .../Base/WarningSuppressingAnnotations.swift | 8 +- .../Conversion/CodableExtensions.swift | 115 ++----- .../Conversion/Configuration.swift | 23 +- .../Conversion/Converter+Client.swift | 68 +--- .../Conversion/Converter+Common.swift | 118 ++----- .../Conversion/Converter+Server.swift | 109 ++----- .../OpenAPIRuntime/Conversion/Converter.swift | 7 +- .../Conversion/CurrencyExtensions.swift | 160 ++-------- .../Conversion/ErrorExtensions.swift | 39 +-- .../Conversion/FoundationExtensions.swift | 4 +- .../Conversion/ParameterStyles.swift | 13 +- .../Conversion/ServerVariable.swift | 13 +- .../Conversion/URLExtensions.swift | 8 +- .../Deprecated/Deprecated.swift | 47 +-- .../OpenAPIRuntime/Errors/ClientError.swift | 4 +- .../OpenAPIRuntime/Errors/CodingErrors.swift | 21 +- .../OpenAPIRuntime/Errors/RuntimeError.swift | 74 ++--- .../OpenAPIRuntime/Errors/ServerError.swift | 4 +- .../Interface/ClientTransport.swift | 9 +- .../Interface/CurrencyTypes.swift | 53 +--- .../OpenAPIRuntime/Interface/HTTPBody.swift | 240 +++----------- .../Interface/UniversalClient.swift | 28 +- .../Interface/UniversalServer.swift | 58 +--- .../URICoder/Common/URIEncodedNode.swift | 33 +- .../URICoder/Decoding/URIDecoder.swift | 41 +-- .../URIValueFromNodeDecoder+Keyed.swift | 179 +++-------- .../URIValueFromNodeDecoder+Single.swift | 145 +++------ .../URIValueFromNodeDecoder+Unkeyed.swift | 179 +++-------- .../Decoding/URIValueFromNodeDecoder.swift | 116 ++----- .../URICoder/Encoding/URIEncoder.swift | 22 +- .../URIValueToNodeEncoder+Keyed.swift | 138 +++----- .../URIValueToNodeEncoder+Single.swift | 112 ++----- .../URIValueToNodeEncoder+Unkeyed.swift | 141 +++------ .../Encoding/URIValueToNodeEncoder.swift | 32 +- .../URICoder/Parsing/URIParser.swift | 78 ++--- .../Serialization/URISerializer.swift | 122 ++----- .../Base/Test_Acceptable.swift | 48 +-- .../Base/Test_CopyOnWriteBox.swift | 53 +--- .../Base/Test_OpenAPIMIMEType.swift | 82 ++--- .../Base/Test_OpenAPIValue.swift | 44 +-- .../Conversion/Test_CodableExtensions.swift | 59 +--- .../Conversion/Test_Converter+Client.swift | 98 +----- .../Conversion/Test_Converter+Common.swift | 298 +++--------------- .../Conversion/Test_Converter+Server.swift | 95 ++---- .../Conversion/Test_ServerVariable.swift | 21 +- .../Interface/Test_HTTPBody.swift | 107 ++----- .../Interface/Test_UniversalClient.swift | 97 ++---- .../Interface/Test_UniversalServer.swift | 57 +--- Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 124 ++------ .../URICoder/Decoder/Test_URIDecoder.swift | 52 +-- .../Test_URIValueFromNodeDecoder.swift | 87 +---- .../URICoder/Encoding/Test_URIEncoder.swift | 9 +- .../Encoding/Test_URIValueToNodeEncoder.swift | 174 ++-------- .../URICoder/Parsing/Test_URIParser.swift | 95 ++---- .../Serialization/Test_URISerializer.swift | 61 +--- .../URICoder/Test_URICodingRoundtrip.swift | 137 ++------ 62 files changed, 1107 insertions(+), 3657 deletions(-) diff --git a/.swift-format b/.swift-format index 7efc7847..3213ba65 100644 --- a/.swift-format +++ b/.swift-format @@ -14,10 +14,11 @@ "lineLength" : 120, "maximumBlankLines" : 1, "prioritizeKeepingFunctionOutputTogether" : false, - "respectsExistingLineBreaks" : true, + "respectsExistingLineBreaks" : false, "rules" : { "AllPublicDeclarationsHaveDocumentation" : true, "AlwaysUseLowerCamelCase" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : true, "AmbiguousTrailingClosureOverload" : true, "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, @@ -38,16 +39,18 @@ "NoLeadingUnderscores" : false, "NoParensAroundConditions" : true, "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : true, "OneCasePerLine" : true, "OneVariableDeclarationPerLine" : true, "OnlyOneTrailingClosureArgument" : true, "OrderedImports" : false, + "ReplaceForEachWithForLoop" : true, "ReturnVoidInsteadOfEmptyTuple" : true, - "UseEarlyExits" : true, + "UseEarlyExits" : false, "UseLetInEveryBoundCaseVariable" : false, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : false, - "UseSynthesizedInitializer" : false, + "UseSynthesizedInitializer" : true, "UseTripleSlashForDocumentationComments" : true, "UseWhereClausesInForLoops" : false, "ValidateDocumentationComments" : true diff --git a/Sources/OpenAPIRuntime/Base/Acceptable.swift b/Sources/OpenAPIRuntime/Base/Acceptable.swift index 217d9f7f..fb19799f 100644 --- a/Sources/OpenAPIRuntime/Base/Acceptable.swift +++ b/Sources/OpenAPIRuntime/Base/Acceptable.swift @@ -29,9 +29,7 @@ public struct QualityValue: Sendable, Hashable { /// Returns a Boolean value indicating whether the quality value is /// at its default value 1.0. - public var isDefault: Bool { - thousands == 1000 - } + public var isDefault: Bool { thousands == 1000 } /// Creates a new quality value from the provided floating-point number. /// @@ -46,9 +44,7 @@ public struct QualityValue: Sendable, Hashable { } /// The value represented as a floating-point number between 0.0 and 1.0, inclusive. - public var doubleValue: Double { - Double(thousands) / 1000 - } + public var doubleValue: Double { Double(thousands) / 1000 } } extension QualityValue: RawRepresentable { @@ -56,16 +52,12 @@ extension QualityValue: RawRepresentable { /// /// - Parameter rawValue: A string representing the quality value. public init?(rawValue: String) { - guard let doubleValue = Double(rawValue) else { - return nil - } + guard let doubleValue = Double(rawValue) else { return nil } self.init(doubleValue: doubleValue) } /// The raw string representation of the `QualityValue`. - public var rawValue: String { - String(format: "%0.3f", doubleValue) - } + public var rawValue: String { String(format: "%0.3f", doubleValue) } } extension QualityValue: ExpressibleByIntegerLiteral { @@ -86,18 +78,14 @@ extension QualityValue: ExpressibleByFloatLiteral { /// Creates a new `QualityValue` instance from a floating-point literal value. /// /// - Parameter value: A floating-point literal value representing the quality value. - public init(floatLiteral value: Double) { - self.init(doubleValue: value) - } + public init(floatLiteral value: Double) { self.init(doubleValue: value) } } extension Array { /// Returns the default values for the acceptable type. public static func defaultValues() -> [AcceptHeaderContentType] - where Element == AcceptHeaderContentType { - T.allCases.map { .init(contentType: $0) } - } + where Element == AcceptHeaderContentType { T.allCases.map { .init(contentType: $0) } } } /// A wrapper of an individual content type in the accept header. @@ -129,9 +117,7 @@ public struct AcceptHeaderContentType: Sendable /// Returns the default set of acceptable content types for this type, in /// the order specified in the OpenAPI document. - public static var defaultValues: [Self] { - ContentType.allCases.map { .init(contentType: $0) } - } + public static var defaultValues: [Self] { ContentType.allCases.map { .init(contentType: $0) } } } extension AcceptHeaderContentType: RawRepresentable { @@ -161,18 +147,12 @@ extension AcceptHeaderContentType: RawRepresentable { } /// The raw representation of the content negotiation as a MIME type string. - public var rawValue: String { - contentType.rawValue + (quality.isDefault ? "" : "; q=\(quality.rawValue)") - } + public var rawValue: String { contentType.rawValue + (quality.isDefault ? "" : "; q=\(quality.rawValue)") } } extension Array { /// Returns the array sorted by the quality value, highest quality first. public func sortedByQuality() -> [AcceptHeaderContentType] - where Element == AcceptHeaderContentType { - sorted { a, b in - a.quality.doubleValue > b.quality.doubleValue - } - } + where Element == AcceptHeaderContentType { sorted { a, b in a.quality.doubleValue > b.quality.doubleValue } } } diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index 3dab18e6..f5116c38 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -56,9 +56,7 @@ public struct Base64EncodedData: Sendable, Hashable { /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. /// - Parameter data: The underlying bytes to wrap. - public init(data: ArraySlice) { - self.data = data - } + public init(data: ArraySlice) { self.data = data } } extension Base64EncodedData: Codable { diff --git a/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift index cbfc8f01..58de90e0 100644 --- a/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift +++ b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift @@ -16,46 +16,31 @@ /// /// It also enables recursive types by introducing a "box" into the cycle, which /// allows the owning type to have a finite size. -@_spi(Generated) -public struct CopyOnWriteBox { +@_spi(Generated) public struct CopyOnWriteBox { /// The reference type storage for the box. - @usableFromInline - internal final class Storage { + @usableFromInline internal final class Storage { /// The stored value. - @usableFromInline - var value: Wrapped + @usableFromInline var value: Wrapped /// Creates a new storage with the provided initial value. /// - Parameter value: The initial value to store in the box. - @inlinable - init(value: Wrapped) { - self.value = value - } + @inlinable init(value: Wrapped) { self.value = value } } /// The internal storage of the box. - @usableFromInline - internal var storage: Storage + @usableFromInline internal var storage: Storage /// Creates a new box. /// - Parameter value: The value to store in the box. - @inlinable - public init(value: Wrapped) { - self.storage = .init(value: value) - } + @inlinable public init(value: Wrapped) { self.storage = .init(value: value) } /// The stored value whose accessors enforce copy-on-write semantics. - @inlinable - public var value: Wrapped { - get { - storage.value - } + @inlinable public var value: Wrapped { + get { storage.value } _modify { - if !isKnownUniquelyReferenced(&storage) { - storage = Storage(value: storage.value) - } + if !isKnownUniquelyReferenced(&storage) { storage = Storage(value: storage.value) } yield &storage.value } } @@ -73,10 +58,7 @@ extension CopyOnWriteBox: Encodable where Wrapped: Encodable { /// /// - Parameter encoder: The encoder to write data to. /// - Throws: On an encoding error. - @inlinable - public func encode(to encoder: any Encoder) throws { - try value.encode(to: encoder) - } + @inlinable public func encode(to encoder: any Encoder) throws { try value.encode(to: encoder) } } extension CopyOnWriteBox: Decodable where Wrapped: Decodable { @@ -88,8 +70,7 @@ extension CopyOnWriteBox: Decodable where Wrapped: Decodable { /// /// - Parameter decoder: The decoder to read data from. /// - Throws: On a decoding error. - @inlinable - public init(from decoder: any Decoder) throws { + @inlinable public init(from decoder: any Decoder) throws { let value = try Wrapped(from: decoder) self.init(value: value) } @@ -106,11 +87,7 @@ extension CopyOnWriteBox: Equatable where Wrapped: Equatable { /// - lhs: A value to compare. /// - rhs: Another value to compare. /// - Returns: A Boolean value indicating whether the values are equal. - @inlinable - public static func == ( - lhs: CopyOnWriteBox, - rhs: CopyOnWriteBox - ) -> Bool { + @inlinable public static func == (lhs: CopyOnWriteBox, rhs: CopyOnWriteBox) -> Bool { lhs.value == rhs.value } } @@ -132,10 +109,7 @@ extension CopyOnWriteBox: Hashable where Wrapped: Hashable { /// /// - Parameter hasher: The hasher to use when combining the components /// of this instance. - @inlinable - public func hash(into hasher: inout Hasher) { - hasher.combine(value) - } + @inlinable public func hash(into hasher: inout Hasher) { hasher.combine(value) } } extension CopyOnWriteBox: CustomStringConvertible where Wrapped: CustomStringConvertible { @@ -163,10 +137,7 @@ extension CopyOnWriteBox: CustomStringConvertible where Wrapped: CustomStringCon /// /// The conversion of `p` to a string in the assignment to `s` uses the /// `Point` type's `description` property. - @inlinable - public var description: String { - value.description - } + @inlinable public var description: String { value.description } } extension CopyOnWriteBox: CustomDebugStringConvertible where Wrapped: CustomDebugStringConvertible { @@ -194,10 +165,7 @@ extension CopyOnWriteBox: CustomDebugStringConvertible where Wrapped: CustomDebu /// /// The conversion of `p` to a string in the assignment to `s` uses the /// `Point` type's `debugDescription` property. - @inlinable - public var debugDescription: String { - value.debugDescription - } + @inlinable public var debugDescription: String { value.debugDescription } } extension CopyOnWriteBox: @unchecked Sendable where Wrapped: Sendable {} diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 95390a7f..6dc2a730 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -14,8 +14,7 @@ import Foundation /// A container for a parsed, valid MIME type. -@_spi(Generated) -public struct OpenAPIMIMEType: Equatable { +@_spi(Generated) public struct OpenAPIMIMEType: Equatable { /// The kind of the MIME type. public enum Kind: Equatable { @@ -38,15 +37,12 @@ public struct OpenAPIMIMEType: Equatable { /// - Returns: `true` if the MIME type kinds are equal, otherwise `false`. public static func == (lhs: Kind, rhs: Kind) -> Bool { switch (lhs, rhs) { - case (.any, .any): - return true - case let (.anySubtype(lhsType), .anySubtype(rhsType)): - return lhsType.lowercased() == rhsType.lowercased() + case (.any, .any): return true + case let (.anySubtype(lhsType), .anySubtype(rhsType)): return lhsType.lowercased() == rhsType.lowercased() case let (.concrete(lhsType, lhsSubtype), .concrete(rhsType, rhsSubtype)): return lhsType.lowercased() == rhsType.lowercased() && lhsSubtype.lowercased() == rhsSubtype.lowercased() - default: - return false + default: return false } } } @@ -74,26 +70,14 @@ public struct OpenAPIMIMEType: Equatable { /// /// - Returns: `true` if the MIME types are equal, otherwise `false`. public static func == (lhs: OpenAPIMIMEType, rhs: OpenAPIMIMEType) -> Bool { - guard lhs.kind == rhs.kind else { - return false - } + guard lhs.kind == rhs.kind else { return false } // Parameter names are case-insensitive, parameter values are // case-sensitive. - guard lhs.parameters.count == rhs.parameters.count else { - return false - } - if lhs.parameters.isEmpty { - return true - } - func normalizeKeyValue(key: String, value: String) -> (String, String) { - (key.lowercased(), value) - } - let normalizedLeftParams = Dictionary( - uniqueKeysWithValues: lhs.parameters.map(normalizeKeyValue) - ) - let normalizedRightParams = Dictionary( - uniqueKeysWithValues: rhs.parameters.map(normalizeKeyValue) - ) + guard lhs.parameters.count == rhs.parameters.count else { return false } + if lhs.parameters.isEmpty { return true } + func normalizeKeyValue(key: String, value: String) -> (String, String) { (key.lowercased(), value) } + let normalizedLeftParams = Dictionary(uniqueKeysWithValues: lhs.parameters.map(normalizeKeyValue)) + let normalizedRightParams = Dictionary(uniqueKeysWithValues: rhs.parameters.map(normalizeKeyValue)) return normalizedLeftParams == normalizedRightParams } } @@ -103,35 +87,23 @@ extension OpenAPIMIMEType.Kind: LosslessStringConvertible { /// /// - Parameter description: A string description of the MIME type kind. public init?(_ description: String) { - let typeAndSubtype = - description - .split(separator: "/") - .map(String.init) - guard typeAndSubtype.count == 2 else { - return nil - } + let typeAndSubtype = description.split(separator: "/").map(String.init) + guard typeAndSubtype.count == 2 else { return nil } switch (typeAndSubtype[0], typeAndSubtype[1]) { case ("*", let subtype): - guard subtype == "*" else { - return nil - } + guard subtype == "*" else { return nil } self = .any - case (let type, "*"): - self = .anySubtype(type: type) - case (let type, let subtype): - self = .concrete(type: type, subtype: subtype) + case (let type, "*"): self = .anySubtype(type: type) + case (let type, let subtype): self = .concrete(type: type, subtype: subtype) } } /// A textual representation of the MIME type kind. public var description: String { switch self { - case .any: - return "*/*" - case .anySubtype(let type): - return "\(type)/*" - case .concrete(let type, let subtype): - return "\(type)/\(subtype)" + case .any: return "*/*" + case .anySubtype(let type): return "\(type)/*" + case .concrete(let type, let subtype): return "\(type)/\(subtype)" } } } @@ -142,32 +114,18 @@ extension OpenAPIMIMEType: LosslessStringConvertible { /// - Parameter description: A string description of the MIME. public init?(_ description: String) { var components = - description - // Split by semicolon - .split(separator: ";") - .map(String.init) - // Trim leading/trailing spaces + description // Split by semicolon + .split(separator: ";").map(String.init) // Trim leading/trailing spaces .map { $0.trimmingLeadingAndTrailingSpaces } - guard !components.isEmpty else { - return nil - } + guard !components.isEmpty else { return nil } let firstComponent = components.removeFirst() - guard let kind = OpenAPIMIMEType.Kind(firstComponent) else { - return nil - } + guard let kind = OpenAPIMIMEType.Kind(firstComponent) else { return nil } func parseParameter(_ string: String) -> (String, String)? { - let components = - string - .split(separator: "=") - .map(String.init) - guard components.count == 2 else { - return nil - } + let components = string.split(separator: "=").map(String.init) + guard components.count == 2 else { return nil } return (components[0], components[1]) } - let parameters = - components - .compactMap(parseParameter) + let parameters = components.compactMap(parseParameter) self.init( kind: kind, parameters: Dictionary( @@ -180,10 +138,7 @@ extension OpenAPIMIMEType: LosslessStringConvertible { /// A string description of the MIME type. public var description: String { - ([kind.description] - + parameters - .sorted(by: { a, b in a.key < b.key }) - .map { "\($0)=\($1)" }) + ([kind.description] + parameters.sorted(by: { a, b in a.key < b.key }).map { "\($0)=\($1)" }) .joined(separator: "; ") } } @@ -225,14 +180,10 @@ extension OpenAPIMIMEType { /// the closer the types are. var score: Int { switch self { - case .incompatible: - return 0 - case .wildcard: - return 1 - case .subtypeWildcard: - return 2 - case .typeAndSubtype(let matchedParameterCount): - return 3 + matchedParameterCount + case .incompatible: return 0 + case .wildcard: return 1 + case .subtypeWildcard: return 2 + case .typeAndSubtype(let matchedParameterCount): return 3 + matchedParameterCount } } } @@ -251,20 +202,15 @@ extension OpenAPIMIMEType { against option: OpenAPIMIMEType ) -> Match { switch option.kind { - case .any: - return .wildcard + case .any: return .wildcard case .anySubtype(let expectedType): - guard receivedType.lowercased() == expectedType.lowercased() else { - return .incompatible(.type) - } + guard receivedType.lowercased() == expectedType.lowercased() else { return .incompatible(.type) } return .subtypeWildcard case .concrete(let expectedType, let expectedSubtype): guard receivedType.lowercased() == expectedType.lowercased() && receivedSubtype.lowercased() == expectedSubtype.lowercased() - else { - return .incompatible(.subtype) - } + else { return .incompatible(.subtype) } // A full concrete match, so also check parameters. // The rule is: @@ -287,12 +233,9 @@ extension OpenAPIMIMEType { var matchedParameterCount = 0 for optionParameter in option.parameters { let normalizedParameterName = optionParameter.key.lowercased() - guard - let receivedValue = receivedNormalizedParameters[normalizedParameterName], + guard let receivedValue = receivedNormalizedParameters[normalizedParameterName], receivedValue == optionParameter.value - else { - return .incompatible(.parameter(name: normalizedParameterName)) - } + else { return .incompatible(.parameter(name: normalizedParameterName)) } matchedParameterCount += 1 } return .typeAndSubtype(matchedParameterCount: matchedParameterCount) diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index d7b395b8..0d39a6c4 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -41,9 +41,7 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// Creates a new container with the given validated value. /// - Parameter value: A value of a JSON-compatible type, such as `String`, /// `[Any]`, and `[String: Any]`. - init(validatedValue value: (any Sendable)?) { - self.value = value - } + init(validatedValue value: (any Sendable)?) { self.value = value } /// Creates a new container with the given unvalidated value. /// @@ -63,24 +61,13 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Returns: A cast value if supported. /// - Throws: When the value is not supported. static func tryCast(_ value: (any Sendable)?) throws -> (any Sendable)? { - guard let value = value else { - return nil - } - 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 - } + guard let value = value else { return nil } + 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 } throw EncodingError.invalidValue( value, - .init( - codingPath: [], - debugDescription: "Type '\(type(of: value))' is not a supported OpenAPI value." - ) + .init(codingPath: [], debugDescription: "Type '\(type(of: value))' is not a supported OpenAPI value.") ) } @@ -89,10 +76,8 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Returns: A cast value if supported, nil otherwise. static func tryCastPrimitiveType(_ value: any Sendable) -> (any Sendable)? { switch value { - case is String, is Int, is Bool, is Double: - return value - default: - return nil + case is String, is Int, is Bool, is Double: return value + default: return nil } } @@ -139,14 +124,10 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { return } 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: 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 [(any Sendable)?]: try container.encode(value.map(OpenAPIValueContainer.init(validatedValue:))) case let value as [String: (any Sendable)?]: @@ -169,47 +150,30 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Returns: `true` if the two instances are equal, `false` otherwise. public static func == (lhs: OpenAPIValueContainer, rhs: OpenAPIValueContainer) -> Bool { switch (lhs.value, rhs.value) { - case (nil, nil), is (Void, Void): - return true - case let (lhs as Bool, rhs as Bool): - return lhs == rhs - case let (lhs as Int, rhs as Int): - return lhs == rhs - case let (lhs as Int64, rhs as Int64): - return lhs == rhs - case let (lhs as Int32, rhs as Int32): - return lhs == rhs - case let (lhs as Float, rhs as Float): - return lhs == rhs - case let (lhs as Double, rhs as Double): - return lhs == rhs - case let (lhs as String, rhs as String): - return lhs == rhs + case (nil, nil), is (Void, Void): return true + case let (lhs as Bool, rhs as Bool): return lhs == rhs + case let (lhs as Int, rhs as Int): return lhs == rhs + case let (lhs as Int64, rhs as Int64): return lhs == rhs + case let (lhs as Int32, rhs as Int32): return lhs == rhs + case let (lhs as Float, rhs as Float): return lhs == rhs + case let (lhs as Double, rhs as Double): return lhs == rhs + case let (lhs as String, rhs as String): return lhs == rhs case let (lhs as [(any Sendable)?], rhs as [(any Sendable)?]): - guard lhs.count == rhs.count else { - return false - } + guard lhs.count == rhs.count else { return false } return zip(lhs, rhs) .allSatisfy { lhs, rhs in OpenAPIValueContainer(validatedValue: lhs) == OpenAPIValueContainer(validatedValue: rhs) } case let (lhs as [String: (any Sendable)?], rhs as [String: (any Sendable)?]): - guard lhs.count == rhs.count else { - return false - } - guard Set(lhs.keys) == Set(rhs.keys) else { - return false - } + guard lhs.count == rhs.count else { return false } + guard Set(lhs.keys) == Set(rhs.keys) else { return false } for key in lhs.keys { guard OpenAPIValueContainer(validatedValue: lhs[key]!) == OpenAPIValueContainer(validatedValue: rhs[key]!) - else { - return false - } + else { return false } } return true - default: - return false + default: return false } } @@ -220,25 +184,18 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Parameter hasher: The hasher used to compute the hash value. public func hash(into hasher: inout Hasher) { switch value { - case let value as Bool: - hasher.combine(value) - case let value as Int: - hasher.combine(value) - case let value as Double: - hasher.combine(value) - case let value as String: - hasher.combine(value) + case let value as Bool: hasher.combine(value) + case let value as Int: hasher.combine(value) + case let value as Double: hasher.combine(value) + case let value as String: hasher.combine(value) case let value as [(any Sendable)?]: - for item in value { - hasher.combine(OpenAPIValueContainer(validatedValue: item)) - } + for item in value { hasher.combine(OpenAPIValueContainer(validatedValue: item)) } case let value as [String: (any Sendable)?]: for (key, itemValue) in value { hasher.combine(key) hasher.combine(OpenAPIValueContainer(validatedValue: itemValue)) } - default: - break + default: break } } } @@ -247,45 +204,35 @@ extension OpenAPIValueContainer: ExpressibleByBooleanLiteral { /// Creates an `OpenAPIValueContainer` with the provided boolean value. /// /// - Parameter value: The boolean value to store in the container. - public init(booleanLiteral value: BooleanLiteralType) { - self.init(validatedValue: value) - } + public init(booleanLiteral value: BooleanLiteralType) { self.init(validatedValue: value) } } extension OpenAPIValueContainer: ExpressibleByStringLiteral { /// Creates an `OpenAPIValueContainer` with the provided string value. /// /// - Parameter value: The string value to store in the container. - public init(stringLiteral value: String) { - self.init(validatedValue: value) - } + public init(stringLiteral value: String) { self.init(validatedValue: value) } } extension OpenAPIValueContainer: ExpressibleByNilLiteral { /// Creates an `OpenAPIValueContainer` with a `nil` value. /// /// - Parameter nilLiteral: The `nil` literal. - public init(nilLiteral: ()) { - self.init(validatedValue: nil) - } + public init(nilLiteral: ()) { self.init(validatedValue: nil) } } extension OpenAPIValueContainer: ExpressibleByIntegerLiteral { /// Creates an `OpenAPIValueContainer` with the provided integer value. /// /// - Parameter value: The integer value to store in the container. - public init(integerLiteral value: Int) { - self.init(validatedValue: value) - } + public init(integerLiteral value: Int) { self.init(validatedValue: value) } } extension OpenAPIValueContainer: ExpressibleByFloatLiteral { /// Creates an `OpenAPIValueContainer` with the provided floating-point value. /// /// - Parameter value: The floating-point value to store in the container. - public init(floatLiteral value: Double) { - self.init(validatedValue: value) - } + public init(floatLiteral value: Double) { self.init(validatedValue: value) } } /// A container for a dictionary with values represented by JSON Schema. @@ -317,14 +264,10 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { /// Creates a new container with the given validated dictionary. /// - Parameter value: A dictionary value. - init(validatedValue value: [String: (any Sendable)?]) { - self.value = value - } + init(validatedValue value: [String: (any Sendable)?]) { self.value = value } /// Creates a new empty container. - public init() { - self.init(validatedValue: [:]) - } + public init() { self.init(validatedValue: [:]) } /// Creates a new container with the given unvalidated value. /// @@ -344,7 +287,7 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { /// - Returns: A cast dictionary if values are supported. /// - Throws: If an unsupported value is found. static func tryCast(_ value: [String: (any Sendable)?]) throws -> [String: (any Sendable)?] { - return try value.mapValues(OpenAPIValueContainer.tryCast(_:)) + try value.mapValues(OpenAPIValueContainer.tryCast(_:)) } // MARK: Decodable @@ -382,17 +325,11 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { public static func == (lhs: OpenAPIObjectContainer, rhs: OpenAPIObjectContainer) -> Bool { let lv = lhs.value let rv = rhs.value - guard lv.count == rv.count else { - return false - } - guard Set(lv.keys) == Set(rv.keys) else { - return false - } + guard lv.count == rv.count else { return false } + guard Set(lv.keys) == Set(rv.keys) else { return false } for key in lv.keys { guard OpenAPIValueContainer(validatedValue: lv[key]!) == OpenAPIValueContainer(validatedValue: rv[key]!) - else { - return false - } + else { return false } } return true } @@ -439,14 +376,10 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { /// Creates a new container with the given validated array. /// - Parameter value: An array value. - init(validatedValue value: [(any Sendable)?]) { - self.value = value - } + init(validatedValue value: [(any Sendable)?]) { self.value = value } /// Creates a new empty container. - public init() { - self.init(validatedValue: []) - } + public init() { self.init(validatedValue: []) } /// Creates a new container with the given unvalidated value. /// @@ -466,7 +399,7 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { /// - Returns: A cast value if values are supported, nil otherwise. /// - Throws: An error if casting to supported values fails for any element. static func tryCast(_ value: [(any Sendable)?]) throws -> [(any Sendable)?] { - return try value.map(OpenAPIValueContainer.tryCast(_:)) + try value.map(OpenAPIValueContainer.tryCast(_:)) } // MARK: Decodable @@ -503,9 +436,7 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { public static func == (lhs: OpenAPIArrayContainer, rhs: OpenAPIArrayContainer) -> Bool { let lv = lhs.value let rv = rhs.value - guard lv.count == rv.count else { - return false - } + guard lv.count == rv.count else { return false } return zip(lv, rv) .allSatisfy { lhs, rhs in OpenAPIValueContainer(validatedValue: lhs) == OpenAPIValueContainer(validatedValue: rhs) @@ -518,8 +449,6 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { /// /// - Parameter hasher: The hasher used to compute the hash value. public func hash(into hasher: inout Hasher) { - for item in value { - hasher.combine(OpenAPIValueContainer(validatedValue: item)) - } + for item in value { hasher.combine(OpenAPIValueContainer(validatedValue: item)) } } } diff --git a/Sources/OpenAPIRuntime/Base/WarningSuppressingAnnotations.swift b/Sources/OpenAPIRuntime/Base/WarningSuppressingAnnotations.swift index d40d07d2..f3eeb6f1 100644 --- a/Sources/OpenAPIRuntime/Base/WarningSuppressingAnnotations.swift +++ b/Sources/OpenAPIRuntime/Base/WarningSuppressingAnnotations.swift @@ -24,9 +24,7 @@ /// There should be no runtime impact in release builds, as the function is inlined and /// has no executable code. /// - Parameter value: The value for which you want to suppress "variable was never mutated, change to let" warnings. -@_spi(Generated) -@inline(__always) -public func suppressMutabilityWarning(_ value: inout T) {} +@_spi(Generated) @inline(__always) public func suppressMutabilityWarning(_ value: inout T) {} /// Suppress "variable unused" warnings. /// @@ -40,6 +38,4 @@ public func suppressMutabilityWarning(_ value: inout T) {} /// There should be no runtime impact in release builds, as the function is inlined and /// has no executable code. /// - Parameter value: The value for which you want to suppress "variable unused" warnings. -@_spi(Generated) -@inline(__always) -public func suppressUnusedWarning(_ value: T) {} +@_spi(Generated) @inline(__always) public func suppressUnusedWarning(_ value: T) {} diff --git a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift index ef35e83a..5aa893bf 100644 --- a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift @@ -12,8 +12,7 @@ // //===----------------------------------------------------------------------===// -@_spi(Generated) -extension Decoder { +@_spi(Generated) extension Decoder { // MARK: - Coding SPI @@ -22,9 +21,7 @@ extension Decoder { /// - Throws: When at least one undocumented key is found. /// - Parameter knownKeys: A set of known and already decoded keys. public func ensureNoAdditionalProperties(knownKeys: Set) throws { - let (unknownKeys, container) = try unknownKeysAndContainer( - knownKeys: knownKeys - ) + let (unknownKeys, container) = try unknownKeysAndContainer(knownKeys: knownKeys) guard unknownKeys.isEmpty else { let key = unknownKeys.sorted().first! throw DecodingError.dataCorruptedError( @@ -43,28 +40,13 @@ extension Decoder { /// - Parameter knownKeys: Known and already decoded keys. /// - Returns: A container with the decoded undocumented properties. /// - Throws: An error if decoding additional properties fails. - public func decodeAdditionalProperties( - knownKeys: Set - ) throws -> OpenAPIObjectContainer { - let (unknownKeys, container) = try unknownKeysAndContainer( - knownKeys: knownKeys - ) - guard !unknownKeys.isEmpty else { - return .init() - } + public func decodeAdditionalProperties(knownKeys: Set) throws -> OpenAPIObjectContainer { + let (unknownKeys, container) = try unknownKeysAndContainer(knownKeys: knownKeys) + guard !unknownKeys.isEmpty else { return .init() } let keyValuePairs: [(String, (any Sendable)?)] = try unknownKeys.map { key in - ( - key.stringValue, - try container.decode( - OpenAPIValueContainer.self, - forKey: key - ) - .value - ) + (key.stringValue, try container.decode(OpenAPIValueContainer.self, forKey: key).value) } - return .init( - validatedValue: Dictionary(uniqueKeysWithValues: keyValuePairs) - ) + return .init(validatedValue: Dictionary(uniqueKeysWithValues: keyValuePairs)) } /// Returns decoded additional properties. @@ -74,17 +56,11 @@ extension Decoder { /// - Parameter knownKeys: Known and already decoded keys. /// - Returns: A container with the decoded undocumented properties. /// - Throws: An error if there are issues with decoding the additional properties. - public func decodeAdditionalProperties( - knownKeys: Set - ) throws -> [String: T] { - let (unknownKeys, container) = try unknownKeysAndContainer( - knownKeys: knownKeys - ) - guard !unknownKeys.isEmpty else { - return .init() - } + public func decodeAdditionalProperties(knownKeys: Set) throws -> [String: T] { + let (unknownKeys, container) = try unknownKeysAndContainer(knownKeys: knownKeys) + guard !unknownKeys.isEmpty else { return .init() } let keyValuePairs: [(String, T)] = try unknownKeys.compactMap { key in - return (key.stringValue, try container.decode(T.self, forKey: key)) + (key.stringValue, try container.decode(T.self, forKey: key)) } return .init(uniqueKeysWithValues: keyValuePairs) } @@ -93,9 +69,7 @@ extension Decoder { /// - Parameter type: The type to decode. /// - Returns: The decoded value. /// - Throws: An error if there are issues with decoding the value from the single value container. - public func decodeFromSingleValueContainer( - _ type: T.Type = T.self - ) throws -> T { + public func decodeFromSingleValueContainer(_ type: T.Type = T.self) throws -> T { let container = try singleValueContainer() return try container.decode(T.self) } @@ -111,36 +85,27 @@ extension Decoder { /// for further decoding of the unknown properties. /// - Throws: An error if there are issues with creating the decoding container or identifying /// the unknown keys. - private func unknownKeysAndContainer( - knownKeys: Set - ) throws -> (Set, KeyedDecodingContainer) { + private func unknownKeysAndContainer(knownKeys: Set) throws -> ( + Set, KeyedDecodingContainer + ) { let container = try container(keyedBy: StringKey.self) - let unknownKeys = Set(container.allKeys) - .subtracting(knownKeys.map(StringKey.init(_:))) + let unknownKeys = Set(container.allKeys).subtracting(knownKeys.map(StringKey.init(_:))) return (unknownKeys, container) } } -@_spi(Generated) -extension Encoder { +@_spi(Generated) extension Encoder { /// Encodes additional properties into the encoder. /// /// The properties are encoded directly into the encoder, rather that /// into a nested container. /// - 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 - } + 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) - ) + try container.encode(OpenAPIValueContainer(unvalidatedValue: value), forKey: .init(key)) } } @@ -150,24 +115,16 @@ extension Encoder { /// into a nested container. /// - 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 - } + 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)) - } + for (key, value) in additionalProperties { try container.encode(value, forKey: .init(key)) } } /// Encodes the value into the encoder using a single value container. /// - Parameter value: The value to encode. /// - Throws: An error if there are issues with encoding the value. - public func encodeToSingleValueContainer( - _ value: T - ) throws { + public func encodeToSingleValueContainer(_ value: T) throws { var container = singleValueContainer() try container.encode(value) } @@ -176,9 +133,7 @@ extension Encoder { /// the encoder using a single value container. /// - Parameter values: An array of optional values. /// - Throws: An error if there are issues with encoding the value. - public func encodeFirstNonNilValueToSingleValueContainer( - _ values: [(any Encodable)?] - ) throws { + public func encodeFirstNonNilValueToSingleValueContainer(_ values: [(any Encodable)?]) throws { for value in values { if let value { try encodeToSingleValueContainer(value) @@ -192,23 +147,13 @@ extension Encoder { private struct StringKey: CodingKey, Hashable, Comparable { var stringValue: String - var intValue: Int? { - Int(stringValue) - } + var intValue: Int? { Int(stringValue) } - init(_ string: String) { - self.stringValue = string - } + init(_ string: String) { self.stringValue = string } - init?(stringValue: String) { - self.stringValue = stringValue - } + init?(stringValue: String) { self.stringValue = stringValue } - init?(intValue: Int) { - self.stringValue = String(intValue) - } + init?(intValue: Int) { self.stringValue = String(intValue) } - static func < (lhs: StringKey, rhs: StringKey) -> Bool { - lhs.stringValue < rhs.stringValue - } + static func < (lhs: StringKey, rhs: StringKey) -> Bool { lhs.stringValue < rhs.stringValue } } diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 439b424b..93b00f32 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -28,18 +28,13 @@ public protocol DateTranscoder: Sendable { public struct ISO8601DateTranscoder: DateTranscoder { /// Creates and returns an ISO 8601 formatted string representation of the specified date. - public func encode(_ date: Date) throws -> String { - ISO8601DateFormatter().string(from: date) - } + public func encode(_ date: Date) throws -> String { ISO8601DateFormatter().string(from: date) } /// Creates and returns a date object from the specified ISO 8601 formatted string representation. public func decode(_ dateString: String) throws -> Date { guard let date = ISO8601DateFormatter().date(from: dateString) else { throw DecodingError.dataCorrupted( - .init( - codingPath: [], - debugDescription: "Expected date string to be ISO8601-formatted." - ) + .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.") ) } return date @@ -48,15 +43,13 @@ public struct ISO8601DateTranscoder: DateTranscoder { extension DateTranscoder where Self == ISO8601DateTranscoder { /// A transcoder that transcodes dates as ISO-8601–formatted string (in RFC 3339 format). - public static var iso8601: Self { - ISO8601DateTranscoder() - } + public static var iso8601: Self { ISO8601DateTranscoder() } } extension JSONEncoder.DateEncodingStrategy { /// Encode the `Date` as a custom value encoded using the given ``DateTranscoder``. static func from(dateTranscoder: any DateTranscoder) -> Self { - return .custom { date, encoder in + .custom { date, encoder in let dateAsString = try dateTranscoder.encode(date) var container = encoder.singleValueContainer() try container.encode(dateAsString) @@ -67,7 +60,7 @@ extension JSONEncoder.DateEncodingStrategy { extension JSONDecoder.DateDecodingStrategy { /// Decode the `Date` as a custom value decoded by the given ``DateTranscoder``. static func from(dateTranscoder: any DateTranscoder) -> Self { - return .custom { decoder in + .custom { decoder in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) return try dateTranscoder.decode(dateString) @@ -85,9 +78,5 @@ public struct Configuration: Sendable { /// /// - Parameter dateTranscoder: The transcoder to use when converting between date /// and string values. - public init( - dateTranscoder: any DateTranscoder = .iso8601 - ) { - self.dateTranscoder = dateTranscoder - } + public init(dateTranscoder: any DateTranscoder = .iso8601) { self.dateTranscoder = dateTranscoder } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 4c6950b2..4b723cac 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -23,9 +23,7 @@ extension Converter { public func setAcceptHeader( in headerFields: inout HTTPFields, contentTypes: [AcceptHeaderContentType] - ) { - headerFields[.accept] = contentTypes.map(\.rawValue).joined(separator: ", ") - } + ) { headerFields[.accept] = contentTypes.map(\.rawValue).joined(separator: ", ") } /// Renders the path template with the specified parameters to construct a URI. /// @@ -36,10 +34,7 @@ extension Converter { /// - Returns: A URI path string with placeholders replaced by the provided parameters. /// /// - Throws: An error if rendering the path fails. - public func renderedPath( - template: String, - parameters: [any Encodable] - ) throws -> String { + public func renderedPath(template: String, parameters: [any Encodable]) throws -> String { var renderedString = template let encoder = URIEncoder( configuration: .init( @@ -52,11 +47,7 @@ extension Converter { for parameter in parameters { let value = try encoder.encode(parameter, forKey: "") if let range = renderedString.range(of: "{}") { - renderedString = renderedString.replacingOccurrences( - of: "{}", - with: value, - range: range - ) + renderedString = renderedString.replacingOccurrences(of: "{}", with: value, range: range) } } return renderedString @@ -86,13 +77,7 @@ extension Converter { name: name, value: value, convert: { value, style, explode in - try convertToURI( - style: style, - explode: explode, - inBody: false, - key: name, - value: value - ) + try convertToURI(style: style, explode: explode, inBody: false, key: name, value: value) } ) } @@ -153,18 +138,9 @@ extension Converter { /// - Returns: An `HTTPBody` representing the binary request body, or `nil` if the `value` is `nil`. /// /// - Throws: An error if setting the request body as binary fails. - public func setOptionalRequestBodyAsBinary( - _ value: HTTPBody?, - headerFields: inout HTTPFields, - contentType: String - ) throws -> HTTPBody? { - try setOptionalRequestBody( - value, - headerFields: &headerFields, - contentType: contentType, - convert: { $0 } - ) - } + public func setOptionalRequestBodyAsBinary(_ value: HTTPBody?, headerFields: inout HTTPFields, contentType: String) + throws -> HTTPBody? + { try setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } /// Sets a required request body as binary in the specified header fields and returns an `HTTPBody`. /// @@ -176,18 +152,9 @@ extension Converter { /// - Returns: An `HTTPBody` representing the binary request body. /// /// - Throws: An error if setting the request body as binary fails. - public func setRequiredRequestBodyAsBinary( - _ value: HTTPBody, - headerFields: inout HTTPFields, - contentType: String - ) throws -> HTTPBody { - try setRequiredRequestBody( - value, - headerFields: &headerFields, - contentType: contentType, - convert: { $0 } - ) - } + public func setRequiredRequestBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) + throws -> HTTPBody + { try setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } /// Sets an optional request body as URL-encoded form data in the specified header fields and returns an `HTTPBody`. /// @@ -250,9 +217,7 @@ extension Converter { from data: HTTPBody?, transforming transform: (T) -> C ) async throws -> C { - guard let data else { - throw RuntimeError.missingRequiredResponseBody - } + guard let data else { throw RuntimeError.missingRequiredResponseBody } return try await getBufferingResponseBody( type, from: data, @@ -276,14 +241,7 @@ extension Converter { from data: HTTPBody?, transforming transform: (HTTPBody) -> C ) throws -> C { - guard let data else { - throw RuntimeError.missingRequiredResponseBody - } - return try getResponseBody( - type, - from: data, - transforming: transform, - convert: { $0 } - ) + guard let data else { throw RuntimeError.missingRequiredResponseBody } + return try getResponseBody(type, from: data, transforming: transform, convert: { $0 }) } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index a9c27f9c..a7f8e979 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -23,9 +23,7 @@ extension Converter { /// type header. /// - Returns: The content type value, or nil if not found or invalid. public func extractContentTypeIfPresent(in headerFields: HTTPFields) -> OpenAPIMIMEType? { - guard let rawValue = headerFields[.contentType] else { - return nil - } + guard let rawValue = headerFields[.contentType] else { return nil } return OpenAPIMIMEType(rawValue) } @@ -37,15 +35,9 @@ extension Converter { /// - Returns: The most appropriate option. /// - Throws: If none of the options match the received content type. /// - Precondition: `options` must not be empty. - public func bestContentType( - received: OpenAPIMIMEType?, - options: [String] - ) throws -> String { + public func bestContentType(received: OpenAPIMIMEType?, options: [String]) throws -> String { precondition(!options.isEmpty, "bestContentType options must not be empty.") - guard - let received, - case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind - else { + guard let received, case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else { // If none received or if we received a wildcard, use the first one. // This behavior isn't well defined by the OpenAPI specification. // Note: We treat a partial wildcard, like `image/*` as a full @@ -66,13 +58,10 @@ extension Converter { ) return (contentType: stringOption, match: match) } - let bestOption = evaluatedOptions.max { a, b in - a.match.score < b.match.score - }! // Safe, we only get here if the array is not empty. + // 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(bestContentType) } return bestContentType } @@ -85,27 +74,13 @@ extension Converter { /// - name: The name of the header field. /// - value: The optional value to be encoded as a URI component if not nil. /// - Throws: An error if there's an issue with encoding the value as a URI component. - public func setHeaderFieldAsURI( - in headerFields: inout HTTPFields, - name: String, - value: T? - ) throws { - guard let value else { - return - } + public func setHeaderFieldAsURI(in headerFields: inout HTTPFields, name: String, value: T?) throws { + guard let value else { return } try setHeaderField( in: &headerFields, name: name, value: value, - convert: { value in - try convertToURI( - style: .simple, - explode: false, - inBody: false, - key: "", - value: value - ) - } + convert: { value in try convertToURI(style: .simple, explode: false, inBody: false, key: "", value: value) } ) } @@ -116,17 +91,8 @@ extension Converter { /// - name: The name of the header field. /// - value: The optional value to be encoded as a JSON component if not nil. /// - Throws: An error if there's an issue with encoding the value as a JSON component. - public func setHeaderFieldAsJSON( - in headerFields: inout HTTPFields, - name: String, - value: T? - ) throws { - try setHeaderField( - in: &headerFields, - name: name, - value: value, - convert: convertHeaderFieldCodableToJSON - ) + public func setHeaderFieldAsJSON(in headerFields: inout HTTPFields, name: String, value: T?) throws { + try setHeaderField(in: &headerFields, name: name, value: value, convert: convertHeaderFieldCodableToJSON) } /// Attempts to retrieve an optional header field value and decodes it as a URI component, returning it as the specified type. @@ -138,23 +104,15 @@ extension Converter { /// - Returns: The decoded header field value as the specified type, or `nil` if the field is not present. /// - Throws: An error if there's an issue with decoding the URI component or /// if the field is present but cannot be decoded as the specified type. - public func getOptionalHeaderFieldAsURI( - in headerFields: HTTPFields, - name: String, - as type: T.Type - ) throws -> T? { + public func getOptionalHeaderFieldAsURI(in headerFields: HTTPFields, name: String, as type: T.Type) + throws -> T? + { try getOptionalHeaderField( in: headerFields, name: name, as: type, convert: { encodedValue in - try convertFromURI( - style: .simple, - explode: false, - inBody: false, - key: "", - encodedValue: encodedValue - ) + try convertFromURI(style: .simple, explode: false, inBody: false, key: "", encodedValue: encodedValue) } ) } @@ -168,23 +126,15 @@ extension Converter { /// - Returns: The decoded header field value as the specified type. /// - Throws: An error if the field is not present or if there's an issue with decoding the URI component or /// if the field is present but cannot be decoded as the specified type. - public func getRequiredHeaderFieldAsURI( - in headerFields: HTTPFields, - name: String, - as type: T.Type - ) throws -> T { + public func getRequiredHeaderFieldAsURI(in headerFields: HTTPFields, name: String, as type: T.Type) + throws -> T + { try getRequiredHeaderField( in: headerFields, name: name, as: type, convert: { encodedValue in - try convertFromURI( - style: .simple, - explode: false, - inBody: false, - key: "", - encodedValue: encodedValue - ) + try convertFromURI(style: .simple, explode: false, inBody: false, key: "", encodedValue: encodedValue) } ) } @@ -198,18 +148,9 @@ extension Converter { /// - Returns: The decoded header field value as the specified type, or /// `nil` if the field is not present in the headerFields dictionary. /// - Throws: An error if there's an issue with decoding the JSON value or if the field is present but cannot be decoded as the specified type. - public func getOptionalHeaderFieldAsJSON( - in headerFields: HTTPFields, - name: String, - as type: T.Type - ) throws -> T? { - try getOptionalHeaderField( - in: headerFields, - name: name, - as: type, - convert: convertJSONToHeaderFieldCodable - ) - } + public func getOptionalHeaderFieldAsJSON(in headerFields: HTTPFields, name: String, as type: T.Type) + throws -> T? + { try getOptionalHeaderField(in: headerFields, name: name, as: type, convert: convertJSONToHeaderFieldCodable) } /// Retrieves a required header field value and decodes it as JSON, returning it as the specified type. /// @@ -220,16 +161,7 @@ extension Converter { /// - Returns: The decoded header field value as the specified type. /// - Throws: An error if the field is not present in the headerFields dictionary, if there's an issue with decoding the JSON value, /// or if the field cannot be decoded as the specified type. - public func getRequiredHeaderFieldAsJSON( - in headerFields: HTTPFields, - name: String, - as type: T.Type - ) throws -> T { - try getRequiredHeaderField( - in: headerFields, - name: name, - as: type, - convert: convertJSONToHeaderFieldCodable - ) - } + public func getRequiredHeaderFieldAsJSON(in headerFields: HTTPFields, name: String, as type: T.Type) + throws -> T + { try getRequiredHeaderField(in: headerFields, name: name, as: type, convert: convertJSONToHeaderFieldCodable) } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index ee84c71e..c354d4aa 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -24,17 +24,11 @@ extension Converter { /// - Returns: The parsed content types, or the default content types if /// the header was not provided. /// - Throws: An error if the "accept" header is present but malformed, or if there are issues parsing its components. - public func extractAcceptHeaderIfPresent( - in headerFields: HTTPFields - ) throws -> [AcceptHeaderContentType] { - guard let rawValue = headerFields[.accept] else { - return AcceptHeaderContentType.defaultValues - } - let rawComponents = - rawValue - .split(separator: ",") - .map(String.init) - .map(\.trimmingLeadingAndTrailingSpaces) + public func extractAcceptHeaderIfPresent(in headerFields: HTTPFields) throws + -> [AcceptHeaderContentType] + { + guard let rawValue = headerFields[.accept] else { return AcceptHeaderContentType.defaultValues } + let rawComponents = rawValue.split(separator: ",").map(String.init).map(\.trimmingLeadingAndTrailingSpaces) let parsedComponents = try rawComponents.map { rawComponent in guard let value = AcceptHeaderContentType(rawValue: rawComponent) else { throw RuntimeError.malformedAcceptHeader(rawComponent) @@ -52,39 +46,21 @@ extension Converter { /// Also supports wildcars, such as "application/\*" and "\*/\*". /// - Throws: An error if the "Accept" header is present but incompatible with the provided content type, /// or if there are issues parsing the header. - public func validateAcceptIfPresent( - _ substring: String, - in headerFields: HTTPFields - ) throws { + public func validateAcceptIfPresent(_ substring: String, in headerFields: HTTPFields) throws { // for example: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8 - guard let acceptHeader = headerFields[.accept] else { - return - } + guard let acceptHeader = headerFields[.accept] else { return } // Split with commas to get the individual values - let acceptValues = - acceptHeader - .split(separator: ",") + let acceptValues = acceptHeader.split(separator: ",") .map { value in // Drop everything after the optional semicolon (q, extensions, ...) - value - .split(separator: ";")[0] - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() + 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 - } + 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 } throw RuntimeError.unexpectedAcceptHeader(acceptHeader) } @@ -114,11 +90,7 @@ extension Converter { dateTranscoder: configuration.dateTranscoder ) ) - let value = try decoder.decode( - T.self, - forKey: name, - from: encodedString - ) + let value = try decoder.decode(T.self, forKey: name, from: encodedString) return value } ) @@ -156,11 +128,7 @@ extension Converter { dateTranscoder: configuration.dateTranscoder ) ) - let value = try decoder.decodeIfPresent( - T.self, - forKey: name, - from: query - ) + let value = try decoder.decodeIfPresent(T.self, forKey: name, from: query) return value } ) @@ -198,11 +166,7 @@ extension Converter { dateTranscoder: configuration.dateTranscoder ) ) - let value = try decoder.decode( - T.self, - forKey: name, - from: query - ) + let value = try decoder.decode(T.self, forKey: name, from: query) return value } ) @@ -262,14 +226,7 @@ extension Converter { _ type: HTTPBody.Type, from data: HTTPBody?, transforming transform: (HTTPBody) -> C - ) throws -> C? { - try getOptionalRequestBody( - type, - from: data, - transforming: transform, - convert: { $0 } - ) - } + ) throws -> C? { try getOptionalRequestBody(type, from: data, transforming: transform, convert: { $0 }) } /// Retrieves and transforms a required binary request body. /// @@ -283,14 +240,7 @@ extension Converter { _ type: HTTPBody.Type, from data: HTTPBody?, transforming transform: (HTTPBody) -> C - ) throws -> C { - try getRequiredRequestBody( - type, - from: data, - transforming: transform, - convert: { $0 } - ) - } + ) throws -> C { try getRequiredRequestBody(type, from: data, transforming: transform, convert: { $0 }) } /// Retrieves and transforms an optional URL-encoded form request body. /// @@ -342,11 +292,9 @@ extension Converter { /// - contentType: The content type to set in the HTTP header fields. /// - Returns: An `HTTPBody` with the response body set as JSON data. /// - Throws: An error if serialization or setting the response body fails. - public func setResponseBodyAsJSON( - _ value: T, - headerFields: inout HTTPFields, - contentType: String - ) throws -> HTTPBody { + public func setResponseBodyAsJSON(_ value: T, headerFields: inout HTTPFields, contentType: String) + throws -> HTTPBody + { try setResponseBody( value, headerFields: &headerFields, @@ -363,16 +311,7 @@ extension Converter { /// - contentType: The content type to set in the header fields. /// - Returns: The updated `HTTPBody` containing the binary response data. /// - Throws: An error if there are issues setting the response body or updating the header fields. - public func setResponseBodyAsBinary( - _ value: HTTPBody, - headerFields: inout HTTPFields, - contentType: String - ) throws -> HTTPBody { - try setResponseBody( - value, - headerFields: &headerFields, - contentType: contentType, - convert: { $0 } - ) - } + public func setResponseBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws + -> HTTPBody + { try setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter.swift b/Sources/OpenAPIRuntime/Conversion/Converter.swift index 2cf78d99..bd7566b9 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter.swift @@ -19,8 +19,7 @@ import Foundation #endif /// Converter between generated and HTTP currency types. -@_spi(Generated) -public struct Converter: Sendable { +@_spi(Generated) public struct Converter: Sendable { /// Configuration used to set up the converter. public let configuration: Configuration @@ -35,9 +34,7 @@ public struct Converter: Sendable { internal var headerFieldEncoder: JSONEncoder /// Creates a new converter with the behavior specified by the configuration. - public init( - configuration: Configuration - ) { + public init(configuration: Configuration) { self.configuration = configuration self.encoder = JSONEncoder() diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 3b7a7d41..55765921 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -24,11 +24,9 @@ extension ParameterStyle { /// - explode: The provided explode value, if any. /// - Throws: For an unsupported input combination. /// - Returns: A tuple of the style and explode values. - static func resolvedQueryStyleAndExplode( - name: String, - style: ParameterStyle?, - explode: Bool? - ) throws -> (ParameterStyle, Bool) { + static func resolvedQueryStyleAndExplode(name: String, style: ParameterStyle?, explode: Bool?) throws -> ( + ParameterStyle, Bool + ) { let resolvedStyle = style ?? .defaultForQueryItems let resolvedExplode = explode ?? ParameterStyle.defaultExplodeFor(forStyle: resolvedStyle) guard resolvedStyle == .form else { @@ -49,9 +47,7 @@ extension HTTPField.Name { /// - Parameter name: A field name. /// - Throws: If the name isn't a valid field name. init(validated name: String) throws { - guard let fieldName = Self(name) else { - throw RuntimeError.invalidHeaderFieldName(name) - } + guard let fieldName = Self(name) else { throw RuntimeError.invalidHeaderFieldName(name) } self = fieldName } } @@ -61,9 +57,7 @@ extension HTTPRequest { /// Returns the path of the request, and throws an error if it's nil. var requiredPath: Substring { get throws { - guard let path else { - throw RuntimeError.pathUnset - } + guard let path else { throw RuntimeError.pathUnset } return path[...] } } @@ -81,11 +75,7 @@ extension Converter { /// used for encoding a body URI. Specify `false` if used for a query, /// header, and so on. /// - Returns: A new URI coder configuration. - func uriCoderConfiguration( - style: ParameterStyle, - explode: Bool, - inBody: Bool - ) -> URICoderConfiguration { + func uriCoderConfiguration(style: ParameterStyle, explode: Bool, inBody: Bool) -> URICoderConfiguration { .init( style: .init(style), explode: explode, @@ -105,20 +95,10 @@ extension Converter { /// - value: The value to be encoded. /// - Returns: A URI encoded string. /// - Throws: An error if encoding fails. - func convertToURI( - style: ParameterStyle, - explode: Bool, - inBody: Bool, - key: String, - value: T - ) throws -> String { - let encoder = URIEncoder( - configuration: uriCoderConfiguration( - style: style, - explode: explode, - inBody: inBody - ) - ) + func convertToURI(style: ParameterStyle, explode: Bool, inBody: Bool, key: String, value: T) throws + -> String + { + let encoder = URIEncoder(configuration: uriCoderConfiguration(style: style, explode: explode, inBody: inBody)) let encodedString = try encoder.encode(value, forKey: key) return encodedString } @@ -141,18 +121,8 @@ extension Converter { key: String, encodedValue: Substring ) throws -> T { - let decoder = URIDecoder( - configuration: uriCoderConfiguration( - style: style, - explode: explode, - inBody: inBody - ) - ) - let value = try decoder.decode( - T.self, - forKey: key, - from: encodedValue - ) + let decoder = URIDecoder(configuration: uriCoderConfiguration(style: style, explode: explode, inBody: inBody)) + let value = try decoder.decode(T.self, forKey: key, from: encodedValue) return value } @@ -160,9 +130,7 @@ extension Converter { /// - Parameter body: The body containing the raw JSON bytes. /// - Returns: A decoded value. /// - Throws: An error if decoding from the body fails. - func convertJSONToBodyCodable( - _ body: HTTPBody - ) async throws -> T { + func convertJSONToBodyCodable(_ body: HTTPBody) async throws -> T { let data = try await Data(collecting: body, upTo: .max) return try decoder.decode(T.self, from: data) } @@ -171,9 +139,7 @@ extension Converter { /// - Parameter value: The value to encode as JSON. /// - Returns: The raw JSON body. /// - Throws: An error if encoding to JSON fails. - func convertBodyCodableToJSON( - _ value: T - ) throws -> HTTPBody { + func convertBodyCodableToJSON(_ value: T) throws -> HTTPBody { let data = try encoder.encode(value) return HTTPBody(data) } @@ -182,9 +148,7 @@ extension Converter { /// - Parameter body: The body containing the raw URL-encoded form bytes. /// - Returns: A decoded value. /// - Throws: An error if decoding from the URL-encoded form fails. - func convertURLEncodedFormToCodable( - _ body: HTTPBody - ) async throws -> T { + func convertURLEncodedFormToCodable(_ body: HTTPBody) async throws -> T { let decoder = URIDecoder( configuration: .init( style: .form, @@ -202,9 +166,7 @@ extension Converter { /// - Parameter value: The value to encode. /// - Returns: The raw URL-encoded form body. /// - Throws: An error if encoding to URL-encoded form fails. - func convertBodyCodableToURLFormData( - _ value: T - ) throws -> HTTPBody { + func convertBodyCodableToURLFormData(_ value: T) throws -> HTTPBody { let encoder = URIEncoder( configuration: .init( style: .form, @@ -221,9 +183,7 @@ extension Converter { /// - Parameter value: The value to encode. /// - Returns: A JSON string. /// - Throws: An error if encoding the value to JSON fails. - func convertHeaderFieldCodableToJSON( - _ value: T - ) throws -> String { + func convertHeaderFieldCodableToJSON(_ value: T) throws -> String { let data = try headerFieldEncoder.encode(value) let stringValue = String(decoding: data, as: UTF8.self) return stringValue @@ -233,9 +193,7 @@ extension Converter { /// - Parameter stringValue: A JSON string. /// - Returns: The decoded value. /// - Throws: An error if decoding from the JSON string fails. - func convertJSONToHeaderFieldCodable( - _ stringValue: Substring - ) throws -> T { + func convertJSONToHeaderFieldCodable(_ stringValue: Substring) throws -> T { let data = Data(stringValue.utf8) return try decoder.decode(T.self, from: data) } @@ -249,21 +207,11 @@ extension Converter { /// - value: The value of the header to set. /// - convert: The closure used to serialize the header value to string. /// - Throws: An error if an issue occurs while serializing the header value. - func setHeaderField( - in headerFields: inout HTTPFields, - name: String, - value: T?, - convert: (T) throws -> String - ) throws { - guard let value else { - return - } - try headerFields.append( - .init( - name: .init(validated: name), - value: convert(value) - ) - ) + func setHeaderField(in headerFields: inout HTTPFields, name: String, value: T?, convert: (T) throws -> String) + throws + { + guard let value else { return } + try headerFields.append(.init(name: .init(validated: name), value: convert(value))) } /// Returns the value of the header with the provided name from the provided @@ -273,10 +221,7 @@ extension Converter { /// - name: The name of the header field. /// - Returns: The value of the header field, if found. Nil otherwise. /// - Throws: An error if an issue occurs while retrieving the header value. - func getHeaderFieldValuesString( - in headerFields: HTTPFields, - name: String - ) throws -> String? { + func getHeaderFieldValuesString(in headerFields: HTTPFields, name: String) throws -> String? { try headerFields[.init(validated: name)] } @@ -294,14 +239,7 @@ extension Converter { as type: T.Type, convert: (Substring) throws -> T ) throws -> T? { - guard - let stringValue = try getHeaderFieldValuesString( - in: headerFields, - name: name - ) - else { - return nil - } + guard let stringValue = try getHeaderFieldValuesString(in: headerFields, name: name) else { return nil } return try convert(stringValue[...]) } @@ -320,12 +258,7 @@ extension Converter { as type: T.Type, convert: (Substring) throws -> T ) throws -> T { - guard - let stringValue = try getHeaderFieldValuesString( - in: headerFields, - name: name - ) - else { + guard let stringValue = try getHeaderFieldValuesString(in: headerFields, name: name) else { throw RuntimeError.missingRequiredHeaderField(name) } return try convert(stringValue[...]) @@ -349,9 +282,7 @@ extension Converter { value: T?, convert: (T, ParameterStyle, Bool) throws -> String ) throws { - guard let value else { - return - } + guard let value else { return } let (resolvedStyle, resolvedExplode) = try ParameterStyle.resolvedQueryStyleAndExplode( name: name, style: style, @@ -403,9 +334,7 @@ extension Converter { as type: T.Type, convert: (Substring, ParameterStyle, Bool) throws -> T? ) throws -> T? { - guard let query, !query.isEmpty else { - return nil - } + guard let query, !query.isEmpty else { return nil } let (resolvedStyle, resolvedExplode) = try ParameterStyle.resolvedQueryStyleAndExplode( name: name, style: style, @@ -442,9 +371,7 @@ extension Converter { as: type, convert: convert ) - else { - throw RuntimeError.missingRequiredQueryParameter(name) - } + else { throw RuntimeError.missingRequiredQueryParameter(name) } return value } @@ -482,9 +409,7 @@ extension Converter { contentType: String, convert: (T) throws -> HTTPBody ) throws -> HTTPBody? { - guard let value else { - return nil - } + guard let value else { return nil } return try setRequiredRequestBody( value, headerFields: &headerFields, @@ -507,9 +432,7 @@ extension Converter { transforming transform: (T) -> C, convert: (HTTPBody) async throws -> T ) async throws -> C? { - guard let body else { - return nil - } + guard let body else { return nil } let decoded = try await convert(body) return transform(decoded) } @@ -535,9 +458,7 @@ extension Converter { transforming: transform, convert: convert ) - else { - throw RuntimeError.missingRequiredRequestBody - } + else { throw RuntimeError.missingRequiredRequestBody } return body } @@ -555,9 +476,7 @@ extension Converter { transforming transform: (T) -> C, convert: (HTTPBody) throws -> T ) throws -> C? { - guard let body else { - return nil - } + guard let body else { return nil } let decoded = try convert(body) return transform(decoded) } @@ -576,14 +495,7 @@ extension Converter { transforming transform: (T) -> C, convert: (HTTPBody) throws -> T ) throws -> C { - guard - let body = try getOptionalRequestBody( - type, - from: body, - transforming: transform, - convert: convert - ) - else { + guard let body = try getOptionalRequestBody(type, from: body, transforming: transform, convert: convert) else { throw RuntimeError.missingRequiredRequestBody } return body @@ -660,9 +572,7 @@ extension Converter { as type: T.Type, convert: (Substring) throws -> T ) throws -> T { - guard let untypedValue = pathParameters[name] else { - throw RuntimeError.missingRequiredPathParameter(name) - } + guard let untypedValue = pathParameters[name] else { throw RuntimeError.missingRequiredPathParameter(name) } return try convert(untypedValue) } } diff --git a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift index 20b7a76a..9d21513c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift @@ -24,11 +24,7 @@ extension DecodingError { /// the type. /// - errors: The errors encountered when decoding individual cases. /// - Returns: A decoding error. - static func failedToDecodeAnySchema( - type: Any.Type, - codingPath: [any CodingKey], - errors: [any Error] - ) -> Self { + static func failedToDecodeAnySchema(type: Any.Type, codingPath: [any CodingKey], errors: [any Error]) -> Self { DecodingError.valueNotFound( type, DecodingError.Context.init( @@ -48,8 +44,7 @@ extension DecodingError { /// the type. /// - errors: The errors encountered when decoding individual cases. /// - Returns: A decoding error. - @_spi(Generated) - public static func failedToDecodeOneOfSchema( + @_spi(Generated) public static func failedToDecodeOneOfSchema( type: Any.Type, codingPath: [any CodingKey], errors: [any Error] @@ -72,13 +67,12 @@ extension DecodingError { /// - codingPath: The coding path to the decoder that attempted to decode /// the type, with the discriminator value as the last component. /// - Returns: A decoding error. - @_spi(Generated) - public static func unknownOneOfDiscriminator( + @_spi(Generated) public static func unknownOneOfDiscriminator( discriminatorKey: any CodingKey, discriminatorValue: String, codingPath: [any CodingKey] ) -> Self { - return DecodingError.keyNotFound( + DecodingError.keyNotFound( discriminatorKey, DecodingError.Context.init( codingPath: codingPath, @@ -98,19 +92,14 @@ extension DecodingError { /// the type. /// - errors: The errors encountered when decoding individual cases. /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. - @_spi(Generated) - public static func verifyAtLeastOneSchemaIsNotNil( + @_spi(Generated) public static func verifyAtLeastOneSchemaIsNotNil( _ values: [Any?], type: Any.Type, codingPath: [any CodingKey], errors: [any Error] ) throws { guard values.contains(where: { $0 != nil }) else { - throw DecodingError.failedToDecodeAnySchema( - type: type, - codingPath: codingPath, - errors: errors - ) + throw DecodingError.failedToDecodeAnySchema(type: type, codingPath: codingPath, errors: errors) } } } @@ -124,21 +113,13 @@ 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 - } + errors.map { error in + guard let error = error as? (any PrettyStringConvertible) else { return error.localizedDescription } return error.prettyDescription } - .enumerated() - .map { ($0.offset + 1, $0.element) } - .map { "Error \($0.0): [\($0.1)]" } - .joined(separator: ", ") + .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? { description } } diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 0d39d580..8f0117b3 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -17,7 +17,5 @@ extension String { /// Returns the string with leading and trailing whitespace (such as spaces /// and newlines) removed. - var trimmingLeadingAndTrailingSpaces: Self { - trimmingCharacters(in: .whitespacesAndNewlines) - } + var trimmingLeadingAndTrailingSpaces: Self { trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift index 032fa425..fb95bce7 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -15,8 +15,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 -@_spi(Generated) -public enum ParameterStyle: Sendable { +@_spi(Generated) public enum ParameterStyle: Sendable { /// The form style. /// @@ -46,18 +45,14 @@ extension ParameterStyle { /// Returns the default value of the explode field for the given style /// - Parameter style: The parameter style. /// - Returns: The explode value. - static func defaultExplodeFor(forStyle style: ParameterStyle) -> Bool { - style == .form - } + static func defaultExplodeFor(forStyle style: ParameterStyle) -> Bool { style == .form } } extension URICoderConfiguration.Style { init(_ style: ParameterStyle) { switch style { - case .form: - self = .form - case .simple: - self = .simple + case .form: self = .form + case .simple: self = .simple } } } diff --git a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift index 4a22853c..a9658afa 100644 --- a/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift +++ b/Sources/OpenAPIRuntime/Conversion/ServerVariable.swift @@ -22,11 +22,7 @@ extension URL { /// - variables: A map of variable values to substitute into the URL /// template. /// - Throws: If the provided string doesn't convert to URL. - @_spi(Generated) - public init( - validatingOpenAPIServerURL string: String, - variables: [ServerVariable] - ) throws { + @_spi(Generated) public init(validatingOpenAPIServerURL string: String, variables: [ServerVariable]) throws { var urlString = string for variable in variables { let name = variable.name @@ -42,16 +38,13 @@ extension URL { } urlString = urlString.replacingOccurrences(of: "{\(name)}", with: value) } - guard let url = Self(string: urlString) else { - throw RuntimeError.invalidServerURL(urlString) - } + guard let url = Self(string: urlString) else { throw RuntimeError.invalidServerURL(urlString) } self = url } } /// A variable of a server URL template in the OpenAPI document. -@_spi(Generated) -public struct ServerVariable: Sendable, Hashable { +@_spi(Generated) public struct ServerVariable: Sendable, Hashable { /// The name of the variable. public var name: String diff --git a/Sources/OpenAPIRuntime/Conversion/URLExtensions.swift b/Sources/OpenAPIRuntime/Conversion/URLExtensions.swift index 9c1a66d5..432d78ae 100644 --- a/Sources/OpenAPIRuntime/Conversion/URLExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/URLExtensions.swift @@ -18,9 +18,7 @@ extension URL { /// /// Specification: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields public static let defaultOpenAPIServerURL: Self = { - guard let url = URL(string: "/") else { - fatalError("Failed to create an URL with the string '/'.") - } + guard let url = URL(string: "/") else { fatalError("Failed to create an URL with the string '/'.") } return url }() @@ -28,9 +26,7 @@ extension URL { /// - Parameter string: A URL string. /// - Throws: If the provided string doesn't convert to URL. public init(validatingOpenAPIServerURL string: String) throws { - guard let url = Self(string: string) else { - throw RuntimeError.invalidServerURL(string) - } + guard let url = Self(string: string) else { throw RuntimeError.invalidServerURL(string) } self = url } } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 5de87792..323da60f 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -34,8 +34,7 @@ extension ClientError { renamed: "ClientError.init(operationID:operationInput:request:requestBody:baseURL:response:responseBody:causeDescription:underlyingError:)", message: "Use the initializer with a causeDescription parameter." - ) - public init( + ) public init( operationID: String, operationInput: any Sendable, request: HTTPRequest? = nil, @@ -76,8 +75,7 @@ extension ServerError { renamed: "ServerError.init(operationID:request:requestBody:requestMetadata:operationInput:operationOutput:causeDescription:underlyingError:)", message: "Use the initializer with a causeDescription parameter." - ) - public init( + ) public init( operationID: String, request: HTTPRequest, requestBody: HTTPBody?, @@ -104,8 +102,7 @@ extension Converter { /// received. /// - Parameter contentType: The content type that was received. /// - Returns: An error representing an unexpected content type. - @available(*, deprecated) - public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error { + @available(*, deprecated) public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error { RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "") } @@ -121,22 +118,17 @@ extension Converter { /// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type. /// - Returns: A Boolean value representing whether the concrete content /// type matches the expected one. - @available(*, deprecated) - public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws -> Bool { - guard let received else { - return false - } - guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else { - return false - } + @available(*, deprecated) public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws + -> Bool + { + guard let received else { return false } + guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else { return false } guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else { throw RuntimeError.invalidExpectedContentType(expectedRaw) } switch expectedContentType.kind { - case .any: - return true - case .anySubtype(let expectedType): - return receivedType.lowercased() == expectedType.lowercased() + case .any: return true + case .anySubtype(let expectedType): return receivedType.lowercased() == expectedType.lowercased() case .concrete(let expectedType, let expectedSubtype): return receivedType.lowercased() == expectedType.lowercased() && receivedSubtype.lowercased() == expectedSubtype.lowercased() @@ -153,9 +145,7 @@ extension DecodingError { /// - codingPath: The coding path to the decoder that attempted to decode /// the type. /// - Returns: A decoding error. - @_spi(Generated) - @available(*, deprecated) - public static func failedToDecodeOneOfSchema( + @_spi(Generated) @available(*, deprecated) public static func failedToDecodeOneOfSchema( type: Any.Type, codingPath: [any CodingKey] ) -> Self { @@ -176,11 +166,7 @@ extension DecodingError { /// - codingPath: The coding path to the decoder that attempted to decode /// the type. /// - Returns: A decoding error. - @available(*, deprecated) - static func failedToDecodeAnySchema( - type: Any.Type, - codingPath: [any CodingKey] - ) -> Self { + @available(*, deprecated) static func failedToDecodeAnySchema(type: Any.Type, codingPath: [any CodingKey]) -> Self { DecodingError.valueNotFound( type, DecodingError.Context.init( @@ -199,18 +185,13 @@ extension DecodingError { /// - codingPath: The coding path to the decoder that attempted to decode /// the type. /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. - @_spi(Generated) - @available(*, deprecated) - public static func verifyAtLeastOneSchemaIsNotNil( + @_spi(Generated) @available(*, deprecated) public static func verifyAtLeastOneSchemaIsNotNil( _ values: [Any?], type: Any.Type, codingPath: [any CodingKey] ) throws { guard values.contains(where: { $0 != nil }) else { - throw DecodingError.failedToDecodeAnySchema( - type: type, - codingPath: codingPath - ) + throw DecodingError.failedToDecodeAnySchema(type: type, codingPath: codingPath) } } } diff --git a/Sources/OpenAPIRuntime/Errors/ClientError.swift b/Sources/OpenAPIRuntime/Errors/ClientError.swift index b820bd4a..5a20f224 100644 --- a/Sources/OpenAPIRuntime/Errors/ClientError.swift +++ b/Sources/OpenAPIRuntime/Errors/ClientError.swift @@ -133,7 +133,5 @@ 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? { description } } diff --git a/Sources/OpenAPIRuntime/Errors/CodingErrors.swift b/Sources/OpenAPIRuntime/Errors/CodingErrors.swift index c31291d7..30a04c4c 100644 --- a/Sources/OpenAPIRuntime/Errors/CodingErrors.swift +++ b/Sources/OpenAPIRuntime/Errors/CodingErrors.swift @@ -17,16 +17,11 @@ extension DecodingError: PrettyStringConvertible { var prettyDescription: String { let output: String switch self { - case .dataCorrupted(let context): - output = "dataCorrupted - \(context.prettyDescription)" - 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)" + case .dataCorrupted(let context): output = "dataCorrupted - \(context.prettyDescription)" + 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)" } return "DecodingError: \(output)" } @@ -43,10 +38,8 @@ extension EncodingError: PrettyStringConvertible { var prettyDescription: String { let output: String switch self { - case .invalidValue(let value, let context): - output = "invalidValue \(value) - \(context.prettyDescription)" - @unknown default: - output = "unknown: \(localizedDescription)" + case .invalidValue(let value, let context): output = "invalidValue \(value) - \(context.prettyDescription)" + @unknown default: output = "unknown: \(localizedDescription)" } return "EncodingError: \(output)" } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 4fbab419..ffb39ab7 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -30,9 +30,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret enum ParameterLocation: String, CustomStringConvertible { case query - var description: String { - rawValue - } + var description: String { rawValue } } case unsupportedParameterStyle(name: String, location: ParameterLocation, style: ParameterStyle, explode: Bool) @@ -65,63 +63,41 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret /// A wrapped root cause error, if one was thrown by other code. var underlyingError: (any Error)? { switch self { - case .transportFailed(let error), - .handlerFailed(let error), - .middlewareFailed(_, let error): - return error - default: - return nil + case .transportFailed(let error), .handlerFailed(let error), .middlewareFailed(_, let error): return error + default: return nil } } // MARK: CustomStringConvertible - var description: String { - prettyDescription - } + var description: String { prettyDescription } var prettyDescription: String { switch self { - case .invalidServerURL(let string): - return "Invalid server URL: \(string)" + case .invalidServerURL(let string): return "Invalid server URL: \(string)" case .invalidServerVariableValue(name: let name, value: let value, allowedValues: let allowedValues): 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 .invalidHeaderFieldName(let name): - return "Invalid header field name: '\(name)'" + case .invalidExpectedContentType(let string): return "Invalid expected content type: '\(string)'" + case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'" case .invalidBase64String(let string): return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'" - case .failedToDecodeStringConvertibleValue(let string): - return "Failed to decode a value of type '\(string)'." + case .failedToDecodeStringConvertibleValue(let string): return "Failed to decode a value of type '\(string)'." case .unsupportedParameterStyle(name: let name, location: let location, style: let style, explode: let explode): 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 .unexpectedAcceptHeader(let accept): - return "Unexpected Accept header: \(accept)" - case .malformedAcceptHeader(let accept): - return "Malformed Accept header: \(accept)" - case .missingRequiredPathParameter(let name): - return "Missing required path parameter named: \(name)" - case .pathUnset: - return "Path was not set on the request." - case .missingRequiredQueryParameter(let name): - return "Missing required query parameter named: \(name)" - case .missingRequiredRequestBody: - return "Missing required request body" - case .missingRequiredResponseBody: - return "Missing required response body" - case .transportFailed: - return "Transport threw an error." - case .middlewareFailed(middlewareType: let type, _): - return "Middleware of type '\(type)' threw an error." - case .handlerFailed: - return "User handler threw an error." + case .missingRequiredHeaderField(let name): return "The required header field named '\(name)' is missing." + case .unexpectedContentTypeHeader(let contentType): return "Unexpected Content-Type header: \(contentType)" + case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" + case .malformedAcceptHeader(let accept): return "Malformed Accept header: \(accept)" + case .missingRequiredPathParameter(let name): return "Missing required path parameter named: \(name)" + case .pathUnset: return "Path was not set on the request." + case .missingRequiredQueryParameter(let name): return "Missing required query parameter named: \(name)" + case .missingRequiredRequestBody: return "Missing required request body" + case .missingRequiredResponseBody: return "Missing required response body" + case .transportFailed: return "Transport threw an error." + case .middlewareFailed(middlewareType: let type, _): return "Middleware of type '\(type)' threw an error." + case .handlerFailed: return "User handler threw an error." case .unexpectedResponseStatus(let expectedStatus, let response): return "Unexpected response, expected status code: \(expectedStatus), response: \(response)" case .unexpectedResponseBody(let expectedContentType, let body): @@ -136,10 +112,9 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret /// - expectedStatus: The expected HTTP response status as a string. /// - response: The HTTP response data. /// - Throws: An error indicating an unexpected response status. -@_spi(Generated) -public func throwUnexpectedResponseStatus(expectedStatus: String, response: any Sendable) throws -> Never { - throw RuntimeError.unexpectedResponseStatus(expectedStatus: expectedStatus, response: response) -} +@_spi(Generated) public func throwUnexpectedResponseStatus(expectedStatus: String, response: any Sendable) throws + -> Never +{ throw RuntimeError.unexpectedResponseStatus(expectedStatus: expectedStatus, response: response) } /// Throws an error to indicate an unexpected response body content. /// @@ -147,7 +122,6 @@ public func throwUnexpectedResponseStatus(expectedStatus: String, response: any /// - expectedContent: The expected content as a string. /// - body: The response body data. /// - Throws: An error indicating an unexpected response body content. -@_spi(Generated) -public func throwUnexpectedResponseBody(expectedContent: String, body: any Sendable) throws -> Never { +@_spi(Generated) public func throwUnexpectedResponseBody(expectedContent: String, body: any Sendable) throws -> Never { throw RuntimeError.unexpectedResponseBody(expectedContent: expectedContent, body: body) } diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 7595a890..92d0552e 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -106,7 +106,5 @@ 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? { description } } diff --git a/Sources/OpenAPIRuntime/Interface/ClientTransport.swift b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift index 5d66ff6b..3786bcea 100644 --- a/Sources/OpenAPIRuntime/Interface/ClientTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift @@ -137,12 +137,9 @@ public protocol ClientTransport: Sendable { /// - operationID: The identifier of the OpenAPI operation. /// - Returns: An HTTP response and its body. /// - Throws: An error if sending the request and receiving the response fails. - func send( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String - ) async throws -> (HTTPResponse, HTTPBody?) + func send(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) async throws -> ( + HTTPResponse, HTTPBody? + ) } /// A type that intercepts HTTP requests and responses. diff --git a/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift index 477c5b93..7093bd75 100644 --- a/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift +++ b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift @@ -24,11 +24,7 @@ public struct ServerRequestMetadata: Hashable, Sendable { /// Creates a new metadata wrapper with the specified path and query parameters. /// - Parameter pathParameters: Path parameters parsed from the URL of the HTTP /// request. - public init( - pathParameters: [String: Substring] = [:] - ) { - self.pathParameters = pathParameters - } + public init(pathParameters: [String: Substring] = [:]) { self.pathParameters = pathParameters } } extension HTTPRequest { @@ -38,31 +34,22 @@ extension HTTPRequest { /// - path: The URL path of the resource. /// - method: The HTTP method. /// - headerFields: The HTTP header fields. - @_spi(Generated) - public init(soar_path path: String, method: Method, headerFields: HTTPFields = .init()) { + @_spi(Generated) public init(soar_path path: String, method: Method, headerFields: HTTPFields = .init()) { self.init(method: method, scheme: nil, authority: nil, path: path, headerFields: headerFields) } /// The query substring of the request's path. - @_spi(Generated) - public var soar_query: Substring? { - guard let path else { - return nil - } - guard let queryStart = path.firstIndex(of: "?") else { - return nil - } + @_spi(Generated) public var soar_query: Substring? { + guard let path else { return nil } + guard let queryStart = path.firstIndex(of: "?") else { return nil } let queryEnd = path.firstIndex(of: "#") ?? path.endIndex let query = path[path.index(after: queryStart)..") [\(headerFields.prettyDescription)]" - } + var prettyDescription: String { "\(method.rawValue) \(path ?? "") [\(headerFields.prettyDescription)]" } } extension HTTPResponse: PrettyStringConvertible { - var prettyDescription: String { - "\(status.code) [\(headerFields.prettyDescription)]" - } + var prettyDescription: String { "\(status.code) [\(headerFields.prettyDescription)]" } } -extension HTTPBody: PrettyStringConvertible { - var prettyDescription: String { - String(describing: self) - } -} +extension HTTPBody: PrettyStringConvertible { var prettyDescription: String { String(describing: self) } } diff --git a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index cb7e53f1..b97906ba 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -171,9 +171,7 @@ public final class HTTPBody: @unchecked Sendable { /// used for testing. internal var testing_iteratorCreated: Bool { lock.lock() - defer { - lock.unlock() - } + defer { lock.unlock() } return locked_iteratorCreated } @@ -182,15 +180,9 @@ public final class HTTPBody: @unchecked Sendable { /// - 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() - } + 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 @@ -202,12 +194,8 @@ public final class HTTPBody: @unchecked Sendable { locked_iteratorCreated = true lock.unlock() } - guard iterationBehavior == .single else { - return - } - if locked_iteratorCreated { - throw TooManyIterationsError() - } + guard iterationBehavior == .single else { return } + if locked_iteratorCreated { throw TooManyIterationsError() } } /// Creates a new body. @@ -217,11 +205,7 @@ public final class HTTPBody: @unchecked Sendable { /// length of all the byte chunks. /// - iterationBehavior: The sequence's iteration behavior, which /// indicates whether the sequence can be iterated multiple times. - @usableFromInline init( - _ sequence: BodySequence, - length: Length, - iterationBehavior: IterationBehavior - ) { + @usableFromInline init(_ sequence: BodySequence, length: Length, iterationBehavior: IterationBehavior) { self.sequence = sequence self.length = length self.iterationBehavior = iterationBehavior @@ -255,21 +239,14 @@ extension HTTPBody: Equatable { /// /// - Returns: `true` if the object identifiers of the two HTTPBody instances are equal, /// indicating that they are the same object in memory; otherwise, returns `false`. - public static func == ( - lhs: HTTPBody, - rhs: HTTPBody - ) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } + public static func == (lhs: HTTPBody, rhs: HTTPBody) -> Bool { ObjectIdentifier(lhs) == ObjectIdentifier(rhs) } } extension HTTPBody: Hashable { /// Hashes the HTTPBody instance by combining its object identifier into the provided hasher. /// /// - Parameter hasher: The hasher used to combine the hash value. - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } + public func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } } // MARK: - Creating the HTTPBody @@ -278,29 +255,20 @@ extension HTTPBody { /// Creates a new empty body. @inlinable public convenience init() { - self.init( - .init(EmptySequence()), - length: .known(0), - iterationBehavior: .multiple - ) + self.init(.init(EmptySequence()), length: .known(0), iterationBehavior: .multiple) } /// Creates a new body with the provided byte chunk. /// - Parameters: /// - bytes: A byte chunk. /// - length: The total length of the body. - @inlinable public convenience init( - _ bytes: ByteChunk, - length: Length - ) { + @inlinable public convenience init(_ bytes: ByteChunk, length: Length) { self.init([bytes], length: length, iterationBehavior: .multiple) } /// Creates a new body with the provided byte chunk. /// - Parameter bytes: A byte chunk. - @inlinable public convenience init( - _ bytes: ByteChunk - ) { + @inlinable public convenience init(_ bytes: ByteChunk) { self.init([bytes], length: .known(bytes.count), iterationBehavior: .multiple) } @@ -314,34 +282,19 @@ extension HTTPBody { _ bytes: some Sequence & Sendable, length: Length, iterationBehavior: IterationBehavior - ) { - self.init( - [ArraySlice(bytes)], - length: length, - iterationBehavior: iterationBehavior - ) - } + ) { self.init([ArraySlice(bytes)], length: length, iterationBehavior: iterationBehavior) } /// Creates a new body with the provided byte collection. /// - Parameters: /// - bytes: A byte chunk. /// - length: The total length of the body. - @inlinable public convenience init( - _ bytes: some Collection & Sendable, - length: Length - ) { - self.init( - ArraySlice(bytes), - length: length, - iterationBehavior: .multiple - ) + @inlinable public convenience init(_ bytes: some Collection & Sendable, length: Length) { + self.init(ArraySlice(bytes), length: length, iterationBehavior: .multiple) } /// Creates a new body with the provided byte collection. /// - Parameter bytes: A byte chunk. - @inlinable public convenience init( - _ bytes: some Collection & Sendable - ) { + @inlinable public convenience init(_ bytes: some Collection & Sendable) { self.init(bytes, length: .known(bytes.count)) } @@ -349,30 +302,16 @@ extension HTTPBody { /// - Parameters: /// - stream: An async throwing stream that provides the byte chunks. /// - length: The total length of the body. - @inlinable public convenience init( - _ stream: AsyncThrowingStream, - length: HTTPBody.Length - ) { - self.init( - .init(stream), - length: length, - iterationBehavior: .single - ) + @inlinable public convenience init(_ stream: AsyncThrowingStream, length: HTTPBody.Length) { + self.init(.init(stream), length: length, iterationBehavior: .single) } /// Creates a new body with the provided async stream. /// - Parameters: /// - stream: An async stream that provides the byte chunks. /// - length: The total length of the body. - @inlinable public convenience init( - _ stream: AsyncStream, - length: HTTPBody.Length - ) { - self.init( - .init(stream), - length: length, - iterationBehavior: .single - ) + @inlinable public convenience init(_ stream: AsyncStream, length: HTTPBody.Length) { + self.init(.init(stream), length: length, iterationBehavior: .single) } /// Creates a new body with the provided async sequence. @@ -386,11 +325,7 @@ extension HTTPBody { length: HTTPBody.Length, iterationBehavior: IterationBehavior ) where Bytes.Element == ByteChunk, Bytes: Sendable { - self.init( - .init(sequence), - length: length, - iterationBehavior: iterationBehavior - ) + self.init(.init(sequence), length: length, iterationBehavior: iterationBehavior) } /// Creates a new body with the provided async sequence of byte sequences. @@ -404,11 +339,7 @@ extension HTTPBody { length: HTTPBody.Length, iterationBehavior: IterationBehavior ) where Bytes: Sendable, Bytes.Element: Sequence & Sendable, Bytes.Element.Element == UInt8 { - self.init( - sequence.map { ArraySlice($0) }, - length: length, - iterationBehavior: iterationBehavior - ) + self.init(sequence.map { ArraySlice($0) }, length: length, iterationBehavior: iterationBehavior) } } @@ -438,13 +369,9 @@ extension HTTPBody { /// The maximum number of bytes acceptable by the user. let maxBytes: Int - var description: String { - "OpenAPIRuntime.HTTPBody contains more than the maximum allowed \(maxBytes) bytes." - } + var description: String { "OpenAPIRuntime.HTTPBody contains more than the maximum allowed \(maxBytes) bytes." } - var errorDescription: String? { - description - } + var errorDescription: String? { description } } /// An error thrown by the collecting initializer when another iteration of @@ -455,9 +382,7 @@ extension HTTPBody { "OpenAPIRuntime.HTTPBody attempted to create a second iterator, but the underlying sequence is only safe to be iterated once." } - var errorDescription: String? { - description - } + var errorDescription: String? { description } } /// Accumulates the full body in-memory into a single buffer @@ -474,17 +399,13 @@ extension HTTPBody { // 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) - } + guard knownBytes <= maxBytes else { throw TooManyBytesError(maxBytes: maxBytes) } } // Accumulate the byte chunks. var buffer = ByteChunk() for try await chunk in self { - guard buffer.count + chunk.count <= maxBytes else { - throw TooManyBytesError(maxBytes: maxBytes) - } + guard buffer.count + chunk.count <= maxBytes else { throw TooManyBytesError(maxBytes: maxBytes) } buffer.append(contentsOf: chunk) } return buffer @@ -527,23 +448,13 @@ extension HTTPBody { /// - Parameters: /// - string: A string to encode as bytes. /// - length: The total length of the body. - @inlinable public convenience init( - _ string: some StringProtocol & Sendable, - length: Length - ) { - self.init( - ByteChunk(string), - length: length - ) + @inlinable public convenience init(_ string: some StringProtocol & Sendable, length: Length) { + self.init(ByteChunk(string), length: length) } /// Creates a new body with the provided string encoded as UTF-8 bytes. /// - Parameter string: A string to encode as bytes. - @inlinable public convenience init( - _ string: some StringProtocol & Sendable - ) { - self.init(ByteChunk(string)) - } + @inlinable public convenience init(_ string: some StringProtocol & Sendable) { self.init(ByteChunk(string)) } /// Creates a new body with the provided async throwing stream of strings. /// - Parameters: @@ -552,27 +463,14 @@ extension HTTPBody { @inlinable public convenience init( _ stream: AsyncThrowingStream, length: HTTPBody.Length - ) { - self.init( - .init(stream.map { ByteChunk.init($0) }), - length: length, - iterationBehavior: .single - ) - } + ) { self.init(.init(stream.map { ByteChunk.init($0) }), length: length, iterationBehavior: .single) } /// Creates a new body with the provided async stream of strings. /// - Parameters: /// - stream: An async stream that provides the string chunks. /// - length: The total length of the body. - @inlinable public convenience init( - _ stream: AsyncStream, - length: HTTPBody.Length - ) { - self.init( - .init(stream.map { ByteChunk.init($0) }), - length: length, - iterationBehavior: .single - ) + @inlinable public convenience init(_ stream: AsyncStream, length: HTTPBody.Length) { + self.init(.init(stream.map { ByteChunk.init($0) }), length: length, iterationBehavior: .single) } /// Creates a new body with the provided async sequence of string chunks. @@ -586,11 +484,7 @@ extension HTTPBody { length: HTTPBody.Length, iterationBehavior: IterationBehavior ) where Strings.Element: StringProtocol & Sendable, Strings: Sendable { - self.init( - .init(sequence.map { ByteChunk.init($0) }), - length: length, - iterationBehavior: iterationBehavior - ) + self.init(.init(sequence.map { ByteChunk.init($0) }), length: length, iterationBehavior: iterationBehavior) } } @@ -598,9 +492,7 @@ extension HTTPBody.ByteChunk { /// Creates a byte chunk compatible with the `HTTPBody` type from the provided string. /// - Parameter string: The string to encode. - @inlinable init(_ string: some StringProtocol & Sendable) { - self = Array(string.utf8)[...] - } + @inlinable init(_ string: some StringProtocol & Sendable) { self = Array(string.utf8)[...] } } extension String { @@ -613,10 +505,7 @@ extension String { /// - Throws: `TooManyBytesError` if the body contains more /// than `maxBytes`. public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { - self = try await String( - decoding: body.collect(upTo: maxBytes), - as: UTF8.self - ) + self = try await String(decoding: body.collect(upTo: maxBytes), as: UTF8.self) } } @@ -626,18 +515,14 @@ extension HTTPBody: ExpressibleByStringLiteral { /// Initializes an `HTTPBody` instance with the provided string value. /// /// - Parameter value: The string literal to use for initializing the `HTTPBody`. - public convenience init(stringLiteral value: String) { - self.init(value) - } + public convenience init(stringLiteral value: String) { self.init(value) } } extension HTTPBody { /// Creates a new body from the provided array of bytes. /// - Parameter bytes: An array of bytes. - @inlinable public convenience init(_ bytes: [UInt8]) { - self.init(bytes[...]) - } + @inlinable public convenience init(_ bytes: [UInt8]) { self.init(bytes[...]) } } extension HTTPBody: ExpressibleByArrayLiteral { @@ -646,18 +531,14 @@ extension HTTPBody: ExpressibleByArrayLiteral { /// Initializes an `HTTPBody` instance with a sequence of `UInt8` elements. /// /// - Parameter elements: A variadic list of `UInt8` elements used to initialize the `HTTPBody`. - public convenience init(arrayLiteral elements: UInt8...) { - self.init(elements) - } + public convenience init(arrayLiteral elements: UInt8...) { self.init(elements) } } extension HTTPBody { /// Creates a new body from the provided data chunk. /// - Parameter data: A single data chunk. - public convenience init(_ data: Data) { - self.init(ArraySlice(data)) - } + public convenience init(_ data: Data) { self.init(ArraySlice(data)) } } extension Data { @@ -689,22 +570,17 @@ extension HTTPBody { /// Creates a new type-erased iterator from the provided iterator. /// - Parameter iterator: The iterator to type-erase. - @usableFromInline init( - _ iterator: Iterator - ) where Iterator.Element == Element { + @usableFromInline init(_ iterator: Iterator) + where Iterator.Element == Element { var iterator = iterator - self.produceNext = { - try await iterator.next() - } + self.produceNext = { try await iterator.next() } } /// 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. /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. - public mutating func next() async throws -> Element? { - try await produceNext() - } + public mutating func next() async throws -> Element? { try await produceNext() } } } @@ -725,14 +601,10 @@ extension HTTPBody { /// Creates a new sequence. /// - Parameter sequence: The input sequence to type-erase. @inlinable init(_ sequence: Bytes) where Bytes.Element == Element, Bytes: Sendable { - self.produceIterator = { - .init(sequence.makeAsyncIterator()) - } + self.produceIterator = { .init(sequence.makeAsyncIterator()) } } - @usableFromInline func makeAsyncIterator() -> AsyncIterator { - produceIterator() - } + @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } } /// An async sequence wrapper for a sync sequence. @@ -754,9 +626,7 @@ extension HTTPBody { /// The underlying sync sequence iterator. var iterator: any IteratorProtocol - @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { - iterator.next() - } + @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { iterator.next() } } /// The underlying sync sequence. @@ -764,13 +634,9 @@ extension HTTPBody { /// Creates a new async sequence with the provided sync sequence. /// - Parameter sequence: The sync sequence to wrap. - @inlinable init(sequence: Bytes) { - self.sequence = sequence - } + @inlinable init(sequence: Bytes) { self.sequence = sequence } - @usableFromInline func makeAsyncIterator() -> Iterator { - Iterator(iterator: sequence.makeIterator()) - } + @usableFromInline func makeAsyncIterator() -> Iterator { Iterator(iterator: sequence.makeIterator()) } } /// An empty async sequence. @@ -788,16 +654,12 @@ extension HTTPBody { /// The byte chunk element type. @usableFromInline typealias Element = ByteChunk - @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { - nil - } + @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { nil } } /// Creates a new empty async sequence. @inlinable init() {} - @usableFromInline func makeAsyncIterator() -> EmptyIterator { - EmptyIterator() - } + @usableFromInline func makeAsyncIterator() -> EmptyIterator { EmptyIterator() } } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 6c75a3ce..431fb8af 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift @@ -90,15 +90,10 @@ import Foundation serializer: @Sendable (OperationInput) throws -> (HTTPRequest, HTTPBody?), deserializer: @Sendable (HTTPResponse, HTTPBody?) async throws -> OperationOutput ) async throws -> OperationOutput where OperationInput: Sendable, OperationOutput: Sendable { - @Sendable func wrappingErrors( - work: () async throws -> R, - mapError: (any Error) -> any Error - ) async throws -> R { - do { - return try await work() - } catch let error as ClientError { - throw error - } catch { + @Sendable func wrappingErrors(work: () async throws -> R, mapError: (any Error) -> any Error) async throws + -> R + { + do { return try await work() } catch let error as ClientError { throw error } catch { throw mapError(error) } } @@ -148,12 +143,7 @@ import Foundation var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { (_request, _body, _url) in try await wrappingErrors { - try await transport.send( - _request, - body: _body, - baseURL: _url, - operationID: operationID - ) + try await transport.send(_request, body: _body, baseURL: _url, operationID: operationID) } mapError: { error in makeError( request: request, @@ -165,8 +155,7 @@ import Foundation } for middleware in middlewares.reversed() { let tmp = next - next = { - (_request, _body, _url) in + next = { (_request, _body, _url) in try await wrappingErrors { try await middleware.intercept( _request, @@ -180,10 +169,7 @@ import Foundation request: request, requestBody: requestBody, baseURL: baseURL, - error: RuntimeError.middlewareFailed( - middlewareType: type(of: middleware), - error - ) + error: RuntimeError.middlewareFailed(middlewareType: type(of: middleware), error) ) } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index e523560f..80d69e25 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -43,12 +43,7 @@ import struct Foundation.URLComponents public var middlewares: [any ServerMiddleware] /// Internal initializer that takes an initialized converter. - internal init( - serverURL: URL, - converter: Converter, - handler: APIHandler, - middlewares: [any ServerMiddleware] - ) { + internal init(serverURL: URL, converter: Converter, handler: APIHandler, middlewares: [any ServerMiddleware]) { self.serverURL = serverURL self.converter = converter self.handler = handler @@ -102,23 +97,16 @@ import struct Foundation.URLComponents 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 -> R { - do { - return try await work() - } catch let error as ServerError { - throw error - } catch { + @Sendable func wrappingErrors(work: () async throws -> R, mapError: (any Error) -> any Error) async throws + -> R + { + do { return try await work() } catch let error as ServerError { throw error } catch { throw mapError(error) } } - @Sendable func makeError( - input: OperationInput? = nil, - output: OperationOutput? = nil, - error: any Error - ) -> any Error { + @Sendable func makeError(input: OperationInput? = nil, output: OperationOutput? = nil, error: any Error) + -> any Error + { if var error = error as? ServerError { error.operationInput = error.operationInput ?? input error.operationOutput = error.operationOutput ?? output @@ -145,10 +133,7 @@ import struct Foundation.URLComponents ) } var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) = - { - _request, - _requestBody, - _metadata in + { _request, _requestBody, _metadata in let input: OperationInput = try await wrappingErrors { try await deserializer(_request, _requestBody, _metadata) } mapError: { error in @@ -159,10 +144,7 @@ import struct Foundation.URLComponents return try await wrappingErrors { try await method(input) } mapError: { error in - makeError( - input: input, - error: RuntimeError.handlerFailed(error) - ) + makeError(input: input, error: RuntimeError.handlerFailed(error)) } } mapError: { error in makeError(input: input, error: error) @@ -175,10 +157,7 @@ import struct Foundation.URLComponents } for middleware in middlewares.reversed() { let tmp = next - next = { - _request, - _requestBody, - _metadata in + next = { _request, _requestBody, _metadata in try await wrappingErrors { try await middleware.intercept( _request, @@ -188,12 +167,7 @@ import struct Foundation.URLComponents next: tmp ) } mapError: { error in - makeError( - error: RuntimeError.middlewareFailed( - middlewareType: type(of: middleware), - error - ) - ) + makeError(error: RuntimeError.middlewareFailed(middlewareType: type(of: middleware), error)) } } } @@ -204,9 +178,7 @@ import struct Foundation.URLComponents /// - Parameter path: The path suffix. /// - Returns: The path appended to the server URL's path. /// - Throws: An error if resolving the server URL components fails or if the server URL is invalid. - public func apiPathComponentsWithServerPrefix( - _ path: String - ) throws -> String { + public func apiPathComponentsWithServerPrefix(_ path: String) throws -> String { // Operation path is for example "/pets/42" // Server may be configured with a prefix, for example http://localhost/foo/bar/v1 // Goal is to return something like "/foo/bar/v1/pets/42". @@ -214,9 +186,7 @@ import struct Foundation.URLComponents throw RuntimeError.invalidServerURL(serverURL.absoluteString) } let prefixPath = components.path - guard prefixPath == "/" else { - return prefixPath + path - } + guard prefixPath == "/" else { return prefixPath + path } return path } } diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift index 4dc882a4..985b7715 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIEncodedNode.swift @@ -77,12 +77,9 @@ extension URIEncodedNode { /// - Throws: If the node is already set. mutating func set(_ value: Primitive) throws { switch self { - case .unset: - self = .primitive(value) - case .primitive: - throw InsertionError.settingPrimitiveValueAgain - case .array, .dictionary: - throw InsertionError.settingValueOnAContainer + case .unset: self = .primitive(value) + case .primitive: throw InsertionError.settingPrimitiveValueAgain + case .array, .dictionary: throw InsertionError.settingValueOnAContainer } } @@ -93,10 +90,7 @@ extension URIEncodedNode { /// - key: The key to save the value for into the dictionary. /// - Throws: If the node is already set to be anything else but a /// dictionary. - mutating func insert( - _ childValue: Self, - atKey key: Key - ) throws { + mutating func insert(_ childValue: Self, atKey key: Key) throws { switch self { case .dictionary(var dictionary): self = .unset @@ -109,25 +103,18 @@ extension URIEncodedNode { guard let intValue = key.intValue else { throw InsertionError.insertingChildValueIntoArrayUsingNonIntValueKey } - precondition( - intValue == array.count, - "Unkeyed container inserting at an incorrect index" - ) + precondition(intValue == array.count, "Unkeyed container inserting at an incorrect index") self = .unset array.append(childValue) self = .array(array) case .unset: if let intValue = key.intValue { - precondition( - intValue == 0, - "Unkeyed container inserting at an incorrect index" - ) + precondition(intValue == 0, "Unkeyed container inserting at an incorrect index") self = .array([childValue]) } else { self = .dictionary([key.stringValue: childValue]) } - default: - throw InsertionError.insertingChildValueIntoNonContainer + default: throw InsertionError.insertingChildValueIntoNonContainer } } @@ -140,10 +127,8 @@ extension URIEncodedNode { self = .unset items.append(childValue) self = .array(items) - case .unset: - self = .array([childValue]) - default: - throw InsertionError.appendingToNonArrayContainer + case .unset: self = .array([childValue]) + default: throw InsertionError.appendingToNonArrayContainer } } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift index 138d60cc..72cc077f 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift @@ -53,9 +53,7 @@ struct URIDecoder: Sendable { /// Creates a new decoder with the provided configuration. /// - Parameter configuration: The configuration used by the decoder. - init(configuration: URICoderConfiguration) { - self.configuration = configuration - } + init(configuration: URICoderConfiguration) { self.configuration = configuration } } extension URIDecoder { @@ -73,14 +71,8 @@ extension URIDecoder { /// - data: The URI-encoded string. /// - 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) - } + 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) } } /// Attempt to decode an object from an URI string, if present. @@ -96,15 +88,9 @@ extension URIDecoder { /// - data: The URI-encoded string. /// - Returns: The decoded value. /// - 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) - } - } + 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. /// @@ -115,10 +101,7 @@ extension URIDecoder { /// 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 { + 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) @@ -146,10 +129,7 @@ struct URICachedDecoder { /// 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 { + func decode(_ type: T.Type = T.self, forKey key: String = "") throws -> T { let decoder = URIValueFromNodeDecoder( node: node, rootKey: key[...], @@ -172,10 +152,7 @@ struct URICachedDecoder { /// 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? { + func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "") throws -> T? { let decoder = URIValueFromNodeDecoder( node: node, rootKey: key[...], diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift index 6590be92..fd47d462 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift @@ -33,10 +33,7 @@ extension URIKeyedDecodingContainer { /// - 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 { - throw DecodingError.keyNotFound( - key, - .init(codingPath: codingPath, debugDescription: "Key not found.") - ) + throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: "Key not found.")) } return value } @@ -49,17 +46,11 @@ extension URIKeyedDecodingContainer { /// - Returns: The converted value found for the provided key. /// - Throws: An error if no value for the key was found or if the /// conversion failed. - private func _decodeBinaryFloatingPoint( - _: T.Type = T.self, - forKey key: Key - ) throws -> T { + private func _decodeBinaryFloatingPoint(_: T.Type = T.self, forKey key: Key) throws -> T { guard let double = Double(try _decodeValue(forKey: key)) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to Double." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to Double.") ) } return T(double) @@ -73,17 +64,11 @@ extension URIKeyedDecodingContainer { /// - Returns: The converted value found for the provided key. /// - Throws: An error if no value for the key was found or if the /// conversion failed. - private func _decodeFixedWidthInteger( - _: T.Type = T.self, - forKey key: Key - ) throws -> T { + private func _decodeFixedWidthInteger(_: T.Type = T.self, forKey key: Key) throws -> T { guard let parsedValue = T(try _decodeValue(forKey: key)) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") ) } return parsedValue @@ -97,17 +82,13 @@ extension URIKeyedDecodingContainer { /// - Returns: The converted value found for the provided key. /// - Throws: An error if no value for the key was found or if the /// conversion failed. - private func _decodeNextLosslessStringConvertible( - _: T.Type = T.self, - forKey key: Key - ) throws -> T { + private func _decodeNextLosslessStringConvertible(_: T.Type = T.self, forKey key: Key) + throws -> T + { guard let parsedValue = T(String(try _decodeValue(forKey: key))) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") ) } return parsedValue @@ -116,141 +97,77 @@ extension URIKeyedDecodingContainer { extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { - var allKeys: [Key] { - values.keys.map { key in - Key.init(stringValue: String(key))! - } - } + var allKeys: [Key] { values.keys.map { key in Key.init(stringValue: String(key))! } } - func contains(_ key: Key) -> Bool { - values[key.stringValue[...]] != nil - } + func contains(_ key: Key) -> Bool { values[key.stringValue[...]] != nil } - var codingPath: [any CodingKey] { - decoder.codingPath - } + var codingPath: [any CodingKey] { decoder.codingPath } - func decodeNil(forKey key: Key) -> Bool { - false - } + func decodeNil(forKey key: Key) -> Bool { false } func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { try _decodeNextLosslessStringConvertible(forKey: key) } - func decode(_ type: String.Type, forKey key: Key) throws -> String { - String(try _decodeValue(forKey: key)) - } + func decode(_ type: String.Type, forKey key: Key) throws -> String { String(try _decodeValue(forKey: key)) } - func decode(_ type: Double.Type, forKey key: Key) throws -> Double { - try _decodeBinaryFloatingPoint(forKey: key) - } + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { try _decodeBinaryFloatingPoint(forKey: key) } - func decode(_ type: Float.Type, forKey key: Key) throws -> Float { - try _decodeBinaryFloatingPoint(forKey: key) - } + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { try _decodeBinaryFloatingPoint(forKey: key) } - func decode(_ type: Int.Type, forKey key: Key) throws -> Int { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { try _decodeFixedWidthInteger(forKey: key) } - func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { - try _decodeFixedWidthInteger(forKey: key) - } + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { try _decodeFixedWidthInteger(forKey: key) } func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { switch type { - case is Bool.Type: - return try decode(Bool.self, forKey: key) as! T - case is String.Type: - return try decode(String.self, forKey: key) as! T - case is Double.Type: - return try decode(Double.self, forKey: key) as! T - case is Float.Type: - return try decode(Float.self, forKey: key) as! T - case is Int.Type: - return try decode(Int.self, forKey: key) as! T - case is Int8.Type: - return try decode(Int8.self, forKey: key) as! T - case is Int16.Type: - return try decode(Int16.self, forKey: key) as! T - case is Int32.Type: - return try decode(Int32.self, forKey: key) as! T - case is Int64.Type: - return try decode(Int64.self, forKey: key) as! T - case is UInt.Type: - return try decode(UInt.self, forKey: key) as! T - case is UInt8.Type: - return try decode(UInt8.self, forKey: key) as! T - case is UInt16.Type: - return try decode(UInt16.self, forKey: key) as! T - case is UInt32.Type: - return try decode(UInt32.self, forKey: key) as! T - 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 + case is Bool.Type: return try decode(Bool.self, forKey: key) as! T + case is String.Type: return try decode(String.self, forKey: key) as! T + case is Double.Type: return try decode(Double.self, forKey: key) as! T + case is Float.Type: return try decode(Float.self, forKey: key) as! T + case is Int.Type: return try decode(Int.self, forKey: key) as! T + case is Int8.Type: return try decode(Int8.self, forKey: key) as! T + case is Int16.Type: return try decode(Int16.self, forKey: key) as! T + case is Int32.Type: return try decode(Int32.self, forKey: key) as! T + case is Int64.Type: return try decode(Int64.self, forKey: key) as! T + case is UInt.Type: return try decode(UInt.self, forKey: key) as! T + case is UInt8.Type: return try decode(UInt8.self, forKey: key) as! T + case is UInt16.Type: return try decode(UInt16.self, forKey: key) as! T + case is UInt32.Type: return try decode(UInt32.self, forKey: key) as! T + 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)) - defer { - decoder.pop() - } + defer { decoder.pop() } return try type.init(from: decoder) } } - func nestedContainer( - keyedBy type: NestedKey.Type, - forKey key: Key - ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { - throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported - } + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer< + NestedKey + > where NestedKey: CodingKey { throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported } - func nestedUnkeyedContainer( - forKey key: Key - ) throws -> any UnkeyedDecodingContainer { + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported } - func superDecoder(forKey key: Key) throws -> any Decoder { - decoder - } + func superDecoder(forKey key: Key) throws -> any Decoder { decoder } - func superDecoder() throws -> any Decoder { - decoder - } + func superDecoder() throws -> any Decoder { decoder } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift index 32592fd7..3c829873 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift @@ -24,11 +24,7 @@ struct URISingleValueDecodingContainer { extension URISingleValueDecodingContainer { /// The underlying value as a single value. - var value: URIParsedValue { - get throws { - try decoder.currentElementAsSingleValue() - } - } + var value: URIParsedValue { get throws { try decoder.currentElementAsSingleValue() } } /// Returns the value found in the underlying node converted to /// the provided type. @@ -36,16 +32,11 @@ extension URISingleValueDecodingContainer { /// - Parameter _: The `BinaryFloatingPoint` type to convert the value to. /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. - private func _decodeBinaryFloatingPoint( - _: T.Type = T.self - ) throws -> T { + private func _decodeBinaryFloatingPoint(_: T.Type = T.self) throws -> T { guard let double = try Double(value) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to Double." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to Double.") ) } return T(double) @@ -57,16 +48,11 @@ extension URISingleValueDecodingContainer { /// - Parameter _: The `FixedWidthInteger` type to convert the value to. /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. - private func _decodeFixedWidthInteger( - _: T.Type = T.self - ) throws -> T { + private func _decodeFixedWidthInteger(_: T.Type = T.self) throws -> T { guard let parsedValue = try T(value) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") ) } return parsedValue @@ -78,16 +64,11 @@ extension URISingleValueDecodingContainer { /// - Parameter _: The `LosslessStringConvertible` type to convert the value to. /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. - private func _decodeLosslessStringConvertible( - _: T.Type = T.self - ) throws -> T { + private func _decodeLosslessStringConvertible(_: T.Type = T.self) throws -> T { guard let parsedValue = try T(String(value)) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") ) } return parsedValue @@ -96,104 +77,56 @@ extension URISingleValueDecodingContainer { extension URISingleValueDecodingContainer: SingleValueDecodingContainer { - var codingPath: [any CodingKey] { - decoder.codingPath - } + var codingPath: [any CodingKey] { decoder.codingPath } - func decodeNil() -> Bool { - false - } + func decodeNil() -> Bool { false } - func decode(_ type: Bool.Type) throws -> Bool { - try _decodeLosslessStringConvertible() - } + 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 { try String(value) } - func decode(_ type: Double.Type) throws -> Double { - try _decodeBinaryFloatingPoint() - } + func decode(_ type: Double.Type) throws -> Double { try _decodeBinaryFloatingPoint() } - func decode(_ type: Float.Type) throws -> Float { - try _decodeBinaryFloatingPoint() - } + func decode(_ type: Float.Type) throws -> Float { try _decodeBinaryFloatingPoint() } - func decode(_ type: Int.Type) throws -> Int { - try _decodeFixedWidthInteger() - } + func decode(_ type: Int.Type) throws -> Int { try _decodeFixedWidthInteger() } - func decode(_ type: Int8.Type) throws -> Int8 { - try _decodeFixedWidthInteger() - } + func decode(_ type: Int8.Type) throws -> Int8 { try _decodeFixedWidthInteger() } - func decode(_ type: Int16.Type) throws -> Int16 { - try _decodeFixedWidthInteger() - } + func decode(_ type: Int16.Type) throws -> Int16 { try _decodeFixedWidthInteger() } - func decode(_ type: Int32.Type) throws -> Int32 { - try _decodeFixedWidthInteger() - } + func decode(_ type: Int32.Type) throws -> Int32 { try _decodeFixedWidthInteger() } - func decode(_ type: Int64.Type) throws -> Int64 { - try _decodeFixedWidthInteger() - } + func decode(_ type: Int64.Type) throws -> Int64 { try _decodeFixedWidthInteger() } - func decode(_ type: UInt.Type) throws -> UInt { - try _decodeFixedWidthInteger() - } + func decode(_ type: UInt.Type) throws -> UInt { try _decodeFixedWidthInteger() } - func decode(_ type: UInt8.Type) throws -> UInt8 { - try _decodeFixedWidthInteger() - } + func decode(_ type: UInt8.Type) throws -> UInt8 { try _decodeFixedWidthInteger() } - func decode(_ type: UInt16.Type) throws -> UInt16 { - try _decodeFixedWidthInteger() - } + func decode(_ type: UInt16.Type) throws -> UInt16 { try _decodeFixedWidthInteger() } - func decode(_ type: UInt32.Type) throws -> UInt32 { - try _decodeFixedWidthInteger() - } + func decode(_ type: UInt32.Type) throws -> UInt32 { try _decodeFixedWidthInteger() } - func decode(_ type: UInt64.Type) throws -> UInt64 { - try _decodeFixedWidthInteger() - } + func decode(_ type: UInt64.Type) throws -> UInt64 { try _decodeFixedWidthInteger() } func decode(_ type: T.Type) throws -> T where T: Decodable { switch type { - case is Bool.Type: - return try decode(Bool.self) as! T - case is String.Type: - return try decode(String.self) as! T - case is Double.Type: - return try decode(Double.self) as! T - case is Float.Type: - return try decode(Float.self) as! T - case is Int.Type: - return try decode(Int.self) as! T - case is Int8.Type: - return try decode(Int8.self) as! T - case is Int16.Type: - return try decode(Int16.self) as! T - case is Int32.Type: - return try decode(Int32.self) as! T - case is Int64.Type: - return try decode(Int64.self) as! T - case is UInt.Type: - return try decode(UInt.self) as! T - case is UInt8.Type: - return try decode(UInt8.self) as! T - 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 - default: - return try T.init(from: decoder) + case is Bool.Type: return try decode(Bool.self) as! T + case is String.Type: return try decode(String.self) as! T + case is Double.Type: return try decode(Double.self) as! T + case is Float.Type: return try decode(Float.self) as! T + case is Int.Type: return try decode(Int.self) as! T + case is Int8.Type: return try decode(Int8.self) as! T + case is Int16.Type: return try decode(Int16.self) as! T + case is Int32.Type: return try decode(Int32.self) as! T + case is Int64.Type: return try decode(Int64.self) as! T + case is UInt.Type: return try decode(UInt.self) as! T + case is UInt8.Type: return try decode(UInt8.self) as! T + 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 + 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 c985145a..44a5cd28 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift @@ -45,12 +45,8 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The result of the closure. /// - 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) - } + guard !isAtEnd else { throw URIValueFromNodeDecoder.GeneralError.reachedEndOfUnkeyedContainer } + defer { values.formIndex(after: &index) } return try work() } @@ -59,9 +55,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 { [values, index] in values[index] } } /// Returns the next value converted to the provided type. @@ -70,16 +64,11 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The converted value. /// - Throws: An error if the container ran out of items or if /// the conversion failed. - private mutating func _decodeNextBinaryFloatingPoint( - _: T.Type = T.self - ) throws -> T { + private mutating func _decodeNextBinaryFloatingPoint(_: T.Type = T.self) throws -> T { guard let double = Double(try _decodeNext()) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to Double." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to Double.") ) } return T(double) @@ -91,16 +80,11 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The converted value. /// - Throws: An error if the container ran out of items or if /// the conversion failed. - private mutating func _decodeNextFixedWidthInteger( - _: T.Type = T.self - ) throws -> T { + private mutating func _decodeNextFixedWidthInteger(_: T.Type = T.self) throws -> T { guard let parsedValue = T(try _decodeNext()) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") ) } return parsedValue @@ -112,16 +96,13 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The converted value. /// - Throws: An error if the container ran out of items or if /// the conversion failed. - private mutating func _decodeNextLosslessStringConvertible( - _: T.Type = T.self - ) throws -> T { + private mutating func _decodeNextLosslessStringConvertible(_: T.Type = T.self) throws + -> T + { guard let parsedValue = T(String(try _decodeNext())) else { throw DecodingError.typeMismatch( T.self, - .init( - codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." - ) + .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") ) } return parsedValue @@ -130,138 +111,76 @@ extension URIUnkeyedDecodingContainer { extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { - var count: Int? { - values.count - } + var count: Int? { values.count } - var isAtEnd: Bool { - index == values.endIndex - } + var isAtEnd: Bool { index == values.endIndex } - var currentIndex: Int { - index - } + var currentIndex: Int { index } - var codingPath: [any CodingKey] { - decoder.codingPath - } + var codingPath: [any CodingKey] { decoder.codingPath } - func decodeNil() -> Bool { - false - } + func decodeNil() -> Bool { false } - mutating func decode(_ type: Bool.Type) throws -> Bool { - try _decodeNextLosslessStringConvertible() - } + mutating func decode(_ type: Bool.Type) throws -> Bool { try _decodeNextLosslessStringConvertible() } - mutating func decode(_ type: String.Type) throws -> String { - String(try _decodeNext()) - } + mutating func decode(_ type: String.Type) throws -> String { String(try _decodeNext()) } - mutating func decode(_ type: Double.Type) throws -> Double { - try _decodeNextBinaryFloatingPoint() - } + mutating func decode(_ type: Double.Type) throws -> Double { try _decodeNextBinaryFloatingPoint() } - mutating func decode(_ type: Float.Type) throws -> Float { - try _decodeNextBinaryFloatingPoint() - } + mutating func decode(_ type: Float.Type) throws -> Float { try _decodeNextBinaryFloatingPoint() } - mutating func decode(_ type: Int.Type) throws -> Int { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: Int.Type) throws -> Int { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: Int8.Type) throws -> Int8 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: Int8.Type) throws -> Int8 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: Int16.Type) throws -> Int16 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: Int16.Type) throws -> Int16 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: Int32.Type) throws -> Int32 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: Int32.Type) throws -> Int32 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: Int64.Type) throws -> Int64 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: Int64.Type) throws -> Int64 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: UInt.Type) throws -> UInt { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: UInt.Type) throws -> UInt { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: UInt8.Type) throws -> UInt8 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: UInt8.Type) throws -> UInt8 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: UInt16.Type) throws -> UInt16 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: UInt16.Type) throws -> UInt16 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: UInt32.Type) throws -> UInt32 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: UInt32.Type) throws -> UInt32 { try _decodeNextFixedWidthInteger() } - mutating func decode(_ type: UInt64.Type) throws -> UInt64 { - try _decodeNextFixedWidthInteger() - } + mutating func decode(_ type: UInt64.Type) throws -> UInt64 { try _decodeNextFixedWidthInteger() } mutating func decode(_ type: T.Type) throws -> T where T: Decodable { switch type { - case is Bool.Type: - return try decode(Bool.self) as! T - case is String.Type: - return try decode(String.self) as! T - case is Double.Type: - return try decode(Double.self) as! T - case is Float.Type: - return try decode(Float.self) as! T - case is Int.Type: - return try decode(Int.self) as! T - case is Int8.Type: - return try decode(Int8.self) as! T - case is Int16.Type: - return try decode(Int16.self) as! T - case is Int32.Type: - return try decode(Int32.self) as! T - case is Int64.Type: - return try decode(Int64.self) as! T - case is UInt.Type: - return try decode(UInt.self) as! T - case is UInt8.Type: - return try decode(UInt8.self) as! T - 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(_decodeNext())) as! T + case is Bool.Type: return try decode(Bool.self) as! T + case is String.Type: return try decode(String.self) as! T + case is Double.Type: return try decode(Double.self) as! T + case is Float.Type: return try decode(Float.self) as! T + case is Int.Type: return try decode(Int.self) as! T + case is Int8.Type: return try decode(Int8.self) as! T + case is Int16.Type: return try decode(Int16.self) as! T + case is Int32.Type: return try decode(Int32.self) as! T + case is Int64.Type: return try decode(Int64.self) as! T + case is UInt.Type: return try decode(UInt.self) as! T + case is UInt8.Type: return try decode(UInt8.self) as! T + 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(_decodeNext())) as! T default: return try _decodingNext { [decoder, currentIndex] in try decoder.push(.init(intValue: currentIndex)) - defer { - decoder.pop() - } + defer { decoder.pop() } return try type.init(from: decoder) } } } - mutating func nestedContainer( - keyedBy type: NestedKey.Type - ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { - throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported - } + mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer + where NestedKey: CodingKey { throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported } mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { throw URIValueFromNodeDecoder.GeneralError.nestedContainersNotSupported } - mutating func superDecoder() throws -> any Decoder { - decoder - } + mutating func superDecoder() throws -> any Decoder { decoder } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift index a8b319f3..55982755 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift @@ -63,19 +63,15 @@ final class URIValueFromNodeDecoder { /// - Throws: When a decoding error occurs. func decodeRoot(_ type: T.Type = T.self) throws -> T { precondition(codingStack.isEmpty) - defer { - precondition(codingStack.isEmpty) - } + defer { precondition(codingStack.isEmpty) } // We have to catch the special values early, otherwise we fall // back to their Codable implementations, which don't give us // a chance to customize the coding in the containers. let value: T switch type { - case is Date.Type: - value = try singleValueContainer().decode(Date.self) as! T - default: - value = try T.init(from: self) + case is Date.Type: value = try singleValueContainer().decode(Date.self) as! T + default: value = try T.init(from: self) } return value } @@ -86,9 +82,7 @@ final class URIValueFromNodeDecoder { /// - 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 - } + if try currentElementAsArray().isEmpty { return nil } return try decodeRoot(type) } } @@ -142,9 +136,7 @@ extension URIValueFromNodeDecoder { } /// The element at the current head of the coding stack. - private var currentElement: URIDecodedNode { - codingStack.last?.element ?? .dictionary(node) - } + 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. @@ -165,22 +157,14 @@ extension URIValueFromNodeDecoder { /// Pops the top container from the stack and restores the previously top /// container to be the current top container. - func pop() { - codingStack.removeLast() - } + func pop() { codingStack.removeLast() } /// Throws a type mismatch error with the provided message. /// - Parameter message: The message to be embedded as debug description /// inside the thrown `DecodingError`. /// - Throws: A `DecodingError` with a type mismatch error if this function is called. private func throwMismatch(_ message: String) throws -> Never { - throw DecodingError.typeMismatch( - String.self, - .init( - codingPath: codingPath, - debugDescription: message - ) - ) + throw DecodingError.typeMismatch(String.self, .init(codingPath: codingPath, debugDescription: message)) } /// Extracts the root value of the provided node using the root key. @@ -204,9 +188,7 @@ extension URIValueFromNodeDecoder { /// 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) - } + private func currentElementAsDictionary() throws -> URIParsedNode { try nodeAsDictionary(currentElement) } /// Checks if the provided node can be treated as a dictionary, and returns /// it if so. @@ -237,14 +219,8 @@ extension URIValueFromNodeDecoder { 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 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 } @@ -253,9 +229,7 @@ extension URIValueFromNodeDecoder { /// 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) - } + private func currentElementAsArray() throws -> URIParsedValueArray { try nodeAsArray(currentElement) } /// Checks if the provided node can be treated as an array, and returns /// it if so. @@ -264,12 +238,9 @@ extension URIValueFromNodeDecoder { /// - 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) + case .single(let value): return [value] + case .array(let values): return values + case .dictionary(let values): return try rootValue(in: values) } } @@ -277,9 +248,7 @@ extension URIValueFromNodeDecoder { /// 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) - } + func currentElementAsSingleValue() throws -> URIParsedValue { try nodeAsSingleValue(currentElement) } /// Checks if the provided node can be treated as a primitive value, and /// returns it if so. @@ -292,17 +261,12 @@ extension URIValueFromNodeDecoder { // 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) + case .single(let value): return value + case .array(let values): array = values + case .dictionary(let values): array = try rootValue(in: values) } guard array.count == 1 else { - if style == .simple { - return Substring(array.joined(separator: ",")) - } + 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).") } @@ -316,13 +280,9 @@ extension URIValueFromNodeDecoder { /// - 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 { + private func nestedValueInCurrentElementAsArray(at index: Int) throws -> URIParsedValue { let values = try currentElementAsArray() - guard index < values.count else { - throw GeneralError.codingKeyOutOfBounds - } + guard index < values.count else { throw GeneralError.codingKeyOutOfBounds } return values[index] } @@ -332,48 +292,30 @@ extension URIValueFromNodeDecoder { /// - 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 { + private func nestedValuesInCurrentElementAsDictionary(forKey key: String) throws -> URIParsedValueArray { let values = try currentElementAsDictionary() - guard let value = values[key[...]] else { - throw GeneralError.codingKeyNotFound - } + guard let value = values[key[...]] else { throw GeneralError.codingKeyNotFound } return value } } extension URIValueFromNodeDecoder: Decoder { - var codingPath: [any CodingKey] { - codingStack.map(\.key) - } + var codingPath: [any CodingKey] { codingStack.map(\.key) } - var userInfo: [CodingUserInfoKey: Any] { - [:] - } + var userInfo: [CodingUserInfoKey: Any] { [:] } - func container( - keyedBy type: Key.Type - ) throws -> KeyedDecodingContainer where Key: CodingKey { + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { let values = try currentElementAsDictionary() - return .init( - URIKeyedDecodingContainer( - decoder: self, - values: values - ) - ) + return .init(URIKeyedDecodingContainer(decoder: self, values: values)) } func unkeyedContainer() throws -> any UnkeyedDecodingContainer { let values = try currentElementAsArray() - return URIUnkeyedDecodingContainer( - decoder: self, - values: values - ) + return URIUnkeyedDecodingContainer(decoder: self, values: values) } func singleValueContainer() throws -> any SingleValueDecodingContainer { - return URISingleValueDecodingContainer(decoder: self) + URISingleValueDecodingContainer(decoder: self) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift index de400dc1..21028207 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift @@ -53,16 +53,12 @@ struct URIEncoder: Sendable { /// Creates a new encoder. /// - Parameter serializer: The serializer used to turn `URIEncodedNode` /// values to a string. - init(serializer: URISerializer) { - self.serializer = serializer - } + init(serializer: URISerializer) { self.serializer = serializer } /// Creates a new encoder. /// - Parameter configuration: The configuration instructing the encoder /// how to serialize the value into an URI-encoded string. - init(configuration: URICoderConfiguration) { - self.init(serializer: .init(configuration: configuration)) - } + init(configuration: URICoderConfiguration) { self.init(serializer: .init(configuration: configuration)) } } extension URIEncoder { @@ -80,10 +76,7 @@ extension URIEncoder { /// in which case you still get a key-value pair, like `=foo`. /// - Returns: The URI string. /// - Throws: An error if encoding the object into a URI string fails - func encode( - _ value: some Encodable, - forKey key: String - ) throws -> String { + func encode(_ value: some Encodable, forKey key: String) throws -> String { let encoder = URIValueToNodeEncoder() let node = try encoder.encodeValue(value) var serializer = serializer @@ -104,13 +97,8 @@ extension URIEncoder { /// in which case you still get a key-value pair, like `=foo`. /// - Returns: The URI string. /// - Throws: An error if encoding the object into a URI string fails. - func encodeIfPresent( - _ value: (some Encodable)?, - forKey key: String - ) throws -> String { - guard let value else { - return "" - } + func encodeIfPresent(_ value: (some Encodable)?, forKey key: String) throws -> String { + guard let value else { return "" } let encoder = URIValueToNodeEncoder() let node = try encoder.encodeValue(value) var serializer = serializer diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift index 1361a307..296ab578 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Keyed.swift @@ -49,10 +49,7 @@ extension URIKeyedEncodingContainer { /// - value: The value to insert. /// - key: The key for the value. /// - Throws: An error if inserting the value into the underlying dictionary at the provided key fails. - private func _insertBinaryFloatingPoint( - _ value: some BinaryFloatingPoint, - atKey key: Key - ) throws { + private func _insertBinaryFloatingPoint(_ value: some BinaryFloatingPoint, atKey key: Key) throws { try _insertValue(.double(Double(value)), atKey: key) } @@ -63,10 +60,7 @@ extension URIKeyedEncodingContainer { /// - key: The key for the value. /// - Throws: An error if the provided value is outside the valid range for an integer, /// or if inserting the value into the underlying dictionary at the provided key fails. - private func _insertFixedWidthInteger( - _ value: some FixedWidthInteger, - atKey key: Key - ) throws { + private func _insertFixedWidthInteger(_ value: some FixedWidthInteger, atKey key: Key) throws { guard let validatedValue = Int(exactly: value) else { throw URIValueToNodeEncoder.GeneralError.integerOutOfRange } @@ -76,102 +70,57 @@ extension URIKeyedEncodingContainer { extension URIKeyedEncodingContainer: KeyedEncodingContainerProtocol { - var codingPath: [any CodingKey] { - encoder.codingPath - } + var codingPath: [any CodingKey] { encoder.codingPath } mutating func encodeNil(forKey key: Key) throws { // Setting a nil value is equivalent to not encoding the value at all. } - mutating func encode(_ value: Bool, forKey key: Key) throws { - try _insertValue(.bool(value), atKey: key) - } + mutating func encode(_ value: Bool, forKey key: Key) throws { try _insertValue(.bool(value), atKey: key) } - mutating func encode(_ value: String, forKey key: Key) throws { - try _insertValue(.string(value), atKey: key) - } + mutating func encode(_ value: String, forKey key: Key) throws { try _insertValue(.string(value), atKey: key) } - mutating func encode(_ value: Double, forKey key: Key) throws { - try _insertBinaryFloatingPoint(value, atKey: key) - } + mutating func encode(_ value: Double, forKey key: Key) throws { try _insertBinaryFloatingPoint(value, atKey: key) } - mutating func encode(_ value: Float, forKey key: Key) throws { - try _insertBinaryFloatingPoint(value, atKey: key) - } + mutating func encode(_ value: Float, forKey key: Key) throws { try _insertBinaryFloatingPoint(value, atKey: key) } - mutating func encode(_ value: Int, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: Int, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: Int8, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: Int8, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: Int16, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: Int16, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: Int32, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: Int32, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: Int64, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: Int64, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: UInt, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: UInt, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: UInt8, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: UInt8, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: UInt16, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: UInt16, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: UInt32, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: UInt32, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } - mutating func encode(_ value: UInt64, forKey key: Key) throws { - try _insertFixedWidthInteger(value, atKey: key) - } + mutating func encode(_ value: UInt64, forKey key: Key) throws { try _insertFixedWidthInteger(value, atKey: key) } mutating func encode(_ value: T, forKey key: Key) throws where T: Encodable { switch value { - case let value as UInt8: - try encode(value, forKey: key) - case let value as Int8: - try encode(value, forKey: key) - case let value as UInt16: - try encode(value, forKey: key) - case let value as Int16: - try encode(value, forKey: key) - case let value as UInt32: - try encode(value, forKey: key) - case let value as Int32: - try encode(value, forKey: key) - case let value as UInt64: - try encode(value, forKey: key) - case let value as Int64: - try encode(value, forKey: key) - case let value as Int: - try encode(value, forKey: key) - case let value as UInt: - try encode(value, forKey: key) - case let value as Float: - try encode(value, forKey: key) - case let value as Double: - try encode(value, forKey: key) - case let value as String: - try encode(value, forKey: key) - case let value as Bool: - try encode(value, forKey: key) - case let value as Date: - try _insertValue(.date(value), atKey: key) + case let value as UInt8: try encode(value, forKey: key) + case let value as Int8: try encode(value, forKey: key) + case let value as UInt16: try encode(value, forKey: key) + case let value as Int16: try encode(value, forKey: key) + case let value as UInt32: try encode(value, forKey: key) + case let value as Int32: try encode(value, forKey: key) + case let value as UInt64: try encode(value, forKey: key) + case let value as Int64: try encode(value, forKey: key) + case let value as Int: try encode(value, forKey: key) + case let value as UInt: try encode(value, forKey: key) + case let value as Float: try encode(value, forKey: key) + case let value as Double: try encode(value, forKey: key) + case let value as String: try encode(value, forKey: key) + case let value as Bool: try encode(value, forKey: key) + case let value as Date: try _insertValue(.date(value), atKey: key) default: encoder.push(key: .init(key), newStorage: .unset) try value.encode(to: encoder) @@ -179,24 +128,13 @@ extension URIKeyedEncodingContainer: KeyedEncodingContainerProtocol { } } - mutating func nestedContainer( - keyedBy keyType: NestedKey.Type, - forKey key: Key - ) -> KeyedEncodingContainer where NestedKey: CodingKey { - encoder.container(keyedBy: NestedKey.self) - } + mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) + -> KeyedEncodingContainer where NestedKey: CodingKey + { encoder.container(keyedBy: NestedKey.self) } - mutating func nestedUnkeyedContainer( - forKey key: Key - ) -> any UnkeyedEncodingContainer { - encoder.unkeyedContainer() - } + mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { encoder.unkeyedContainer() } - mutating func superEncoder() -> any Encoder { - encoder - } + mutating func superEncoder() -> any Encoder { encoder } - mutating func superEncoder(forKey key: Key) -> any Encoder { - encoder - } + mutating func superEncoder(forKey key: Key) -> any Encoder { encoder } } diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift index e2a45b6a..31a82f60 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Single.swift @@ -26,9 +26,7 @@ extension URISingleValueEncodingContainer { /// Sets the provided primitive value to the underlying node. /// - Parameter node: The primitive value to set. /// - Throws: An error if setting the primitive value to the underlying node fails. - private func _setValue(_ node: URIEncodedNode.Primitive) throws { - try encoder.currentStackEntry.storage.set(node) - } + private func _setValue(_ node: URIEncodedNode.Primitive) throws { try encoder.currentStackEntry.storage.set(node) } /// Sets the provided value to the underlying node. /// - Parameter value: The value to set. @@ -50,104 +48,58 @@ extension URISingleValueEncodingContainer { extension URISingleValueEncodingContainer: SingleValueEncodingContainer { - var codingPath: [any CodingKey] { - encoder.codingPath - } + var codingPath: [any CodingKey] { encoder.codingPath } func encodeNil() throws { // Nil is encoded as no value. } - func encode(_ value: Bool) throws { - try _setValue(.bool(value)) - } + func encode(_ value: Bool) throws { try _setValue(.bool(value)) } - func encode(_ value: String) throws { - try _setValue(.string(value)) - } + func encode(_ value: String) throws { try _setValue(.string(value)) } - func encode(_ value: Double) throws { - try _setBinaryFloatingPoint(value) - } + func encode(_ value: Double) throws { try _setBinaryFloatingPoint(value) } - func encode(_ value: Float) throws { - try _setBinaryFloatingPoint(value) - } + func encode(_ value: Float) throws { try _setBinaryFloatingPoint(value) } - func encode(_ value: Int) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: Int) throws { try _setFixedWidthInteger(value) } - func encode(_ value: Int8) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: Int8) throws { try _setFixedWidthInteger(value) } - func encode(_ value: Int16) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: Int16) throws { try _setFixedWidthInteger(value) } - func encode(_ value: Int32) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: Int32) throws { try _setFixedWidthInteger(value) } - func encode(_ value: Int64) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: Int64) throws { try _setFixedWidthInteger(value) } - func encode(_ value: UInt) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: UInt) throws { try _setFixedWidthInteger(value) } - func encode(_ value: UInt8) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: UInt8) throws { try _setFixedWidthInteger(value) } - func encode(_ value: UInt16) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: UInt16) throws { try _setFixedWidthInteger(value) } - func encode(_ value: UInt32) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: UInt32) throws { try _setFixedWidthInteger(value) } - func encode(_ value: UInt64) throws { - try _setFixedWidthInteger(value) - } + func encode(_ value: UInt64) throws { try _setFixedWidthInteger(value) } func encode(_ value: T) throws where T: Encodable { switch value { - case let value as UInt8: - try encode(value) - case let value as Int8: - try encode(value) - case let value as UInt16: - try encode(value) - case let value as Int16: - try encode(value) - case let value as UInt32: - try encode(value) - case let value as Int32: - try encode(value) - case let value as UInt64: - try encode(value) - case let value as Int64: - try encode(value) - case let value as Int: - try encode(value) - case let value as UInt: - try encode(value) - case let value as Float: - try encode(value) - case let value as Double: - try encode(value) - case let value as String: - try encode(value) - case let value as Bool: - try encode(value) - case let value as Date: - try _setValue(.date(value)) - default: - try value.encode(to: encoder) + case let value as UInt8: try encode(value) + case let value as Int8: try encode(value) + case let value as UInt16: try encode(value) + case let value as Int16: try encode(value) + case let value as UInt32: try encode(value) + case let value as Int32: try encode(value) + case let value as UInt64: try encode(value) + case let value as Int64: try encode(value) + case let value as Int: try encode(value) + case let value as UInt: try encode(value) + case let value as Float: try encode(value) + case let value as Double: try encode(value) + case let value as String: try encode(value) + case let value as Bool: try encode(value) + case let value as Date: try _setValue(.date(value)) + default: try value.encode(to: encoder) } } } diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift index 7dbf7d7a..35f71884 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift @@ -26,16 +26,12 @@ extension URIUnkeyedEncodingContainer { /// Appends the provided node to the underlying array. /// - Parameter node: The node to append. /// - Throws: An error if appending the node to the underlying array fails. - private func _appendValue(_ node: URIEncodedNode) throws { - try encoder.currentStackEntry.storage.append(node) - } + private func _appendValue(_ node: URIEncodedNode) throws { try encoder.currentStackEntry.storage.append(node) } /// Appends the provided primitive value as a node to the underlying array. /// - Parameter node: The value to append. /// - Throws: An error if appending the node to the underlying array fails. - private func _appendValue(_ node: URIEncodedNode.Primitive) throws { - try _appendValue(.primitive(node)) - } + private func _appendValue(_ node: URIEncodedNode.Primitive) throws { try _appendValue(.primitive(node)) } /// Appends the provided value as a node to the underlying array. /// - Parameter value: The value to append. @@ -57,127 +53,70 @@ extension URIUnkeyedEncodingContainer { extension URIUnkeyedEncodingContainer: UnkeyedEncodingContainer { - var codingPath: [any CodingKey] { - encoder.codingPath - } + var codingPath: [any CodingKey] { encoder.codingPath } var count: Int { switch encoder.currentStackEntry.storage { - case .array(let array): - return array.count - case .unset: - return 0 - default: - fatalError("Cannot have an unkeyed container at \(encoder.currentStackEntry).") + case .array(let array): return array.count + case .unset: return 0 + default: fatalError("Cannot have an unkeyed container at \(encoder.currentStackEntry).") } } - func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { - encoder.unkeyedContainer() - } + func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { encoder.unkeyedContainer() } - func nestedContainer( - keyedBy keyType: NestedKey.Type - ) -> KeyedEncodingContainer where NestedKey: CodingKey { - encoder.container(keyedBy: NestedKey.self) - } + func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer + where NestedKey: CodingKey { encoder.container(keyedBy: NestedKey.self) } - func superEncoder() -> any Encoder { - encoder - } + func superEncoder() -> any Encoder { encoder } - func encodeNil() throws { - throw URIValueToNodeEncoder.GeneralError.nilNotSupported - } + func encodeNil() throws { throw URIValueToNodeEncoder.GeneralError.nilNotSupported } - func encode(_ value: Bool) throws { - try _appendValue(.bool(value)) - } + func encode(_ value: Bool) throws { try _appendValue(.bool(value)) } - func encode(_ value: String) throws { - try _appendValue(.string(value)) - } + func encode(_ value: String) throws { try _appendValue(.string(value)) } - func encode(_ value: Double) throws { - try _appendBinaryFloatingPoint(value) - } + func encode(_ value: Double) throws { try _appendBinaryFloatingPoint(value) } - func encode(_ value: Float) throws { - try _appendBinaryFloatingPoint(value) - } + func encode(_ value: Float) throws { try _appendBinaryFloatingPoint(value) } - func encode(_ value: Int) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: Int) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: Int8) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: Int8) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: Int16) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: Int16) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: Int32) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: Int32) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: Int64) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: Int64) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: UInt) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: UInt) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: UInt8) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: UInt8) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: UInt16) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: UInt16) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: UInt32) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: UInt32) throws { try _appendFixedWidthInteger(value) } - func encode(_ value: UInt64) throws { - try _appendFixedWidthInteger(value) - } + func encode(_ value: UInt64) throws { try _appendFixedWidthInteger(value) } func encode(_ value: T) throws where T: Encodable { switch value { - case let value as UInt8: - try encode(value) - case let value as Int8: - try encode(value) - case let value as UInt16: - try encode(value) - case let value as Int16: - try encode(value) - case let value as UInt32: - try encode(value) - case let value as Int32: - try encode(value) - case let value as UInt64: - try encode(value) - case let value as Int64: - try encode(value) - case let value as Int: - try encode(value) - case let value as UInt: - try encode(value) - case let value as Float: - try encode(value) - case let value as Double: - try encode(value) - case let value as String: - try encode(value) - case let value as Bool: - try encode(value) - case let value as Date: - try _appendValue(.date(value)) + case let value as UInt8: try encode(value) + case let value as Int8: try encode(value) + case let value as UInt16: try encode(value) + case let value as Int16: try encode(value) + case let value as UInt32: try encode(value) + case let value as Int32: try encode(value) + case let value as UInt64: try encode(value) + case let value as Int64: try encode(value) + case let value as Int: try encode(value) + case let value as UInt: try encode(value) + case let value as Float: try encode(value) + case let value as Double: try encode(value) + case let value as String: try encode(value) + case let value as Bool: try encode(value) + case let value as Date: try _appendValue(.date(value)) default: encoder.push(key: .init(intValue: count), newStorage: .unset) try value.encode(to: encoder) diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift index d46ec9df..b48f2c2f 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder.swift @@ -52,10 +52,7 @@ final class URIValueToNodeEncoder { /// Creates a new encoder. init() { self._codingPath = [] - self.currentStackEntry = CodingStackEntry( - key: .init(stringValue: ""), - storage: .unset - ) + self.currentStackEntry = CodingStackEntry(key: .init(stringValue: ""), storage: .unset) } /// Encodes the provided value into a node. @@ -65,10 +62,7 @@ final class URIValueToNodeEncoder { func encodeValue(_ value: some Encodable) throws -> URIEncodedNode { defer { _codingPath = [] - currentStackEntry = CodingStackEntry( - key: .init(stringValue: ""), - storage: .unset - ) + currentStackEntry = CodingStackEntry(key: .init(stringValue: ""), storage: .unset) } // We have to catch the special values early, otherwise we fall @@ -117,28 +111,16 @@ extension URIValueToNodeEncoder: Encoder { // The coding path meaningful to the types conforming to Codable. // 1. Omit the root coding path. // 2. Add the current stack entry's coding path. - (_codingPath - .dropFirst() - .map(\.key) - + [currentStackEntry.key]) - .map { $0 as any CodingKey } + (_codingPath.dropFirst().map(\.key) + [currentStackEntry.key]).map { $0 as any CodingKey } } - var userInfo: [CodingUserInfoKey: Any] { - [:] - } + var userInfo: [CodingUserInfoKey: Any] { [:] } - func container( - keyedBy type: Key.Type - ) -> KeyedEncodingContainer where Key: CodingKey { + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { KeyedEncodingContainer(URIKeyedEncodingContainer(encoder: self)) } - func unkeyedContainer() -> any UnkeyedEncodingContainer { - URIUnkeyedEncodingContainer(encoder: self) - } + func unkeyedContainer() -> any UnkeyedEncodingContainer { URIUnkeyedEncodingContainer(encoder: self) } - func singleValueContainer() -> any SingleValueEncodingContainer { - URISingleValueEncodingContainer(encoder: self) - } + func singleValueContainer() -> any SingleValueEncodingContainer { URISingleValueEncodingContainer(encoder: self) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index 793f7fcc..3be75420 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -59,21 +59,15 @@ extension URIParser { // if the style is simple, otherwise it's an empty dictionary. if data.isEmpty { switch configuration.style { - case .form: - return [:] - case .simple: - return ["": [""]] + case .form: return [:] + case .simple: return ["": [""]] } } 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 (.form, true): return try parseExplodedFormRoot() + case (.form, false): return try parseUnexplodedFormRoot() + case (.simple, true): return try parseExplodedSimpleRoot() + case (.simple, false): return try parseUnexplodedSimpleRoot() } } @@ -154,8 +148,7 @@ extension URIParser { } key = firstValue values = accumulatedValues - case .foundSecondOrEnd: - throw ParsingError.malformedKeyValuePair(firstValue) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } appendPair(key, values) } @@ -207,9 +200,7 @@ extension URIParser { try parseGenericRoot { data, appendPair in let pairSeparator: Character = "," while !data.isEmpty { - let value = data.parseUpToCharacterOrEnd( - pairSeparator - ) + let value = data.parseUpToCharacterOrEnd(pairSeparator) appendPair(.init(), [value]) } } @@ -225,18 +216,14 @@ extension URIParser { /// 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 { + 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) - } + let unescapeValue: (Raw) -> Raw = { Self.unescapeValue($0, spaceEscapingCharacter: spaceEscapingCharacter) } try parser(&data) { key, values in - let newItem = [ - unescapeValue(key): values.map(unescapeValue) - ] + let newItem = [unescapeValue(key): values.map(unescapeValue)] root.merge(newItem) { $0 + $1 } } return root @@ -246,10 +233,7 @@ extension URIParser { /// - Parameter escapedValue: An escaped string. /// - Returns: The provided string with escaping removed. private func unescapeValue(_ escapedValue: Raw) -> Raw { - Self.unescapeValue( - escapedValue, - spaceEscapingCharacter: configuration.spaceEscapingCharacter - ) + Self.unescapeValue(escapedValue, spaceEscapingCharacter: configuration.spaceEscapingCharacter) } /// Removes escaping from the provided string. @@ -263,10 +247,7 @@ extension URIParser { spaceEscapingCharacter: URICoderConfiguration.SpaceEscapingCharacter ) -> Raw { // The inverse of URISerializer.computeSafeString. - let partiallyDecoded = escapedValue.replacingOccurrences( - of: spaceEscapingCharacter.rawValue, - with: " " - ) + let partiallyDecoded = escapedValue.replacingOccurrences(of: spaceEscapingCharacter.rawValue, with: " ") return (partiallyDecoded.removingPercentEncoding ?? "")[...] } } @@ -292,19 +273,14 @@ extension String.SubSequence { /// - second: Another character to stop at. /// - Returns: A result indicating which character was detected, if any, and /// the accumulated substring. - fileprivate mutating func parseUpToEitherCharacterOrEnd( - first: Character, - second: Character - ) -> (ParseUpToEitherCharacterResult, Self) { + fileprivate mutating func parseUpToEitherCharacterOrEnd(first: Character, second: Character) -> ( + ParseUpToEitherCharacterResult, Self + ) { let startIndex = startIndex - guard startIndex != endIndex else { - return (.foundSecondOrEnd, .init()) - } + guard startIndex != endIndex else { return (.foundSecondOrEnd, .init()) } var currentIndex = startIndex - func finalize( - _ result: ParseUpToEitherCharacterResult - ) -> (ParseUpToEitherCharacterResult, Self) { + func finalize(_ result: ParseUpToEitherCharacterResult) -> (ParseUpToEitherCharacterResult, Self) { let parsed = self[startIndex.. Self { + fileprivate mutating func parseUpToCharacterOrEnd(_ character: Character) -> Self { let startIndex = startIndex - guard startIndex != endIndex else { - return .init() - } + guard startIndex != endIndex else { return .init() } var currentIndex = startIndex func finalize() -> Self { @@ -350,11 +322,7 @@ extension String.SubSequence { } while currentIndex != endIndex { let currentChar = self[currentIndex] - if currentChar == character { - return finalize() - } else { - formIndex(after: ¤tIndex) - } + if currentChar == character { return finalize() } else { formIndex(after: ¤tIndex) } } return finalize() } diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index 2e3e8b20..26071f85 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -39,13 +39,8 @@ struct URISerializer { /// style and explode parameters in the configuration). /// - Returns: The URI-encoded data for the provided node. /// - Throws: An error if serialization of the node fails. - mutating func serializeNode( - _ value: URIEncodedNode, - forKey key: String - ) throws -> String { - defer { - data.removeAll(keepingCapacity: true) - } + mutating func serializeNode(_ value: URIEncodedNode, forKey key: String) throws -> String { + defer { data.removeAll(keepingCapacity: true) } try serializeTopLevelNode(value, forKey: key) return data } @@ -83,10 +78,7 @@ extension URISerializer { // The space character needs to be encoded based on the config, // so first allow it to be unescaped, and then we'll do a second // pass and only encode the space based on the config. - let partiallyEncoded = - unsafeString.addingPercentEncoding( - withAllowedCharacters: .unreservedAndSpace - ) ?? "" + let partiallyEncoded = unsafeString.addingPercentEncoding(withAllowedCharacters: .unreservedAndSpace) ?? "" let fullyEncoded = partiallyEncoded.replacingOccurrences( of: " ", with: configuration.spaceEscapingCharacter.rawValue @@ -100,9 +92,7 @@ extension URISerializer { /// - Throws: An error if the key cannot be converted to an escaped string. private func stringifiedKey(_ key: String) throws -> String { // The root key is handled separately. - guard !key.isEmpty else { - return "" - } + guard !key.isEmpty else { return "" } let safeTopLevelKey = computeSafeString(key) return safeTopLevelKey } @@ -113,14 +103,9 @@ 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 value fails. - private mutating func serializeTopLevelNode( - _ value: URIEncodedNode, - forKey key: String - ) throws { + private mutating func serializeTopLevelNode(_ value: URIEncodedNode, forKey key: String) throws { func unwrapPrimitiveValue(_ node: URIEncodedNode) throws -> URIEncodedNode.Primitive { - guard case let .primitive(primitive) = node else { - throw SerializationError.nestedContainersNotSupported - } + guard case let .primitive(primitive) = node else { throw SerializationError.nestedContainersNotSupported } return primitive } switch value { @@ -130,47 +115,27 @@ extension URISerializer { case .primitive(let primitive): let keyAndValueSeparator: String? switch configuration.style { - case .form: - keyAndValueSeparator = "=" - case .simple: - keyAndValueSeparator = nil + case .form: keyAndValueSeparator = "=" + case .simple: keyAndValueSeparator = nil } - try serializePrimitiveKeyValuePair( - primitive, - forKey: key, - separator: keyAndValueSeparator - ) - case .array(let array): - try serializeArray( - array.map(unwrapPrimitiveValue), - forKey: key - ) + 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(unwrapPrimitiveValue), forKey: key) } } /// Serializes the provided value into the underlying string. /// - Parameter value: The primitive value to serialize. /// - Throws: An error if serialization of the primitive value fails. - private mutating func serializePrimitiveValue( - _ value: URIEncodedNode.Primitive - ) throws { + private mutating func serializePrimitiveValue(_ value: URIEncodedNode.Primitive) throws { let stringValue: String switch value { - case .bool(let bool): - stringValue = bool.description - case .string(let string): - stringValue = computeSafeString(string) - case .integer(let int): - stringValue = int.description - case .double(let double): - stringValue = double.description - case .date(let date): - stringValue = try computeSafeString(configuration.dateTranscoder.encode(date)) + case .bool(let bool): stringValue = bool.description + case .string(let string): stringValue = computeSafeString(string) + case .integer(let int): stringValue = int.description + case .double(let double): stringValue = double.description + case .date(let date): stringValue = try computeSafeString(configuration.dateTranscoder.encode(date)) } data.append(stringValue) } @@ -201,13 +166,8 @@ 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 array fails. - private mutating func serializeArray( - _ array: [URIEncodedNode.Primitive], - forKey key: String - ) throws { - guard !array.isEmpty else { - return - } + 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) { @@ -223,11 +183,7 @@ extension URISerializer { } func serializeNext(_ element: URIEncodedNode.Primitive) throws { if let keyAndValueSeparator { - try serializePrimitiveKeyValuePair( - element, - forKey: key, - separator: keyAndValueSeparator - ) + try serializePrimitiveKeyValuePair(element, forKey: key, separator: keyAndValueSeparator) } else { try serializePrimitiveValue(element) } @@ -240,9 +196,7 @@ extension URISerializer { try serializeNext(element) data.append(pairSeparator) } - if let element = array.last { - try serializeNext(element) - } + if let element = array.last { try serializeNext(element) } } /// Serializes the provided dictionary into the underlying string. @@ -251,19 +205,13 @@ 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 { - guard !dictionary.isEmpty else { - return + private mutating func serializeDictionary(_ dictionary: [String: URIEncodedNode.Primitive], forKey key: String) + throws + { + guard !dictionary.isEmpty else { return } + let sortedDictionary = dictionary.sorted { a, b in + a.key.localizedCaseInsensitiveCompare(b.key) == .orderedAscending } - let sortedDictionary = - dictionary - .sorted { a, b in - a.key.localizedCaseInsensitiveCompare(b.key) - == .orderedAscending - } let keyAndValueSeparator: String let pairSeparator: String @@ -283,11 +231,7 @@ extension URISerializer { } func serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws { - try serializePrimitiveKeyValuePair( - element, - forKey: elementKey, - separator: keyAndValueSeparator - ) + try serializePrimitiveKeyValuePair(element, forKey: elementKey, separator: keyAndValueSeparator) } if let containerKeyAndValue = configuration.containerKeyAndValueSeparator { data.append(try stringifiedKey(key)) @@ -297,9 +241,7 @@ extension URISerializer { try serializeNext(element, forKey: elementKey) data.append(pairSeparator) } - if let (elementKey, element) = sortedDictionary.last { - try serializeNext(element, forKey: elementKey) - } + if let (elementKey, element) = sortedDictionary.last { try serializeNext(element, forKey: elementKey) } } } @@ -310,10 +252,8 @@ extension URICoderConfiguration { /// serialized, only the value. fileprivate var containerKeyAndValueSeparator: String? { switch (style, explode) { - case (.form, false): - return "=" - default: - return nil + case (.form, false): return "=" + default: return nil } } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift b/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift index 405af225..31927b0b 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift @@ -20,25 +20,19 @@ enum TestAcceptable: AcceptableProtocol { init?(rawValue: String) { switch rawValue { - case "application/json": - self = .json - default: - self = .other(rawValue) + case "application/json": self = .json + default: self = .other(rawValue) } } var rawValue: String { switch self { - case .json: - return "application/json" - case .other(let string): - return string + case .json: return "application/json" + case .other(let string): return string } } - static var allCases: [TestAcceptable] { - [.json] - } + static var allCases: [TestAcceptable] { [.json] } } final class Test_AcceptHeaderContentType: Test_Runtime { @@ -48,43 +42,23 @@ final class Test_AcceptHeaderContentType: Test_Runtime { XCTAssertEqual(contentType.contentType, .json) XCTAssertEqual(contentType.quality, 1.0) XCTAssertEqual(contentType.rawValue, "application/json") - XCTAssertEqual( - AcceptHeaderContentType(rawValue: "application/json"), - contentType - ) + XCTAssertEqual(AcceptHeaderContentType(rawValue: "application/json"), contentType) } do { - let contentType = AcceptHeaderContentType( - contentType: TestAcceptable.json, - quality: 0.5 - ) + let contentType = AcceptHeaderContentType(contentType: TestAcceptable.json, quality: 0.5) XCTAssertEqual(contentType.contentType, .json) XCTAssertEqual(contentType.quality, 0.5) XCTAssertEqual(contentType.rawValue, "application/json; q=0.500") - XCTAssertEqual( - AcceptHeaderContentType(rawValue: "application/json; q=0.500"), - contentType - ) - } - do { - XCTAssertEqual( - AcceptHeaderContentType.defaultValues, - [ - .init(contentType: .json) - ] - ) + XCTAssertEqual(AcceptHeaderContentType(rawValue: "application/json; q=0.500"), contentType) } + do { XCTAssertEqual(AcceptHeaderContentType.defaultValues, [.init(contentType: .json)]) } do { let unsorted: [AcceptHeaderContentType] = [ - .init(contentType: .other("*/*"), quality: 0.3), - .init(contentType: .json, quality: 0.5), + .init(contentType: .other("*/*"), quality: 0.3), .init(contentType: .json, quality: 0.5), ] XCTAssertEqual( unsorted.sortedByQuality(), - [ - .init(contentType: .json, quality: 0.5), - .init(contentType: .other("*/*"), quality: 0.3), - ] + [.init(contentType: .json, quality: 0.5), .init(contentType: .other("*/*"), quality: 0.3)] ) } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_CopyOnWriteBox.swift b/Tests/OpenAPIRuntimeTests/Base/Test_CopyOnWriteBox.swift index 59c9bd56..36121d7b 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_CopyOnWriteBox.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_CopyOnWriteBox.swift @@ -22,62 +22,19 @@ final class Test_CopyOnWriteBox: Test_Runtime { } func testModification() throws { - var value = Node( - id: 3, - parent: .init( - value: .init( - id: 2 - ) - ) - ) - XCTAssertEqual( - value, - Node( - id: 3, - parent: .init( - value: .init( - id: 2 - ) - ) - ) - ) + var value = Node(id: 3, parent: .init(value: .init(id: 2))) + XCTAssertEqual(value, Node(id: 3, parent: .init(value: .init(id: 2)))) value.parent!.value.parent = .init(value: .init(id: 1)) - XCTAssertEqual( - value, - Node( - id: 3, - parent: .init( - value: .init( - id: 2, - parent: .init( - value: .init(id: 1) - ) - ) - ) - ) - ) + XCTAssertEqual(value, Node(id: 3, parent: .init(value: .init(id: 2, parent: .init(value: .init(id: 1)))))) } func testSerialization() throws { let value = CopyOnWriteBox(value: "Hello") - try testRoundtrip( - value, - expectedJSON: #""Hello""# - ) + try testRoundtrip(value, expectedJSON: #""Hello""#) } func testIntegration() throws { - let value = Node( - id: 3, - parent: .init( - value: .init( - id: 2, - parent: .init( - value: .init(id: 1) - ) - ) - ) - ) + let value = Node(id: 3, parent: .init(value: .init(id: 2, parent: .init(value: .init(id: 1))))) try testRoundtrip( value, expectedJSON: #""" diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift index 3fbbf97d..207d3920 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -20,35 +20,23 @@ final class Test_OpenAPIMIMEType: Test_Runtime { // Common ( - "application/json", - OpenAPIMIMEType(kind: .concrete(type: "application", subtype: "json")), + "application/json", OpenAPIMIMEType(kind: .concrete(type: "application", subtype: "json")), "application/json" ), // Subtype wildcard - ( - "application/*", - OpenAPIMIMEType(kind: .anySubtype(type: "application")), - "application/*" - ), + ("application/*", OpenAPIMIMEType(kind: .anySubtype(type: "application")), "application/*"), // Type wildcard - ( - "*/*", - OpenAPIMIMEType(kind: .any), - "*/*" - ), + ("*/*", OpenAPIMIMEType(kind: .any), "*/*"), // Common with a parameter ( "application/json; charset=UTF-8", OpenAPIMIMEType( kind: .concrete(type: "application", subtype: "json"), - parameters: [ - "charset": "UTF-8" - ] - ), - "application/json; charset=UTF-8" + parameters: ["charset": "UTF-8"] + ), "application/json; charset=UTF-8" ), // Common with two parameters @@ -56,12 +44,8 @@ final class Test_OpenAPIMIMEType: Test_Runtime { "application/json; charset=UTF-8; boundary=1234", OpenAPIMIMEType( kind: .concrete(type: "application", subtype: "json"), - parameters: [ - "charset": "UTF-8", - "boundary": "1234", - ] - ), - "application/json; boundary=1234; charset=UTF-8" + parameters: ["charset": "UTF-8", "boundary": "1234"] + ), "application/json; boundary=1234; charset=UTF-8" ), // Common case preserving, but case insensitive equality @@ -69,17 +53,12 @@ final class Test_OpenAPIMIMEType: Test_Runtime { "APPLICATION/JSON;CHARSET=UTF-8", OpenAPIMIMEType( kind: .concrete(type: "application", subtype: "json"), - parameters: [ - "charset": "UTF-8" - ] - ), - "APPLICATION/JSON; CHARSET=UTF-8" + parameters: ["charset": "UTF-8"] + ), "APPLICATION/JSON; CHARSET=UTF-8" ), // Invalid - ("application", nil, nil), - ("application/foo/bar", nil, nil), - ("", nil, nil), + ("application", nil, nil), ("application/foo/bar", nil, nil), ("", nil, nil), ] for (inputString, expectedMIME, outputString) in cases { let mime = OpenAPIMIMEType(inputString) @@ -91,20 +70,15 @@ final class Test_OpenAPIMIMEType: Test_Runtime { func testScore() throws { let cases: [(OpenAPIMIMEType.Match, Int)] = [ - (.incompatible(.type), 0), - (.incompatible(.subtype), 0), - (.incompatible(.parameter(name: "foo")), 0), + (.incompatible(.type), 0), (.incompatible(.subtype), 0), (.incompatible(.parameter(name: "foo")), 0), (.wildcard, 1), (.subtypeWildcard, 2), - (.typeAndSubtype(matchedParameterCount: 0), 3), - (.typeAndSubtype(matchedParameterCount: 2), 5), + (.typeAndSubtype(matchedParameterCount: 0), 3), (.typeAndSubtype(matchedParameterCount: 2), 5), ] - for (match, score) in cases { - XCTAssertEqual(match.score, score, "Mismatch for match: \(match)") - } + for (match, score) in cases { XCTAssertEqual(match.score, score, "Mismatch for match: \(match)") } } func testEvaluate() throws { @@ -141,10 +115,7 @@ final class Test_OpenAPIMIMEType: Test_Runtime { testCase( receivedType: "application", receivedSubtype: "json", - receivedParameters: [ - "charset": "utf-8", - "version": "1", - ], + receivedParameters: ["charset": "utf-8", "version": "1"], against: option, expected: expectedMatch, file: file, @@ -154,25 +125,10 @@ final class Test_OpenAPIMIMEType: Test_Runtime { // Actual test cases start here. - testJSONWith2Params( - against: jsonWith2Params, - expected: .typeAndSubtype(matchedParameterCount: 2) - ) - testJSONWith2Params( - against: jsonWith1Param, - expected: .typeAndSubtype(matchedParameterCount: 1) - ) - testJSONWith2Params( - against: json, - expected: .typeAndSubtype(matchedParameterCount: 0) - ) - testJSONWith2Params( - against: subtypeWildcard, - expected: .subtypeWildcard - ) - testJSONWith2Params( - against: fullWildcard, - expected: .wildcard - ) + testJSONWith2Params(against: jsonWith2Params, expected: .typeAndSubtype(matchedParameterCount: 2)) + testJSONWith2Params(against: jsonWith1Param, expected: .typeAndSubtype(matchedParameterCount: 1)) + testJSONWith2Params(against: json, expected: .typeAndSubtype(matchedParameterCount: 0)) + testJSONWith2Params(against: subtypeWildcard, expected: .subtypeWildcard) + testJSONWith2Params(against: fullWildcard, expected: .wildcard) } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 5549e88a..080f5dd1 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -36,18 +36,8 @@ final class Test_OpenAPIValue: Test_Runtime { func testEncoding_container_success() throws { let values: [(any Sendable)?] = [ - nil, - "Hello", - [ - "key": "value", - "anotherKey": [ - 1, - "two", - ] as [any Sendable], - ] as [String: any Sendable], - 1 as Int, - 2.5 as Double, - [true], + nil, "Hello", ["key": "value", "anotherKey": [1, "two"] as [any Sendable]] as [String: any Sendable], + 1 as Int, 2.5 as Double, [true], ] let container = try OpenAPIValueContainer(unvalidatedValue: values) let expectedString = #""" @@ -127,12 +117,7 @@ final class Test_OpenAPIValue: Test_Runtime { } func testEncoding_object_success() throws { - let values: [String: (any Sendable)?] = [ - "key": "value", - "keyMore": [ - true - ], - ] + let values: [String: (any Sendable)?] = ["key": "value", "keyMore": [true]] let container = try OpenAPIObjectContainer(unvalidatedValue: values) let expectedString = #""" { @@ -162,10 +147,7 @@ final class Test_OpenAPIValue: Test_Runtime { } func testEncoding_array_success() throws { - let values: [(any Sendable)?] = [ - "one", - ["two": 2], - ] + let values: [(any Sendable)?] = ["one", ["two": 2]] let container = try OpenAPIArrayContainer(unvalidatedValue: values) let expectedString = #""" [ @@ -203,17 +185,8 @@ final class Test_OpenAPIValue: Test_Runtime { Foo( bar: "hi", dict: try .init(unvalidatedValue: [ - "baz": "bar", - "number": 1, - "nestedArray": [ - 1, - [ - "k": "v" - ], - ] as [(any Sendable)?], - "nestedDict": [ - "nested": 2 - ], + "baz": "bar", "number": 1, "nestedArray": [1, ["k": "v"]] as [(any Sendable)?], + "nestedDict": ["nested": 2], ]) ), expectedJSON: #""" @@ -280,10 +253,7 @@ final class Test_OpenAPIValue: Test_Runtime { // `testStructBase64EncodedString` quoted and base64-encoded again let JSONEncoded = Data(base64Encoded: "ImV5SnVZVzFsSWpvaVJteDFabVo2SW4wPSI=")! - XCTAssertEqual( - try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoded), - encodedData - ) + XCTAssertEqual(try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoded), encodedData) } func testEncodingDecodingRoundtrip_base64_success() throws { diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_CodableExtensions.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_CodableExtensions.swift index 03d02e12..8c613659 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_CodableExtensions.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_CodableExtensions.swift @@ -16,9 +16,7 @@ import XCTest final class Test_CodableExtensions: Test_Runtime { - var testDecoder: JSONDecoder { - JSONDecoder() - } + var testDecoder: JSONDecoder { JSONDecoder() } var testEncoder: JSONEncoder { let encoder = JSONEncoder() @@ -31,16 +29,12 @@ final class Test_CodableExtensions: Test_Runtime { struct Foo: Decodable { var bar: String - enum CodingKeys: String, CodingKey { - case bar - } + enum CodingKeys: String, CodingKey { case bar } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.bar = try container.decode(String.self, forKey: .bar) - try decoder.ensureNoAdditionalProperties( - knownKeys: ["bar"] - ) + try decoder.ensureNoAdditionalProperties(knownKeys: ["bar"]) } } @@ -87,16 +81,12 @@ final class Test_CodableExtensions: Test_Runtime { var bar: String var additionalProperties: OpenAPIObjectContainer - enum CodingKeys: String, CodingKey { - case bar - } + enum CodingKeys: String, CodingKey { case bar } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.bar = try container.decode(String.self, forKey: .bar) - self.additionalProperties = - try decoder - .decodeAdditionalProperties(knownKeys: ["bar"]) + self.additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: ["bar"]) } } @@ -138,16 +128,12 @@ final class Test_CodableExtensions: Test_Runtime { var bar: String var additionalProperties: [String: Int] - enum CodingKeys: String, CodingKey { - case bar - } + enum CodingKeys: String, CodingKey { case bar } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.bar = try container.decode(String.self, forKey: .bar) - self.additionalProperties = - try decoder - .decodeAdditionalProperties(knownKeys: ["bar"]) + self.additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: ["bar"]) } } @@ -189,9 +175,7 @@ final class Test_CodableExtensions: Test_Runtime { var bar: String var additionalProperties = OpenAPIObjectContainer() - enum CodingKeys: String, CodingKey { - case bar - } + enum CodingKeys: String, CodingKey { case bar } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -201,9 +185,7 @@ final class Test_CodableExtensions: Test_Runtime { } do { - let value = Foo( - bar: "hi" - ) + let value = Foo(bar: "hi") let data = try testEncoder.encode(value) XCTAssertEqual( String(decoding: data, as: UTF8.self), @@ -216,13 +198,7 @@ final class Test_CodableExtensions: Test_Runtime { } do { - let value = Foo( - bar: "hi", - additionalProperties: try .init(unvalidatedValue: [ - "baz": "bar", - "number": 1, - ]) - ) + let value = Foo(bar: "hi", additionalProperties: try .init(unvalidatedValue: ["baz": "bar", "number": 1])) let data = try testEncoder.encode(value) XCTAssertEqual( String(decoding: data, as: UTF8.self), @@ -243,9 +219,7 @@ final class Test_CodableExtensions: Test_Runtime { var bar: String var additionalProperties: [String: Int] = [:] - enum CodingKeys: String, CodingKey { - case bar - } + enum CodingKeys: String, CodingKey { case bar } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -255,9 +229,7 @@ final class Test_CodableExtensions: Test_Runtime { } do { - let value = Foo( - bar: "hi" - ) + let value = Foo(bar: "hi") let data = try testEncoder.encode(value) XCTAssertEqual( String(decoding: data, as: UTF8.self), @@ -270,12 +242,7 @@ final class Test_CodableExtensions: Test_Runtime { } do { - let value = Foo( - bar: "hi", - additionalProperties: [ - "number": 1 - ] - ) + let value = Foo(bar: "hi", additionalProperties: ["number": 1]) let data = try testEncoder.encode(value) XCTAssertEqual( String(decoding: data, as: UTF8.self), diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 250642ce..135bdf46 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -23,12 +23,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { in: &headerFields, contentTypes: [.init(contentType: TestAcceptable.json, quality: 0.8)] ) - XCTAssertEqual( - headerFields, - [ - .accept: "application/json; q=0.800" - ] - ) + XCTAssertEqual(headerFields, [.accept: "application/json; q=0.800"]) } // MARK: Converter helper methods @@ -37,11 +32,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { func test_renderedPath_string() throws { let renderedPath = try converter.renderedPath( template: "/items/{}/detail/{}/habitats/{}", - parameters: [ - 1 as Int, - "foo" as String, - [.land, .air] as [TestHabitat], - ] + parameters: [1 as Int, "foo" as String, [.land, .air] as [TestHabitat]] ) XCTAssertEqual(renderedPath, "/items/1/detail/foo/habitats/land,air") } @@ -49,37 +40,19 @@ final class Test_ClientConverterExtensions: Test_Runtime { // | client | set | request query | URI | both | setQueryItemAsURI | func test_setQueryItemAsURI_string() throws { var request = testRequest - try converter.setQueryItemAsURI( - in: &request, - style: nil, - explode: nil, - name: "search", - value: "foo" - ) + try converter.setQueryItemAsURI(in: &request, style: nil, explode: nil, name: "search", value: "foo") XCTAssertEqual(request.soar_query, "search=foo") } func test_setQueryItemAsURI_stringConvertible_needsEncoding() throws { var request = testRequest - try converter.setQueryItemAsURI( - in: &request, - style: nil, - explode: nil, - name: "search", - value: "h%llo" - ) + try converter.setQueryItemAsURI(in: &request, style: nil, explode: nil, name: "search", value: "h%llo") XCTAssertEqual(request.soar_query, "search=h%25llo") } func test_setQueryItemAsURI_arrayOfStrings() throws { var request = testRequest - try converter.setQueryItemAsURI( - in: &request, - style: nil, - explode: nil, - name: "search", - value: ["foo", "bar"] - ) + try converter.setQueryItemAsURI(in: &request, style: nil, explode: nil, name: "search", value: ["foo", "bar"]) XCTAssertEqual(request.soar_query, "search=foo&search=bar") } @@ -97,13 +70,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { func test_setQueryItemAsURI_date() throws { var request = testRequest - try converter.setQueryItemAsURI( - in: &request, - style: nil, - explode: nil, - name: "search", - value: testDate - ) + try converter.setQueryItemAsURI(in: &request, style: nil, explode: nil, name: "search", value: testDate) XCTAssertEqual(request.soar_query, "search=2023-01-18T10%3A04%3A11Z") } @@ -128,12 +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"]) } func test_setOptionalRequestBodyAsJSON_codable_string() async throws { @@ -144,12 +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"]) } // | client | set | request body | JSON | required | setRequiredRequestBodyAsJSON | @@ -161,12 +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"]) } // | client | set | request body | urlEncodedForm | codable | optional | setRequiredRequestBodyAsURLEncodedForm | @@ -184,12 +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"]) } // | client | set | request body | urlEncodedForm | codable | required | setRequiredRequestBodyAsURLEncodedForm | @@ -201,12 +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"]) } // | client | set | request body | binary | optional | setOptionalRequestBodyAsBinary | @@ -218,12 +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"]) } // | client | set | request body | binary | required | setRequiredRequestBodyAsBinary | @@ -235,12 +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"]) } // | client | get | response body | JSON | required | getResponseBodyAsJSON | @@ -282,7 +214,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.localizedDescription, file: file, line: line) } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 4819e912..da68208f 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -15,40 +15,26 @@ import XCTest @_spi(Generated) import OpenAPIRuntime import HTTPTypes -extension HTTPField.Name { - static var foo: Self { - Self("foo")! - } -} +extension HTTPField.Name { static var foo: Self { Self("foo")! } } final class Test_CommonConverterExtensions: Test_Runtime { // MARK: Miscs - @available(*, deprecated) - func testContentTypeMatching() throws { + @available(*, deprecated) func testContentTypeMatching() throws { let cases: [(received: String, expected: String, isMatch: Bool)] = [ - ("application/json", "application/json", true), - ("APPLICATION/JSON", "application/json", true), - ("application/json", "application/*", true), - ("application/json", "*/*", true), - ("application/json", "text/*", false), - ("application/json", "application/xml", false), + ("application/json", "application/json", true), ("APPLICATION/JSON", "application/json", true), + ("application/json", "application/*", true), ("application/json", "*/*", true), + ("application/json", "text/*", false), ("application/json", "application/xml", false), ("application/json", "text/plain", false), - ("text/plain; charset=UTF-8", "text/plain", true), - ("TEXT/PLAIN; CHARSET=UTF-8", "text/plain", true), - ("text/plain; charset=UTF-8", "text/*", true), - ("text/plain; charset=UTF-8", "*/*", true), - ("text/plain; charset=UTF-8", "application/*", false), - ("text/plain; charset=UTF-8", "text/html", false), + ("text/plain; charset=UTF-8", "text/plain", true), ("TEXT/PLAIN; CHARSET=UTF-8", "text/plain", true), + ("text/plain; charset=UTF-8", "text/*", true), ("text/plain; charset=UTF-8", "*/*", true), + ("text/plain; charset=UTF-8", "application/*", false), ("text/plain; charset=UTF-8", "text/html", false), ] for testCase in cases { XCTAssertEqual( - try converter.isMatchingContentType( - received: .init(testCase.received), - expectedRaw: testCase.expected - ), + try converter.isMatchingContentType(received: .init(testCase.received), expectedRaw: testCase.expected), testCase.isMatch, "Wrong result for (\(testCase.received), \(testCase.expected), \(testCase.isMatch))" ) @@ -63,63 +49,21 @@ final class Test_CommonConverterExtensions: Test_Runtime { file: StaticString = #file, line: UInt = #line ) throws { - let choice = try converter.bestContentType( - received: received.map { .init($0)! }, - options: options - ) + let choice = try converter.bestContentType(received: received.map { .init($0)! }, options: options) XCTAssertEqual(choice, expectedChoice, file: file, line: line) } - try testCase( - received: nil, - options: [ - "application/json", - "*/*", - ], - expected: "application/json" - ) - try testCase( - received: "*/*", - options: [ - "application/json", - "*/*", - ], - expected: "application/json" - ) - try testCase( - received: "application/*", - options: [ - "application/json", - "*/*", - ], - expected: "application/json" - ) - XCTAssertThrowsError( - try testCase( - received: "application/json", - options: [ - "whoops" - ], - expected: "-" - ) - ) + try testCase(received: nil, options: ["application/json", "*/*"], expected: "application/json") + try testCase(received: "*/*", options: ["application/json", "*/*"], expected: "application/json") + try testCase(received: "application/*", options: ["application/json", "*/*"], expected: "application/json") + XCTAssertThrowsError(try testCase(received: "application/json", options: ["whoops"], expected: "-")) XCTAssertThrowsError( - try testCase( - received: "application/json", - options: [ - "text/plain", - "image/*", - ], - expected: "-" - ) + try testCase(received: "application/json", options: ["text/plain", "image/*"], expected: "-") ) try testCase( received: "application/json; charset=utf-8; version=1", options: [ - "*/*", - "application/*", - "application/json", - "application/json; charset=utf-8", + "*/*", "application/*", "application/json", "application/json; charset=utf-8", "application/json; charset=utf-8; version=1", ], expected: "application/json; charset=utf-8; version=1" @@ -127,10 +71,7 @@ final class Test_CommonConverterExtensions: Test_Runtime { try testCase( received: "application/json; version=1; CHARSET=utf-8", options: [ - "*/*", - "application/*", - "application/json", - "application/json; charset=utf-8", + "*/*", "application/*", "application/json", "application/json; charset=utf-8", "application/json; charset=utf-8; version=1", ], expected: "application/json; charset=utf-8; version=1" @@ -138,67 +79,31 @@ final class Test_CommonConverterExtensions: Test_Runtime { try testCase( received: "application/json", options: [ - "application/json; charset=utf-8", - "application/json; charset=utf-8; version=1", - "*/*", - "application/*", + "application/json; charset=utf-8", "application/json; charset=utf-8; version=1", "*/*", "application/*", "application/json", ], expected: "application/json" ) try testCase( received: "application/json; charset=utf-8", - options: [ - "application/json; charset=utf-8; version=1", - "*/*", - "application/*", - "application/json", - ], + options: ["application/json; charset=utf-8; version=1", "*/*", "application/*", "application/json"], expected: "application/json" ) try testCase( received: "application/json; charset=utf-8; version=1", - options: [ - "*/*", - "application/*", - "application/json; charset=utf-8", - "application/json", - ], + options: ["*/*", "application/*", "application/json; charset=utf-8", "application/json"], expected: "application/json; charset=utf-8" ) try testCase( received: "application/json; charset=utf-8; version=1", - options: [ - "*/*", - "application/*", - ], + options: ["*/*", "application/*"], expected: "application/*" ) - try testCase( - received: "application/json; charset=utf-8; version=1", - options: [ - "*/*" - ], - expected: "*/*" - ) + try testCase(received: "application/json; charset=utf-8; version=1", options: ["*/*"], expected: "*/*") - try testCase( - received: "image/png", - options: [ - "image/*", - "*/*", - ], - expected: "image/*" - ) + try testCase(received: "image/png", options: ["image/*", "*/*"], expected: "image/*") XCTAssertThrowsError( - try testCase( - received: "text/csv", - options: [ - "text/html", - "application/json", - ], - expected: "-" - ) + try testCase(received: "text/csv", options: ["text/html", "application/json"], expected: "-") ) } @@ -207,140 +112,63 @@ final class Test_CommonConverterExtensions: Test_Runtime { // | common | set | header field | URI | both | setHeaderFieldAsURI | func test_setHeaderFieldAsURI_string() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsURI( - in: &headerFields, - name: "foo", - value: "bar" - ) - XCTAssertEqual( - headerFields, - [ - .foo: "bar" - ] - ) + try converter.setHeaderFieldAsURI(in: &headerFields, name: "foo", value: "bar") + XCTAssertEqual(headerFields, [.foo: "bar"]) } func test_setHeaderFieldAsURI_arrayOfStrings() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsURI( - in: &headerFields, - name: "foo", - value: ["bar", "baz"] as [String] - ) - XCTAssertEqual( - headerFields, - [ - .foo: "bar,baz" - ] - ) + try converter.setHeaderFieldAsURI(in: &headerFields, name: "foo", value: ["bar", "baz"] as [String]) + XCTAssertEqual(headerFields, [.foo: "bar,baz"]) } func test_setHeaderFieldAsURI_date() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsURI( - in: &headerFields, - name: "foo", - value: testDate - ) - XCTAssertEqual( - headerFields, - [ - .foo: testDateEscapedString - ] - ) + try converter.setHeaderFieldAsURI(in: &headerFields, name: "foo", value: testDate) + XCTAssertEqual(headerFields, [.foo: testDateEscapedString]) } func test_setHeaderFieldAsURI_arrayOfDates() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsURI( - in: &headerFields, - name: "foo", - value: [testDate, testDate] - ) - XCTAssertEqual( - headerFields, - [ - .foo: "\(testDateEscapedString),\(testDateEscapedString)" - ] - ) + try converter.setHeaderFieldAsURI(in: &headerFields, name: "foo", value: [testDate, testDate]) + XCTAssertEqual(headerFields, [.foo: "\(testDateEscapedString),\(testDateEscapedString)"]) } func test_setHeaderFieldAsURI_struct() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsURI( - in: &headerFields, - name: "foo", - value: testStruct - ) - XCTAssertEqual( - headerFields, - [ - .foo: "name,Fluffz" - ] - ) + try converter.setHeaderFieldAsURI(in: &headerFields, name: "foo", value: testStruct) + XCTAssertEqual(headerFields, [.foo: "name,Fluffz"]) } // | common | set | header field | JSON | both | setHeaderFieldAsJSON | func test_setHeaderFieldAsJSON_codable() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsJSON( - in: &headerFields, - name: "foo", - value: testStruct - ) - XCTAssertEqual( - headerFields, - [ - .foo: testStructString - ] - ) + try converter.setHeaderFieldAsJSON(in: &headerFields, name: "foo", value: testStruct) + XCTAssertEqual(headerFields, [.foo: testStructString]) } func test_setHeaderFieldAsJSON_codable_string() throws { var headerFields: HTTPFields = [:] - try converter.setHeaderFieldAsJSON( - in: &headerFields, - name: "foo", - value: "hello" - ) - XCTAssertEqual( - headerFields, - [ - .foo: "\"hello\"" - ] - ) + try converter.setHeaderFieldAsJSON(in: &headerFields, name: "foo", value: "hello") + XCTAssertEqual(headerFields, [.foo: "\"hello\""]) } // | common | get | header field | URI | optional | getOptionalHeaderFieldAsURI | func test_getOptionalHeaderFieldAsURI_string() throws { - let headerFields: HTTPFields = [ - .foo: "bar" - ] - let value: String? = try converter.getOptionalHeaderFieldAsURI( - in: headerFields, - name: "foo", - as: String.self - ) + let headerFields: HTTPFields = [.foo: "bar"] + let value: String? = try converter.getOptionalHeaderFieldAsURI(in: headerFields, name: "foo", as: String.self) XCTAssertEqual(value, "bar") } // | common | get | header field | URI | required | getRequiredHeaderFieldAsURI | func test_getRequiredHeaderFieldAsURI_stringConvertible() throws { - let headerFields: HTTPFields = [ - .foo: "bar" - ] - let value: String = try converter.getRequiredHeaderFieldAsURI( - in: headerFields, - name: "foo", - as: String.self - ) + let headerFields: HTTPFields = [.foo: "bar"] + let value: String = try converter.getRequiredHeaderFieldAsURI(in: headerFields, name: "foo", as: String.self) XCTAssertEqual(value, "bar") } func test_getOptionalHeaderFieldAsURI_arrayOfStrings_singleHeader() throws { - let headerFields: HTTPFields = [ - .foo: "bar,baz" - ] + let headerFields: HTTPFields = [.foo: "bar,baz"] let value: [String]? = try converter.getOptionalHeaderFieldAsURI( in: headerFields, name: "foo", @@ -350,14 +178,8 @@ final class Test_CommonConverterExtensions: Test_Runtime { } func test_getOptionalHeaderFieldAsURI_date() throws { - let headerFields: HTTPFields = [ - .foo: testDateEscapedString - ] - let value: Date? = try converter.getOptionalHeaderFieldAsURI( - in: headerFields, - name: "foo", - as: Date.self - ) + let headerFields: HTTPFields = [.foo: testDateEscapedString] + let value: Date? = try converter.getOptionalHeaderFieldAsURI(in: headerFields, name: "foo", as: Date.self) XCTAssertEqual(value, testDate) } @@ -365,31 +187,19 @@ final class Test_CommonConverterExtensions: Test_Runtime { let headerFields: HTTPFields = [ .foo: "\(testDateString),\(testDateEscapedString)" // escaped and unescaped ] - let value: [Date] = try converter.getRequiredHeaderFieldAsURI( - in: headerFields, - name: "foo", - as: [Date].self - ) + let value: [Date] = try converter.getRequiredHeaderFieldAsURI(in: headerFields, name: "foo", as: [Date].self) XCTAssertEqual(value, [testDate, testDate]) } func test_getOptionalHeaderFieldAsURI_struct() throws { - let headerFields: HTTPFields = [ - .foo: "name,Sprinkles" - ] - let value: TestPet? = try converter.getOptionalHeaderFieldAsURI( - in: headerFields, - name: "foo", - as: TestPet.self - ) + let headerFields: HTTPFields = [.foo: "name,Sprinkles"] + let value: TestPet? = try converter.getOptionalHeaderFieldAsURI(in: headerFields, name: "foo", as: TestPet.self) XCTAssertEqual(value, .init(name: "Sprinkles")) } // | common | get | header field | JSON | optional | getOptionalHeaderFieldAsJSON | func test_getOptionalHeaderFieldAsJSON_codable() throws { - let headerFields: HTTPFields = [ - .foo: testStructString - ] + let headerFields: HTTPFields = [.foo: testStructString] let value: TestPet? = try converter.getOptionalHeaderFieldAsJSON( in: headerFields, name: "foo", @@ -400,14 +210,8 @@ final class Test_CommonConverterExtensions: Test_Runtime { // | common | get | header field | JSON | required | getRequiredHeaderFieldAsJSON | func test_getRequiredHeaderFieldAsJSON_codable() throws { - let headerFields: HTTPFields = [ - .foo: testStructString - ] - let value: TestPet = try converter.getRequiredHeaderFieldAsJSON( - in: headerFields, - name: "foo", - as: TestPet.self - ) + let headerFields: HTTPFields = [.foo: testStructString] + let value: TestPet = try converter.getRequiredHeaderFieldAsJSON(in: headerFields, name: "foo", as: TestPet.self) XCTAssertEqual(value, testStruct) } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 6617f60a..91525af4 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -18,18 +18,13 @@ import HTTPTypes final class Test_ServerConverterExtensions: Test_Runtime { func testExtractAccept() throws { - let headerFields: HTTPFields = [ - .accept: "application/json, */*; q=0.8" - ] + let headerFields: HTTPFields = [.accept: "application/json, */*; q=0.8"] let accept: [AcceptHeaderContentType] = try converter.extractAcceptHeaderIfPresent( in: headerFields ) XCTAssertEqual( accept, - [ - .init(contentType: .json, quality: 1.0), - .init(contentType: .other("*/*"), quality: 0.8), - ] + [.init(contentType: .json, quality: 1.0), .init(contentType: .other("*/*"), quality: 0.8)] ) } @@ -37,21 +32,13 @@ final class Test_ServerConverterExtensions: Test_Runtime { func testValidateAccept() throws { let emptyHeaders: HTTPFields = [:] - let wildcard: HTTPFields = [ - .accept: "*/*" - ] - let partialWildcard: HTTPFields = [ - .accept: "text/*" - ] - let short: HTTPFields = [ - .accept: "text/plain" - ] + let wildcard: HTTPFields = [.accept: "*/*"] + let partialWildcard: HTTPFields = [.accept: "text/*"] + let short: HTTPFields = [.accept: "text/plain"] let long: HTTPFields = [ .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 multiple: HTTPFields = [.accept: "text/plain, application/json"] let cases: [(HTTPFields, String, Bool)] = [ // No Accept header, any string validates successfully (emptyHeaders, "foobar", true), @@ -60,40 +47,27 @@ final class Test_ServerConverterExtensions: Test_Runtime { (wildcard, "foobar", true), // Accept: text/*, so text/plain succeeds, application/json fails - (partialWildcard, "text/plain", true), - (partialWildcard, "application/json", false), + (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), // A bunch of acceptable content types - (long, "text/html", true), - (long, "application/xhtml+xml", true), - (long, "application/xml", true), - (long, "image/webp", true), - (long, "application/json", true), + (long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true), + (long, "image/webp", true), (long, "application/json", true), // Multiple values - (multiple, "text/plain", true), - (multiple, "application/json", true), - (multiple, "application/xml", false), + (multiple, "text/plain", true), (multiple, "application/json", true), (multiple, "application/xml", false), ] for (headers, contentType, success) in cases { if success { XCTAssertNoThrow( - try converter.validateAcceptIfPresent( - contentType, - in: headers - ), + try converter.validateAcceptIfPresent(contentType, in: headers), "Unexpected error when validating string: \(contentType) against headers: \(headers)" ) } else { XCTAssertThrowsError( - try converter.validateAcceptIfPresent( - contentType, - in: headers - ), + try converter.validateAcceptIfPresent(contentType, in: headers), "Expected to throw error when validating string: \(contentType) against headers: \(headers)" ) } @@ -105,41 +79,22 @@ final class Test_ServerConverterExtensions: Test_Runtime { // | server | get | request path | URI | required | getPathParameterAsURI | func test_getPathParameterAsURI_various() throws { let path: [String: Substring] = [ - "foo": "bar", - "number": "1", - "habitats": "land,air", - "withEscaping": "Hello%20world%21", + "foo": "bar", "number": "1", "habitats": "land,air", "withEscaping": "Hello%20world%21", ] do { - let value = try converter.getPathParameterAsURI( - in: path, - name: "foo", - as: String.self - ) + let value = try converter.getPathParameterAsURI(in: path, name: "foo", as: String.self) XCTAssertEqual(value, "bar") } do { - let value = try converter.getPathParameterAsURI( - in: path, - name: "number", - as: Int.self - ) + let value = try converter.getPathParameterAsURI(in: path, name: "number", as: Int.self) XCTAssertEqual(value, 1) } do { - let value = try converter.getPathParameterAsURI( - in: path, - name: "habitats", - as: [TestHabitat].self - ) + let value = try converter.getPathParameterAsURI(in: path, name: "habitats", as: [TestHabitat].self) XCTAssertEqual(value, [.land, .air]) } do { - let value = try converter.getPathParameterAsURI( - in: path, - name: "withEscaping", - as: String.self - ) + let value = try converter.getPathParameterAsURI(in: path, name: "withEscaping", as: String.self) XCTAssertEqual(value, "Hello world!") } } @@ -342,12 +297,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"]) } // | server | set | response body | binary | required | setResponseBodyAsBinary | @@ -359,11 +309,6 @@ 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"]) } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift index 0b134b2e..793a2d71 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_ServerVariable.swift @@ -18,29 +18,14 @@ final class Test_ServerVariable: Test_Runtime { func testOnlyConstants() throws { XCTAssertEqual( - try URL( - validatingOpenAPIServerURL: "https://example.com", - variables: [] - ) - .absoluteString, + try URL(validatingOpenAPIServerURL: "https://example.com", variables: []).absoluteString, "https://example.com" ) XCTAssertEqual( - try URL( - validatingOpenAPIServerURL: "https://example.com/api", - variables: [] - ) - .absoluteString, + try URL(validatingOpenAPIServerURL: "https://example.com/api", variables: []).absoluteString, "https://example.com/api" ) - XCTAssertEqual( - try URL( - validatingOpenAPIServerURL: "/api", - variables: [] - ) - .absoluteString, - "/api" - ) + XCTAssertEqual(try URL(validatingOpenAPIServerURL: "/api", variables: []).absoluteString, "/api") } func testVariables() throws { diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift index bc6cc623..16a684c1 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift @@ -22,65 +22,44 @@ final class Test_Body: Test_Runtime { // A single string. do { let body: HTTPBody = HTTPBody("hello") - try await _testConsume( - body, - expected: "hello" - ) + try await _testConsume(body, expected: "hello") } // A literal string. do { let body: HTTPBody = "hello" - try await _testConsume( - body, - expected: "hello" - ) + try await _testConsume(body, expected: "hello") } // A single substring. do { let substring: Substring = "hello" let body: HTTPBody = HTTPBody(substring) - try await _testConsume( - body, - expected: "hello" - ) + try await _testConsume(body, expected: "hello") } // A single array of bytes. do { let body: HTTPBody = HTTPBody([0]) - try await _testConsume( - body, - expected: [0] - ) + try await _testConsume(body, expected: [0]) } // A literal array of bytes. do { let body: HTTPBody = [0] - try await _testConsume( - body, - expected: [0] - ) + try await _testConsume(body, expected: [0]) } // A single data. do { let body: HTTPBody = HTTPBody(Data([0])) - try await _testConsume( - body, - expected: [0] - ) + try await _testConsume(body, expected: [0]) } // A single slice of an array of bytes. do { let body: HTTPBody = HTTPBody([0][...]) - try await _testConsume( - body, - expected: [0][...] - ) + try await _testConsume(body, expected: [0][...]) } // An async throwing stream. @@ -96,10 +75,7 @@ final class Test_Body: Test_Runtime { ), length: .known(5) ) - try await _testConsume( - body, - expected: "hello" - ) + try await _testConsume(body, expected: "hello") } // An async throwing stream, unknown length. @@ -115,10 +91,7 @@ final class Test_Body: Test_Runtime { ), length: .unknown ) - try await _testConsume( - body, - expected: "hello" - ) + try await _testConsume(body, expected: "hello") } // An async stream. @@ -134,10 +107,7 @@ final class Test_Body: Test_Runtime { ), length: .known(5) ) - try await _testConsume( - body, - expected: "hello" - ) + try await _testConsume(body, expected: "hello") } // Another async sequence. @@ -151,15 +121,8 @@ final class Test_Body: Test_Runtime { } ) .map { $0 } - let body: HTTPBody = HTTPBody( - sequence, - length: .known(5), - iterationBehavior: .single - ) - try await _testConsume( - body, - expected: "hello" - ) + let body: HTTPBody = HTTPBody(sequence, length: .known(5), iterationBehavior: .single) + try await _testConsume(body, expected: "hello") } } @@ -173,15 +136,9 @@ final class Test_Body: Test_Runtime { } ) .map { $0 } - let body: HTTPBody = HTTPBody( - sequence, - length: .known(5), - iterationBehavior: .single - ) + let body: HTTPBody = HTTPBody(sequence, length: .known(5), iterationBehavior: .single) var chunks: [HTTPBody.ByteChunk] = [] - for try await chunk in body { - chunks.append(chunk) - } + for try await chunk in body { chunks.append(chunk) } XCTAssertEqual(chunks, ["hel", "lo"].map { Array($0.utf8)[...] }) } @@ -202,18 +159,12 @@ final class Test_Body: Test_Runtime { } ) .map { $0 } - let body: HTTPBody = HTTPBody( - sequence, - length: .unknown, - iterationBehavior: .single - ) + let body: HTTPBody = HTTPBody(sequence, length: .unknown, iterationBehavior: .single) XCTAssertFalse(body.testing_iteratorCreated) var chunkCount = 0 - for try await _ in body { - chunkCount += 1 - } + for try await _ in body { chunkCount += 1 } XCTAssertEqual(chunkCount, 2) XCTAssertTrue(body.testing_iteratorCreated) @@ -231,9 +182,7 @@ final class Test_Body: Test_Runtime { do { var chunkCount = 0 - for try await _ in body { - chunkCount += 1 - } + for try await _ in body { chunkCount += 1 } XCTAssertEqual(chunkCount, 1) } @@ -241,9 +190,7 @@ final class Test_Body: Test_Runtime { do { var chunkCount = 0 - for try await _ in body { - chunkCount += 1 - } + for try await _ in body { chunkCount += 1 } XCTAssertEqual(chunkCount, 1) } @@ -277,22 +224,16 @@ 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 = #file, 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 = #file, 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/Interface/Test_UniversalClient.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift index 64e38c86..b0063e70 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift @@ -18,29 +18,16 @@ import Foundation struct MockClientTransport: ClientTransport { var sendBlock: @Sendable (HTTPRequest, HTTPBody?, URL, String) async throws -> (HTTPResponse, HTTPBody?) - func send( - _ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String - ) async throws -> (HTTPResponse, HTTPBody?) { - try await sendBlock(request, body, baseURL, operationID) - } + func send(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) async throws -> ( + HTTPResponse, HTTPBody? + ) { try await sendBlock(request, body, baseURL, operationID) } static let requestBody: HTTPBody = HTTPBody("hello") static let responseBody: HTTPBody = HTTPBody("bye") - static var successful: Self { - MockClientTransport { _, _, _, _ in - (HTTPResponse(status: .ok), responseBody) - } - } + static var successful: Self { MockClientTransport { _, _, _, _ in (HTTPResponse(status: .ok), responseBody) } } - static var failing: Self { - MockClientTransport { _, _, _, _ in - throw TestError() - } - } + static var failing: Self { MockClientTransport { _, _, _, _ in throw TestError() } } } final class Test_UniversalClient: Test_Runtime { @@ -50,12 +37,7 @@ final class Test_UniversalClient: Test_Runtime { let output = try await client.send( input: "input", forOperation: "op", - serializer: { input in - ( - HTTPRequest(soar_path: "/", method: .post), - MockClientTransport.requestBody - ) - }, + serializer: { input in (HTTPRequest(soar_path: "/", method: .post), MockClientTransport.requestBody) }, deserializer: { response, body in let body = try XCTUnwrap(body) let string = try await String(collecting: body, upTo: 10) @@ -71,12 +53,8 @@ final class Test_UniversalClient: Test_Runtime { try await client.send( input: "input", forOperation: "op", - serializer: { input in - throw TestError() - }, - deserializer: { response, body in - fatalError() - } + serializer: { input in throw TestError() }, + deserializer: { response, body in fatalError() } ) } catch { let clientError = try XCTUnwrap(error as? ClientError) @@ -96,22 +74,13 @@ final class Test_UniversalClient: Test_Runtime { do { let client = UniversalClient( transport: MockClientTransport.successful, - middlewares: [ - MockMiddleware(failurePhase: .onRequest) - ] + middlewares: [MockMiddleware(failurePhase: .onRequest)] ) try await client.send( input: "input", forOperation: "op", - serializer: { input in - ( - HTTPRequest(soar_path: "/", method: .post), - MockClientTransport.requestBody - ) - }, - deserializer: { response, body in - fatalError() - } + serializer: { input in (HTTPRequest(soar_path: "/", method: .post), MockClientTransport.requestBody) }, + deserializer: { response, body in fatalError() } ) } catch { let clientError = try XCTUnwrap(error as? ClientError) @@ -129,24 +98,12 @@ final class Test_UniversalClient: Test_Runtime { func testErrorPropagation_transport() async throws { do { - let client = UniversalClient( - transport: MockClientTransport.failing, - middlewares: [ - MockMiddleware() - ] - ) + let client = UniversalClient(transport: MockClientTransport.failing, middlewares: [MockMiddleware()]) try await client.send( input: "input", forOperation: "op", - serializer: { input in - ( - HTTPRequest(soar_path: "/", method: .post), - MockClientTransport.requestBody - ) - }, - deserializer: { response, body in - fatalError() - } + serializer: { input in (HTTPRequest(soar_path: "/", method: .post), MockClientTransport.requestBody) }, + deserializer: { response, body in fatalError() } ) } catch { let clientError = try XCTUnwrap(error as? ClientError) @@ -166,22 +123,13 @@ final class Test_UniversalClient: Test_Runtime { do { let client = UniversalClient( transport: MockClientTransport.successful, - middlewares: [ - MockMiddleware(failurePhase: .onResponse) - ] + middlewares: [MockMiddleware(failurePhase: .onResponse)] ) try await client.send( input: "input", forOperation: "op", - serializer: { input in - ( - HTTPRequest(soar_path: "/", method: .post), - MockClientTransport.requestBody - ) - }, - deserializer: { response, body in - fatalError() - } + serializer: { input in (HTTPRequest(soar_path: "/", method: .post), MockClientTransport.requestBody) }, + deserializer: { response, body in fatalError() } ) } catch { let clientError = try XCTUnwrap(error as? ClientError) @@ -203,15 +151,8 @@ final class Test_UniversalClient: Test_Runtime { try await client.send( input: "input", forOperation: "op", - serializer: { input in - ( - HTTPRequest(soar_path: "/", method: .post), - MockClientTransport.requestBody - ) - }, - deserializer: { response, body in - throw TestError() - } + serializer: { input in (HTTPRequest(soar_path: "/", method: .post), MockClientTransport.requestBody) }, + deserializer: { response, body in throw TestError() } ) } catch { let clientError = try XCTUnwrap(error as? ClientError) diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift index 88b2ae96..e65afe4f 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift @@ -19,12 +19,8 @@ import Foundation struct MockHandler: Sendable { var shouldFail: Bool = false func greet(_ input: String) async throws -> String { - if shouldFail { - throw TestError() - } - guard input == "hello" else { - throw TestError() - } + if shouldFail { throw TestError() } + guard input == "hello" else { throw TestError() } return "bye" } @@ -46,9 +42,7 @@ final class Test_UniversalServer: Test_Runtime { let body = try XCTUnwrap(body) return try await String(collecting: body, upTo: 10) }, - serializer: { output, _ in - (HTTPResponse(status: .ok), MockHandler.responseBody) - } + serializer: { output, _ in (HTTPResponse(status: .ok), MockHandler.responseBody) } ) XCTAssertEqual(response, HTTPResponse(status: .ok)) XCTAssertEqual(responseBody, MockHandler.responseBody) @@ -58,9 +52,7 @@ final class Test_UniversalServer: Test_Runtime { do { let server = UniversalServer( handler: MockHandler(), - middlewares: [ - MockMiddleware(failurePhase: .onRequest) - ] + middlewares: [MockMiddleware(failurePhase: .onRequest)] ) _ = try await server.handle( request: .init(soar_path: "/", method: .post), @@ -68,12 +60,8 @@ final class Test_UniversalServer: Test_Runtime { metadata: .init(), forOperation: "op", using: { MockHandler.greet($0) }, - deserializer: { request, body, metadata in - fatalError() - }, - serializer: { output, _ in - fatalError() - } + deserializer: { request, body, metadata in fatalError() }, + serializer: { output, _ in fatalError() } ) } catch { let serverError = try XCTUnwrap(error as? ServerError) @@ -97,12 +85,8 @@ final class Test_UniversalServer: Test_Runtime { metadata: .init(), forOperation: "op", using: { MockHandler.greet($0) }, - deserializer: { request, body, metadata in - throw TestError() - }, - serializer: { output, _ in - fatalError() - } + deserializer: { request, body, metadata in throw TestError() }, + serializer: { output, _ in fatalError() } ) } catch { let serverError = try XCTUnwrap(error as? ServerError) @@ -130,9 +114,7 @@ final class Test_UniversalServer: Test_Runtime { let body = try XCTUnwrap(body) return try await String(collecting: body, upTo: 10) }, - serializer: { output, _ in - fatalError() - } + serializer: { output, _ in fatalError() } ) } catch { let serverError = try XCTUnwrap(error as? ServerError) @@ -160,9 +142,7 @@ final class Test_UniversalServer: Test_Runtime { let body = try XCTUnwrap(body) return try await String(collecting: body, upTo: 10) }, - serializer: { output, _ in - throw TestError() - } + serializer: { output, _ in throw TestError() } ) } catch { let serverError = try XCTUnwrap(error as? ServerError) @@ -181,9 +161,7 @@ final class Test_UniversalServer: Test_Runtime { do { let server = UniversalServer( handler: MockHandler(), - middlewares: [ - MockMiddleware(failurePhase: .onResponse) - ] + middlewares: [MockMiddleware(failurePhase: .onResponse)] ) _ = try await server.handle( request: .init(soar_path: "/", method: .post), @@ -195,9 +173,7 @@ final class Test_UniversalServer: Test_Runtime { let body = try XCTUnwrap(body) return try await String(collecting: body, upTo: 10) }, - serializer: { output, _ in - (HTTPResponse(status: .ok), MockHandler.responseBody) - } + serializer: { output, _ in (HTTPResponse(status: .ok), MockHandler.responseBody) } ) } catch { let serverError = try XCTUnwrap(error as? ServerError) @@ -213,9 +189,7 @@ final class Test_UniversalServer: Test_Runtime { } func testApiPathComponentsWithServerPrefix_noPrefix() throws { - let server = UniversalServer( - handler: MockHandler() - ) + let server = UniversalServer(handler: MockHandler()) let components = "/foo/{bar}" let prefixed = try server.apiPathComponentsWithServerPrefix(components) // When no server path prefix, components stay the same @@ -223,10 +197,7 @@ final class Test_UniversalServer: Test_Runtime { } func testApiPathComponentsWithServerPrefix_withPrefix() throws { - let server = UniversalServer( - serverURL: try serverURL, - handler: MockHandler() - ) + let server = UniversalServer(serverURL: try serverURL, handler: MockHandler()) let components = "/foo/{bar}" let prefixed = try server.apiPathComponentsWithServerPrefix(components) let expected = "/api/foo/{bar}" diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 704b1ef6..29666cc1 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -24,19 +24,11 @@ class Test_Runtime: XCTestCase { continueAfterFailure = false } - var serverURL: URL { - get throws { - try URL(validatingOpenAPIServerURL: "/api") - } - } + var serverURL: URL { get throws { try URL(validatingOpenAPIServerURL: "/api") } } - var configuration: Configuration { - .init() - } + var configuration: Configuration { .init() } - var converter: Converter { - .init(configuration: configuration) - } + var converter: Converter { .init(configuration: configuration) } var testComponents: URLComponents { var components = URLComponents() @@ -44,57 +36,31 @@ class Test_Runtime: XCTestCase { return components } - var testRequest: HTTPRequest { - .init(soar_path: "/api", method: .get) - } + var testRequest: HTTPRequest { .init(soar_path: "/api", method: .get) } - var testDate: Date { - Date(timeIntervalSince1970: 1_674_036_251) - } + var testDate: Date { Date(timeIntervalSince1970: 1_674_036_251) } - var testDateString: String { - "2023-01-18T10:04:11Z" - } + var testDateString: String { "2023-01-18T10:04:11Z" } - var testDateEscapedString: String { - "2023-01-18T10%3A04%3A11Z" - } + var testDateEscapedString: String { "2023-01-18T10%3A04%3A11Z" } - var testDateStringData: Data { - Data(testDateString.utf8) - } + var testDateStringData: Data { Data(testDateString.utf8) } - var testDateEscapedStringData: Data { - Data(testDateEscapedString.utf8) - } + var testDateEscapedStringData: Data { Data(testDateEscapedString.utf8) } - var testString: String { - "hello" - } + var testString: String { "hello" } - var testStringData: Data { - Data(testString.utf8) - } + var testStringData: Data { Data(testString.utf8) } - var testQuotedString: String { - "\"hello\"" - } + var testQuotedString: String { "\"hello\"" } - var testQuotedStringData: Data { - Data(testQuotedString.utf8) - } + var testQuotedStringData: Data { Data(testQuotedString.utf8) } - var testStruct: TestPet { - .init(name: "Fluffz") - } + var testStruct: TestPet { .init(name: "Fluffz") } - var testStructDetailed: TestPetDetailed { - .init(name: "Rover!", type: "Golden Retriever", age: "3") - } + var testStructDetailed: TestPetDetailed { .init(name: "Rover!", type: "Golden Retriever", age: "3") } - var testStructString: String { - #"{"name":"Fluffz"}"# - } + var testStructString: String { #"{"name":"Fluffz"}"# } var testStructPrettyString: String { #""" @@ -104,36 +70,24 @@ class Test_Runtime: XCTestCase { """# } - var testStructURLFormString: String { - "age=3&name=Rover%21&type=Golden+Retriever" - } + var testStructURLFormString: String { "age=3&name=Rover%21&type=Golden+Retriever" } var testStructBase64EncodedString: String { #""eyJuYW1lIjoiRmx1ZmZ6In0=""# // {"name":"Fluffz"} } - var testEnum: TestHabitat { - .water - } + var testEnum: TestHabitat { .water } - var testEnumString: String { - "water" - } + var testEnumString: String { "water" } - var testStructData: Data { - Data(testStructString.utf8) - } + var testStructData: Data { Data(testStructString.utf8) } - var testStructPrettyData: Data { - Data(testStructPrettyString.utf8) - } + var testStructPrettyData: Data { Data(testStructPrettyString.utf8) } - var testStructURLFormData: Data { - Data(testStructURLFormString.utf8) - } + var testStructURLFormData: Data { Data(testStructURLFormString.utf8) } - @discardableResult - func _testPrettyEncoded(_ value: Value, expectedJSON: String) throws -> String { + @discardableResult func _testPrettyEncoded(_ value: Value, expectedJSON: String) throws -> String + { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(value) @@ -172,13 +126,9 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware { operationID: String, next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { - if failurePhase == .onRequest { - throw TestError() - } + if failurePhase == .onRequest { throw TestError() } let (response, responseBody) = try await next(request, body, baseURL) - if failurePhase == .onResponse { - throw TestError() - } + if failurePhase == .onResponse { throw TestError() } return (response, responseBody) } @@ -189,13 +139,9 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware { operationID: String, next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { - if failurePhase == .onRequest { - throw TestError() - } + if failurePhase == .onRequest { throw TestError() } let (response, responseBody) = try await next(request, body, metadata) - if failurePhase == .onResponse { - throw TestError() - } + if failurePhase == .onResponse { throw TestError() } return (response, responseBody) } } @@ -215,9 +161,7 @@ public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticStri XCTAssertEqual(lhs.absoluteString, rhs, file: file, line: line) } -struct TestPet: Codable, Equatable { - var name: String -} +struct TestPet: Codable, Equatable { var name: String } struct TestPetDetailed: Codable, Equatable { var name: String @@ -293,9 +237,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.localizedDescription, file: file, line: line) } } /// Asserts that the string representation of binary data in an HTTP body is equal to an expected string. @@ -314,10 +256,6 @@ public func XCTAssertEqualStringifiedData( line: UInt = #line ) async throws { let data: Data - if let body = try expression1() { - data = try await Data(collecting: body, upTo: .max) - } else { - data = .init() - } + if let body = try expression1() { data = try await Data(collecting: body, upTo: .max) } else { data = .init() } XCTAssertEqualStringifiedData(data, try expression2(), message(), file: file, line: line) } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift index a98e91b5..c02c83c3 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift @@ -17,15 +17,9 @@ import XCTest final class Test_URIDecoder: Test_Runtime { func testDecoding() throws { - struct Foo: Decodable, Equatable { - var bar: String - } + struct Foo: Decodable, Equatable { var bar: String } let decoder = URIDecoder(configuration: .formDataExplode) - let decodedValue = try decoder.decode( - Foo.self, - forKey: "", - from: "bar=hello+world" - ) + let decodedValue = try decoder.decode(Foo.self, forKey: "", from: "bar=hello+world") XCTAssertEqual(decodedValue, Foo(bar: "hello world")) } @@ -36,19 +30,11 @@ final class Test_URIDecoder: Test_Runtime { } let decoder = URIDecoder(configuration: .formDataExplode) do { - let decodedValue = try decoder.decode( - Foo.self, - forKey: "", - from: "baz=1&bar=hello+world" - ) + let decodedValue = try decoder.decode(Foo.self, forKey: "", from: "baz=1&bar=hello+world") XCTAssertEqual(decodedValue, Foo(bar: "hello world", baz: 1)) } do { - let decodedValue = try decoder.decode( - Foo.self, - forKey: "", - from: "baz=1" - ) + let decodedValue = try decoder.decode(Foo.self, forKey: "", from: "baz=1") XCTAssertEqual(decodedValue, Foo(baz: 1)) } } @@ -56,27 +42,15 @@ final class Test_URIDecoder: Test_Runtime { func testDecoding_rootValue() throws { let decoder = URIDecoder(configuration: .formDataExplode) do { - let decodedValue = try decoder.decode( - Int.self, - forKey: "root", - from: "root=1" - ) + let decodedValue = try decoder.decode(Int.self, forKey: "root", from: "root=1") XCTAssertEqual(decodedValue, 1) } do { - let decodedValue = try decoder.decodeIfPresent( - Int.self, - forKey: "root", - from: "baz=1" - ) + let decodedValue = try decoder.decodeIfPresent(Int.self, forKey: "root", from: "baz=1") XCTAssertEqual(decodedValue, nil) } do { - let decodedValue = try decoder.decodeIfPresent( - Int.self, - forKey: "root", - from: "" - ) + let decodedValue = try decoder.decodeIfPresent(Int.self, forKey: "root", from: "") XCTAssertEqual(decodedValue, nil) } } @@ -85,11 +59,7 @@ final class Test_URIDecoder: Test_Runtime { let decoder = URIDecoder(configuration: .simpleUnexplode) do { - let decodedValue = try decoder.decode( - String.self, - forKey: "", - from: "foo%2C%20bar" - ) + let decodedValue = try decoder.decode(String.self, forKey: "", from: "foo%2C%20bar") XCTAssertEqual(decodedValue, "foo, bar") } } @@ -98,11 +68,7 @@ final class Test_URIDecoder: Test_Runtime { let decoder = URIDecoder(configuration: .simpleUnexplode) do { - let decodedValue = try decoder.decode( - String.self, - forKey: "", - from: "foo, bar" - ) + let decodedValue = try decoder.decode(String.self, forKey: "", from: "foo, bar") XCTAssertEqual(decodedValue, "foo, bar") } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift index 8a67ac0e..bbbf4dae 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift @@ -30,97 +30,43 @@ 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(["root": [""]], "", key: "root", style: .simple) // A string with a space. - try test( - ["root": ["Hello World"]], - "Hello World", - key: "root" - ) + try test(["root": ["Hello World"]], "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", "b", "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", "green", "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 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(["root": ["one", "1", "two", "2"]], ["one": 1, "two": 2], key: "root", explode: false) // A dictionary of enums. try test( @@ -146,12 +92,7 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { dateTranscoder: .iso8601 ) let decodedValue = try decoder.decodeRoot(T.self) - XCTAssertEqual( - decodedValue, - expectedValue, - file: file, - line: line - ) + XCTAssertEqual(decodedValue, expectedValue, file: file, line: line) } } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift index 4250db26..fe9d445e 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift @@ -17,15 +17,10 @@ import XCTest final class Test_URIEncoder: Test_Runtime { func testEncoding() throws { - struct Foo: Encodable { - var bar: String - } + struct Foo: Encodable { var bar: String } let serializer = URISerializer(configuration: .formDataExplode) let encoder = URIEncoder(serializer: serializer) - let encodedString = try encoder.encode( - Foo(bar: "hello world"), - forKey: "root" - ) + let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") XCTAssertEqual(encodedString, "bar=hello+world") } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift index d6967014..913511b6 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift @@ -28,11 +28,7 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { _ expectedNode: URIEncodedNode, file: StaticString = #file, line: UInt = #line - ) - -> Case - { - .init(value: value, expectedNode: expectedNode, file: file, line: line) - } + ) -> Case { .init(value: value, expectedNode: expectedNode, file: file, line: line) } enum SimpleEnum: String, Encodable { case foo @@ -45,226 +41,118 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { var val: SimpleEnum? } - struct NestedStruct: Encodable { - var simple: SimpleStruct - } + struct NestedStruct: Encodable { var simple: SimpleStruct } let cases: [Case] = [ // An empty string. - makeCase( - "", - .primitive(.string("")) - ), + makeCase("", .primitive(.string(""))), // A string with a space. - makeCase( - "Hello World", - .primitive(.string("Hello World")) - ), + makeCase("Hello World", .primitive(.string("Hello World"))), // An integer. - makeCase( - 1234, - .primitive(.integer(1234)) - ), + makeCase(1234, .primitive(.integer(1234))), // A float. - makeCase( - 12.34, - .primitive(.double(12.34)) - ), + makeCase(12.34, .primitive(.double(12.34))), // A bool. - makeCase( - true, - .primitive(.bool(true)) - ), + makeCase(true, .primitive(.bool(true))), // An enum. - makeCase( - SimpleEnum.foo, - .primitive(.string("foo")) - ), + makeCase(SimpleEnum.foo, .primitive(.string("foo"))), // A simple array of strings. makeCase( ["a", "b", "c"], - .array([ - .primitive(.string("a")), - .primitive(.string("b")), - .primitive(.string("c")), - ]) + .array([.primitive(.string("a")), .primitive(.string("b")), .primitive(.string("c"))]) ), // A simple array of enums. makeCase( [SimpleEnum.foo, SimpleEnum.bar], - .array([ - .primitive(.string("foo")), - .primitive(.string("bar")), - ]) + .array([.primitive(.string("foo")), .primitive(.string("bar"))]) ), // A nested array. makeCase( [["a"], ["b", "c"]], .array([ - .array([ - .primitive(.string("a")) - ]), - .array([ - .primitive(.string("b")), - .primitive(.string("c")), - ]), + .array([.primitive(.string("a"))]), .array([.primitive(.string("b")), .primitive(.string("c"))]), ]) ), // A struct. makeCase( SimpleStruct(foo: "bar", val: .foo), - .dictionary([ - "foo": .primitive(.string("bar")), - "val": .primitive(.string("foo")), - ]) + .dictionary(["foo": .primitive(.string("bar")), "val": .primitive(.string("foo"))]) ), // A nested struct. makeCase( NestedStruct(simple: SimpleStruct(foo: "bar")), - .dictionary([ - "simple": .dictionary([ - "foo": .primitive(.string("bar")) - ]) - ]) + .dictionary(["simple": .dictionary(["foo": .primitive(.string("bar"))])]) ), // An array of structs. makeCase( - [ - SimpleStruct(foo: "bar"), - SimpleStruct(foo: "baz", val: .bar), - ], + [SimpleStruct(foo: "bar"), SimpleStruct(foo: "baz", val: .bar)], .array([ - .dictionary([ - "foo": .primitive(.string("bar")) - ]), - .dictionary([ - "foo": .primitive(.string("baz")), - "val": .primitive(.string("bar")), - ]), + .dictionary(["foo": .primitive(.string("bar"))]), + .dictionary(["foo": .primitive(.string("baz")), "val": .primitive(.string("bar"))]), ]) ), // An array of arrays of structs. makeCase( - [ - [ - SimpleStruct(foo: "bar") - ], - [ - SimpleStruct(foo: "baz") - ], - ], + [[SimpleStruct(foo: "bar")], [SimpleStruct(foo: "baz")]], .array([ - .array([ - .dictionary([ - "foo": .primitive(.string("bar")) - ]) - ]), - .array([ - .dictionary([ - "foo": .primitive(.string("baz")) - ]) - ]), + .array([.dictionary(["foo": .primitive(.string("bar"))])]), + .array([.dictionary(["foo": .primitive(.string("baz"))])]), ]) ), // A simple dictionary of string -> int pairs. makeCase( ["one": 1, "two": 2], - .dictionary([ - "one": .primitive(.integer(1)), - "two": .primitive(.integer(2)), - ]) + .dictionary(["one": .primitive(.integer(1)), "two": .primitive(.integer(2))]) ), // A simple dictionary of string -> enum pairs. - makeCase( - ["one": SimpleEnum.bar], - .dictionary([ - "one": .primitive(.string("bar")) - ]) - ), + makeCase(["one": SimpleEnum.bar], .dictionary(["one": .primitive(.string("bar"))])), // A nested dictionary. makeCase( - [ - "A": ["one": 1, "two": 2], - "B": ["three": 3, "four": 4], - ], + ["A": ["one": 1, "two": 2], "B": ["three": 3, "four": 4]], .dictionary([ - "A": .dictionary([ - "one": .primitive(.integer(1)), - "two": .primitive(.integer(2)), - ]), - "B": .dictionary([ - "three": .primitive(.integer(3)), - "four": .primitive(.integer(4)), - ]), + "A": .dictionary(["one": .primitive(.integer(1)), "two": .primitive(.integer(2))]), + "B": .dictionary(["three": .primitive(.integer(3)), "four": .primitive(.integer(4))]), ]) ), // A dictionary of structs. makeCase( - [ - "barkey": SimpleStruct(foo: "bar"), - "bazkey": SimpleStruct(foo: "baz"), - ], + ["barkey": SimpleStruct(foo: "bar"), "bazkey": SimpleStruct(foo: "baz")], .dictionary([ - "barkey": .dictionary([ - "foo": .primitive(.string("bar")) - ]), - "bazkey": .dictionary([ - "foo": .primitive(.string("baz")) - ]), + "barkey": .dictionary(["foo": .primitive(.string("bar"))]), + "bazkey": .dictionary(["foo": .primitive(.string("baz"))]), ]) ), // An dictionary of dictionaries of structs. makeCase( - [ - "outBar": - [ - "inBar": SimpleStruct(foo: "bar") - ], - "outBaz": [ - "inBaz": SimpleStruct(foo: "baz") - ], - ], + ["outBar": ["inBar": SimpleStruct(foo: "bar")], "outBaz": ["inBaz": SimpleStruct(foo: "baz")]], .dictionary([ - "outBar": .dictionary([ - "inBar": .dictionary([ - "foo": .primitive(.string("bar")) - ]) - ]), - "outBaz": .dictionary([ - "inBaz": .dictionary([ - "foo": .primitive(.string("baz")) - ]) - ]), + "outBar": .dictionary(["inBar": .dictionary(["foo": .primitive(.string("bar"))])]), + "outBaz": .dictionary(["inBaz": .dictionary(["foo": .primitive(.string("baz"))])]), ]) ), ] let encoder = URIValueToNodeEncoder() for testCase in cases { let encodedNode = try encoder.encodeValue(testCase.value) - XCTAssertEqual( - encodedNode, - testCase.expectedNode, - file: testCase.file, - line: testCase.line - ) + XCTAssertEqual(encodedNode, testCase.expectedNode, file: testCase.file, line: testCase.line) } } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 33ede7b9..9bd8f3e8 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -17,12 +17,7 @@ import XCTest final class Test_URIParser: Test_Runtime { let testedVariants: [URICoderConfiguration] = [ - .formExplode, - .formUnexplode, - .simpleExplode, - .simpleUnexplode, - .formDataExplode, - .formDataUnexplode, + .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, ] func testParsing() throws { @@ -36,9 +31,7 @@ final class Test_URIParser: Test_Runtime { formDataExplode: "empty=", formDataUnexplode: "empty=" ), - value: [ - "empty": [""] - ] + value: ["empty": [""]] ), makeCase( .init( @@ -60,9 +53,7 @@ final class Test_URIParser: Test_Runtime { formDataExplode: "who=fred", formDataUnexplode: "who=fred" ), - value: [ - "who": ["fred"] - ] + value: ["who": ["fred"]] ), makeCase( .init( @@ -73,28 +64,18 @@ final class Test_URIParser: Test_Runtime { formDataExplode: "hello=Hello+World", formDataUnexplode: "hello=Hello+World" ), - value: [ - "hello": ["Hello World"] - ] + 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"]] - ), + 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" ), - value: [ - "list": ["red", "green", "blue"] - ] + value: ["list": ["red", "green", "blue"]] ), makeCase( .init( @@ -114,22 +95,12 @@ final class Test_URIParser: Test_Runtime { value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] ) ), - value: [ - "semi": [";"], - "dot": ["."], - "comma": [","], - ] + value: ["semi": [";"], "dot": ["."], "comma": [","]] ), ] for testCase in cases { - func testVariant( - _ variant: Case.Variant, - _ input: Case.Variants.Input - ) throws { - var parser = URIParser( - configuration: variant.config, - data: input.string[...] - ) + 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, @@ -156,30 +127,12 @@ extension Test_URIParser { var name: String var config: URICoderConfiguration - static let formExplode: Self = .init( - name: "formExplode", - config: .formExplode - ) - static let formUnexplode: Self = .init( - name: "formUnexplode", - config: .formUnexplode - ) - static let simpleExplode: Self = .init( - name: "simpleExplode", - config: .simpleExplode - ) - 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 formExplode: Self = .init(name: "formExplode", config: .formExplode) + static let formUnexplode: Self = .init(name: "formUnexplode", config: .formUnexplode) + static let simpleExplode: Self = .init(name: "simpleExplode", config: .simpleExplode) + 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) } struct Variants { @@ -214,17 +167,7 @@ extension Test_URIParser { 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 makeCase(_ variants: Case.Variants, value: URIParsedNode, file: StaticString = #file, line: UInt = #line) + -> Case + { .init(variants: variants, value: value, file: file, line: line) } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index 1e25109b..f93fabed 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -17,12 +17,7 @@ import XCTest final class Test_URISerializer: Test_Runtime { let testedVariants: [URICoderConfiguration] = [ - .formExplode, - .formUnexplode, - .simpleExplode, - .simpleUnexplode, - .formDataExplode, - .formDataUnexplode, + .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, ] func testSerializing() throws { @@ -100,11 +95,7 @@ final class Test_URISerializer: Test_Runtime { ) ), makeCase( - value: .array([ - .primitive(.string("red")), - .primitive(.string("green")), - .primitive(.string("blue")), - ]), + value: .array([.primitive(.string("red")), .primitive(.string("green")), .primitive(.string("blue"))]), key: "list", .init( formExplode: "list=red&list=green&list=blue", @@ -117,8 +108,7 @@ final class Test_URISerializer: Test_Runtime { ), makeCase( value: .dictionary([ - "semi": .primitive(.string(";")), - "dot": .primitive(.string(".")), + "semi": .primitive(.string(";")), "dot": .primitive(.string(".")), "comma": .primitive(.string(",")), ]), key: "keys", @@ -135,10 +125,7 @@ final class Test_URISerializer: Test_Runtime { for testCase in cases { func testVariant(_ variant: Case.Variant, _ expectedString: String) throws { var serializer = URISerializer(configuration: variant.config) - let encodedString = try serializer.serializeNode( - testCase.value, - forKey: testCase.key - ) + let encodedString = try serializer.serializeNode(testCase.value, forKey: testCase.key) XCTAssertEqual( encodedString, expectedString, @@ -163,30 +150,12 @@ extension Test_URISerializer { var name: String var config: URICoderConfiguration - static let formExplode: Self = .init( - name: "formExplode", - config: .formExplode - ) - static let formUnexplode: Self = .init( - name: "formUnexplode", - config: .formUnexplode - ) - static let simpleExplode: Self = .init( - name: "simpleExplode", - config: .simpleExplode - ) - 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 formExplode: Self = .init(name: "formExplode", config: .formExplode) + static let formUnexplode: Self = .init(name: "formUnexplode", config: .formUnexplode) + static let simpleExplode: Self = .init(name: "simpleExplode", config: .simpleExplode) + 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) } struct Variants { var formExplode: String @@ -208,13 +177,5 @@ extension Test_URISerializer { _ variants: Case.Variants, file: StaticString = #file, line: UInt = #line - ) -> Case { - .init( - value: value, - key: key, - variants: variants, - file: file, - line: line - ) - } + ) -> Case { .init(value: value, key: key, variants: variants, file: file, line: line) } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index 44c62520..0487c756 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -30,9 +30,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { var maybeFoo: String? } - struct TrivialStruct: Codable, Equatable { - var foo: String - } + struct TrivialStruct: Codable, Equatable { var foo: String } enum SimpleEnum: String, Codable, Equatable { case red @@ -54,21 +52,15 @@ final class Test_URICodingRoundtrip: Test_Runtime { do { let container = try decoder.singleValueContainer() value1 = try container.decode(Foundation.Date.self) - } catch { - errors.append(error) - } + } catch { errors.append(error) } do { let container = try decoder.singleValueContainer() value2 = try container.decode(SimpleEnum.self) - } catch { - errors.append(error) - } + } catch { errors.append(error) } do { let container = try decoder.singleValueContainer() value3 = try container.decode(TrivialStruct.self) - } catch { - errors.append(error) - } + } catch { errors.append(error) } try DecodingError.verifyAtLeastOneSchemaIsNotNil( [value1, value2, value3], type: Self.self, @@ -206,10 +198,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { // A simple array of dates. try _test( - [ - Date(timeIntervalSince1970: 1_692_948_899), - Date(timeIntervalSince1970: 1_692_948_901), - ], + [Date(timeIntervalSince1970: 1_692_948_899), Date(timeIntervalSince1970: 1_692_948_901)], key: "list", .init( formExplode: "list=2023-08-25T07%3A34%3A59Z&list=2023-08-25T07%3A35%3A01Z", @@ -251,13 +240,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { // A struct. try _test( - SimpleStruct( - foo: "hi!", - bar: 24, - color: .red, - empty: "", - date: Date(timeIntervalSince1970: 1_692_948_899) - ), + SimpleStruct(foo: "hi!", bar: 24, color: .red, empty: "", date: Date(timeIntervalSince1970: 1_692_948_899)), key: "keys", .init( formExplode: "bar=24&color=red&date=2023-08-25T07%3A34%3A59Z&empty=&foo=hi%21", @@ -272,9 +255,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { // A struct with a custom Codable implementation that forwards // decoding to nested values. try _test( - AnyOf( - value1: Date(timeIntervalSince1970: 1_674_036_251) - ), + AnyOf(value1: Date(timeIntervalSince1970: 1_674_036_251)), key: "root", .init( formExplode: "root=2023-01-18T10%3A04%3A11Z", @@ -286,9 +267,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { ) ) try _test( - AnyOf( - value2: .green - ), + AnyOf(value2: .green), key: "root", .init( formExplode: "root=green", @@ -300,9 +279,7 @@ final class Test_URICodingRoundtrip: Test_Runtime { ) ) try _test( - AnyOf( - value3: .init(foo: "bar") - ), + AnyOf(value3: .init(foo: "bar")), key: "root", .init( formExplode: "foo=bar", @@ -362,30 +339,12 @@ final class Test_URICodingRoundtrip: Test_Runtime { var name: String var configuration: URICoderConfiguration - static let formExplode: Self = .init( - name: "formExplode", - configuration: .formExplode - ) - static let formUnexplode: Self = .init( - name: "formUnexplode", - configuration: .formUnexplode - ) - static let simpleExplode: Self = .init( - name: "simpleExplode", - configuration: .simpleExplode - ) - 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 formExplode: Self = .init(name: "formExplode", configuration: .formExplode) + static let formUnexplode: Self = .init(name: "formUnexplode", configuration: .formUnexplode) + static let simpleExplode: Self = .init(name: "simpleExplode", configuration: .simpleExplode) + 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) } struct Variants { @@ -398,13 +357,9 @@ final class Test_URICodingRoundtrip: Test_Runtime { self.customValue = customValue } - init(stringLiteral value: String) { - self.init(string: value, customValue: nil) - } + init(stringLiteral value: String) { self.init(string: value, customValue: 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) } } var formExplode: Input @@ -422,59 +377,19 @@ final class Test_URICodingRoundtrip: Test_Runtime { file: StaticString = #file, line: UInt = #line ) throws { - func testVariant( - name: String, - configuration: URICoderConfiguration, - variant: Variants.Input - ) 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 - ) + 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 - ) + let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) + XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) } - try testVariant( - name: "formExplode", - configuration: .formExplode, - variant: variants.formExplode - ) - try testVariant( - name: "formUnexplode", - configuration: .formUnexplode, - variant: variants.formUnexplode - ) - try testVariant( - name: "simpleExplode", - configuration: .simpleExplode, - variant: variants.simpleExplode - ) - try testVariant( - name: "simpleUnexplode", - configuration: .simpleUnexplode, - variant: variants.simpleUnexplode - ) - try testVariant( - name: "formDataExplode", - configuration: .formDataExplode, - variant: variants.formDataExplode - ) + try testVariant(name: "formExplode", configuration: .formExplode, variant: variants.formExplode) + try testVariant(name: "formUnexplode", configuration: .formUnexplode, variant: variants.formUnexplode) + try testVariant(name: "simpleExplode", configuration: .simpleExplode, variant: variants.simpleExplode) + try testVariant(name: "simpleUnexplode", configuration: .simpleUnexplode, variant: variants.simpleUnexplode) + try testVariant(name: "formDataExplode", configuration: .formDataExplode, variant: variants.formDataExplode) try testVariant( name: "formDataUnexplode", configuration: .formDataUnexplode, From 9da9ad67b09d38e307d95ce15fabd9bd0769a80b Mon Sep 17 00:00:00 2001 From: PARAIPAN SORIN <51127880+PARAIPAN9@users.noreply.github.com> Date: Thu, 9 Nov 2023 23:57:41 +0200 Subject: [PATCH 08/79] Base64EncodedData initializer taking an array slice shouldn't have a label (#71) ### Motivation - Fixes [#369](https://github.com/apple/swift-openapi-generator/issues/368) ### Modifications - Remove init label argument from Base64EncodedData ### Result - The Base64EncodedData init will be called without the label. ### Test Plan - Adjust some tests from Test_OpenAPIValue --- .../Base/Base64EncodedData.swift | 18 +++++++++++++++++- .../Base/Test_OpenAPIValue.swift | 6 +++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index f5116c38..408e9f90 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -56,7 +56,23 @@ public struct Base64EncodedData: Sendable, Hashable { /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. /// - Parameter data: The underlying bytes to wrap. + @available(*, deprecated, renamed: "init(_:)") + public init(data: ArraySlice) { self.data = data } + + /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. + /// - Parameter data: The underlying bytes to wrap. + public init(_ data: ArraySlice) { self.data = data } + + /// Initializes an instance of ``Base64EncodedData`` wrapping the provided sequence of bytes. + /// - Parameter data: The underlying bytes to wrap. + public init(_ data: some Sequence) { self.init(ArraySlice(data)) } +} + +extension Base64EncodedData: ExpressibleByArrayLiteral { + /// Initializes an instance of ``Base64EncodedData`` with a sequence of bytes provided as an array literal. + /// - Parameter elements: The sequence of `UInt8` elements representing the underlying bytes. + public init(arrayLiteral elements: UInt8...) { self.init(elements) } } extension Base64EncodedData: Codable { @@ -74,7 +90,7 @@ extension Base64EncodedData: Codable { guard let data = Data(base64Encoded: base64EncodedString, options: options) else { throw RuntimeError.invalidBase64String(base64EncodedString) } - self.init(data: ArraySlice(data)) + self.init(data) } /// Encodes the binary data as a base64-encoded string. diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index 080f5dd1..d95ee8c4 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -241,14 +241,14 @@ final class Test_OpenAPIValue: Test_Runtime { } func testEncoding_base64_success() throws { - let encodedData = Base64EncodedData(data: ArraySlice(testStructData)) + let encodedData = Base64EncodedData(testStructData) let JSONEncoded = try JSONEncoder().encode(encodedData) XCTAssertEqual(String(data: JSONEncoded, encoding: .utf8)!, testStructBase64EncodedString) } func testDecoding_base64_success() throws { - let encodedData = Base64EncodedData(data: ArraySlice(testStructData)) + let encodedData = Base64EncodedData(testStructData) // `testStructBase64EncodedString` quoted and base64-encoded again let JSONEncoded = Data(base64Encoded: "ImV5SnVZVzFsSWpvaVJteDFabVo2SW4wPSI=")! @@ -257,7 +257,7 @@ final class Test_OpenAPIValue: Test_Runtime { } func testEncodingDecodingRoundtrip_base64_success() throws { - let encodedData = Base64EncodedData(data: ArraySlice(testStructData)) + let encodedData = Base64EncodedData(testStructData) XCTAssertEqual( try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoder().encode(encodedData)), encodedData From cefdc80e08be81bdc436bbc28ffe71421bb93af4 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Wed, 15 Nov 2023 09:48:20 +0100 Subject: [PATCH 09/79] [Multipart] Introduce a bytes -> frames parser (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Multipart] Introduce a bytes -> frames parser ### Motivation Start landing the parts of the multipart machinery that is unlikely to change as part of the multipart proposal that's finishing review tomorrow. ### Modifications Introduce a bytes -> frames parser and an async sequence that wraps it. A "frame" is either the full header fields section or a single chunk of a part body. ### Result We can now frame bytes of a multipart body. ### Test Plan Added unit tests for the state machine, the parser, and the async sequence. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/72 --- NOTICE.txt | 9 + .../Multipart/ByteUtilities.swift | 121 ++++++ .../MultipartBytesToFramesSequence.swift | 67 ++++ .../Multipart/MultipartInternalTypes.swift | 26 ++ .../Multipart/MultipartParser.swift | 350 ++++++++++++++++++ .../Test_MultipartBytesToFramesSequence.swift | 46 +++ .../Multipart/Test_MultipartParser.swift | 159 ++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 21 ++ 8 files changed, 799 insertions(+) create mode 100644 Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartParser.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift diff --git a/NOTICE.txt b/NOTICE.txt index 7b160cf4..cd34ef6d 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -41,3 +41,12 @@ This product contains coder implementations inspired by swift-http-structured-he * https://www.apache.org/licenses/LICENSE-2.0 * HOMEPAGE: * https://github.com/apple/swift-http-structured-headers + +--- + +This product contains header character set validation logic inspired by swift-http-types. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-http-types diff --git a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift b/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift new file mode 100644 index 00000000..05c47f1c --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A namespace of utilities for byte parsers and serializers. +enum ASCII { + + /// The dash `-` character. + static let dash: UInt8 = 0x2d + + /// The carriage return `` character. + static let cr: UInt8 = 0x0d + + /// The line feed `` character. + static let lf: UInt8 = 0x0a + + /// The colon `:` character. + static let colon: UInt8 = 0x3a + + /// The space ` ` character. + static let space: UInt8 = 0x20 + + /// The horizontal tab `` character. + static let tab: UInt8 = 0x09 + + /// Two dash characters. + static let dashes: [UInt8] = [dash, dash] + + /// The `` character follow by the `` character. + static let crlf: [UInt8] = [cr, lf] + + /// The characters that represent optional whitespace (OWS). + static let optionalWhitespace: Set = [space, tab] + + /// Checks whether the provided byte can appear in a header field name. + /// - Parameter byte: The byte to check. + /// - Returns: A Boolean value; `true` if the byte is valid in a header field + /// name, `false` otherwise. + static func isValidHeaderFieldNameByte(_ byte: UInt8) -> Bool { + // Copied from swift-http-types, because we create HTTPField.Name from these anyway later. + switch byte { + case 0x21, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2D, 0x2E, 0x5E, 0x5F, 0x60, 0x7C, 0x7E: return true + case 0x30...0x39, 0x41...0x5A, 0x61...0x7A: // DIGHT, ALPHA + return true + default: return false + } + } +} + +/// A value returned by the `firstIndexAfterPrefix` method. +enum FirstIndexAfterPrefixResult { + + /// The index after the end of the prefix match. + case index(C.Index) + + /// Matched all characters so far, but reached the end of self before matching all. + /// When more data is fetched, it's possible this will fully match. + case reachedEndOfSelf + + /// The character at the provided index does not match the expected character. + case unexpectedPrefix(C.Index) +} + +extension RandomAccessCollection where Element: Equatable { + + /// Verifies that the elements match the provided sequence and returns the first index past the match. + /// - Parameter expectedElements: The elements to match against. + /// - Returns: The result. + func firstIndexAfterPrefix(_ expectedElements: some Sequence) -> FirstIndexAfterPrefixResult { + var index = startIndex + for expectedElement in expectedElements { + guard index < endIndex else { return .reachedEndOfSelf } + guard self[index] == expectedElement else { return .unexpectedPrefix(index) } + formIndex(after: &index) + } + return .index(index) + } +} + +/// A value returned by the `longestMatch` method. +enum LongestMatchResult { + + /// No match found at any position in self. + case noMatch + + /// Found a prefix match but reached the end of self. + /// Provides the index of the first matching character. + /// When more data is fetched, this might become a full match. + case prefixMatch(fromIndex: C.Index) + + /// Found a full match within self at the provided range. + case fullMatch(Range) +} + +extension RandomAccessCollection where Element: Equatable { + + /// Returns the longest match found within the sequence. + /// - Parameter expectedElements: The elements to match in the sequence. + /// - Returns: The result. + func longestMatch(_ expectedElements: some Sequence) -> LongestMatchResult { + var index = startIndex + while index < endIndex { + switch self[index...].firstIndexAfterPrefix(expectedElements) { + case .index(let end): return .fullMatch(index..: Sendable +where Upstream.Element == ArraySlice { + + /// The source of byte chunks. + var upstream: Upstream + + /// The boundary string used to separate multipart parts. + var boundary: String +} + +extension MultipartBytesToFramesSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + typealias Element = MultipartFrame + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator(), boundary: boundary) + } + + /// An iterator that pulls byte chunks from the upstream iterator and provides + /// parsed multipart frames. + struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == ArraySlice { + /// The iterator that provides the byte chunks. + private var upstream: UpstreamIterator + + /// The multipart frame parser. + private var parser: MultipartParser + /// Creates a new iterator from the provided source of byte chunks and a boundary string. + /// - Parameters: + /// - upstream: The iterator that provides the byte chunks. + /// - boundary: The boundary separating the multipart parts. + init(upstream: UpstreamIterator, boundary: String) { + self.upstream = upstream + self.parser = .init(boundary: boundary) + } + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + mutating func next() async throws -> MultipartFrame? { try await parser.next { try await upstream.next() } } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift new file mode 100644 index 00000000..49e57b9f --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes + +/// A frame of a multipart message, either the whole header fields +/// section or a chunk of the body bytes. +enum MultipartFrame: Sendable, Hashable { + + /// The header fields section. + case headerFields(HTTPFields) + + /// One byte chunk of the part's body. + case bodyChunk(ArraySlice) +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift b/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift new file mode 100644 index 00000000..87267a6c --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift @@ -0,0 +1,350 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +/// A parser of mutlipart frames from bytes. +struct MultipartParser { + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// Creates a new parser. + /// - Parameter boundary: The boundary that separates parts. + init(boundary: String) { self.stateMachine = .init(boundary: boundary) } + + /// Parses the next frame. + /// - Parameter fetchChunk: A closure that is called when the parser + /// needs more bytes to parse the next frame. + /// - Returns: A parsed frame, or nil at the end of the message. + /// - Throws: When a parsing error is encountered. + mutating func next(_ fetchChunk: () async throws -> ArraySlice?) async throws -> MultipartFrame? { + while true { + switch stateMachine.readNextPart() { + case .none: continue + case .emitError(let actionError): throw ParserError(error: actionError) + case .returnNil: return nil + case .emitHeaderFields(let httpFields): return .headerFields(httpFields) + case .emitBodyChunk(let bodyChunk): return .bodyChunk(bodyChunk) + case .needsMore: + let chunk = try await fetchChunk() + switch stateMachine.receivedChunk(chunk) { + case .none: continue + case .returnNil: return nil + case .emitError(let actionError): throw ParserError(error: actionError) + } + } + } + } +} +extension MultipartParser { + + /// An error thrown by the parser. + struct ParserError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + let error: MultipartParser.StateMachine.ActionError + + var description: String { + switch error { + case .invalidInitialBoundary: return "Invalid initial boundary." + case .invalidCRLFAtStartOfHeaderField: return "Invalid CRLF at the start of a header field." + case .missingColonAfterHeaderName: return "Missing colon after header field name." + case .invalidCharactersInHeaderFieldName: return "Invalid characters in a header field name." + case .incompleteMultipartMessage: return "Incomplete multipart message." + case .receivedChunkWhenFinished: return "Received a chunk after being finished." + } + } + + var errorDescription: String? { description } + } +} + +extension MultipartParser { + + /// A state machine representing the byte to multipart frame parser. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet fully parsed the initial boundary. + case parsingInitialBoundary([UInt8]) + + /// A substate when parsing a part. + enum PartState: Hashable { + + /// Accumulating part headers. + case parsingHeaderFields(HTTPFields) + + /// Forwarding body chunks. + case parsingBody + } + + /// Is parsing a part. + case parsingPart([UInt8], PartState) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// The bytes of the boundary. + private let boundary: ArraySlice + + /// The bytes of the boundary with the double dash prepended. + private let dashDashBoundary: ArraySlice + + /// The bytes of the boundary prepended by CRLF + double dash. + private let crlfDashDashBoundary: ArraySlice + + /// Creates a new state machine. + /// - Parameter boundary: The boundary used to separate parts. + init(boundary: String) { + self.state = .parsingInitialBoundary([]) + self.boundary = ArraySlice(boundary.utf8) + self.dashDashBoundary = ASCII.dashes + self.boundary + self.crlfDashDashBoundary = ASCII.crlf + dashDashBoundary + } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The initial boundary is malformed. + case invalidInitialBoundary + + /// The expected CRLF at the start of a header is missing. + case invalidCRLFAtStartOfHeaderField + + /// A header field name contains an invalid character. + case invalidCharactersInHeaderFieldName + + /// The header field name is not followed by a colon. + case missingColonAfterHeaderName + + /// More bytes were received after completion. + case receivedChunkWhenFinished + + /// Ran out of bytes without the message being complete. + case incompleteMultipartMessage + } + + /// An action returned by the `readNextPart` method. + enum ReadNextPartAction: Hashable { + + /// No action, call `readNextPart` again. + case none + + /// Throw the provided error. + case emitError(ActionError) + + /// Return nil to the caller, no more frames. + case returnNil + + /// Emit a frame with the provided header fields. + case emitHeaderFields(HTTPFields) + + /// Emit a frame with the provided part body chunk. + case emitBodyChunk(ArraySlice) + + /// Needs more bytes to parse the next frame. + case needsMore + } + + /// Read the next part from the accumulated bytes. + /// - Returns: An action to perform. + mutating func readNextPart() -> ReadNextPartAction { + switch state { + case .mutating: preconditionFailure("Invalid state: \(state)") + case .finished: return .returnNil + case .parsingInitialBoundary(var buffer): + state = .mutating + // These first bytes must be the boundary already, otherwise this is a malformed multipart body. + switch buffer.firstIndexAfterPrefix(dashDashBoundary) { + case .index(let index): + buffer.removeSubrange(buffer.startIndex...Index + switch buffer.firstIndexAfterPrefix(ASCII.crlf) { + case .index(let index): indexAfterFirstCRLF = index + case .reachedEndOfSelf: + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + case .unexpectedPrefix: + state = .finished + return .emitError(.invalidCRLFAtStartOfHeaderField) + } + // If CRLF is here, this is the end of header fields section. + switch buffer[indexAfterFirstCRLF...].firstIndexAfterPrefix(ASCII.crlf) { + case .index(let index): + buffer.removeSubrange(buffer.startIndex...Index + // Check that what follows is a colon, otherwise this is a malformed header field line. + // Source: RFC 7230, section 3.2.4. + switch buffer[endHeaderNameIndex...].firstIndexAfterPrefix([ASCII.colon]) { + case .index(let index): startHeaderValueWithWhitespaceIndex = index + case .reachedEndOfSelf: + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + case .unexpectedPrefix: + state = .finished + return .emitError(.missingColonAfterHeaderName) + } + guard + let startHeaderValueIndex = buffer[startHeaderValueWithWhitespaceIndex...] + .firstIndex(where: { !ASCII.optionalWhitespace.contains($0) }) + else { + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + } + + // Find the CRLF first, then remove any trailing whitespace. + guard + let endHeaderValueWithWhitespaceRange = buffer[startHeaderValueIndex...] + .firstRange(of: ASCII.crlf) + else { + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + } + let headerFieldValueBytes = buffer[ + startHeaderValueIndex..?) -> ReceivedChunkAction { + switch state { + case .parsingInitialBoundary(var buffer): + guard let chunk else { return .emitError(.incompleteMultipartMessage) } + state = .mutating + buffer.append(contentsOf: chunk) + state = .parsingInitialBoundary(buffer) + return .none + case .parsingPart(var buffer, let part): + guard let chunk else { return .emitError(.incompleteMultipartMessage) } + state = .mutating + buffer.append(contentsOf: chunk) + state = .parsingPart(buffer, part) + return .none + case .finished: + guard chunk == nil else { return .emitError(.receivedChunkWhenFinished) } + return .returnNil + case .mutating: preconditionFailure("Invalid state: \(state)") + } + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift new file mode 100644 index 00000000..7229e45b --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartBytesToFramesSequence: Test_Runtime { + func test() async throws { + var chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + let next: () async throws -> ArraySlice? = { + if let first = chunk.first { + let out: ArraySlice = [first] + chunk = chunk.dropFirst() + return out + } else { + return nil + } + } + let upstream = HTTPBody(AsyncThrowingStream(unfolding: next), length: .unknown, iterationBehavior: .single) + let sequence = MultipartBytesToFramesSequence(upstream: upstream, boundary: "__abcd__") + var frames: [MultipartFrame] = [] + for try await frame in sequence { frames.append(frame) } + XCTAssertEqual( + frames, + [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + ) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift new file mode 100644 index 00000000..5587868b --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift @@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartParser: Test_Runtime { + func test() async throws { + var chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + var parser = MultipartParser(boundary: "__abcd__") + let next: () async throws -> ArraySlice? = { + if let first = chunk.first { + let out: ArraySlice = [first] + chunk = chunk.dropFirst() + return out + } else { + return nil + } + } + var frames: [MultipartFrame] = [] + while let frame = try await parser.next(next) { frames.append(frame) } + XCTAssertEqual( + frames, + [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + ) + } +} + +private func newStateMachine() -> MultipartParser.StateMachine { .init(boundary: "__abcd__") } + +final class Test_MultipartParserStateMachine: Test_Runtime { + + func testInvalidInitialBoundary() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("invalid")), .none) + XCTAssertEqual(stateMachine.readNextPart(), .emitError(.invalidInitialBoundary)) + } + + func testHeaderFields() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("--__ab")), .none) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(bufferFromString("--__ab"))) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__", addCRLFs: 1)), .none) + XCTAssertEqual(stateMachine.readNextPart(), .none) + XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a], .parsingHeaderFields(.init()))) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(#"Content-Disposi"#)), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart([0x0d, 0x0a] + bufferFromString(#"Content-Disposi"#), .parsingHeaderFields(.init())) + ) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual( + stateMachine.receivedChunk(chunkFromString(#"tion: form-data; name="name""#, addCRLFs: 2)), + .none + ) + XCTAssertEqual( + stateMachine.state, + .parsingPart( + [0x0d, 0x0a] + bufferFromString(#"Content-Disposition: form-data; name="name""#) + [ + 0x0d, 0x0a, 0x0d, 0x0a, + ], + .parsingHeaderFields(.init()) + ) + ) + // Reads the first header field. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Reads the end of the header fields section. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) + } + + func testPartBody() throws { + var stateMachine = newStateMachine() + let chunk = chunkFromStringLines(["--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24"]) + XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) + XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(Array(chunk))) + // Parse the initial boundary and first header field. + for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .parsingPart(bufferFromString(#"24"#) + [0x0d, 0x0a], .parsingBody)) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(".42")), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42"), .parsingBody) + ) + XCTAssertEqual( + stateMachine.readNextPart(), + .emitBodyChunk(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42")) + ) + XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) + XCTAssertEqual(stateMachine.receivedChunk([0x0d, 0x0a] + chunkFromString("--__ab")), .none) + XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a] + chunkFromString("--__ab"), .parsingBody)) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__--", addCRLFs: 1)), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart([0x0d, 0x0a] + chunkFromString("--__abcd__--", addCRLFs: 1), .parsingBody) + ) + // Parse the final boundary. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Parse the trailing two dashes. + XCTAssertEqual(stateMachine.readNextPart(), .returnNil) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + let chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) + // Parse the initial boundary and first header field. + for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + // Parse the first part's body. + XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("24"))) + // Parse the boundary. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="info""#]) + ) + // Parse the second part's body. + XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("{}"))) + // Parse the trailing two dashes. + XCTAssertEqual(stateMachine.readNextPart(), .returnNil) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 29666cc1..e2fe87c0 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -109,6 +109,27 @@ class Test_Runtime: XCTestCase { } } +/// Each line gets a CRLF added. Extra CRLFs are added after the last line's CRLF. +func chunkFromStringLines(_ strings: [String], addExtraCRLFs: Int = 0) -> ArraySlice { + var slice: ArraySlice = [] + for string in strings { slice.append(contentsOf: chunkFromString(string, addCRLFs: 1)) } + slice.append(contentsOf: chunkFromString("", addCRLFs: addExtraCRLFs)) + return slice +} + +func chunkFromString(_ string: String, addCRLFs: Int = 0) -> ArraySlice { + var slice = ArraySlice(string.utf8) + for _ in 0.. [UInt8] { Array(string.utf8) } + +extension ArraySlice { + mutating func append(_ string: String) { append(contentsOf: chunkFromString(string)) } + mutating func appendCRLF() { append(contentsOf: [0x0d, 0x0a]) } +} + struct TestError: Error, Equatable {} struct MockMiddleware: ClientMiddleware, ServerMiddleware { From ce4fc058f4a2e81bd21aee53bde418a1c6c76470 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 16 Nov 2023 09:40:13 +0100 Subject: [PATCH 10/79] Add README badges (#70) ### Motivation Surface the Swift version and platform support status from Swift Package Index. ### Modifications Added badges, plus a quick link to the docc docs, to the top of the README. ### Result Easier to quickly see our support matrix, plus the quick link to docs. ### Test Plan Previewed locally. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d54d870b..5fd6a69a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Swift OpenAPI Generator Runtime +[![](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fapple%2Fswift-openapi-runtime%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/apple/swift-openapi-runtime) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fapple%2Fswift-openapi-runtime%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/apple/swift-openapi-runtime) + This library provides common abstractions and helper functions used by the client and server code generated by [Swift OpenAPI Generator][0]. ## Overview From 94c1b30e40a38b8cf6cbad2fdd41628c53bb6995 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 16 Nov 2023 15:27:35 +0100 Subject: [PATCH 11/79] [Multipart] Add the frame -> bytes serializer (#73) ### Motivation Second state machine, doing the inverse of #72, serializes frames into bytes. ### Modifications - A new state machine. - A new serializer wrapping the state machine. - An async sequence wrapping the serializer. ### Result We can now serialize multipart frames into bytes. ### Test Plan Unit tests for all 3 layers. --- .../Multipart/ByteUtilities.swift | 5 +- .../MultipartFramesToBytesSequence.swift | 71 +++++ .../Multipart/MultipartParser.swift | 2 +- .../Multipart/MultipartSerializer.swift | 260 ++++++++++++++++++ .../Test_MultipartBytesToFramesSequence.swift | 14 +- .../Test_MultipartFramesToBytesSequence.swift | 36 +++ .../Multipart/Test_MultipartSerializer.swift | 79 ++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 59 ++++ 8 files changed, 513 insertions(+), 13 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift diff --git a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift b/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift index 05c47f1c..9ae1c6a5 100644 --- a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift +++ b/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift @@ -36,9 +36,12 @@ enum ASCII { /// Two dash characters. static let dashes: [UInt8] = [dash, dash] - /// The `` character follow by the `` character. + /// The `` character followed by the `` character. static let crlf: [UInt8] = [cr, lf] + /// The colon character followed by the space character. + static let colonSpace: [UInt8] = [colon, space] + /// The characters that represent optional whitespace (OWS). static let optionalWhitespace: Set = [space, tab] diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift new file mode 100644 index 00000000..e1d55542 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes + +/// A sequence that serializes multipart frames into bytes. +struct MultipartFramesToBytesSequence: Sendable +where Upstream.Element == MultipartFrame { + + /// The source of multipart frames. + var upstream: Upstream + + /// The boundary string used to separate multipart parts. + var boundary: String +} + +extension MultipartFramesToBytesSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + typealias Element = ArraySlice + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator(), boundary: boundary) + } + + /// An iterator that pulls frames from the upstream iterator and provides + /// serialized byte chunks. + struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == MultipartFrame { + + /// The iterator that provides the multipart frames. + private var upstream: UpstreamIterator + + /// The multipart frame serializer. + private var serializer: MultipartSerializer + + /// Creates a new iterator from the provided source of frames and a boundary string. + /// - Parameters: + /// - upstream: The iterator that provides the multipart frames. + /// - boundary: The boundary separating the multipart parts. + init(upstream: UpstreamIterator, boundary: String) { + self.upstream = upstream + self.serializer = .init(boundary: boundary) + } + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + mutating func next() async throws -> ArraySlice? { + try await serializer.next { try await upstream.next() } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift b/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift index 87267a6c..d98db13e 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift @@ -15,7 +15,7 @@ import Foundation import HTTPTypes -/// A parser of mutlipart frames from bytes. +/// A parser of multipart frames from bytes. struct MultipartParser { /// The underlying state machine. diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift b/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift new file mode 100644 index 00000000..8f744784 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift @@ -0,0 +1,260 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +/// A serializer of multipart frames into bytes. +struct MultipartSerializer { + + /// The boundary that separates parts. + private let boundary: ArraySlice + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// The buffer of bytes ready to be written out. + private var outBuffer: [UInt8] + + /// Creates a new serializer. + /// - Parameter boundary: The boundary that separates parts. + init(boundary: String) { + self.boundary = ArraySlice(boundary.utf8) + self.stateMachine = .init() + self.outBuffer = [] + } + /// Requests the next byte chunk. + /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. + /// - Returns: A byte chunk. + /// - Throws: When a serialization error is encountered. + mutating func next(_ fetchFrame: () async throws -> MultipartFrame?) async throws -> ArraySlice? { + + func flushedBytes() -> ArraySlice { + let outChunk = ArraySlice(outBuffer) + outBuffer.removeAll(keepingCapacity: true) + return outChunk + } + + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitStart: + emitStart() + return flushedBytes() + case .needsMore: + let frame = try await fetchFrame() + switch stateMachine.receivedFrame(frame) { + case .returnNil: return nil + case .emitEvents(let events): + for event in events { + switch event { + case .headerFields(let headerFields): emitHeaders(headerFields) + case .bodyChunk(let chunk): emitBodyChunk(chunk) + case .endOfPart: emitEndOfPart() + case .start: emitStart() + case .end: emitEnd() + } + } + return flushedBytes() + case .emitError(let error): throw SerializerError(error: error) + } + } + } + } +} + +extension MultipartSerializer { + + /// An error thrown by the serializer. + struct SerializerError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + var error: StateMachine.ActionError + + var description: String { + switch error { + case .noHeaderFieldsAtStart: return "No header fields found at the start of the multipart body." + } + } + + var errorDescription: String? { description } + } +} + +extension MultipartSerializer { + + /// Writes the provided header fields into the buffer. + /// - Parameter headerFields: The header fields to serialize. + private mutating func emitHeaders(_ headerFields: HTTPFields) { + outBuffer.append(contentsOf: ASCII.crlf) + let sortedHeaders = headerFields.sorted { a, b in a.name.canonicalName < b.name.canonicalName } + for headerField in sortedHeaders { + outBuffer.append(contentsOf: headerField.name.canonicalName.utf8) + outBuffer.append(contentsOf: ASCII.colonSpace) + outBuffer.append(contentsOf: headerField.value.utf8) + outBuffer.append(contentsOf: ASCII.crlf) + } + outBuffer.append(contentsOf: ASCII.crlf) + } + + /// Writes the part body chunk into the buffer. + /// - Parameter bodyChunk: The body chunk to write. + private mutating func emitBodyChunk(_ bodyChunk: ArraySlice) { outBuffer.append(contentsOf: bodyChunk) } + + /// Writes an end of part boundary into the buffer. + private mutating func emitEndOfPart() { + outBuffer.append(contentsOf: ASCII.crlf) + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: boundary) + } + + /// Writes the start boundary into the buffer. + private mutating func emitStart() { + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: boundary) + } + + /// Writes the end double dash to the buffer. + private mutating func emitEnd() { + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: ASCII.crlf) + outBuffer.append(contentsOf: ASCII.crlf) + } +} + +extension MultipartSerializer { + + /// A state machine representing the multipart frame serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet written any bytes. + case initial + + /// Emitted start, but no frames yet. + case emittedStart + + /// Finished, the terminal state. + case finished + + /// Last emitted a header fields frame. + case emittedHeaders + + /// Last emitted a part body chunk frame. + case emittedBodyChunk + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The first frame from upstream was not a header fields frame. + case noHeaderFieldsAtStart + } + + /// An action returned by the `next` method. + enum NextAction: Hashable { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit the initial boundary. + case emitStart + + /// Ready for the next frame. + case needsMore + } + + /// Read the next byte chunk serialized from upstream frames. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .initial: + state = .emittedStart + return .emitStart + case .finished: return .returnNil + case .emittedStart, .emittedHeaders, .emittedBodyChunk: return .needsMore + } + } + + /// An event to serialize to bytes. + enum Event: Hashable { + + /// The header fields of a part. + case headerFields(HTTPFields) + + /// A byte chunk of a part. + case bodyChunk(ArraySlice) + + /// A boundary between parts. + case endOfPart + + /// The initial boundary. + case start + + /// The final dashes. + case end + } + + /// An action returned by the `receivedFrame` method. + enum ReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Write the provided events as bytes. + case emitEvents([Event]) + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Ingest the provided frame. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func receivedFrame(_ frame: MultipartFrame?) -> ReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Invalid state: \(state)") + case .finished: return .returnNil + case .emittedStart, .emittedHeaders, .emittedBodyChunk: break + } + switch (state, frame) { + case (.initial, _), (.finished, _): preconditionFailure("Already handled above.") + case (_, .none): + state = .finished + return .emitEvents([.endOfPart, .end]) + case (.emittedStart, .headerFields(let headerFields)): + state = .emittedHeaders + return .emitEvents([.headerFields(headerFields)]) + case (.emittedStart, .bodyChunk): + state = .finished + return .emitError(.noHeaderFieldsAtStart) + case (.emittedHeaders, .headerFields(let headerFields)), + (.emittedBodyChunk, .headerFields(let headerFields)): + state = .emittedHeaders + return .emitEvents([.endOfPart, .headerFields(headerFields)]) + case (.emittedHeaders, .bodyChunk(let bodyChunk)), (.emittedBodyChunk, .bodyChunk(let bodyChunk)): + state = .emittedBodyChunk + return .emitEvents([.bodyChunk(bodyChunk)]) + } + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift index 7229e45b..88036301 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift @@ -17,20 +17,12 @@ import Foundation final class Test_MultipartBytesToFramesSequence: Test_Runtime { func test() async throws { - var chunk = chunkFromStringLines([ + let chunk = chunkFromStringLines([ "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", ]) - let next: () async throws -> ArraySlice? = { - if let first = chunk.first { - let out: ArraySlice = [first] - chunk = chunk.dropFirst() - return out - } else { - return nil - } - } - let upstream = HTTPBody(AsyncThrowingStream(unfolding: next), length: .unknown, iterationBehavior: .single) + var iterator = chunk.makeIterator() + let upstream = AsyncStream { iterator.next().map { ArraySlice([$0]) } } let sequence = MultipartBytesToFramesSequence(upstream: upstream, boundary: "__abcd__") var frames: [MultipartFrame] = [] for try await frame in sequence { frames.append(frame) } diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift new file mode 100644 index 00000000..257c9614 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartFramesToBytesSequence: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var iterator = frames.makeIterator() + let upstream = AsyncStream { iterator.next() } + let sequence = MultipartFramesToBytesSequence(upstream: upstream, boundary: "__abcd__") + var bytes: ArraySlice = [] + for try await chunk in sequence { bytes.append(contentsOf: chunk) } + let expectedBytes = chunkFromStringLines([ + "--__abcd__", #"content-disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"content-disposition: form-data; name="info""#, "", "{}", "--__abcd__--", "", + ]) + XCTAssertEqualData(bytes, expectedBytes) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift new file mode 100644 index 00000000..7dd96a64 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartSerializer: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var serializer = MultipartSerializer(boundary: "__abcd__") + var iterator = frames.makeIterator() + var bytes: [UInt8] = [] + while let chunk = try await serializer.next({ iterator.next() }) { bytes.append(contentsOf: chunk) } + let expectedBytes = chunkFromStringLines([ + "--__abcd__", #"content-disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"content-disposition: form-data; name="info""#, "", "{}", "--__abcd__--", "", + ]) + XCTAssertEqualData(bytes, expectedBytes) + } +} + +private func newStateMachine() -> MultipartSerializer.StateMachine { .init() } + +final class Test_MultipartSerializerStateMachine: Test_Runtime { + + func testInvalidFirstFrame() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.next(), .emitStart) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual(stateMachine.receivedFrame(.bodyChunk([])), .emitError(.noHeaderFieldsAtStart)) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.next(), .emitStart) + XCTAssertEqual(stateMachine.state, .emittedStart) + XCTAssertEqual( + stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), + .emitEvents([.headerFields([.contentDisposition: #"form-data; name="name""#])]) + ) + XCTAssertEqual(stateMachine.state, .emittedHeaders) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.bodyChunk(chunkFromString("24"))), + .emitEvents([.bodyChunk(chunkFromString("24"))]) + ) + XCTAssertEqual(stateMachine.state, .emittedBodyChunk) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), + .emitEvents([.endOfPart, .headerFields([.contentDisposition: #"form-data; name="info""#])]) + ) + XCTAssertEqual(stateMachine.state, .emittedHeaders) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.bodyChunk(chunkFromString("{}"))), + .emitEvents([.bodyChunk(chunkFromString("{}"))]) + ) + XCTAssertEqual(stateMachine.state, .emittedBodyChunk) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual(stateMachine.receivedFrame(nil), .emitEvents([.endOfPart, .end])) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index e2fe87c0..2e6d386e 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -280,3 +280,62 @@ public func XCTAssertEqualStringifiedData( if let body = try expression1() { data = try await Data(collecting: body, upTo: .max) } else { data = .init() } XCTAssertEqualStringifiedData(data, try expression2(), message(), file: file, line: line) } + +fileprivate extension UInt8 { + var asHex: String { + let original: String + switch self { + case 0x0d: original = "CR" + case 0x0a: original = "LF" + default: original = "\(UnicodeScalar(self)) " + } + return String(format: "%02x \(original)", self) + } +} +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> C1?, + _ expression2: @autoclosure () throws -> C2, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) where C1.Element == UInt8, C2.Element == UInt8 { + do { + guard let actualBytes = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let expectedBytes = try expression2() + if ArraySlice(actualBytes) == ArraySlice(expectedBytes) { return } + let actualCount = actualBytes.count + let expectedCount = expectedBytes.count + let minCount = min(actualCount, expectedCount) + print("Printing both byte sequences, first is the actual value and second is the expected one.") + for (index, byte) in zip(actualBytes.prefix(minCount), expectedBytes.prefix(minCount)).enumerated() { + print("\(String(format: "%04d", index)): \(byte.0 != byte.1 ? "x" : " ") \(byte.0.asHex) | \(byte.1.asHex)") + } + let direction: String + let extraBytes: ArraySlice + if actualCount > expectedCount { + direction = "Actual bytes has extra bytes" + extraBytes = ArraySlice(actualBytes.dropFirst(minCount)) + } else if expectedCount > actualCount { + direction = "Actual bytes is missing expected bytes" + extraBytes = ArraySlice(expectedBytes.dropFirst(minCount)) + } else { + direction = "" + extraBytes = [] + } + if !extraBytes.isEmpty { + print("\(direction):") + for (index, byte) in extraBytes.enumerated() { + print("\(String(format: "%04d", minCount + index)): \(byte.asHex)") + } + } + XCTFail( + "Actual stringified data '\(String(decoding: actualBytes, as: UTF8.self))' doesn't equal to expected stringified data '\(String(decoding: expectedBytes, as: UTF8.self))'. Details: \(message())", + file: file, + line: line + ) + } catch { XCTFail(error.localizedDescription, file: file, line: line) } +} From 927f93009947d2bf0be32f14975e6a9107d4a725 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 17 Nov 2023 17:56:26 +0100 Subject: [PATCH 12/79] [Multipart] Add a frames -> raw parts parsing sequence (#74) --- .../MultipartBytesToFramesSequence.swift | 336 ++++++++++++++++ .../MultipartFramesToBytesSequence.swift | 245 +++++++++++ .../MultipartFramesToRawPartsSequence.swift | 380 ++++++++++++++++++ .../Multipart/MultipartParser.swift | 350 ---------------- .../Multipart/MultipartPublicTypes.swift | 35 ++ .../Multipart/MultipartSerializer.swift | 260 ------------ .../Test_MultipartBytesToFramesSequence.swift | 143 +++++++ .../Test_MultipartFramesToBytesSequence.swift | 63 +++ ...st_MultipartFramesToRawPartsSequence.swift | 134 ++++++ .../Multipart/Test_MultipartParser.swift | 159 -------- .../Multipart/Test_MultipartSerializer.swift | 79 ---- docker/docker-compose.2204.510.yaml | 4 +- 12 files changed, 1339 insertions(+), 849 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift delete mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartParser.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift delete mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift delete mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift delete mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift index b95ce563..1e03fc75 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBytesToFramesSequence.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes +import Foundation /// A sequence that parses multipart frames from bytes. struct MultipartBytesToFramesSequence: Sendable @@ -65,3 +66,338 @@ extension MultipartBytesToFramesSequence: AsyncSequence { mutating func next() async throws -> MultipartFrame? { try await parser.next { try await upstream.next() } } } } + +/// A parser of multipart frames from bytes. +struct MultipartParser { + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// Creates a new parser. + /// - Parameter boundary: The boundary that separates parts. + init(boundary: String) { self.stateMachine = .init(boundary: boundary) } + + /// Parses the next frame. + /// - Parameter fetchChunk: A closure that is called when the parser + /// needs more bytes to parse the next frame. + /// - Returns: A parsed frame, or nil at the end of the message. + /// - Throws: When a parsing error is encountered. + mutating func next(_ fetchChunk: () async throws -> ArraySlice?) async throws -> MultipartFrame? { + while true { + switch stateMachine.readNextPart() { + case .none: continue + case .emitError(let actionError): throw ParserError(error: actionError) + case .returnNil: return nil + case .emitHeaderFields(let httpFields): return .headerFields(httpFields) + case .emitBodyChunk(let bodyChunk): return .bodyChunk(bodyChunk) + case .needsMore: + let chunk = try await fetchChunk() + switch stateMachine.receivedChunk(chunk) { + case .none: continue + case .returnNil: return nil + case .emitError(let actionError): throw ParserError(error: actionError) + } + } + } + } +} + +extension MultipartParser { + + /// An error thrown by the parser. + struct ParserError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + let error: MultipartParser.StateMachine.ActionError + + var description: String { + switch error { + case .invalidInitialBoundary: return "Invalid initial boundary." + case .invalidCRLFAtStartOfHeaderField: return "Invalid CRLF at the start of a header field." + case .missingColonAfterHeaderName: return "Missing colon after header field name." + case .invalidCharactersInHeaderFieldName: return "Invalid characters in a header field name." + case .incompleteMultipartMessage: return "Incomplete multipart message." + case .receivedChunkWhenFinished: return "Received a chunk after being finished." + } + } + + var errorDescription: String? { description } + } +} + +extension MultipartParser { + + /// A state machine representing the byte to multipart frame parser. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet fully parsed the initial boundary. + case parsingInitialBoundary([UInt8]) + + /// A substate when parsing a part. + enum PartState: Hashable { + + /// Accumulating part headers. + case parsingHeaderFields(HTTPFields) + + /// Forwarding body chunks. + case parsingBody + } + + /// Is parsing a part. + case parsingPart([UInt8], PartState) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// The bytes of the boundary. + private let boundary: ArraySlice + + /// The bytes of the boundary with the double dash prepended. + private let dashDashBoundary: ArraySlice + + /// The bytes of the boundary prepended by CRLF + double dash. + private let crlfDashDashBoundary: ArraySlice + + /// Creates a new state machine. + /// - Parameter boundary: The boundary used to separate parts. + init(boundary: String) { + self.state = .parsingInitialBoundary([]) + self.boundary = ArraySlice(boundary.utf8) + self.dashDashBoundary = ASCII.dashes + self.boundary + self.crlfDashDashBoundary = ASCII.crlf + dashDashBoundary + } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The initial boundary is malformed. + case invalidInitialBoundary + + /// The expected CRLF at the start of a header is missing. + case invalidCRLFAtStartOfHeaderField + + /// A header field name contains an invalid character. + case invalidCharactersInHeaderFieldName + + /// The header field name is not followed by a colon. + case missingColonAfterHeaderName + + /// More bytes were received after completion. + case receivedChunkWhenFinished + + /// Ran out of bytes without the message being complete. + case incompleteMultipartMessage + } + + /// An action returned by the `readNextPart` method. + enum ReadNextPartAction: Hashable { + + /// No action, call `readNextPart` again. + case none + + /// Throw the provided error. + case emitError(ActionError) + + /// Return nil to the caller, no more frames. + case returnNil + + /// Emit a frame with the provided header fields. + case emitHeaderFields(HTTPFields) + + /// Emit a frame with the provided part body chunk. + case emitBodyChunk(ArraySlice) + + /// Needs more bytes to parse the next frame. + case needsMore + } + + /// Read the next frame from the accumulated bytes. + /// - Returns: An action to perform. + mutating func readNextPart() -> ReadNextPartAction { + switch state { + case .mutating: preconditionFailure("Invalid state: \(state)") + case .finished: return .returnNil + case .parsingInitialBoundary(var buffer): + state = .mutating + // These first bytes must be the boundary already, otherwise this is a malformed multipart body. + switch buffer.firstIndexAfterPrefix(dashDashBoundary) { + case .index(let index): + buffer.removeSubrange(buffer.startIndex...Index + switch buffer.firstIndexAfterPrefix(ASCII.crlf) { + case .index(let index): indexAfterFirstCRLF = index + case .reachedEndOfSelf: + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + case .unexpectedPrefix: + state = .finished + return .emitError(.invalidCRLFAtStartOfHeaderField) + } + // If CRLF is here, this is the end of header fields section. + switch buffer[indexAfterFirstCRLF...].firstIndexAfterPrefix(ASCII.crlf) { + case .index(let index): + buffer.removeSubrange(buffer.startIndex...Index + // Check that what follows is a colon, otherwise this is a malformed header field line. + // Source: RFC 7230, section 3.2.4. + switch buffer[endHeaderNameIndex...].firstIndexAfterPrefix([ASCII.colon]) { + case .index(let index): startHeaderValueWithWhitespaceIndex = index + case .reachedEndOfSelf: + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + case .unexpectedPrefix: + state = .finished + return .emitError(.missingColonAfterHeaderName) + } + guard + let startHeaderValueIndex = buffer[startHeaderValueWithWhitespaceIndex...] + .firstIndex(where: { !ASCII.optionalWhitespace.contains($0) }) + else { + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + } + + // Find the CRLF first, then remove any trailing whitespace. + guard + let endHeaderValueWithWhitespaceRange = buffer[startHeaderValueIndex...] + .firstRange(of: ASCII.crlf) + else { + state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) + return .needsMore + } + let headerFieldValueBytes = buffer[ + startHeaderValueIndex..?) -> ReceivedChunkAction { + switch state { + case .parsingInitialBoundary(var buffer): + guard let chunk else { return .emitError(.incompleteMultipartMessage) } + state = .mutating + buffer.append(contentsOf: chunk) + state = .parsingInitialBoundary(buffer) + return .none + case .parsingPart(var buffer, let part): + guard let chunk else { return .emitError(.incompleteMultipartMessage) } + state = .mutating + buffer.append(contentsOf: chunk) + state = .parsingPart(buffer, part) + return .none + case .finished: + guard chunk == nil else { return .emitError(.receivedChunkWhenFinished) } + return .returnNil + case .mutating: preconditionFailure("Invalid state: \(state)") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift index e1d55542..441c85fd 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes +import Foundation /// A sequence that serializes multipart frames into bytes. struct MultipartFramesToBytesSequence: Sendable @@ -69,3 +70,247 @@ extension MultipartFramesToBytesSequence: AsyncSequence { } } } + +/// A serializer of multipart frames into bytes. +struct MultipartSerializer { + + /// The boundary that separates parts. + private let boundary: ArraySlice + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// The buffer of bytes ready to be written out. + private var outBuffer: [UInt8] + + /// Creates a new serializer. + /// - Parameter boundary: The boundary that separates parts. + init(boundary: String) { + self.boundary = ArraySlice(boundary.utf8) + self.stateMachine = .init() + self.outBuffer = [] + } + /// Requests the next byte chunk. + /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. + /// - Returns: A byte chunk. + /// - Throws: When a serialization error is encountered. + mutating func next(_ fetchFrame: () async throws -> MultipartFrame?) async throws -> ArraySlice? { + + func flushedBytes() -> ArraySlice { + let outChunk = ArraySlice(outBuffer) + outBuffer.removeAll(keepingCapacity: true) + return outChunk + } + + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitStart: + emitStart() + return flushedBytes() + case .needsMore: + let frame = try await fetchFrame() + switch stateMachine.receivedFrame(frame) { + case .returnNil: return nil + case .emitEvents(let events): + for event in events { + switch event { + case .headerFields(let headerFields): emitHeaders(headerFields) + case .bodyChunk(let chunk): emitBodyChunk(chunk) + case .endOfPart: emitEndOfPart() + case .start: emitStart() + case .end: emitEnd() + } + } + return flushedBytes() + case .emitError(let error): throw SerializerError(error: error) + } + } + } + } +} + +extension MultipartSerializer { + + /// An error thrown by the serializer. + struct SerializerError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + var error: StateMachine.ActionError + + var description: String { + switch error { + case .noHeaderFieldsAtStart: return "No header fields found at the start of the multipart body." + } + } + + var errorDescription: String? { description } + } +} + +extension MultipartSerializer { + + /// Writes the provided header fields into the buffer. + /// - Parameter headerFields: The header fields to serialize. + private mutating func emitHeaders(_ headerFields: HTTPFields) { + outBuffer.append(contentsOf: ASCII.crlf) + let sortedHeaders = headerFields.sorted { a, b in a.name.canonicalName < b.name.canonicalName } + for headerField in sortedHeaders { + outBuffer.append(contentsOf: headerField.name.canonicalName.utf8) + outBuffer.append(contentsOf: ASCII.colonSpace) + outBuffer.append(contentsOf: headerField.value.utf8) + outBuffer.append(contentsOf: ASCII.crlf) + } + outBuffer.append(contentsOf: ASCII.crlf) + } + + /// Writes the part body chunk into the buffer. + /// - Parameter bodyChunk: The body chunk to write. + private mutating func emitBodyChunk(_ bodyChunk: ArraySlice) { outBuffer.append(contentsOf: bodyChunk) } + + /// Writes an end of part boundary into the buffer. + private mutating func emitEndOfPart() { + outBuffer.append(contentsOf: ASCII.crlf) + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: boundary) + } + + /// Writes the start boundary into the buffer. + private mutating func emitStart() { + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: boundary) + } + + /// Writes the end double dash to the buffer. + private mutating func emitEnd() { + outBuffer.append(contentsOf: ASCII.dashes) + outBuffer.append(contentsOf: ASCII.crlf) + outBuffer.append(contentsOf: ASCII.crlf) + } +} + +extension MultipartSerializer { + + /// A state machine representing the multipart frame serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet written any bytes. + case initial + + /// Emitted start, but no frames yet. + case startedNothingEmittedYet + + /// Finished, the terminal state. + case finished + + /// Last emitted a header fields frame. + case emittedHeaders + + /// Last emitted a part body chunk frame. + case emittedBodyChunk + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The first frame from upstream was not a header fields frame. + case noHeaderFieldsAtStart + } + + /// An action returned by the `next` method. + enum NextAction: Hashable { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit the initial boundary. + case emitStart + + /// Ready for the next frame. + case needsMore + } + + /// Read the next byte chunk serialized from upstream frames. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .initial: + state = .startedNothingEmittedYet + return .emitStart + case .finished: return .returnNil + case .startedNothingEmittedYet, .emittedHeaders, .emittedBodyChunk: return .needsMore + } + } + + /// An event to serialize to bytes. + enum Event: Hashable { + + /// The header fields of a part. + case headerFields(HTTPFields) + + /// A byte chunk of a part. + case bodyChunk(ArraySlice) + + /// A boundary between parts. + case endOfPart + + /// The initial boundary. + case start + + /// The final dashes. + case end + } + + /// An action returned by the `receivedFrame` method. + enum ReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Write the provided events as bytes. + case emitEvents([Event]) + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Ingest the provided frame. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func receivedFrame(_ frame: MultipartFrame?) -> ReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Invalid state: \(state)") + case .finished: return .returnNil + case .startedNothingEmittedYet, .emittedHeaders, .emittedBodyChunk: break + } + switch (state, frame) { + case (.initial, _), (.finished, _): preconditionFailure("Already handled above.") + case (_, .none): + state = .finished + return .emitEvents([.endOfPart, .end]) + case (.startedNothingEmittedYet, .headerFields(let headerFields)): + state = .emittedHeaders + return .emitEvents([.headerFields(headerFields)]) + case (.startedNothingEmittedYet, .bodyChunk): + state = .finished + return .emitError(.noHeaderFieldsAtStart) + case (.emittedHeaders, .headerFields(let headerFields)), + (.emittedBodyChunk, .headerFields(let headerFields)): + state = .emittedHeaders + return .emitEvents([.endOfPart, .headerFields(headerFields)]) + case (.emittedHeaders, .bodyChunk(let bodyChunk)), (.emittedBodyChunk, .bodyChunk(let bodyChunk)): + state = .emittedBodyChunk + return .emitEvents([.bodyChunk(bodyChunk)]) + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift new file mode 100644 index 00000000..3345c088 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift @@ -0,0 +1,380 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import Foundation + +/// A sequence that parses raw multipart parts from multipart frames. +struct MultipartFramesToRawPartsSequence: Sendable +where Upstream.Element == MultipartFrame { + + /// The source of multipart frames. + var upstream: Upstream +} + +extension MultipartFramesToRawPartsSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + typealias Element = MultipartRawPart + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + func makeAsyncIterator() -> Iterator { Iterator(makeUpstreamIterator: { upstream.makeAsyncIterator() }) } + + /// An iterator that pulls frames from the upstream iterator and provides + /// raw multipart parts. + struct Iterator: AsyncIteratorProtocol { + + /// The underlying shared iterator. + var shared: SharedIterator + + /// The closure invoked to fetch the next byte chunk of the part's body. + var bodyClosure: @Sendable () async throws -> ArraySlice? + + /// Creates a new iterator. + /// - Parameter makeUpstreamIterator: A closure that creates the upstream source of frames. + init(makeUpstreamIterator: @Sendable () -> Upstream.AsyncIterator) { + let shared = SharedIterator(makeUpstreamIterator: makeUpstreamIterator) + self.shared = shared + self.bodyClosure = { try await shared.nextFromBodySubsequence() } + } + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + mutating func next() async throws -> Element? { + try await shared.nextFromPartSequence(bodyClosure: bodyClosure) + } + } +} + +extension HTTPBody { + + /// Creates a new body from the provided header fields and body closure. + /// - Parameters: + /// - headerFields: The header fields to inspect for a `content-length` header. + /// - bodyClosure: A closure invoked to fetch the next byte chunk of the body. + fileprivate convenience init( + headerFields: HTTPFields, + bodyClosure: @escaping @Sendable () async throws -> ArraySlice? + ) { + let stream = AsyncThrowingStream(unfolding: bodyClosure) + let length: HTTPBody.Length + if let contentLengthString = headerFields[.contentLength], let contentLength = Int(contentLengthString) { + length = .known(contentLength) + } else { + length = .unknown + } + self.init(stream, length: length) + } +} + +extension MultipartFramesToRawPartsSequence { + + /// A state machine representing the frame to raw part parser. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not started parsing any parts yet. + case initial + + /// Waiting to send header fields to start a new part. + /// + /// Associated value is optional headers. + /// If they're non-nil, they arrived already, so just send them right away. + /// If they're nil, you need to fetch the next frame to get them. + case waitingToSendHeaders(HTTPFields?) + + /// In the process of streaming the byte chunks of a part body. + case streamingBody + + /// Finished, the terminal state. + case finished + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The outer, raw part sequence called next before the current part's body was fully consumed. + /// + /// This is a usage error by the consumer of the sequence. + case partSequenceNextCalledBeforeBodyWasConsumed + + /// The first frame received was a body chunk instead of header fields, which is invalid. + /// + /// This indicates an issue in the source of frames. + case receivedBodyChunkInInitial + + /// Received a body chunk when waiting for header fields, which is invalid. + /// + /// This indicates an issue in the source of frames. + case receivedBodyChunkWhenWaitingForHeaders + + /// Received another frame before having had a chance to send out header fields, this is an error caused + /// by the driver of the state machine. + case receivedFrameWhenAlreadyHasUnsentHeaders + } + + /// An action returned by the `nextFromPartSequence` method. + enum NextFromPartSequenceAction: Hashable { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Fetch the next frame. + case fetchFrame + + /// Throw the provided error. + case emitError(ActionError) + + /// Emit a part with the provided header fields. + case emitPart(HTTPFields) + } + + /// Read the next part from the upstream frames. + /// - Returns: An action to perform. + mutating func nextFromPartSequence() -> NextFromPartSequenceAction { + switch state { + case .initial: + state = .waitingToSendHeaders(nil) + return .fetchFrame + case .waitingToSendHeaders(.some(let headers)): + state = .streamingBody + return .emitPart(headers) + case .waitingToSendHeaders(.none), .streamingBody: + state = .finished + return .emitError(.partSequenceNextCalledBeforeBodyWasConsumed) + case .finished: return .returnNil + } + } + + /// An action returned by the `partReceivedFrame` method. + enum PartReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Throw the provided error. + case emitError(ActionError) + + /// Emit a part with the provided header fields. + case emitPart(HTTPFields) + } + + /// Ingest the provided frame, requested by the part sequence. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func partReceivedFrame(_ frame: MultipartFrame?) -> PartReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Haven't asked for a part chunk, how did we receive one?") + case .waitingToSendHeaders(.some): + state = .finished + return .emitError(.receivedFrameWhenAlreadyHasUnsentHeaders) + case .waitingToSendHeaders(.none): + if let frame { + switch frame { + case .headerFields(let headers): + state = .streamingBody + return .emitPart(headers) + case .bodyChunk: + state = .finished + return .emitError(.receivedBodyChunkWhenWaitingForHeaders) + } + } else { + state = .finished + return .returnNil + } + case .streamingBody: + state = .finished + return .emitError(.partSequenceNextCalledBeforeBodyWasConsumed) + case .finished: return .returnNil + } + } + + /// An action returned by the `nextFromBodySubsequence` method. + enum NextFromBodySubsequenceAction: Hashable { + + /// Return nil to the caller, no more byte chunks. + case returnNil + + /// Fetch the next frame. + case fetchFrame + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Read the next byte chunk requested by the current part's body sequence. + /// - Returns: An action to perform. + mutating func nextFromBodySubsequence() -> NextFromBodySubsequenceAction { + switch state { + case .initial: + state = .finished + return .emitError(.receivedBodyChunkInInitial) + case .waitingToSendHeaders: + state = .finished + return .emitError(.receivedBodyChunkWhenWaitingForHeaders) + case .streamingBody: return .fetchFrame + case .finished: return .returnNil + } + } + + /// An action returned by the `bodyReceivedFrame` method. + enum BodyReceivedFrameAction: Hashable { + + /// Return nil to the caller, no more byte chunks. + case returnNil + + /// Return the provided byte chunk. + case returnChunk(ArraySlice) + + /// Throw the provided error. + case emitError(ActionError) + } + + /// Ingest the provided frame, requested by the body sequence. + /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. + /// - Returns: An action to perform. + mutating func bodyReceivedFrame(_ frame: MultipartFrame?) -> BodyReceivedFrameAction { + switch state { + case .initial: preconditionFailure("Haven't asked for a frame, how did we receive one?") + case .waitingToSendHeaders: + state = .finished + return .emitError(.receivedBodyChunkWhenWaitingForHeaders) + case .streamingBody: + if let frame { + switch frame { + case .headerFields(let headers): + state = .waitingToSendHeaders(headers) + return .returnNil + case .bodyChunk(let bodyChunk): return .returnChunk(bodyChunk) + } + } else { + state = .finished + return .returnNil + } + case .finished: return .returnNil + } + } + } +} + +extension MultipartFramesToRawPartsSequence { + + /// A type-safe iterator shared by the outer part sequence iterator and an inner body sequence iterator. + /// + /// It enforces that when a new part is emitted by the outer sequence, that the new part's body is then fully + /// consumed before the outer sequence is asked for the next part. + /// + /// This is required as the source of bytes is a single stream, so without the current part's body being consumed, + /// we can't move on to the next part. + actor SharedIterator { + + /// The upstream source of frames. + private var upstream: Upstream.AsyncIterator + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// Creates a new iterator. + /// - Parameter makeUpstreamIterator: A closure that creates the upstream source of frames. + init(makeUpstreamIterator: @Sendable () -> Upstream.AsyncIterator) { + let upstream = makeUpstreamIterator() + self.upstream = upstream + self.stateMachine = .init() + } + + /// An error thrown by the shared iterator. + struct IteratorError: Swift.Error, CustomStringConvertible, LocalizedError { + + /// The underlying error emitted by the state machine. + let error: StateMachine.ActionError + + var description: String { + switch error { + case .partSequenceNextCalledBeforeBodyWasConsumed: + return + "The outer part sequence was asked for the next element before the current part's inner body sequence was fully consumed." + case .receivedBodyChunkInInitial: + return + "Received a body chunk from the upstream sequence as the first element, instead of header fields." + case .receivedBodyChunkWhenWaitingForHeaders: + return "Received a body chunk from the upstream sequence when expecting header fields." + case .receivedFrameWhenAlreadyHasUnsentHeaders: + return "Received another frame before the current frame with header fields was written out." + } + } + + var errorDescription: String? { description } + } + + /// Request the next element from the outer part sequence. + /// - Parameter bodyClosure: The closure invoked to fetch the next byte chunk of the part's body. + /// - Returns: The next element, or `nil` if finished. + /// - Throws: When a parsing error is encountered. + func nextFromPartSequence(bodyClosure: @escaping @Sendable () async throws -> ArraySlice?) async throws + -> Element? + { + switch stateMachine.nextFromPartSequence() { + case .returnNil: return nil + case .fetchFrame: + var upstream = upstream + let frame = try await upstream.next() + self.upstream = upstream + switch stateMachine.partReceivedFrame(frame) { + case .returnNil: return nil + case .emitError(let error): throw IteratorError(error: error) + case .emitPart(let headers): + let body = HTTPBody(headerFields: headers, bodyClosure: bodyClosure) + return .init(headerFields: headers, body: body) + } + case .emitError(let error): throw IteratorError(error: error) + case .emitPart(let headers): + let body = HTTPBody(headerFields: headers, bodyClosure: bodyClosure) + return .init(headerFields: headers, body: body) + } + } + + /// Request the next element from the inner body bytes sequence. + /// - Returns: The next element, or `nil` if finished. + func nextFromBodySubsequence() async throws -> ArraySlice? { + switch stateMachine.nextFromBodySubsequence() { + case .returnNil: return nil + case .fetchFrame: + var upstream = upstream + let frame = try await upstream.next() + self.upstream = upstream + switch stateMachine.bodyReceivedFrame(frame) { + case .returnNil: return nil + case .returnChunk(let bodyChunk): return bodyChunk + case .emitError(let error): throw IteratorError(error: error) + } + case .emitError(let error): throw IteratorError(error: error) + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift b/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift deleted file mode 100644 index d98db13e..00000000 --- a/Sources/OpenAPIRuntime/Multipart/MultipartParser.swift +++ /dev/null @@ -1,350 +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 -import HTTPTypes - -/// A parser of multipart frames from bytes. -struct MultipartParser { - - /// The underlying state machine. - private var stateMachine: StateMachine - - /// Creates a new parser. - /// - Parameter boundary: The boundary that separates parts. - init(boundary: String) { self.stateMachine = .init(boundary: boundary) } - - /// Parses the next frame. - /// - Parameter fetchChunk: A closure that is called when the parser - /// needs more bytes to parse the next frame. - /// - Returns: A parsed frame, or nil at the end of the message. - /// - Throws: When a parsing error is encountered. - mutating func next(_ fetchChunk: () async throws -> ArraySlice?) async throws -> MultipartFrame? { - while true { - switch stateMachine.readNextPart() { - case .none: continue - case .emitError(let actionError): throw ParserError(error: actionError) - case .returnNil: return nil - case .emitHeaderFields(let httpFields): return .headerFields(httpFields) - case .emitBodyChunk(let bodyChunk): return .bodyChunk(bodyChunk) - case .needsMore: - let chunk = try await fetchChunk() - switch stateMachine.receivedChunk(chunk) { - case .none: continue - case .returnNil: return nil - case .emitError(let actionError): throw ParserError(error: actionError) - } - } - } - } -} -extension MultipartParser { - - /// An error thrown by the parser. - struct ParserError: Swift.Error, CustomStringConvertible, LocalizedError { - - /// The underlying error emitted by the state machine. - let error: MultipartParser.StateMachine.ActionError - - var description: String { - switch error { - case .invalidInitialBoundary: return "Invalid initial boundary." - case .invalidCRLFAtStartOfHeaderField: return "Invalid CRLF at the start of a header field." - case .missingColonAfterHeaderName: return "Missing colon after header field name." - case .invalidCharactersInHeaderFieldName: return "Invalid characters in a header field name." - case .incompleteMultipartMessage: return "Incomplete multipart message." - case .receivedChunkWhenFinished: return "Received a chunk after being finished." - } - } - - var errorDescription: String? { description } - } -} - -extension MultipartParser { - - /// A state machine representing the byte to multipart frame parser. - struct StateMachine { - - /// The possible states of the state machine. - enum State: Hashable { - - /// Has not yet fully parsed the initial boundary. - case parsingInitialBoundary([UInt8]) - - /// A substate when parsing a part. - enum PartState: Hashable { - - /// Accumulating part headers. - case parsingHeaderFields(HTTPFields) - - /// Forwarding body chunks. - case parsingBody - } - - /// Is parsing a part. - case parsingPart([UInt8], PartState) - - /// Finished, the terminal state. - case finished - - /// Helper state to avoid copy-on-write copies. - case mutating - } - - /// The current state of the state machine. - private(set) var state: State - - /// The bytes of the boundary. - private let boundary: ArraySlice - - /// The bytes of the boundary with the double dash prepended. - private let dashDashBoundary: ArraySlice - - /// The bytes of the boundary prepended by CRLF + double dash. - private let crlfDashDashBoundary: ArraySlice - - /// Creates a new state machine. - /// - Parameter boundary: The boundary used to separate parts. - init(boundary: String) { - self.state = .parsingInitialBoundary([]) - self.boundary = ArraySlice(boundary.utf8) - self.dashDashBoundary = ASCII.dashes + self.boundary - self.crlfDashDashBoundary = ASCII.crlf + dashDashBoundary - } - - /// An error returned by the state machine. - enum ActionError: Hashable { - - /// The initial boundary is malformed. - case invalidInitialBoundary - - /// The expected CRLF at the start of a header is missing. - case invalidCRLFAtStartOfHeaderField - - /// A header field name contains an invalid character. - case invalidCharactersInHeaderFieldName - - /// The header field name is not followed by a colon. - case missingColonAfterHeaderName - - /// More bytes were received after completion. - case receivedChunkWhenFinished - - /// Ran out of bytes without the message being complete. - case incompleteMultipartMessage - } - - /// An action returned by the `readNextPart` method. - enum ReadNextPartAction: Hashable { - - /// No action, call `readNextPart` again. - case none - - /// Throw the provided error. - case emitError(ActionError) - - /// Return nil to the caller, no more frames. - case returnNil - - /// Emit a frame with the provided header fields. - case emitHeaderFields(HTTPFields) - - /// Emit a frame with the provided part body chunk. - case emitBodyChunk(ArraySlice) - - /// Needs more bytes to parse the next frame. - case needsMore - } - - /// Read the next part from the accumulated bytes. - /// - Returns: An action to perform. - mutating func readNextPart() -> ReadNextPartAction { - switch state { - case .mutating: preconditionFailure("Invalid state: \(state)") - case .finished: return .returnNil - case .parsingInitialBoundary(var buffer): - state = .mutating - // These first bytes must be the boundary already, otherwise this is a malformed multipart body. - switch buffer.firstIndexAfterPrefix(dashDashBoundary) { - case .index(let index): - buffer.removeSubrange(buffer.startIndex...Index - switch buffer.firstIndexAfterPrefix(ASCII.crlf) { - case .index(let index): indexAfterFirstCRLF = index - case .reachedEndOfSelf: - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - case .unexpectedPrefix: - state = .finished - return .emitError(.invalidCRLFAtStartOfHeaderField) - } - // If CRLF is here, this is the end of header fields section. - switch buffer[indexAfterFirstCRLF...].firstIndexAfterPrefix(ASCII.crlf) { - case .index(let index): - buffer.removeSubrange(buffer.startIndex...Index - // Check that what follows is a colon, otherwise this is a malformed header field line. - // Source: RFC 7230, section 3.2.4. - switch buffer[endHeaderNameIndex...].firstIndexAfterPrefix([ASCII.colon]) { - case .index(let index): startHeaderValueWithWhitespaceIndex = index - case .reachedEndOfSelf: - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - case .unexpectedPrefix: - state = .finished - return .emitError(.missingColonAfterHeaderName) - } - guard - let startHeaderValueIndex = buffer[startHeaderValueWithWhitespaceIndex...] - .firstIndex(where: { !ASCII.optionalWhitespace.contains($0) }) - else { - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - } - - // Find the CRLF first, then remove any trailing whitespace. - guard - let endHeaderValueWithWhitespaceRange = buffer[startHeaderValueIndex...] - .firstRange(of: ASCII.crlf) - else { - state = .parsingPart(buffer, .parsingHeaderFields(headerFields)) - return .needsMore - } - let headerFieldValueBytes = buffer[ - startHeaderValueIndex..?) -> ReceivedChunkAction { - switch state { - case .parsingInitialBoundary(var buffer): - guard let chunk else { return .emitError(.incompleteMultipartMessage) } - state = .mutating - buffer.append(contentsOf: chunk) - state = .parsingInitialBoundary(buffer) - return .none - case .parsingPart(var buffer, let part): - guard let chunk else { return .emitError(.incompleteMultipartMessage) } - state = .mutating - buffer.append(contentsOf: chunk) - state = .parsingPart(buffer, part) - return .none - case .finished: - guard chunk == nil else { return .emitError(.receivedChunkWhenFinished) } - return .returnNil - case .mutating: preconditionFailure("Invalid state: \(state)") - } - } - } -} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift new file mode 100644 index 00000000..213dcfb6 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +/// A raw multipart part containing the header fields and the body stream. +public struct MultipartRawPart: Sendable, Hashable { + + /// The header fields contained in this part, such as `content-disposition`. + public var headerFields: HTTPFields + + /// The body stream of this part. + public var body: HTTPBody + + /// Creates a new part. + /// - Parameters: + /// - headerFields: The header fields contained in this part, such as `content-disposition`. + /// - body: The body stream of this part. + public init(headerFields: HTTPFields, body: HTTPBody) { + self.headerFields = headerFields + self.body = body + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift b/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift deleted file mode 100644 index 8f744784..00000000 --- a/Sources/OpenAPIRuntime/Multipart/MultipartSerializer.swift +++ /dev/null @@ -1,260 +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 -import HTTPTypes - -/// A serializer of multipart frames into bytes. -struct MultipartSerializer { - - /// The boundary that separates parts. - private let boundary: ArraySlice - - /// The underlying state machine. - private var stateMachine: StateMachine - - /// The buffer of bytes ready to be written out. - private var outBuffer: [UInt8] - - /// Creates a new serializer. - /// - Parameter boundary: The boundary that separates parts. - init(boundary: String) { - self.boundary = ArraySlice(boundary.utf8) - self.stateMachine = .init() - self.outBuffer = [] - } - /// Requests the next byte chunk. - /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. - /// - Returns: A byte chunk. - /// - Throws: When a serialization error is encountered. - mutating func next(_ fetchFrame: () async throws -> MultipartFrame?) async throws -> ArraySlice? { - - func flushedBytes() -> ArraySlice { - let outChunk = ArraySlice(outBuffer) - outBuffer.removeAll(keepingCapacity: true) - return outChunk - } - - while true { - switch stateMachine.next() { - case .returnNil: return nil - case .emitStart: - emitStart() - return flushedBytes() - case .needsMore: - let frame = try await fetchFrame() - switch stateMachine.receivedFrame(frame) { - case .returnNil: return nil - case .emitEvents(let events): - for event in events { - switch event { - case .headerFields(let headerFields): emitHeaders(headerFields) - case .bodyChunk(let chunk): emitBodyChunk(chunk) - case .endOfPart: emitEndOfPart() - case .start: emitStart() - case .end: emitEnd() - } - } - return flushedBytes() - case .emitError(let error): throw SerializerError(error: error) - } - } - } - } -} - -extension MultipartSerializer { - - /// An error thrown by the serializer. - struct SerializerError: Swift.Error, CustomStringConvertible, LocalizedError { - - /// The underlying error emitted by the state machine. - var error: StateMachine.ActionError - - var description: String { - switch error { - case .noHeaderFieldsAtStart: return "No header fields found at the start of the multipart body." - } - } - - var errorDescription: String? { description } - } -} - -extension MultipartSerializer { - - /// Writes the provided header fields into the buffer. - /// - Parameter headerFields: The header fields to serialize. - private mutating func emitHeaders(_ headerFields: HTTPFields) { - outBuffer.append(contentsOf: ASCII.crlf) - let sortedHeaders = headerFields.sorted { a, b in a.name.canonicalName < b.name.canonicalName } - for headerField in sortedHeaders { - outBuffer.append(contentsOf: headerField.name.canonicalName.utf8) - outBuffer.append(contentsOf: ASCII.colonSpace) - outBuffer.append(contentsOf: headerField.value.utf8) - outBuffer.append(contentsOf: ASCII.crlf) - } - outBuffer.append(contentsOf: ASCII.crlf) - } - - /// Writes the part body chunk into the buffer. - /// - Parameter bodyChunk: The body chunk to write. - private mutating func emitBodyChunk(_ bodyChunk: ArraySlice) { outBuffer.append(contentsOf: bodyChunk) } - - /// Writes an end of part boundary into the buffer. - private mutating func emitEndOfPart() { - outBuffer.append(contentsOf: ASCII.crlf) - outBuffer.append(contentsOf: ASCII.dashes) - outBuffer.append(contentsOf: boundary) - } - - /// Writes the start boundary into the buffer. - private mutating func emitStart() { - outBuffer.append(contentsOf: ASCII.dashes) - outBuffer.append(contentsOf: boundary) - } - - /// Writes the end double dash to the buffer. - private mutating func emitEnd() { - outBuffer.append(contentsOf: ASCII.dashes) - outBuffer.append(contentsOf: ASCII.crlf) - outBuffer.append(contentsOf: ASCII.crlf) - } -} - -extension MultipartSerializer { - - /// A state machine representing the multipart frame serializer. - struct StateMachine { - - /// The possible states of the state machine. - enum State: Hashable { - - /// Has not yet written any bytes. - case initial - - /// Emitted start, but no frames yet. - case emittedStart - - /// Finished, the terminal state. - case finished - - /// Last emitted a header fields frame. - case emittedHeaders - - /// Last emitted a part body chunk frame. - case emittedBodyChunk - } - - /// The current state of the state machine. - private(set) var state: State - - /// Creates a new state machine. - init() { self.state = .initial } - - /// An error returned by the state machine. - enum ActionError: Hashable { - - /// The first frame from upstream was not a header fields frame. - case noHeaderFieldsAtStart - } - - /// An action returned by the `next` method. - enum NextAction: Hashable { - - /// Return nil to the caller, no more bytes. - case returnNil - - /// Emit the initial boundary. - case emitStart - - /// Ready for the next frame. - case needsMore - } - - /// Read the next byte chunk serialized from upstream frames. - /// - Returns: An action to perform. - mutating func next() -> NextAction { - switch state { - case .initial: - state = .emittedStart - return .emitStart - case .finished: return .returnNil - case .emittedStart, .emittedHeaders, .emittedBodyChunk: return .needsMore - } - } - - /// An event to serialize to bytes. - enum Event: Hashable { - - /// The header fields of a part. - case headerFields(HTTPFields) - - /// A byte chunk of a part. - case bodyChunk(ArraySlice) - - /// A boundary between parts. - case endOfPart - - /// The initial boundary. - case start - - /// The final dashes. - case end - } - - /// An action returned by the `receivedFrame` method. - enum ReceivedFrameAction: Hashable { - - /// Return nil to the caller, no more bytes. - case returnNil - - /// Write the provided events as bytes. - case emitEvents([Event]) - - /// Throw the provided error. - case emitError(ActionError) - } - - /// Ingest the provided frame. - /// - Parameter frame: A new frame. If `nil`, then the source of frames is finished. - /// - Returns: An action to perform. - mutating func receivedFrame(_ frame: MultipartFrame?) -> ReceivedFrameAction { - switch state { - case .initial: preconditionFailure("Invalid state: \(state)") - case .finished: return .returnNil - case .emittedStart, .emittedHeaders, .emittedBodyChunk: break - } - switch (state, frame) { - case (.initial, _), (.finished, _): preconditionFailure("Already handled above.") - case (_, .none): - state = .finished - return .emitEvents([.endOfPart, .end]) - case (.emittedStart, .headerFields(let headerFields)): - state = .emittedHeaders - return .emitEvents([.headerFields(headerFields)]) - case (.emittedStart, .bodyChunk): - state = .finished - return .emitError(.noHeaderFieldsAtStart) - case (.emittedHeaders, .headerFields(let headerFields)), - (.emittedBodyChunk, .headerFields(let headerFields)): - state = .emittedHeaders - return .emitEvents([.endOfPart, .headerFields(headerFields)]) - case (.emittedHeaders, .bodyChunk(let bodyChunk)), (.emittedBodyChunk, .bodyChunk(let bodyChunk)): - state = .emittedBodyChunk - return .emitEvents([.bodyChunk(bodyChunk)]) - } - } - } -} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift index 88036301..acdee3f4 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift @@ -36,3 +36,146 @@ final class Test_MultipartBytesToFramesSequence: Test_Runtime { ) } } + +final class Test_MultipartParser: Test_Runtime { + func test() async throws { + var chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + var parser = MultipartParser(boundary: "__abcd__") + let next: () async throws -> ArraySlice? = { + if let first = chunk.first { + let out: ArraySlice = [first] + chunk = chunk.dropFirst() + return out + } else { + return nil + } + } + var frames: [MultipartFrame] = [] + while let frame = try await parser.next(next) { frames.append(frame) } + XCTAssertEqual( + frames, + [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + ) + } +} + +private func newStateMachine() -> MultipartParser.StateMachine { .init(boundary: "__abcd__") } + +final class Test_MultipartParserStateMachine: Test_Runtime { + + func testInvalidInitialBoundary() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("invalid")), .none) + XCTAssertEqual(stateMachine.readNextPart(), .emitError(.invalidInitialBoundary)) + } + + func testHeaderFields() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("--__ab")), .none) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(bufferFromString("--__ab"))) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__", addCRLFs: 1)), .none) + XCTAssertEqual(stateMachine.readNextPart(), .none) + XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a], .parsingHeaderFields(.init()))) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(#"Content-Disposi"#)), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart([0x0d, 0x0a] + bufferFromString(#"Content-Disposi"#), .parsingHeaderFields(.init())) + ) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual( + stateMachine.receivedChunk(chunkFromString(#"tion: form-data; name="name""#, addCRLFs: 2)), + .none + ) + XCTAssertEqual( + stateMachine.state, + .parsingPart( + [0x0d, 0x0a] + bufferFromString(#"Content-Disposition: form-data; name="name""#) + [ + 0x0d, 0x0a, 0x0d, 0x0a, + ], + .parsingHeaderFields(.init()) + ) + ) + // Reads the first header field. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Reads the end of the header fields section. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) + } + + func testPartBody() throws { + var stateMachine = newStateMachine() + let chunk = chunkFromStringLines(["--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24"]) + XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) + XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(Array(chunk))) + // Parse the initial boundary and first header field. + for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .parsingPart(bufferFromString(#"24"#) + [0x0d, 0x0a], .parsingBody)) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(".42")), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42"), .parsingBody) + ) + XCTAssertEqual( + stateMachine.readNextPart(), + .emitBodyChunk(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42")) + ) + XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) + XCTAssertEqual(stateMachine.receivedChunk([0x0d, 0x0a] + chunkFromString("--__ab")), .none) + XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a] + chunkFromString("--__ab"), .parsingBody)) + XCTAssertEqual(stateMachine.readNextPart(), .needsMore) + XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__--", addCRLFs: 1)), .none) + XCTAssertEqual( + stateMachine.state, + .parsingPart([0x0d, 0x0a] + chunkFromString("--__abcd__--", addCRLFs: 1), .parsingBody) + ) + // Parse the final boundary. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Parse the trailing two dashes. + XCTAssertEqual(stateMachine.readNextPart(), .returnNil) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + let chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) + // Parse the initial boundary and first header field. + for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + // Parse the first part's body. + XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("24"))) + // Parse the boundary. + XCTAssertEqual(stateMachine.readNextPart(), .none) + // Parse the end of header fields. + XCTAssertEqual( + stateMachine.readNextPart(), + .emitHeaderFields([.contentDisposition: #"form-data; name="info""#]) + ) + // Parse the second part's body. + XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("{}"))) + // Parse the trailing two dashes. + XCTAssertEqual(stateMachine.readNextPart(), .returnNil) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift index 257c9614..de487ed6 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift @@ -34,3 +34,66 @@ final class Test_MultipartFramesToBytesSequence: Test_Runtime { XCTAssertEqualData(bytes, expectedBytes) } } + +final class Test_MultipartSerializer: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var serializer = MultipartSerializer(boundary: "__abcd__") + var iterator = frames.makeIterator() + var bytes: [UInt8] = [] + while let chunk = try await serializer.next({ iterator.next() }) { bytes.append(contentsOf: chunk) } + let expectedBytes = chunkFromStringLines([ + "--__abcd__", #"content-disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"content-disposition: form-data; name="info""#, "", "{}", "--__abcd__--", "", + ]) + XCTAssertEqualData(bytes, expectedBytes) + } +} + +private func newStateMachine() -> MultipartSerializer.StateMachine { .init() } + +final class Test_MultipartSerializerStateMachine: Test_Runtime { + + func testInvalidFirstFrame() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.next(), .emitStart) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual(stateMachine.receivedFrame(.bodyChunk([])), .emitError(.noHeaderFieldsAtStart)) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.next(), .emitStart) + XCTAssertEqual(stateMachine.state, .startedNothingEmittedYet) + XCTAssertEqual( + stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), + .emitEvents([.headerFields([.contentDisposition: #"form-data; name="name""#])]) + ) + XCTAssertEqual(stateMachine.state, .emittedHeaders) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.bodyChunk(chunkFromString("24"))), + .emitEvents([.bodyChunk(chunkFromString("24"))]) + ) + XCTAssertEqual(stateMachine.state, .emittedBodyChunk) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), + .emitEvents([.endOfPart, .headerFields([.contentDisposition: #"form-data; name="info""#])]) + ) + XCTAssertEqual(stateMachine.state, .emittedHeaders) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual( + stateMachine.receivedFrame(.bodyChunk(chunkFromString("{}"))), + .emitEvents([.bodyChunk(chunkFromString("{}"))]) + ) + XCTAssertEqual(stateMachine.state, .emittedBodyChunk) + XCTAssertEqual(stateMachine.next(), .needsMore) + XCTAssertEqual(stateMachine.receivedFrame(nil), .emitEvents([.endOfPart, .end])) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift new file mode 100644 index 00000000..4a75b727 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartFramesToRawPartsSequence: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var upstreamIterator = frames.makeIterator() + let upstream = AsyncStream { upstreamIterator.next() } + let sequence = MultipartFramesToRawPartsSequence(upstream: upstream) + var iterator = sequence.makeAsyncIterator() + guard let part1 = try await iterator.next() else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part1.headerFields, [.contentDisposition: #"form-data; name="name""#]) + try await XCTAssertEqualStringifiedData(part1.body, "24") + guard let part2 = try await iterator.next() else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part2.headerFields, [.contentDisposition: #"form-data; name="info""#]) + try await XCTAssertEqualStringifiedData(part2.body, "{}") + + let part3 = try await iterator.next() + XCTAssertNil(part3) + } +} + +final class Test_MultipartFramesToRawPartsSequenceIterator: Test_Runtime { + func test() async throws { + let frames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + var upstreamSyncIterator = frames.makeIterator() + let upstream = AsyncStream { upstreamSyncIterator.next() } + let sharedIterator = MultipartFramesToRawPartsSequence> + .SharedIterator(makeUpstreamIterator: { upstream.makeAsyncIterator() }) + let bodyClosure: @Sendable () async throws -> ArraySlice? = { + try await sharedIterator.nextFromBodySubsequence() + } + guard let part1 = try await sharedIterator.nextFromPartSequence(bodyClosure: bodyClosure) else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part1.headerFields, [.contentDisposition: #"form-data; name="name""#]) + try await XCTAssertEqualStringifiedData(part1.body, "24") + guard let part2 = try await sharedIterator.nextFromPartSequence(bodyClosure: bodyClosure) else { + XCTFail("Missing part") + return + } + XCTAssertEqual(part2.headerFields, [.contentDisposition: #"form-data; name="info""#]) + try await XCTAssertEqualStringifiedData(part2.body, "{}") + + let part3 = try await sharedIterator.nextFromPartSequence(bodyClosure: bodyClosure) + XCTAssertNil(part3) + } +} + +private func newStateMachine() -> MultipartFramesToRawPartsSequence>.StateMachine { + .init() +} + +final class Test_MultipartFramesToRawPartsSequenceIteratorStateMachine: Test_Runtime { + + func testInvalidFirstFrame() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.nextFromPartSequence(), .fetchFrame) + XCTAssertEqual(stateMachine.state, .waitingToSendHeaders(nil)) + XCTAssertEqual( + stateMachine.partReceivedFrame(.bodyChunk([])), + .emitError(.receivedBodyChunkWhenWaitingForHeaders) + ) + } + + func testTwoParts() throws { + var stateMachine = newStateMachine() + XCTAssertEqual(stateMachine.state, .initial) + XCTAssertEqual(stateMachine.nextFromPartSequence(), .fetchFrame) + XCTAssertEqual(stateMachine.state, .waitingToSendHeaders(nil)) + XCTAssertEqual( + stateMachine.partReceivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), + .emitPart([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual( + stateMachine.bodyReceivedFrame(.bodyChunk(chunkFromString("24"))), + .returnChunk(chunkFromString("24")) + ) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual( + stateMachine.bodyReceivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), + .returnNil + ) + XCTAssertEqual(stateMachine.state, .waitingToSendHeaders([.contentDisposition: #"form-data; name="info""#])) + XCTAssertEqual( + stateMachine.nextFromPartSequence(), + .emitPart([.contentDisposition: #"form-data; name="info""#]) + ) + XCTAssertEqual(stateMachine.state, .streamingBody) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual( + stateMachine.bodyReceivedFrame(.bodyChunk(chunkFromString("{}"))), + .returnChunk(chunkFromString("{}")) + ) + XCTAssertEqual(stateMachine.nextFromBodySubsequence(), .fetchFrame) + XCTAssertEqual(stateMachine.bodyReceivedFrame(nil), .returnNil) + XCTAssertEqual(stateMachine.state, .finished) + XCTAssertEqual(stateMachine.nextFromPartSequence(), .returnNil) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift deleted file mode 100644 index 5587868b..00000000 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartParser.swift +++ /dev/null @@ -1,159 +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 XCTest -@_spi(Generated) @testable import OpenAPIRuntime -import Foundation - -final class Test_MultipartParser: Test_Runtime { - func test() async throws { - var chunk = chunkFromStringLines([ - "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", - #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", - ]) - var parser = MultipartParser(boundary: "__abcd__") - let next: () async throws -> ArraySlice? = { - if let first = chunk.first { - let out: ArraySlice = [first] - chunk = chunk.dropFirst() - return out - } else { - return nil - } - } - var frames: [MultipartFrame] = [] - while let frame = try await parser.next(next) { frames.append(frame) } - XCTAssertEqual( - frames, - [ - .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), - .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), - .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), - ] - ) - } -} - -private func newStateMachine() -> MultipartParser.StateMachine { .init(boundary: "__abcd__") } - -final class Test_MultipartParserStateMachine: Test_Runtime { - - func testInvalidInitialBoundary() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("invalid")), .none) - XCTAssertEqual(stateMachine.readNextPart(), .emitError(.invalidInitialBoundary)) - } - - func testHeaderFields() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("--__ab")), .none) - XCTAssertEqual(stateMachine.readNextPart(), .needsMore) - XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(bufferFromString("--__ab"))) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__", addCRLFs: 1)), .none) - XCTAssertEqual(stateMachine.readNextPart(), .none) - XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a], .parsingHeaderFields(.init()))) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(#"Content-Disposi"#)), .none) - XCTAssertEqual( - stateMachine.state, - .parsingPart([0x0d, 0x0a] + bufferFromString(#"Content-Disposi"#), .parsingHeaderFields(.init())) - ) - XCTAssertEqual(stateMachine.readNextPart(), .needsMore) - XCTAssertEqual( - stateMachine.receivedChunk(chunkFromString(#"tion: form-data; name="name""#, addCRLFs: 2)), - .none - ) - XCTAssertEqual( - stateMachine.state, - .parsingPart( - [0x0d, 0x0a] + bufferFromString(#"Content-Disposition: form-data; name="name""#) + [ - 0x0d, 0x0a, 0x0d, 0x0a, - ], - .parsingHeaderFields(.init()) - ) - ) - // Reads the first header field. - XCTAssertEqual(stateMachine.readNextPart(), .none) - // Reads the end of the header fields section. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) - ) - XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) - } - - func testPartBody() throws { - var stateMachine = newStateMachine() - let chunk = chunkFromStringLines(["--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24"]) - XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) - XCTAssertEqual(stateMachine.state, .parsingInitialBoundary(Array(chunk))) - // Parse the initial boundary and first header field. - for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } - // Parse the end of header fields. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) - ) - XCTAssertEqual(stateMachine.state, .parsingPart(bufferFromString(#"24"#) + [0x0d, 0x0a], .parsingBody)) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString(".42")), .none) - XCTAssertEqual( - stateMachine.state, - .parsingPart(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42"), .parsingBody) - ) - XCTAssertEqual( - stateMachine.readNextPart(), - .emitBodyChunk(bufferFromString("24") + [0x0d, 0x0a] + bufferFromString(".42")) - ) - XCTAssertEqual(stateMachine.state, .parsingPart([], .parsingBody)) - XCTAssertEqual(stateMachine.receivedChunk([0x0d, 0x0a] + chunkFromString("--__ab")), .none) - XCTAssertEqual(stateMachine.state, .parsingPart([0x0d, 0x0a] + chunkFromString("--__ab"), .parsingBody)) - XCTAssertEqual(stateMachine.readNextPart(), .needsMore) - XCTAssertEqual(stateMachine.receivedChunk(chunkFromString("cd__--", addCRLFs: 1)), .none) - XCTAssertEqual( - stateMachine.state, - .parsingPart([0x0d, 0x0a] + chunkFromString("--__abcd__--", addCRLFs: 1), .parsingBody) - ) - // Parse the final boundary. - XCTAssertEqual(stateMachine.readNextPart(), .none) - // Parse the trailing two dashes. - XCTAssertEqual(stateMachine.readNextPart(), .returnNil) - } - - func testTwoParts() throws { - var stateMachine = newStateMachine() - let chunk = chunkFromStringLines([ - "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", - #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", - ]) - XCTAssertEqual(stateMachine.receivedChunk(chunk), .none) - // Parse the initial boundary and first header field. - for _ in 0..<2 { XCTAssertEqual(stateMachine.readNextPart(), .none) } - // Parse the end of header fields. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) - ) - // Parse the first part's body. - XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("24"))) - // Parse the boundary. - XCTAssertEqual(stateMachine.readNextPart(), .none) - // Parse the end of header fields. - XCTAssertEqual( - stateMachine.readNextPart(), - .emitHeaderFields([.contentDisposition: #"form-data; name="info""#]) - ) - // Parse the second part's body. - XCTAssertEqual(stateMachine.readNextPart(), .emitBodyChunk(chunkFromString("{}"))) - // Parse the trailing two dashes. - XCTAssertEqual(stateMachine.readNextPart(), .returnNil) - } -} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift deleted file mode 100644 index 7dd96a64..00000000 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartSerializer.swift +++ /dev/null @@ -1,79 +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 XCTest -@_spi(Generated) @testable import OpenAPIRuntime -import Foundation - -final class Test_MultipartSerializer: Test_Runtime { - func test() async throws { - let frames: [MultipartFrame] = [ - .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), - .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), - .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), - ] - var serializer = MultipartSerializer(boundary: "__abcd__") - var iterator = frames.makeIterator() - var bytes: [UInt8] = [] - while let chunk = try await serializer.next({ iterator.next() }) { bytes.append(contentsOf: chunk) } - let expectedBytes = chunkFromStringLines([ - "--__abcd__", #"content-disposition: form-data; name="name""#, "", "24", "--__abcd__", - #"content-disposition: form-data; name="info""#, "", "{}", "--__abcd__--", "", - ]) - XCTAssertEqualData(bytes, expectedBytes) - } -} - -private func newStateMachine() -> MultipartSerializer.StateMachine { .init() } - -final class Test_MultipartSerializerStateMachine: Test_Runtime { - - func testInvalidFirstFrame() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.next(), .emitStart) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual(stateMachine.receivedFrame(.bodyChunk([])), .emitError(.noHeaderFieldsAtStart)) - } - - func testTwoParts() throws { - var stateMachine = newStateMachine() - XCTAssertEqual(stateMachine.state, .initial) - XCTAssertEqual(stateMachine.next(), .emitStart) - XCTAssertEqual(stateMachine.state, .emittedStart) - XCTAssertEqual( - stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="name""#])), - .emitEvents([.headerFields([.contentDisposition: #"form-data; name="name""#])]) - ) - XCTAssertEqual(stateMachine.state, .emittedHeaders) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual( - stateMachine.receivedFrame(.bodyChunk(chunkFromString("24"))), - .emitEvents([.bodyChunk(chunkFromString("24"))]) - ) - XCTAssertEqual(stateMachine.state, .emittedBodyChunk) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual( - stateMachine.receivedFrame(.headerFields([.contentDisposition: #"form-data; name="info""#])), - .emitEvents([.endOfPart, .headerFields([.contentDisposition: #"form-data; name="info""#])]) - ) - XCTAssertEqual(stateMachine.state, .emittedHeaders) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual( - stateMachine.receivedFrame(.bodyChunk(chunkFromString("{}"))), - .emitEvents([.bodyChunk(chunkFromString("{}"))]) - ) - XCTAssertEqual(stateMachine.state, .emittedBodyChunk) - XCTAssertEqual(stateMachine.next(), .needsMore) - XCTAssertEqual(stateMachine.receivedFrame(nil), .emitEvents([.endOfPart, .end])) - } -} diff --git a/docker/docker-compose.2204.510.yaml b/docker/docker-compose.2204.510.yaml index d031df5a..02e5d46e 100644 --- a/docker/docker-compose.2204.510.yaml +++ b/docker/docker-compose.2204.510.yaml @@ -12,7 +12,9 @@ services: 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 + # 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 From 304808aaebda67cdc08cefc7b95bc34f0bff36a8 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 17 Nov 2023 18:15:28 +0100 Subject: [PATCH 13/79] [Multipart] Add a raw parts to frames serializer sequence. (#75) --- .../MultipartRawPartsToFramesSequence.swift | 219 ++++++++++++++++++ ...st_MultipartRawPartsToFramesSequence.swift | 144 ++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartRawPartsToFramesSequence.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartRawPartsToFramesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartRawPartsToFramesSequence.swift new file mode 100644 index 00000000..343eb0f2 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartRawPartsToFramesSequence.swift @@ -0,0 +1,219 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import Foundation + +/// A sequence that serializes raw multipart parts into multipart frames. +struct MultipartRawPartsToFramesSequence: Sendable +where Upstream.Element == MultipartRawPart { + + /// The source of raw parts. + var upstream: Upstream +} + +extension MultipartRawPartsToFramesSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + typealias Element = MultipartFrame + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + func makeAsyncIterator() -> Iterator { Iterator(upstream: upstream.makeAsyncIterator()) } + + /// An iterator that pulls raw parts from the upstream iterator and provides + /// multipart frames. + struct Iterator: AsyncIteratorProtocol { + + /// The iterator that provides the raw parts. + var upstream: Upstream.AsyncIterator + + /// The underlying parts to frames serializer. + var serializer: Serializer + + /// Creates a new iterator. + /// - Parameter upstream: The iterator that provides the raw parts. + init(upstream: Upstream.AsyncIterator) { + self.upstream = upstream + self.serializer = .init(upstream: upstream) + } + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + mutating func next() async throws -> Element? { try await serializer.next() } + } +} + +extension MultipartRawPartsToFramesSequence { + + /// A state machine representing the raw part to frame serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State { + + /// Has not emitted any frames yet. + case initial + + /// Waiting for the next part. + case waitingForPart + + /// Returning body chunks from the current part's body. + case streamingBody(HTTPBody.AsyncIterator) + + /// Finished, the terminal state. + case finished + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Fetch the next part. + case fetchPart + + /// Fetch the next body chunk from the provided iterator. + case fetchBodyChunk(HTTPBody.AsyncIterator) + } + + /// Read the next part from the upstream frames. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .initial: + state = .waitingForPart + return .fetchPart + case .streamingBody(let iterator): return .fetchBodyChunk(iterator) + case .finished: return .returnNil + case .waitingForPart: preconditionFailure("Invalid state: \(state)") + } + } + + /// An action returned by the `receivedPart` method. + enum ReceivedPartAction: Hashable { + + /// Return nil to the caller, no more frames. + case returnNil + + /// Return the provided header fields. + case emitHeaderFields(HTTPFields) + } + + /// Ingest the provided part. + /// - Parameter part: A new part. If `nil`, then the source of parts is finished. + /// - Returns: An action to perform. + mutating func receivedPart(_ part: MultipartRawPart?) -> ReceivedPartAction { + switch state { + case .waitingForPart: + if let part { + state = .streamingBody(part.body.makeAsyncIterator()) + return .emitHeaderFields(part.headerFields) + } else { + state = .finished + return .returnNil + } + case .finished: return .returnNil + case .initial, .streamingBody: preconditionFailure("Invalid state: \(state)") + } + } + + /// An action returned by the `receivedBodyChunk` method. + enum ReceivedBodyChunkAction: Hashable { + + /// Return nil to the caller, no more frames. + case returnNil + + /// Fetch the next part. + case fetchPart + + /// Return the provided body chunk. + case emitBodyChunk(ArraySlice) + } + + /// Ingest the provided part. + /// - Parameter bodyChunk: A new body chunk. If `nil`, then the current part's body is finished. + /// - Returns: An action to perform. + mutating func receivedBodyChunk(_ bodyChunk: ArraySlice?) -> ReceivedBodyChunkAction { + switch state { + case .streamingBody: + if let bodyChunk { + return .emitBodyChunk(bodyChunk) + } else { + state = .waitingForPart + return .fetchPart + } + case .finished: return .returnNil + case .initial, .waitingForPart: preconditionFailure("Invalid state: \(state)") + } + } + } +} + +extension MultipartRawPartsToFramesSequence { + + /// A serializer of multipart raw parts into multipart frames. + struct Serializer { + + /// The upstream source of raw parts. + private var upstream: Upstream.AsyncIterator + + /// The underlying state machine. + private var stateMachine: StateMachine + + /// Creates a new iterator. + /// - Parameter upstream: The upstream source of raw parts. + init(upstream: Upstream.AsyncIterator) { + self.upstream = upstream + self.stateMachine = .init() + } + + /// Requests the next frame. + /// - Returns: A frame. + /// - Throws: When a serialization error is encountered. + mutating func next() async throws -> MultipartFrame? { + func handleFetchPart() async throws -> MultipartFrame? { + let part = try await upstream.next() + switch stateMachine.receivedPart(part) { + case .returnNil: return nil + case .emitHeaderFields(let headerFields): return .headerFields(headerFields) + } + } + switch stateMachine.next() { + case .returnNil: return nil + case .fetchPart: return try await handleFetchPart() + case .fetchBodyChunk(var iterator): + let bodyChunk = try await iterator.next() + switch stateMachine.receivedBodyChunk(bodyChunk) { + case .returnNil: return nil + case .fetchPart: return try await handleFetchPart() + case .emitBodyChunk(let bodyChunk): return .bodyChunk(bodyChunk) + } + } + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift new file mode 100644 index 00000000..5017e532 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartRawPartsToFramesSequence: Test_Runtime { + func test() async throws { + var secondPartChunks = "{}".utf8.makeIterator() + let secondPartBody = HTTPBody( + AsyncStream(unfolding: { secondPartChunks.next().map { ArraySlice([$0]) } }), + length: .unknown + ) + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24"), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondPartBody), + ] + var upstreamIterator = parts.makeIterator() + let upstream = AsyncStream { upstreamIterator.next() } + let sequence = MultipartRawPartsToFramesSequence(upstream: upstream) + + var frames: [MultipartFrame] = [] + for try await frame in sequence { frames.append(frame) } + let expectedFrames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("24")), + .headerFields([.contentDisposition: #"form-data; name="info""#]), .bodyChunk(chunkFromString("{")), + .bodyChunk(chunkFromString("}")), + ] + XCTAssertEqual(frames, expectedFrames) + } +} + +final class Test_MultipartRawPartsToFramesSequenceSerializer: Test_Runtime { + func test() async throws { + var secondPartChunks = "{}".utf8.makeIterator() + let secondPartBody = HTTPBody( + AsyncStream(unfolding: { secondPartChunks.next().map { ArraySlice([$0]) } }), + length: .unknown + ) + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24"), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondPartBody), + ] + var upstreamIterator = parts.makeIterator() + let upstream = AsyncStream { upstreamIterator.next() } + var serializer = MultipartRawPartsToFramesSequence> + .Serializer(upstream: upstream.makeAsyncIterator()) + var frames: [MultipartFrame] = [] + while let frame = try await serializer.next() { frames.append(frame) } + let expectedFrames: [MultipartFrame] = [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("24")), + .headerFields([.contentDisposition: #"form-data; name="info""#]), .bodyChunk(chunkFromString("{")), + .bodyChunk(chunkFromString("}")), + ] + XCTAssertEqual(frames, expectedFrames) + } +} + +private func newStateMachine() -> MultipartRawPartsToFramesSequence>.StateMachine { + .init() +} + +final class Test_MultipartRawPartsToFramesSequenceStateMachine: Test_Runtime { + + func testTwoParts() throws { + var stateMachine = newStateMachine() + XCTAssertTrue(stateMachine.state.isInitial) + XCTAssertTrue(stateMachine.next().isFetchPart) + XCTAssertTrue(stateMachine.state.isWaitingForPart) + XCTAssertEqual( + stateMachine.receivedPart( + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ), + .emitHeaderFields([.contentDisposition: #"form-data; name="name""#]) + ) + XCTAssertTrue(stateMachine.state.isStreamingBody) + XCTAssertTrue(stateMachine.next().isFetchBodyChunk) + XCTAssertEqual(stateMachine.receivedBodyChunk(chunkFromString("24")), .emitBodyChunk(chunkFromString("24"))) + XCTAssertTrue(stateMachine.state.isStreamingBody) + XCTAssertTrue(stateMachine.next().isFetchBodyChunk) + XCTAssertEqual(stateMachine.receivedBodyChunk(nil), .fetchPart) + XCTAssertEqual( + stateMachine.receivedPart( + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: "{}") + ), + .emitHeaderFields([.contentDisposition: #"form-data; name="info""#]) + ) + XCTAssertTrue(stateMachine.state.isStreamingBody) + XCTAssertTrue(stateMachine.next().isFetchBodyChunk) + XCTAssertEqual(stateMachine.receivedBodyChunk(chunkFromString("{")), .emitBodyChunk(chunkFromString("{"))) + XCTAssertTrue(stateMachine.state.isStreamingBody) + XCTAssertTrue(stateMachine.next().isFetchBodyChunk) + XCTAssertEqual(stateMachine.receivedBodyChunk(chunkFromString("}")), .emitBodyChunk(chunkFromString("}"))) + XCTAssertTrue(stateMachine.state.isStreamingBody) + XCTAssertTrue(stateMachine.next().isFetchBodyChunk) + XCTAssertEqual(stateMachine.receivedBodyChunk(nil), .fetchPart) + XCTAssertEqual(stateMachine.receivedPart(nil), .returnNil) + } +} + +extension MultipartRawPartsToFramesSequence.StateMachine.State { + var isInitial: Bool { + guard case .initial = self else { return false } + return true + } + var isWaitingForPart: Bool { + guard case .waitingForPart = self else { return false } + return true + } + var isStreamingBody: Bool { + guard case .streamingBody = self else { return false } + return true + } + var isFinished: Bool { + guard case .finished = self else { return false } + return true + } +} + +extension MultipartRawPartsToFramesSequence.StateMachine.NextAction { + var isReturnNil: Bool { + guard case .returnNil = self else { return false } + return true + } + var isFetchPart: Bool { + guard case .fetchPart = self else { return false } + return true + } + var isFetchBodyChunk: Bool { + guard case .fetchBodyChunk = self else { return false } + return true + } +} From d50b48957ccb388fb89db98a56c2337276298e79 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 20 Nov 2023 12:42:08 +0100 Subject: [PATCH 14/79] [Multipart] Validation sequence (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Multipart] Validation sequence ### Motivation The OpenAPI document provides information about which parts are required, optional, arrays, and single values, so we need to enforce those semantics for the adopter, just like we enforce (using JSONDecoder) that a received JSON payload follows the documented structure. ### Modifications Since the mutlipart body is not a struct, but an async sequence of parts, it's a little more complicated. We introduce a `MultipartValidationSequence` with a state machine that keeps track of the requirements and which of them have already been fulfilled over time. And it throws an error if any of the requirements are violated. For missing required parts, the error is thrown when `nil` is received from the upstream sequence, indicating that there will be no more parts coming. To implement this, an internal type `ContentDisposition` was also introduced for working with that header's values, and helper accessors on `MultipartRawPart` as well. ### Result Adopters don't have to validate these semantics manually, if they successfully iterate over the parts without an error being thrown, they can be confident that the received (or sent) parts match the requirements from the OpenAPI document. ### Test Plan Unit tests for the sequence, the validator, and the state machine were added. Also added unit tests for the `ContentDisposition` type. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/76 --- .../Base/ContentDisposition.swift | 128 ++++++++ .../MultipartPublicTypesExtensions.swift | 78 +++++ .../Multipart/MultipartValidation.swift | 282 +++++++++++++++++ .../Base/Test_ContentDisposition.swift | 85 ++++++ .../Test_MultipartValidationSequence.swift | 283 ++++++++++++++++++ 5 files changed, 856 insertions(+) create mode 100644 Sources/OpenAPIRuntime/Base/ContentDisposition.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift create mode 100644 Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift new file mode 100644 index 00000000..c0b25074 --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A parsed representation of the `content-disposition` header described by RFC 6266 containing only +/// the features relevant to OpenAPI multipart bodies. +struct ContentDisposition: Hashable { + + /// A `disposition-type` parameter value. + enum DispositionType: Hashable { + + /// A form data value. + case formData + + /// Any other value. + case other(String) + + /// Creates a new disposition type value. + /// - Parameter rawValue: A string representation of the value. + init(rawValue: String) { + switch rawValue.lowercased() { + case "form-data": self = .formData + default: self = .other(rawValue) + } + } + + /// A string representation of the value. + var rawValue: String { + switch self { + case .formData: return "form-data" + case .other(let string): return string + } + } + } + + /// The disposition type value. + var dispositionType: DispositionType + + /// A content disposition parameter name. + enum ParameterName: Hashable { + + /// The name parameter. + case name + + /// The filename parameter. + case filename + + /// Any other parameter. + case other(String) + + /// Creates a new parameter name. + /// - Parameter rawValue: A string representation of the name. + init(rawValue: String) { + switch rawValue.lowercased() { + case "name": self = .name + case "filename": self = .filename + default: self = .other(rawValue) + } + } + + /// A string representation of the name. + var rawValue: String { + switch self { + case .name: return "name" + case .filename: return "filename" + case .other(let string): return string + } + } + } + + /// The parameters of the content disposition value. + var parameters: [ParameterName: String] = [:] + + /// The name parameter value. + var name: String? { + get { parameters[.name] } + set { parameters[.name] = newValue } + } + + /// The filename parameter value. + var filename: String? { + get { parameters[.filename] } + set { parameters[.filename] = newValue } + } +} + +extension ContentDisposition: RawRepresentable { + + /// Creates a new instance with the specified raw value. + /// + /// https://datatracker.ietf.org/doc/html/rfc6266#section-4.1 + /// - Parameter rawValue: The raw value to use for the new instance. + init?(rawValue: String) { + var components = rawValue.split(separator: ";").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !components.isEmpty else { return nil } + self.dispositionType = DispositionType(rawValue: components.removeFirst()) + let parameterTuples: [(ParameterName, String)] = components.compactMap { component in + let parameterComponents = component.split(separator: "=", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard parameterComponents.count == 2 else { return nil } + let valueWithoutQuotes = parameterComponents[1].trimmingCharacters(in: ["\""]) + return (.init(rawValue: parameterComponents[0]), valueWithoutQuotes) + } + self.parameters = Dictionary(parameterTuples, uniquingKeysWith: { a, b in a }) + } + + /// The corresponding value of the raw type. + var rawValue: String { + var string = "" + string.append(dispositionType.rawValue) + if !parameters.isEmpty { + for (key, value) in parameters.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { + string.append("; \(key.rawValue)=\"\(value)\"") + } + } + return string + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift new file mode 100644 index 00000000..ac9d9d5f --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypesExtensions.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +// MARK: - Extensions + +extension MultipartRawPart { + + /// Creates a new raw part by injecting the provided name and filename into + /// the `content-disposition` header field. + /// - Parameters: + /// - name: The name of the part. + /// - filename: The file name of the part. + /// - headerFields: The header fields of the part. + /// - body: The body stream of the part. + public init(name: String?, filename: String? = nil, headerFields: HTTPFields, body: HTTPBody) { + var parameters: [ContentDisposition.ParameterName: String] = [:] + if let name { parameters[.name] = name } + if let filename { parameters[.filename] = filename } + let contentDisposition = ContentDisposition(dispositionType: .formData, parameters: parameters) + var headerFields = headerFields + headerFields[.contentDisposition] = contentDisposition.rawValue + self.init(headerFields: headerFields, body: body) + } + + /// Returns the parameter value for the provided name. + /// - Parameter name: The parameter name. + /// - Returns: The parameter value. Nil if not found in the content disposition header field. + private func getParameter(_ name: ContentDisposition.ParameterName) -> String? { + guard let contentDispositionString = headerFields[.contentDisposition], + let contentDisposition = ContentDisposition(rawValue: contentDispositionString) + else { return nil } + return contentDisposition.parameters[name] + } + + /// Sets the parameter name to the provided value. + /// - Parameters: + /// - name: The parameter name. + /// - value: The value of the parameter. + private mutating func setParameter(_ name: ContentDisposition.ParameterName, _ value: String?) { + guard let contentDispositionString = headerFields[.contentDisposition], + var contentDisposition = ContentDisposition(rawValue: contentDispositionString) + else { + if let value { + headerFields[.contentDisposition] = + ContentDisposition(dispositionType: .formData, parameters: [name: value]).rawValue + } + return + } + contentDisposition.parameters[name] = value + headerFields[.contentDisposition] = contentDisposition.rawValue + } + + /// The name of the part stored in the `content-disposition` header field. + public var name: String? { + get { getParameter(.name) } + set { setParameter(.name, newValue) } + } + + /// The file name of the part stored in the `content-disposition` header field. + public var filename: String? { + get { getParameter(.filename) } + set { setParameter(.filename, newValue) } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift b/Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift new file mode 100644 index 00000000..dbac2bc8 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartValidation.swift @@ -0,0 +1,282 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPTypes +import Foundation + +/// A container for multipart body requirements. +struct MultipartBodyRequirements: Sendable, Hashable { + + /// A Boolean value indicating whether unknown part names are allowed. + var allowsUnknownParts: Bool + + /// A set of known part names that must appear exactly once. + var requiredExactlyOncePartNames: Set + + /// A set of known part names that must appear at least once. + var requiredAtLeastOncePartNames: Set + + /// A set of known part names that can appear at most once. + var atMostOncePartNames: Set + + /// A set of known part names that can appear any number of times. + var zeroOrMoreTimesPartNames: Set +} + +/// A sequence that validates that the raw parts passing through the sequence match the provided semantics. +struct MultipartValidationSequence: Sendable +where Upstream.Element == MultipartRawPart { + + /// The source of raw parts. + var upstream: Upstream + + /// The requirements to enforce. + var requirements: MultipartBodyRequirements +} + +extension MultipartValidationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + typealias Element = MultipartRawPart + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + /// + /// - Returns: An instance of the `AsyncIterator` type used to produce + /// elements of the asynchronous sequence. + func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator(), requirements: requirements) + } + + /// An iterator that pulls raw parts from the upstream iterator and validates their semantics. + struct Iterator: AsyncIteratorProtocol { + + /// The iterator that provides the raw parts. + var upstream: Upstream.AsyncIterator + + /// The underlying requirements validator. + var validator: Validator + + /// Creates a new iterator. + /// - Parameters: + /// - upstream: The iterator that provides the raw parts. + /// - requirements: The requirements to enforce. + init(upstream: Upstream.AsyncIterator, requirements: MultipartBodyRequirements) { + self.upstream = upstream + self.validator = .init(requirements: requirements) + } + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + /// + /// - Returns: The next element, if it exists, or `nil` to signal the end of + /// the sequence. + mutating func next() async throws -> Element? { try await validator.next(upstream.next()) } + } +} + +extension MultipartValidationSequence { + + /// A state machine representing the validator. + struct StateMachine { + + /// The state of the state machine. + struct State: Hashable { + + /// A Boolean value indicating whether unknown part names are allowed. + let allowsUnknownParts: Bool + + /// A set of known part names that must appear exactly once. + let exactlyOncePartNames: Set + + /// A set of known part names that must appear at least once. + let atLeastOncePartNames: Set + + /// A set of known part names that can appear at most once. + let atMostOncePartNames: Set + + /// A set of known part names that can appear any number of times. + let zeroOrMoreTimesPartNames: Set + + /// The remaining part names that must appear exactly once. + var remainingExactlyOncePartNames: Set + + /// The remaining part names that must appear at least once. + var remainingAtLeastOncePartNames: Set + + /// The remaining part names that can appear at most once. + var remainingAtMostOncePartNames: Set + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + /// - Parameters: + /// - allowsUnknownParts: A Boolean value indicating whether unknown part names are allowed. + /// - requiredExactlyOncePartNames: A set of known part names that must appear exactly once. + /// - requiredAtLeastOncePartNames: A set of known part names that must appear at least once. + /// - atMostOncePartNames: A set of known part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: A set of known part names that can appear any number of times. + init( + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set + ) { + self.state = .init( + allowsUnknownParts: allowsUnknownParts, + exactlyOncePartNames: requiredExactlyOncePartNames, + atLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames, + remainingExactlyOncePartNames: requiredExactlyOncePartNames, + remainingAtLeastOncePartNames: requiredAtLeastOncePartNames, + remainingAtMostOncePartNames: atMostOncePartNames + ) + } + + /// An error returned by the state machine. + enum ActionError: Hashable { + + /// The sequence finished without encountering at least one required part. + case missingRequiredParts(expectedExactlyOnce: Set, expectedAtLeastOnce: Set) + + /// The validator encountered a part without a name, but `allowsUnknownParts` is set to `false`. + case receivedUnnamedPart + + /// The validator encountered a part with an unknown name, but `allowsUnknownParts` is set to `false`. + case receivedUnknownPart(String) + + /// The validator encountered a repeated part of the provided name, even though the part + /// is only allowed to appear at most once. + case receivedMultipleValuesForSingleValuePart(String) + } + + /// An action returned by the `next` method. + enum NextAction: Hashable { + + /// Return nil to the caller, no more parts. + case returnNil + + /// Fetch the next part. + case emitError(ActionError) + + /// Return the part to the caller. + case emitPart(MultipartRawPart) + } + + /// Read the next part from the upstream and validate it. + /// - Returns: An action to perform. + mutating func next(_ part: MultipartRawPart?) -> NextAction { + guard let part else { + guard state.remainingExactlyOncePartNames.isEmpty && state.remainingAtLeastOncePartNames.isEmpty else { + return .emitError( + .missingRequiredParts( + expectedExactlyOnce: state.remainingExactlyOncePartNames, + expectedAtLeastOnce: state.remainingAtLeastOncePartNames + ) + ) + } + return .returnNil + } + guard let name = part.name else { + guard state.allowsUnknownParts else { return .emitError(.receivedUnnamedPart) } + return .emitPart(part) + } + if state.remainingExactlyOncePartNames.contains(name) { + state.remainingExactlyOncePartNames.remove(name) + return .emitPart(part) + } + if state.remainingAtLeastOncePartNames.contains(name) { + state.remainingAtLeastOncePartNames.remove(name) + return .emitPart(part) + } + if state.remainingAtMostOncePartNames.contains(name) { + state.remainingAtMostOncePartNames.remove(name) + return .emitPart(part) + } + if state.exactlyOncePartNames.contains(name) || state.atMostOncePartNames.contains(name) { + return .emitError(.receivedMultipleValuesForSingleValuePart(name)) + } + if state.atLeastOncePartNames.contains(name) { return .emitPart(part) } + if state.zeroOrMoreTimesPartNames.contains(name) { return .emitPart(part) } + guard state.allowsUnknownParts else { return .emitError(.receivedUnknownPart(name)) } + return .emitPart(part) + } + } +} + +extension MultipartValidationSequence { + + /// A validator of multipart raw parts. + struct Validator { + + /// The underlying state machine. + private var stateMachine: StateMachine + /// Creates a new validator. + /// - Parameter requirements: The requirements to validate. + init(requirements: MultipartBodyRequirements) { + self.stateMachine = .init( + allowsUnknownParts: requirements.allowsUnknownParts, + requiredExactlyOncePartNames: requirements.requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requirements.requiredAtLeastOncePartNames, + atMostOncePartNames: requirements.atMostOncePartNames, + zeroOrMoreTimesPartNames: requirements.zeroOrMoreTimesPartNames + ) + } + + /// Ingests the next part. + /// - Parameter part: A part provided by the upstream sequence. Nil if the sequence is finished. + /// - Returns: The validated part. Nil if the incoming part was nil. + /// - Throws: When a validation error is encountered. + mutating func next(_ part: MultipartRawPart?) async throws -> MultipartRawPart? { + switch stateMachine.next(part) { + case .returnNil: return nil + case .emitPart(let outPart): return outPart + case .emitError(let error): throw ValidatorError(error: error) + } + } + } +} + +extension MultipartValidationSequence { + + /// An error thrown by the validator. + struct ValidatorError: Swift.Error, LocalizedError, CustomStringConvertible { + + /// The underlying error emitted by the state machine. + var error: StateMachine.ActionError + + var description: String { + switch error { + case .missingRequiredParts(let expectedExactlyOnce, let expectedAtLeastOnce): + let allSorted = expectedExactlyOnce.union(expectedAtLeastOnce).sorted() + return "Missing required parts: \(allSorted.joined(separator: ", "))." + case .receivedUnnamedPart: + return + "Received an unnamed part, which is disallowed in the OpenAPI document using \"additionalProperties: false\"." + case .receivedUnknownPart(let name): + return + "Received an unknown part '\(name)', which is disallowed in the OpenAPI document using \"additionalProperties: false\"." + case .receivedMultipleValuesForSingleValuePart(let name): + return + "Received more than one value of the part '\(name)', but according to the OpenAPI document this part can only appear at most once." + } + } + + var errorDescription: String? { description } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift new file mode 100644 index 00000000..b820929d --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +final class Test_ContentDisposition: Test_Runtime { + + func testParsing() { + func _test( + input: String, + parsed: ContentDisposition?, + output: String?, + file: StaticString = #file, + line: UInt = #line + ) { + let value = ContentDisposition(rawValue: input) + XCTAssertEqual(value, parsed, file: file, line: line) + XCTAssertEqual(value?.rawValue, output, file: file, line: line) + } + + // Common + _test(input: "form-data", parsed: ContentDisposition(dispositionType: .formData), output: "form-data") + // With an unquoted name parameter. + _test( + input: "form-data; name=Foo", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]), + output: "form-data; name=\"Foo\"" + ) + + // With a quoted name parameter. + _test( + input: "form-data; name=\"Foo\"", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]), + output: "form-data; name=\"Foo\"" + ) + + // With quoted name and filename parameters. + _test( + input: "form-data; name=\"Foo\"; filename=\"foo.txt\"", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo", .filename: "foo.txt"]), + output: "form-data; filename=\"foo.txt\"; name=\"Foo\"" + ) + + // With an unknown parameter. + _test( + input: "form-data; bar=\"Foo\"", + parsed: ContentDisposition(dispositionType: .formData, parameters: [.other("bar"): "Foo"]), + output: "form-data; bar=\"Foo\"" + ) + + // Other + _test( + input: "attachment", + parsed: ContentDisposition(dispositionType: .other("attachment")), + output: "attachment" + ) + + // Empty + _test(input: "", parsed: nil, output: nil) + } + func testAccessors() { + var value = ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]) + XCTAssertEqual(value.name, "Foo") + XCTAssertNil(value.filename) + value.name = nil + XCTAssertNil(value.name) + XCTAssertNil(value.filename) + value.name = "Foo2" + value.filename = "foo.txt" + XCTAssertEqual(value.name, "Foo2") + XCTAssertEqual(value.filename, "foo.txt") + XCTAssertEqual(value.rawValue, "form-data; filename=\"foo.txt\"; name=\"Foo2\"") + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift new file mode 100644 index 00000000..3951e864 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -0,0 +1,283 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartValidationSequence: Test_Runtime { + func test() async throws { + let firstBody: HTTPBody = "24" + let secondBody: HTTPBody = "{}" + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + var upstreamIterator = parts.makeIterator() + let upstream = AsyncStream { upstreamIterator.next() } + let sequence = MultipartValidationSequence( + upstream: upstream, + requirements: .init( + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [] + ) + ) + var outParts: [MultipartRawPart] = [] + for try await part in sequence { outParts.append(part) } + let expectedParts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + XCTAssertEqual(outParts, expectedParts) + } +} + +final class Test_MultipartValidationSequenceValidator: Test_Runtime { + func test() async throws { + let firstBody: HTTPBody = "24" + let secondBody: HTTPBody = "{}" + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + var validator = MultipartValidationSequence> + .Validator( + requirements: .init( + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [] + ) + ) + let outParts: [MultipartRawPart?] = try await [validator.next(parts[0]), validator.next(parts[1])] + let expectedParts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: firstBody), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: secondBody), + ] + XCTAssertEqual(outParts, expectedParts) + } +} + +private func newStateMachine( + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set +) -> MultipartValidationSequence>.StateMachine { + .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ) +} + +final class Test_MultipartValidationSequenceStateMachine: Test_Runtime { + + func testTwoParts() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24"), + .init(headerFields: [.contentDisposition: #"form-data; name="info""#], body: "{}"), + ] + var stateMachine = newStateMachine( + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual( + stateMachine.state, + .init( + allowsUnknownParts: true, + exactlyOncePartNames: ["name"], + atLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [], + remainingExactlyOncePartNames: ["name"], + remainingAtLeastOncePartNames: [], + remainingAtMostOncePartNames: ["info"] + ) + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual( + stateMachine.state, + .init( + allowsUnknownParts: true, + exactlyOncePartNames: ["name"], + atLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [], + remainingExactlyOncePartNames: [], + remainingAtLeastOncePartNames: [], + remainingAtMostOncePartNames: ["info"] + ) + ) + XCTAssertEqual(stateMachine.next(parts[1]), .emitPart(parts[1])) + XCTAssertEqual( + stateMachine.state, + .init( + allowsUnknownParts: true, + exactlyOncePartNames: ["name"], + atLeastOncePartNames: [], + atMostOncePartNames: ["info"], + zeroOrMoreTimesPartNames: [], + remainingExactlyOncePartNames: [], + remainingAtLeastOncePartNames: [], + remainingAtMostOncePartNames: [] + ) + ) + XCTAssertEqual(stateMachine.next(nil), .returnNil) + } + func testUnknownWithName() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedUnknownPart("name"))) + } + + func testUnnamed_disallowed() throws { + let parts: [MultipartRawPart] = [.init(headerFields: [.contentDisposition: #"form-data"#], body: "24")] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedUnnamedPart)) + } + func testUnnamed_allowed() throws { + let parts: [MultipartRawPart] = [.init(headerFields: [.contentDisposition: #"form-data"#], body: "24")] + var stateMachine = newStateMachine( + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + func testUnknown_disallowed_zeroOrMore() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: ["name"] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + func testUnknown_allowed() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: true, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + + func testMissingRequiredExactlyOnce() throws { + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual( + stateMachine.next(nil), + .emitError(.missingRequiredParts(expectedExactlyOnce: ["name"], expectedAtLeastOnce: [])) + ) + } + + func testMissingRequiredAtLeastOnce_once() throws { + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: ["info"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual( + stateMachine.next(nil), + .emitError(.missingRequiredParts(expectedExactlyOnce: [], expectedAtLeastOnce: ["info"])) + ) + } + func testMissingRequiredAtLeastOnce_multipleTimes() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: ["name"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + } + + func testMissingRequiredExactlyOnce_multipleTimes() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: ["name"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) + } + + func testMissingRequiredAtMostOnce() throws { + let parts: [MultipartRawPart] = [ + .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24") + ] + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: [], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: ["name"], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) + } +} From 5060bb9f1fe02d7d23c35a592d6a00c04ed2e9d3 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 24 Nov 2023 11:47:18 +0100 Subject: [PATCH 15/79] [Multipart] Add public types (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Multipart] Add public types ### Motivation Add the public types approved in SOAR-0009. ### Modifications Added the public types and make other changes outlined in SOAR-0009. ### Result Most of the required runtime types are now in place. ### Test Plan Added unit tests for the new code, where it made sense. ⚠️ The pipeline `pull request validation (api breakage)` is failing with: ``` 1 breaking change detected in OpenAPIRuntime: 💔 API breakage: constructor Configuration.init(dateTranscoder:) has removed default argument from parameter 0 ** ERROR: ❌ Breaking API changes detected. ``` but that seems to be a false positive in the tool, as there is now a newer initializer that you can use as `.init()`, `.init(dateTranscoder:)`, `.init(multipartBoundaryGenerator:)`, or `init(dateTranscoder:multipartBoundaryGenerator:)`, so no existing code could be broken by this change. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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. ✖︎ pull request validation (api breakage) - Build finished. https://github.com/apple/swift-openapi-runtime/pull/77 --- .../OpenAPIRuntime/Base/CopyOnWriteBox.swift | 4 +- .../Conversion/Configuration.swift | 15 +- .../Deprecated/Deprecated.swift | 21 ++ .../Interface/AsyncSequenceCommon.swift | 120 +++++++ .../OpenAPIRuntime/Interface/HTTPBody.swift | 120 +------ .../MultipartBoundaryGenerator.swift | 75 ++++ .../Multipart/MultipartPublicTypes.swift | 329 ++++++++++++++++++ .../Test_MultipartBoundaryGenerator.swift | 36 ++ 8 files changed, 610 insertions(+), 110 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift create mode 100644 Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift create mode 100644 Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift diff --git a/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift index 58de90e0..f876666e 100644 --- a/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift +++ b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift @@ -26,7 +26,7 @@ /// Creates a new storage with the provided initial value. /// - Parameter value: The initial value to store in the box. - @inlinable init(value: Wrapped) { self.value = value } + @usableFromInline init(value: Wrapped) { self.value = value } } /// The internal storage of the box. @@ -34,7 +34,7 @@ /// Creates a new box. /// - Parameter value: The value to store in the box. - @inlinable public init(value: Wrapped) { self.storage = .init(value: value) } + public init(value: Wrapped) { self.storage = .init(value: value) } /// The stored value whose accessors enforce copy-on-write semantics. @inlinable public var value: Wrapped { diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 93b00f32..6cff9130 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -74,9 +74,20 @@ public struct Configuration: Sendable { /// The transcoder used when converting between date and string values. public var dateTranscoder: any DateTranscoder + /// The generator to use when creating mutlipart bodies. + public var multipartBoundaryGenerator: any MultipartBoundaryGenerator + /// Creates a new configuration with the specified values. /// - /// - Parameter dateTranscoder: The transcoder to use when converting between date + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date /// and string values. - public init(dateTranscoder: any DateTranscoder = .iso8601) { self.dateTranscoder = dateTranscoder } + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + ) { + self.dateTranscoder = dateTranscoder + self.multipartBoundaryGenerator = multipartBoundaryGenerator + } } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 323da60f..5dfee0b0 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -195,3 +195,24 @@ extension DecodingError { } } } + +extension Configuration { + /// Creates a new configuration with the specified values. + /// + /// - Parameter dateTranscoder: The transcoder to use when converting between date + /// and string values. + @available(*, deprecated, renamed: "init(dateTranscoder:multipartBoundaryGenerator:)") @_disfavoredOverload + public init(dateTranscoder: any DateTranscoder) { + self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: .random) + } +} + +extension HTTPBody { + /// Describes how many times the provided sequence can be iterated. + @available( + *, + deprecated, + renamed: "IterationBehavior", + message: "Use the top level IterationBehavior directly instead of HTTPBody.IterationBehavior." + ) public typealias IterationBehavior = OpenAPIRuntime.IterationBehavior +} diff --git a/Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift b/Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift new file mode 100644 index 00000000..392eead8 --- /dev/null +++ b/Sources/OpenAPIRuntime/Interface/AsyncSequenceCommon.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Describes how many times the provided sequence can be iterated. +public enum IterationBehavior: Sendable { + + /// The input sequence can only be iterated once. + /// + /// If a retry or a redirect is encountered, fail the call with + /// a descriptive error. + case single + + /// The input sequence can be iterated multiple times. + /// + /// Supports retries and redirects, as a new iterator is created each + /// time. + case multiple +} + +// MARK: - Internal + +/// A type-erasing closure-based iterator. +@usableFromInline struct AnyIterator: AsyncIteratorProtocol { + + /// The closure that produces the next element. + private let produceNext: () async throws -> Element? + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline init(_ iterator: Iterator) where Iterator.Element == Element { + var iterator = iterator + self.produceNext = { try await iterator.next() } + } + + /// 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. + /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. + public mutating func next() async throws -> Element? { try await produceNext() } +} + +/// A type-erased async sequence that wraps input sequences. +@usableFromInline struct AnySequence: AsyncSequence, Sendable { + + /// The type of the type-erased iterator. + @usableFromInline typealias AsyncIterator = AnyIterator + + /// A closure that produces a new iterator. + @usableFromInline let produceIterator: @Sendable () -> AsyncIterator + + /// Creates a new sequence. + /// - Parameter sequence: The input sequence to type-erase. + @usableFromInline init(_ sequence: Upstream) + where Upstream.Element == Element, Upstream: Sendable { + self.produceIterator = { .init(sequence.makeAsyncIterator()) } + } + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } +} + +/// An async sequence wrapper for a sync sequence. +@usableFromInline struct WrappedSyncSequence: AsyncSequence, Sendable +where Upstream.Element: Sendable { + + /// The type of the iterator. + @usableFromInline typealias AsyncIterator = Iterator + + /// The element type. + @usableFromInline typealias Element = Upstream.Element + + /// An iterator type that wraps a sync sequence iterator. + @usableFromInline struct Iterator: AsyncIteratorProtocol { + + /// The element type. + @usableFromInline typealias Element = IteratorElement + + /// The underlying sync sequence iterator. + var iterator: any IteratorProtocol + + @usableFromInline mutating func next() async throws -> IteratorElement? { iterator.next() } + } + + /// The underlying sync sequence. + @usableFromInline let sequence: Upstream + + /// Creates a new async sequence with the provided sync sequence. + /// - Parameter sequence: The sync sequence to wrap. + @usableFromInline init(sequence: Upstream) { self.sequence = sequence } + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { Iterator(iterator: sequence.makeIterator()) } +} + +/// An empty async sequence. +@usableFromInline struct EmptySequence: AsyncSequence, Sendable { + + /// The type of the empty iterator. + @usableFromInline typealias AsyncIterator = EmptyIterator + + /// An async iterator of an empty sequence. + @usableFromInline struct EmptyIterator: AsyncIteratorProtocol { + + @usableFromInline mutating func next() async throws -> IteratorElement? { nil } + } + + /// Creates a new empty async sequence. + @usableFromInline init() {} + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { EmptyIterator() } +} diff --git a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index b97906ba..eb163459 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -121,25 +121,9 @@ public final class HTTPBody: @unchecked Sendable { /// The underlying byte chunk type. public typealias ByteChunk = ArraySlice - /// Describes how many times the provided sequence can be iterated. - public enum IterationBehavior: Sendable { - - /// The input sequence can only be iterated once. - /// - /// If a retry or a redirect is encountered, fail the call with - /// a descriptive error. - case single - - /// The input sequence can be iterated multiple times. - /// - /// Supports retries and redirects, as a new iterator is created each - /// time. - case multiple - } - - /// The body's iteration behavior, which controls how many times + /// The iteration behavior, which controls how many times /// the input sequence can be iterated. - public let iterationBehavior: IterationBehavior + public let iterationBehavior: OpenAPIRuntime.IterationBehavior /// Describes the total length of the body, if known. public enum Length: Sendable, Equatable { @@ -155,7 +139,7 @@ public final class HTTPBody: @unchecked Sendable { public let length: Length /// The underlying type-erased async sequence. - private let sequence: BodySequence + private let sequence: AnySequence /// A lock for shared mutable state. private let lock: NSLock = { @@ -205,7 +189,11 @@ public final class HTTPBody: @unchecked Sendable { /// length of all the byte chunks. /// - iterationBehavior: The sequence's iteration behavior, which /// indicates whether the sequence can be iterated multiple times. - @usableFromInline init(_ sequence: BodySequence, length: Length, iterationBehavior: IterationBehavior) { + @usableFromInline init( + _ sequence: AnySequence, + length: Length, + iterationBehavior: OpenAPIRuntime.IterationBehavior + ) { self.sequence = sequence self.length = length self.iterationBehavior = iterationBehavior @@ -220,7 +208,7 @@ public final class HTTPBody: @unchecked Sendable { @usableFromInline convenience init( _ byteChunks: some Sequence & Sendable, length: Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) { self.init( .init(WrappedSyncSequence(sequence: byteChunks)), @@ -281,7 +269,7 @@ extension HTTPBody { @inlinable public convenience init( _ bytes: some Sequence & Sendable, length: Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) { self.init([ArraySlice(bytes)], length: length, iterationBehavior: iterationBehavior) } /// Creates a new body with the provided byte collection. @@ -323,7 +311,7 @@ extension HTTPBody { @inlinable public convenience init( _ sequence: Bytes, length: HTTPBody.Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) where Bytes.Element == ByteChunk, Bytes: Sendable { self.init(.init(sequence), length: length, iterationBehavior: iterationBehavior) } @@ -337,7 +325,7 @@ extension HTTPBody { @inlinable public convenience init( _ sequence: Bytes, length: HTTPBody.Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) where Bytes: Sendable, Bytes.Element: Sequence & Sendable, Bytes.Element.Element == UInt8 { self.init(sequence.map { ArraySlice($0) }, length: length, iterationBehavior: iterationBehavior) } @@ -356,7 +344,7 @@ extension HTTPBody: AsyncSequence { public func makeAsyncIterator() -> AsyncIterator { // The crash on error is intentional here. try! tryToMarkIteratorCreated() - return sequence.makeAsyncIterator() + return .init(sequence.makeAsyncIterator()) } } @@ -482,7 +470,7 @@ extension HTTPBody { @inlinable public convenience init( _ sequence: Strings, length: HTTPBody.Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) where Strings.Element: StringProtocol & Sendable, Strings: Sendable { self.init(.init(sequence.map { ByteChunk.init($0) }), length: length, iterationBehavior: iterationBehavior) } @@ -583,83 +571,3 @@ extension HTTPBody { public mutating func next() async throws -> Element? { try await produceNext() } } } - -extension HTTPBody { - - /// A type-erased async sequence that wraps input sequences. - @usableFromInline struct BodySequence: AsyncSequence, Sendable { - - /// The type of the type-erased iterator. - @usableFromInline typealias AsyncIterator = HTTPBody.Iterator - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// A closure that produces a new iterator. - @usableFromInline let produceIterator: @Sendable () -> AsyncIterator - - /// Creates a new sequence. - /// - Parameter sequence: The input sequence to type-erase. - @inlinable init(_ sequence: Bytes) where Bytes.Element == Element, Bytes: Sendable { - self.produceIterator = { .init(sequence.makeAsyncIterator()) } - } - - @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } - } - - /// An async sequence wrapper for a sync sequence. - @usableFromInline struct WrappedSyncSequence: AsyncSequence, Sendable - where Bytes.Element == ByteChunk, Bytes.Iterator.Element == ByteChunk, Bytes: Sendable { - - /// The type of the iterator. - @usableFromInline typealias AsyncIterator = Iterator - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// An iterator type that wraps a sync sequence iterator. - @usableFromInline struct Iterator: AsyncIteratorProtocol { - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// The underlying sync sequence iterator. - var iterator: any IteratorProtocol - - @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { iterator.next() } - } - - /// The underlying sync sequence. - @usableFromInline let sequence: Bytes - - /// Creates a new async sequence with the provided sync sequence. - /// - Parameter sequence: The sync sequence to wrap. - @inlinable init(sequence: Bytes) { self.sequence = sequence } - - @usableFromInline func makeAsyncIterator() -> Iterator { Iterator(iterator: sequence.makeIterator()) } - } - - /// An empty async sequence. - @usableFromInline struct EmptySequence: AsyncSequence, Sendable { - - /// The type of the empty iterator. - @usableFromInline typealias AsyncIterator = EmptyIterator - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - /// An async iterator of an empty sequence. - @usableFromInline struct EmptyIterator: AsyncIteratorProtocol { - - /// The byte chunk element type. - @usableFromInline typealias Element = ByteChunk - - @usableFromInline mutating func next() async throws -> HTTPBody.ByteChunk? { nil } - } - - /// Creates a new empty async sequence. - @inlinable init() {} - - @usableFromInline func makeAsyncIterator() -> EmptyIterator { EmptyIterator() } - } -} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift new file mode 100644 index 00000000..39bc9d21 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A generator of a new boundary string used by multipart messages to separate parts. +public protocol MultipartBoundaryGenerator: Sendable { + + /// Generates a boundary string for a multipart message. + /// - Returns: A boundary string. + func makeBoundary() -> String +} + +extension MultipartBoundaryGenerator where Self == ConstantMultipartBoundaryGenerator { + + /// A generator that always returns the same boundary string. + public static var constant: Self { ConstantMultipartBoundaryGenerator() } +} + +extension MultipartBoundaryGenerator where Self == RandomMultipartBoundaryGenerator { + + /// A generator that produces a random boundary every time. + public static var random: Self { RandomMultipartBoundaryGenerator() } +} + +/// A generator that always returns the same constant boundary string. +public struct ConstantMultipartBoundaryGenerator: MultipartBoundaryGenerator { + + /// The boundary string to return. + public let boundary: String + /// Creates a new generator. + /// - Parameter boundary: The boundary string to return every time. + public init(boundary: String = "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__") { self.boundary = boundary } + + /// Generates a boundary string for a multipart message. + /// - Returns: A boundary string. + public func makeBoundary() -> String { boundary } +} + +/// A generator that returns a boundary containg a constant prefix and a random suffix. +public struct RandomMultipartBoundaryGenerator: MultipartBoundaryGenerator { + + /// The constant prefix of each boundary. + public let boundaryPrefix: String + /// The length, in bytes, of the random boundary suffix. + public let randomNumberSuffixLength: Int + + /// The options for the random bytes suffix. + private let values: [UInt8] = Array("0123456789".utf8) + + /// Create a new generator. + /// - Parameters: + /// - boundaryPrefix: The constant prefix of each boundary. + /// - randomNumberSuffixLength: The length, in bytes, of the random boundary suffix. + public init(boundaryPrefix: String = "__X_SWIFT_OPENAPI_", randomNumberSuffixLength: Int = 20) { + self.boundaryPrefix = boundaryPrefix + self.randomNumberSuffixLength = randomNumberSuffixLength + } + /// Generates a boundary string for a multipart message. + /// - Returns: A boundary string. + public func makeBoundary() -> String { + var randomSuffix = [UInt8](repeating: 0, count: randomNumberSuffixLength) + for i in randomSuffix.startIndex..: Sendable, Hashable { + + /// The underlying typed part payload, which has a statically known part name. + public var payload: Payload + + /// A file name parameter provided in the `content-disposition` part header field. + public var filename: String? + + /// Creates a new wrapper. + /// - Parameters: + /// - payload: The underlying typed part payload, which has a statically known part name. + /// - filename: A file name parameter provided in the `content-disposition` part header field. + public init(payload: Payload, filename: String? = nil) { + self.payload = payload + self.filename = filename + } +} + +/// A wrapper of a typed part without a statically known name that adds +/// dynamic `content-disposition` parameter values, such as `name` and `filename`. +public struct MultipartDynamicallyNamedPart: Sendable, Hashable { + + /// The underlying typed part payload, which has a statically known part name. + public var payload: Payload + + /// A file name parameter provided in the `content-disposition` part header field. + public var filename: String? + + /// A name parameter provided in the `content-disposition` part header field. + public var name: String? + + /// Creates a new wrapper. + /// - Parameters: + /// - payload: The underlying typed part payload, which has a statically known part name. + /// - filename: A file name parameter provided in the `content-disposition` part header field. + /// - name: A name parameter provided in the `content-disposition` part header field. + public init(payload: Payload, filename: String? = nil, name: String? = nil) { + self.payload = payload + self.filename = filename + self.name = name + } +} + +/// The body of multipart requests and responses. +/// +/// `MultipartBody` represents an async sequence of multipart parts of a specific type. +/// +/// The `Part` generic type parameter is usually a generated enum representing +/// the different values documented for this multipart body. +/// +/// ## Creating a body from buffered parts +/// +/// Create a body from an array of values of type `Part`: +/// +/// ```swift +/// let body: MultipartBody = [ +/// .myCaseA(...), +/// .myCaseB(...), +/// ] +/// ``` +/// +/// ## Creating a body from an async sequence of parts +/// +/// The body type also supports initialization from an async sequence. +/// +/// ```swift +/// let producingSequence = ... // an AsyncSequence of MyPartType +/// let body = MultipartBody( +/// producingSequence, +/// iterationBehavior: .single // or .multiple +/// ) +/// ``` +/// +/// In addition to the async sequence, also specify whether the sequence is safe +/// to be iterated multiple times, or can only be iterated once. +/// +/// Sequences that can be iterated multiple times work better when an HTTP +/// request needs to be retried, or if a redirect is encountered. +/// +/// In addition to providing the async sequence, you can also produce the body +/// using an `AsyncStream` or `AsyncThrowingStream`: +/// +/// ```swift +/// let (stream, continuation) = AsyncStream.makeStream(of: MyPartType.self) +/// // Pass the continuation to another task that produces the parts asynchronously. +/// Task { +/// continuation.yield(.myCaseA(...)) +/// // ... later +/// continuation.yield(.myCaseB(...)) +/// continuation.finish() +/// } +/// let body = MultipartBody(stream) +/// ``` +/// +/// ## Consuming a body as an async sequence +/// +/// The `MultipartBody` type conforms to `AsyncSequence` and uses a generic element type, +/// so it can be consumed in a streaming fashion, without ever buffering the whole body +/// in your process. +/// +/// ```swift +/// let multipartBody: MultipartBody = ... +/// for try await part in multipartBody { +/// switch part { +/// case .myCaseA(let myCaseAValue): +/// // Handle myCaseAValue. +/// case .myCaseB(let myCaseBValue): +/// // Handle myCaseBValue, which is a raw type with a streaming part body. +/// // +/// // Option 1: Process the part body bytes in chunks. +/// for try await bodyChunk in myCaseBValue.body { +/// // Handle bodyChunk. +/// } +/// // Option 2: Accumulate the body into a byte array. +/// // (For other convenience initializers, check out ``HTTPBody``. +/// let fullPartBody = try await [UInt8](collecting: myCaseBValue.body, upTo: 1024) +/// // ... +/// } +/// } +/// ``` +/// +/// Multipart parts of different names can arrive in any order, and the order is not significant. +/// +/// Consuming the multipart body should be resilient to parts of different names being reordered. +/// +/// However, multiple parts of the same name, if allowed by the OpenAPI document by defining it as an array, +/// should be treated as an ordered array of values, and those cannot be reordered without changing +/// the message's meaning. +/// +/// > Important: Parts that contain a raw streaming body (of type ``HTTPBody``) must +/// have their bodies fully consumed before the multipart body sequence is asked for +/// the next part. The multipart body sequence does not buffer internally, and since +/// the parts and their bodies arrive in a single stream of bytes, you cannot move on +/// to the next part until the current one is consumed. +public final class MultipartBody: @unchecked Sendable { + + /// The iteration behavior, which controls how many times the input sequence can be iterated. + public let iterationBehavior: IterationBehavior + + /// The underlying type-erased async sequence. + private let sequence: AnySequence + + /// A lock for shared mutable state. + private let lock: NSLock = { + let lock = NSLock() + lock.name = "com.apple.swift-openapi-generator.runtime.multipart-body" + return lock + }() + + /// A flag indicating whether an iterator has already been created. + private var locked_iteratorCreated: Bool = false + + /// A flag indicating whether an iterator has already been created, only + /// used for testing. + internal var testing_iteratorCreated: Bool { + lock.lock() + defer { lock.unlock() } + return locked_iteratorCreated + } + + /// An error thrown by the collecting initializer when another iteration of + /// the body is not allowed. + private struct TooManyIterationsError: Error, CustomStringConvertible, LocalizedError { + + /// A textual representation of this instance. + var description: String { + "OpenAPIRuntime.MultipartBody attempted to create a second iterator, but the underlying sequence is only safe to be iterated once." + } + + /// A localized message describing what error occurred. + 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. + private func tryToMarkIteratorCreated() throws { + lock.lock() + defer { + locked_iteratorCreated = true + lock.unlock() + } + guard iterationBehavior == .single else { return } + if locked_iteratorCreated { throw TooManyIterationsError() } + } + + /// Creates a new sequence. + /// - Parameters: + /// - sequence: The input sequence providing the parts. + /// - iterationBehavior: The sequence's iteration behavior, which indicates whether the sequence + /// can be iterated multiple times. + @usableFromInline init(_ sequence: AnySequence, iterationBehavior: IterationBehavior) { + self.sequence = sequence + self.iterationBehavior = iterationBehavior + } +} + +extension MultipartBody: Equatable { + + /// Compares two OpenAPISequence instances for equality by comparing their object identifiers. + /// + /// - Parameters: + /// - lhs: The left-hand side OpenAPISequence. + /// - rhs: The right-hand side OpenAPISequence. + /// + /// - Returns: `true` if the object identifiers of the two OpenAPISequence instances are equal, + /// indicating that they are the same object in memory; otherwise, returns `false`. + public static func == (lhs: MultipartBody, rhs: MultipartBody) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } +} + +extension MultipartBody: Hashable { + + /// Hashes the OpenAPISequence instance by combining its object identifier into the provided hasher. + /// + /// - Parameter hasher: The hasher used to combine the hash value. + public func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } +} + +// MARK: - Creating the MultipartBody. + +extension MultipartBody { + + /// Creates a new sequence with the provided async sequence of parts. + /// - Parameters: + /// - sequence: An async sequence that provides the parts. + /// - iterationBehavior: The iteration behavior of the sequence, which indicates whether it + /// can be iterated multiple times. + @inlinable public convenience init( + _ sequence: Input, + iterationBehavior: IterationBehavior + ) where Input.Element == Element { self.init(.init(sequence), iterationBehavior: iterationBehavior) } + + /// Creates a new sequence with the provided sequence parts. + /// - Parameters: + /// - elements: A sequence of parts. + /// - iterationBehavior: The iteration behavior of the sequence, which indicates whether it + /// can be iterated multiple times. + @usableFromInline convenience init( + _ elements: some Sequence & Sendable, + iterationBehavior: IterationBehavior + ) { self.init(.init(WrappedSyncSequence(sequence: elements)), iterationBehavior: iterationBehavior) } + + /// Creates a new sequence with the provided collection of parts. + /// - Parameter elements: A collection of parts. + @inlinable public convenience init(_ elements: some Collection & Sendable) { + self.init(elements, iterationBehavior: .multiple) + } + + /// Creates a new sequence with the provided async throwing stream. + /// - Parameter stream: An async throwing stream that provides the parts. + @inlinable public convenience init(_ stream: AsyncThrowingStream) { + self.init(.init(stream), iterationBehavior: .single) + } + + /// Creates a new sequence with the provided async stream. + /// - Parameter stream: An async stream that provides the parts. + @inlinable public convenience init(_ stream: AsyncStream) { + self.init(.init(stream), iterationBehavior: .single) + } +} + +// MARK: - Conversion from literals +extension MultipartBody: ExpressibleByArrayLiteral { + + /// The type of the elements of an array literal. + public typealias ArrayLiteralElement = Element + + /// Creates an instance initialized with the given elements. + public convenience init(arrayLiteral elements: Element...) { self.init(elements) } +} + +// MARK: - Consuming the sequence +extension MultipartBody: AsyncSequence { + + /// The type of the element. + public typealias Element = Part + + /// Represents an asynchronous iterator over a sequence of elements. + public typealias AsyncIterator = Iterator + + /// Creates and returns an asynchronous iterator + /// + /// - Returns: An asynchronous iterator for parts. + public func makeAsyncIterator() -> AsyncIterator { + // The crash on error is intentional here. + try! tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } +} + +// MARK: - Underlying async sequences +extension MultipartBody { + + /// An async iterator of both input async sequences and of the sequence itself. + public struct Iterator: AsyncIteratorProtocol { + + /// The closure that produces the next element. + private let produceNext: () async throws -> Element? + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline init(_ iterator: Iterator) + where Iterator.Element == Element { + var iterator = iterator + self.produceNext = { try await iterator.next() } + } + + /// 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. + /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. + public mutating func next() async throws -> Element? { try await produceNext() } + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift new file mode 100644 index 00000000..edb8e033 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBoundaryGenerator.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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_MultipartBoundaryGenerator: Test_Runtime { + + func testConstant() throws { + let generator = ConstantMultipartBoundaryGenerator(boundary: "__abcd__") + let firstBoundary = generator.makeBoundary() + let secondBoundary = generator.makeBoundary() + XCTAssertEqual(firstBoundary, "__abcd__") + XCTAssertEqual(secondBoundary, "__abcd__") + } + + func testRandom() throws { + let generator = RandomMultipartBoundaryGenerator(boundaryPrefix: "__abcd__", randomNumberSuffixLength: 8) + let firstBoundary = generator.makeBoundary() + let secondBoundary = generator.makeBoundary() + XCTAssertNotEqual(firstBoundary, secondBoundary) + XCTAssertTrue(firstBoundary.hasPrefix("__abcd__")) + XCTAssertTrue(secondBoundary.hasPrefix("__abcd__")) + } +} From bb2d2b3cb2f3062f7b0d0ce607aa89f6911755d2 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 24 Nov 2023 12:01:21 +0100 Subject: [PATCH 16/79] [Multipart] Add converter SPI methods (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Multipart] Add converter SPI methods ### Motivation Last planned runtime PR for multipart, this adds the remaining SPI methods that allow the generated code to serialize/deserialize multipart bodies, both for client and server. ### Modifications Added SPI methods on `Converter` for multipart. ### Result Client and server generated code can now serialize/deserialize multipart bodies. ### Test Plan Added unit tests for the SPI methods. Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - 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/78 --- .../Conversion/Converter+Client.swift | 98 ++++++++++++++++++- .../Conversion/Converter+Common.swift | 23 +++++ .../Conversion/Converter+Server.swift | 97 +++++++++++++++++- .../Conversion/CurrencyExtensions.swift | 55 +++++++++-- .../OpenAPIRuntime/Errors/RuntimeError.swift | 10 ++ .../Multipart/OpenAPIMIMEType+Multipart.swift | 30 ++++++ .../Conversion/Test_Converter+Client.swift | 40 ++++++++ .../Conversion/Test_Converter+Common.swift | 31 ++++++ .../Conversion/Test_Converter+Server.swift | 41 ++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 74 +++++++++++++- 10 files changed, 488 insertions(+), 11 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 4b723cac..ea575002 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -140,7 +140,7 @@ extension Converter { /// - Throws: An error if setting the request body as binary fails. public func setOptionalRequestBodyAsBinary(_ value: HTTPBody?, headerFields: inout HTTPFields, contentType: String) throws -> HTTPBody? - { try setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + { setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } /// Sets a required request body as binary in the specified header fields and returns an `HTTPBody`. /// @@ -154,7 +154,7 @@ extension Converter { /// - Throws: An error if setting the request body as binary fails. public func setRequiredRequestBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws -> HTTPBody - { try setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + { setRequiredRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } /// Sets an optional request body as URL-encoded form data in the specified header fields and returns an `HTTPBody`. /// @@ -202,6 +202,56 @@ extension Converter { ) } + /// Sets a required request body as multipart and returns the streaming body. + /// + /// - Parameters: + /// - value: The multipart body 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. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - encode: A closure that transforms the type-safe part into a raw part. + /// - Returns: A streaming body representing the multipart-encoded request body. + /// - Throws: Currently never, but might in the future. + public func setRequiredRequestBodyAsMultipart( + _ value: MultipartBody, + headerFields: inout HTTPFields, + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart + ) throws -> HTTPBody { + let boundary = configuration.multipartBoundaryGenerator.makeBoundary() + let contentTypeWithBoundary = contentType + "; boundary=\(boundary)" + return setRequiredRequestBody( + value, + headerFields: &headerFields, + contentType: contentTypeWithBoundary, + convert: { value in + convertMultipartToBytes( + value, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + boundary: boundary, + encode: encode + ) + } + ) + } + /// Retrieves the response body as JSON and transforms it into a specified type. /// /// - Parameters: @@ -244,4 +294,48 @@ extension Converter { guard let data else { throw RuntimeError.missingRequiredResponseBody } return try getResponseBody(type, from: data, transforming: transform, convert: { $0 }) } + /// Returns an async sequence of multipart parts parsed from the provided body stream. + /// + /// - Parameters: + /// - type: The type representing the type-safe multipart body. + /// - data: The HTTP body data to transform. + /// - transform: A closure that transforms the multipart body into the output type. + /// - boundary: The multipart boundary string. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - decoder: A closure that parses a raw part into a type-safe part. + /// - Returns: A value of the output type. + /// - Throws: If the transform closure throws. + public func getResponseBodyAsMultipart( + _ type: MultipartBody.Type, + from data: HTTPBody?, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, + boundary: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) throws -> C { + guard let data else { throw RuntimeError.missingRequiredResponseBody } + let multipart = convertBytesToMultipart( + data, + boundary: boundary, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + transform: decoder + ) + return try transform(multipart) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index a7f8e979..dc908e75 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -65,6 +65,29 @@ extension Converter { return bestContentType } + /// Verifies the MIME type from the content-type header, if present. + /// - Parameters: + /// - headerFields: The header fields to inspect for the content type header. + /// - match: The content type to verify. + /// - Throws: If the content type is incompatible or malformed. + public func verifyContentTypeIfPresent(in headerFields: HTTPFields, matches match: String) throws { + guard let rawValue = headerFields[.contentType] else { return } + _ = try bestContentType(received: .init(rawValue), options: [match]) + } + + /// Returns the name and file name parameter values from the `content-disposition` header field, if found. + /// - Parameter headerFields: The header fields to inspect for a `content-disposition` header field. + /// - Returns: A tuple of the name and file name string values. + /// - Throws: Currently doesn't, but might in the future. + public func extractContentDispositionNameAndFilename(in headerFields: HTTPFields) throws -> ( + name: String?, filename: String? + ) { + guard let rawValue = headerFields[.contentDisposition], + let contentDisposition = ContentDisposition(rawValue: rawValue) + else { return (nil, nil) } + return (contentDisposition.name, contentDisposition.filename) + } + // MARK: - Converter helper methods /// Sets a header field with an optional value, encoding it as a URI component if not nil. diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index c354d4aa..e8f36306 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -284,6 +284,51 @@ extension Converter { ) } + /// Returns an async sequence of multipart parts parsed from the provided body stream. + /// + /// - Parameters: + /// - type: The type representing the type-safe multipart body. + /// - data: The HTTP body data to transform. + /// - transform: A closure that transforms the multipart body into the output type. + /// - boundary: The multipart boundary string. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - decoder: A closure that parses a raw part into a type-safe part. + /// - Returns: A value of the output type. + /// - Throws: If the transform closure throws. + public func getRequiredRequestBodyAsMultipart( + _ type: MultipartBody.Type, + from data: HTTPBody?, + transforming transform: @escaping @Sendable (MultipartBody) throws -> C, + boundary: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + decoding decoder: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) throws -> C { + guard let data else { throw RuntimeError.missingRequiredRequestBody } + let multipart = convertBytesToMultipart( + data, + boundary: boundary, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + transform: decoder + ) + return try transform(multipart) + } + /// Sets the response body as JSON data, serializing the provided value. /// /// - Parameters: @@ -313,5 +358,55 @@ extension Converter { /// - Throws: An error if there are issues setting the response body or updating the header fields. public func setResponseBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws -> HTTPBody - { try setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + { setResponseBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + + /// Sets a response body as multipart and returns the streaming body. + /// + /// - Parameters: + /// - value: The multipart body to be set as the response body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// - allowsUnknownParts: A Boolean value indicating whether parts with unknown names + /// should be pass through. If `false`, encountering an unknown part throws an error + /// whent the returned body sequence iterates it. + /// - requiredExactlyOncePartNames: The list of part names that are required exactly once. + /// - requiredAtLeastOncePartNames: The list of part names that are required at least once. + /// - atMostOncePartNames: The list of part names that can appear at most once. + /// - zeroOrMoreTimesPartNames: The list of names that can appear any number of times. + /// - encode: A closure that transforms the type-safe part into a raw part. + /// - Returns: A streaming body representing the multipart-encoded response body. + /// - Throws: Currently never, but might in the future. + public func setResponseBodyAsMultipart( + _ value: MultipartBody, + headerFields: inout HTTPFields, + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart + ) throws -> HTTPBody { + let boundary = configuration.multipartBoundaryGenerator.makeBoundary() + let contentTypeWithBoundary = contentType + "; boundary=\(boundary)" + return setResponseBody( + value, + headerFields: &headerFields, + contentType: contentTypeWithBoundary, + convert: { value in + convertMultipartToBytes( + value, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + boundary: boundary, + encode: encode + ) + } + ) + } } diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 55765921..df6caf04 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -179,6 +179,52 @@ extension Converter { return HTTPBody(encodedString) } + /// Returns a serialized multipart body stream. + /// - Parameters: + /// - multipart: The multipart body. + /// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence. + /// - boundary: The multipart boundary string. + /// - encode: A closure that converts a typed part into a raw part. + /// - Returns: The serialized body stream. + func convertMultipartToBytes( + _ multipart: MultipartBody, + requirements: MultipartBodyRequirements, + boundary: String, + encode: @escaping @Sendable (Part) throws -> MultipartRawPart + ) -> HTTPBody { + let untyped = multipart.map { part in + var untypedPart = try encode(part) + if case .known(let byteCount) = untypedPart.body.length { + untypedPart.headerFields[.contentLength] = String(byteCount) + } + return untypedPart + } + let validated = MultipartValidationSequence(upstream: untyped, requirements: requirements) + let frames = MultipartRawPartsToFramesSequence(upstream: validated) + let bytes = MultipartFramesToBytesSequence(upstream: frames, boundary: boundary) + return HTTPBody(bytes, length: .unknown, iterationBehavior: multipart.iterationBehavior) + } + + /// Returns a parsed multipart body. + /// - Parameters: + /// - bytes: The multipart body byte stream. + /// - boundary: The multipart boundary string. + /// - requirements: The multipart requirements to enforce. When violated, an error is thrown in the sequence. + /// - transform: A closure that converts a raw part into a typed part. + /// - Returns: The typed multipart body stream. + func convertBytesToMultipart( + _ bytes: HTTPBody, + boundary: String, + requirements: MultipartBodyRequirements, + transform: @escaping @Sendable (MultipartRawPart) async throws -> Part + ) -> MultipartBody { + let frames = MultipartBytesToFramesSequence(upstream: bytes, boundary: boundary) + let raw = MultipartFramesToRawPartsSequence(upstream: frames) + let validated = MultipartValidationSequence(upstream: raw, requirements: requirements) + let typed = validated.map(transform) + return .init(typed, iterationBehavior: bytes.iterationBehavior) + } + /// Returns a JSON string for the provided encodable value. /// - Parameter value: The value to encode. /// - Returns: A JSON string. @@ -383,13 +429,12 @@ extension Converter { /// - contentType: The content type value. /// - convert: The closure that encodes the value into a raw body. /// - Returns: The body. - /// - Throws: An error if an issue occurs while encoding the request body or setting the content type. func setRequiredRequestBody( _ value: T, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody { + ) rethrows -> HTTPBody { headerFields[.contentType] = contentType return try convert(value) } @@ -402,13 +447,12 @@ extension Converter { /// - contentType: The content type value. /// - convert: The closure that encodes the value into a raw body. /// - Returns: The body, if value was not nil. - /// - Throws: An error if an issue occurs while encoding the request body or setting the content type. func setOptionalRequestBody( _ value: T?, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody? { + ) rethrows -> HTTPBody? { guard let value else { return nil } return try setRequiredRequestBody( value, @@ -547,13 +591,12 @@ extension Converter { /// - contentType: The content type value. /// - convert: The closure that encodes the value into a raw body. /// - Returns: The body, if value was not nil. - /// - Throws: An error if an issue occurs while encoding the request body. func setResponseBody( _ value: T, headerFields: inout HTTPFields, contentType: String, convert: (T) throws -> HTTPBody - ) throws -> HTTPBody { + ) rethrows -> HTTPBody { headerFields[.contentType] = contentType return try convert(value) } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index ffb39ab7..150b804c 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -39,6 +39,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case unexpectedContentTypeHeader(String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) + case missingOrMalformedContentDispositionName // Path case missingRequiredPathParameter(String) @@ -51,6 +52,10 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case missingRequiredRequestBody case missingRequiredResponseBody + // Multipart + case missingRequiredMultipartFormDataContentType + case missingMultipartBoundaryContentTypeParameter + // Transport/Handler case transportFailed(any Error) case middlewareFailed(middlewareType: Any.Type, any Error) @@ -90,11 +95,16 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case .unexpectedContentTypeHeader(let contentType): return "Unexpected Content-Type header: \(contentType)" case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" case .malformedAcceptHeader(let accept): return "Malformed Accept header: \(accept)" + case .missingOrMalformedContentDispositionName: + return "Missing or malformed Content-Disposition header or it's missing a name." case .missingRequiredPathParameter(let name): return "Missing required path parameter named: \(name)" case .pathUnset: return "Path was not set on the request." case .missingRequiredQueryParameter(let name): return "Missing required query parameter named: \(name)" case .missingRequiredRequestBody: return "Missing required request body" case .missingRequiredResponseBody: return "Missing required response body" + case .missingRequiredMultipartFormDataContentType: return "Expected a 'multipart/form-data' content type." + case .missingMultipartBoundaryContentTypeParameter: + return "Missing 'boundary' parameter in the 'multipart/form-data' content type." case .transportFailed: return "Transport threw an error." case .middlewareFailed(middlewareType: let type, _): return "Middleware of type '\(type)' threw an error." case .handlerFailed: return "User handler threw an error." diff --git a/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift b/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift new file mode 100644 index 00000000..4d8b2f25 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/OpenAPIMIMEType+Multipart.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// +@_spi(Generated) extension Optional where Wrapped == OpenAPIMIMEType { + + /// Unwraps the boundary parameter from the parsed MIME type. + /// - Returns: The boundary value. + /// - Throws: If self is nil, or if the MIME type isn't a `multipart/form-data` + /// with a boundary parameter. + public func requiredBoundary() throws -> String { + guard let self else { throw RuntimeError.missingRequiredMultipartFormDataContentType } + guard case .concrete(type: "multipart", subtype: "form-data") = self.kind else { + throw RuntimeError.missingRequiredMultipartFormDataContentType + } + guard let boundary = self.parameters["boundary"] else { + throw RuntimeError.missingMultipartBoundaryContentTypeParameter + } + return boundary + } +} diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 135bdf46..57c11580 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -175,6 +175,28 @@ final class Test_ClientConverterExtensions: Test_Runtime { XCTAssertEqual(headerFields, [.contentType: "application/octet-stream"]) } + // | client | set | request body | multipart | required | setRequiredRequestBodyAsMultipart | + func test_setRequiredRequestBodyAsMultipart() async throws { + let multipartBody: MultipartBody = .init(MultipartTestPart.all) + var headerFields: HTTPFields = [:] + let body = try converter.setRequiredRequestBodyAsMultipart( + multipartBody, + headerFields: &headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in part.rawPart } + ) + try await XCTAssertEqualData(body, testMultipartStringBytes) + XCTAssertEqual( + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + } + // | client | get | response body | JSON | required | getResponseBodyAsJSON | func test_getResponseBodyAsJSON_codable() async throws { let value: TestPet = try await converter.getResponseBodyAsJSON( @@ -194,6 +216,24 @@ final class Test_ClientConverterExtensions: Test_Runtime { ) try await XCTAssertEqualStringifiedData(value, testString) } + // | client | get | response body | multipart | required | getResponseBodyAsMultipart | + func test_getResponseBodyAsMultipart() async throws { + let value = try converter.getResponseBodyAsMultipart( + MultipartBody.self, + from: .init(testMultipartStringBytes), + transforming: { $0 }, + boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in try await .init(part) } + ) + var parts: [MultipartTestPart] = [] + for try await part in value { parts.append(part) } + XCTAssertEqual(parts, MultipartTestPart.all) + } } /// Asserts that the string representation of binary data is equal to an expected string. diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index da68208f..bca29837 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -107,6 +107,37 @@ final class Test_CommonConverterExtensions: Test_Runtime { ) } + func testVerifyContentTypeIfPresent() throws { + func testCase(received: String?, match: String, file: StaticString = #file, line: UInt = #line) throws { + let headerFields: HTTPFields + if let received { headerFields = [.contentType: received] } else { headerFields = [:] } + try converter.verifyContentTypeIfPresent(in: headerFields, matches: match) + } + try testCase(received: nil, match: "application/json") + try testCase(received: "application/json", match: "application/json") + try testCase(received: "application/json", match: "application/*") + try testCase(received: "application/json", match: "*/*") + } + + func testExtractContentDispositionNameAndFilename() throws { + func testCase(value: String?, name: String?, filename: String?, file: StaticString = #file, line: UInt = #line) + throws + { + let headerFields: HTTPFields + if let value { headerFields = [.contentDisposition: value] } else { headerFields = [:] } + let (actualName, actualFilename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) + XCTAssertEqual(actualName, name, file: file, line: line) + XCTAssertEqual(actualFilename, filename, file: file, line: line) + } + try testCase(value: nil, name: nil, filename: nil) + try testCase(value: "form-data", name: nil, filename: nil) + try testCase(value: "form-data; filename=\"foo.txt\"", name: nil, filename: "foo.txt") + try testCase(value: "form-data; name=\"Foo and Bar\"", name: "Foo and Bar", filename: nil) + try testCase(value: "form-data; filename=foo.txt", name: nil, filename: "foo.txt") + try testCase(value: "form-data; name=Foo", name: "Foo", filename: nil) + try testCase(value: "form-data; filename=\"foo.txt\"; name=\"Foo\"", name: "Foo", filename: "foo.txt") + } + // MARK: Converter helper methods // | common | set | header field | URI | both | setHeaderFieldAsURI | diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 91525af4..d70a58d7 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -288,6 +288,25 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(body, testString) } + // | server | get | request body | multipart | required | getRequiredRequestBodyAsMultipart | + func test_getRequiredRequestBodyAsMultipart() async throws { + let value = try converter.getRequiredRequestBodyAsMultipart( + MultipartBody.self, + from: .init(testMultipartStringBytes), + transforming: { $0 }, + boundary: "__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + decoding: { part in try await .init(part) } + ) + var parts: [MultipartTestPart] = [] + for try await part in value { parts.append(part) } + XCTAssertEqual(parts, MultipartTestPart.all) + } + // | server | set | response body | JSON | required | setResponseBodyAsJSON | func test_setResponseBodyAsJSON_codable() async throws { var headers: HTTPFields = [:] @@ -311,4 +330,26 @@ final class Test_ServerConverterExtensions: Test_Runtime { try await XCTAssertEqualStringifiedData(data, testString) XCTAssertEqual(headers, [.contentType: "application/octet-stream"]) } + + // | server | set | response body | multipart | required | setResponseBodyAsMultipart | + func test_setResponseBodyAsMultipart() async throws { + let multipartBody: MultipartBody = .init(MultipartTestPart.all) + var headerFields: HTTPFields = [:] + let body = try converter.setResponseBodyAsMultipart( + multipartBody, + headerFields: &headerFields, + contentType: "multipart/form-data", + allowsUnknownParts: true, + requiredExactlyOncePartNames: ["hello"], + requiredAtLeastOncePartNames: ["world"], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [], + encoding: { part in part.rawPart } + ) + try await XCTAssertEqualData(body, testMultipartStringBytes) + XCTAssertEqual( + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 2e6d386e..0d7d108e 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@_spi(Generated) @testable import OpenAPIRuntime import HTTPTypes class Test_Runtime: XCTestCase { @@ -26,7 +26,7 @@ class Test_Runtime: XCTestCase { var serverURL: URL { get throws { try URL(validatingOpenAPIServerURL: "/api") } } - var configuration: Configuration { .init() } + var configuration: Configuration { .init(multipartBoundaryGenerator: .constant) } var converter: Converter { .init(configuration: configuration) } @@ -52,6 +52,34 @@ class Test_Runtime: XCTestCase { var testStringData: Data { Data(testString.utf8) } + var testMultipartString: String { "hello" } + + var testMultipartStringBytes: ArraySlice { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="foo.txt"; name="hello""#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-length: 5"#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "hello".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="bar.txt"; name="world""#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: #"content-length: 5"#.utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "world".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__--".utf8) + bytes.append(contentsOf: ASCII.crlf) + bytes.append(contentsOf: ASCII.crlf) + return ArraySlice(bytes) + } + var testQuotedString: String { "\"hello\"" } var testQuotedStringData: Data { Data(testQuotedString.utf8) } @@ -196,6 +224,31 @@ enum TestHabitat: String, Codable, Equatable { case air } +enum MultipartTestPart: Hashable { + case hello(payload: String, filename: String?) + case world(payload: String, filename: String?) + var rawPart: MultipartRawPart { + switch self { + case .hello(let payload, let filename): + return .init(name: "hello", filename: filename, headerFields: [:], body: .init(payload)) + case .world(let payload, let filename): + return .init(name: "world", filename: filename, headerFields: [:], body: .init(payload)) + } + } + init(_ rawPart: MultipartRawPart) async throws { + switch rawPart.name { + case "hello": + self = .hello(payload: try await String(collecting: rawPart.body, upTo: .max), filename: rawPart.filename) + case "world": + self = .world(payload: try await String(collecting: rawPart.body, upTo: .max), filename: rawPart.filename) + default: preconditionFailure("Unexpected part: \(rawPart.name ?? "")") + } + } + static var all: [MultipartTestPart] { + [.hello(payload: "hello", filename: "foo.txt"), .world(payload: "world", filename: "bar.txt")] + } +} + /// Injects an authentication header to every request. struct AuthenticationMiddleware: ClientMiddleware { @@ -292,6 +345,7 @@ fileprivate extension UInt8 { return String(format: "%02x \(original)", self) } } + /// Asserts that the data matches the expected value. public func XCTAssertEqualData( _ expression1: @autoclosure () throws -> C1?, @@ -339,3 +393,19 @@ public func XCTAssertEqualData( ) } catch { XCTFail(error.localizedDescription, file: file, line: line) } } + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> C, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) async throws where C.Element == UInt8 { + guard let actualBytesBody = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let actualBytes = try await [UInt8](collecting: actualBytesBody, upTo: .max) + XCTAssertEqualData(actualBytes, try expression2(), file: file, line: line) +} From 391703120df51fb22869ad29b91b4e9913d8458e Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Mon, 27 Nov 2023 12:35:54 +0000 Subject: [PATCH 17/79] Change type of HTTPBody.Length.known from Int to Int64 (#79) ### Motivation The associated value of enum case `HTTPBody.Length.known` was `Int`, and, on watchOS, `Int = Int32`, which is not ideal for a content length. ### Modifications - (API breaking) Change type of HTTPBody.Length.known from Int to Int64. ### Result Can now express larger values for content type on 32-bit platforms. ### Test Plan Unit tests pass. ### Related Issues - Fixes https://github.com/apple/swift-openapi-generator/issues/354. ### Note I have marked this PR as `semver/major` as it constitutes the first of the PRs we make in the run up to 1.0.0-alpha.1 and the release notes generator should therefore catch us from inadvertently cutting another pre-1.0 patch release. --- Sources/OpenAPIRuntime/Interface/HTTPBody.swift | 10 +++++----- .../Multipart/MultipartFramesToRawPartsSequence.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index eb163459..648c504a 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -125,17 +125,17 @@ public final class HTTPBody: @unchecked Sendable { /// the input sequence can be iterated. public let iterationBehavior: OpenAPIRuntime.IterationBehavior - /// Describes the total length of the body, if known. + /// Describes the total length of the body, in bytes, if known. public enum Length: Sendable, Equatable { /// Total length not known yet. case unknown /// Total length is known. - case known(Int) + case known(Int64) } - /// The total length of the body, if known. + /// The total length of the body, in bytes, if known. public let length: Length /// The underlying type-erased async sequence. @@ -257,7 +257,7 @@ extension HTTPBody { /// Creates a new body with the provided byte chunk. /// - Parameter bytes: A byte chunk. @inlinable public convenience init(_ bytes: ByteChunk) { - self.init([bytes], length: .known(bytes.count), iterationBehavior: .multiple) + self.init([bytes], length: .known(Int64(bytes.count)), iterationBehavior: .multiple) } /// Creates a new body with the provided byte sequence. @@ -283,7 +283,7 @@ extension HTTPBody { /// Creates a new body with the provided byte collection. /// - Parameter bytes: A byte chunk. @inlinable public convenience init(_ bytes: some Collection & Sendable) { - self.init(bytes, length: .known(bytes.count)) + self.init(bytes, length: .known(Int64(bytes.count))) } /// Creates a new body with the provided async throwing stream. diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift index 3345c088..c5823ec2 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift @@ -77,7 +77,7 @@ extension HTTPBody { let stream = AsyncThrowingStream(unfolding: bodyClosure) let length: HTTPBody.Length if let contentLengthString = headerFields[.contentLength], let contentLength = Int(contentLengthString) { - length = .known(contentLength) + length = .known(Int64(contentLength)) } else { length = .unknown } From e5816cb665f1a54a25e5c15195f31bad83c55603 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 27 Nov 2023 16:16:30 +0100 Subject: [PATCH 18/79] Move to Swift 5.9 as the minimum version (#80) ### Motivation Part of addressing https://github.com/apple/swift-openapi-generator/issues/75 and https://github.com/apple/swift-openapi-generator/issues/119. ### Modifications Bumped Swift tools version to 5.9 and made the `ExistentialAny` build setting unconditional. ### Result Building the package requires 5.9 now. ### Test Plan Ran tests, all passed when using a Swift 5.9 toolchain. --- Package.swift | 10 +++------- docker/Dockerfile | 2 +- docker/docker-compose.2204.58.yaml | 19 ------------------- docker/docker-compose.yaml | 2 +- 4 files changed, 5 insertions(+), 28 deletions(-) delete mode 100644 docker/docker-compose.2204.58.yaml diff --git a/Package.swift b/Package.swift index 960e3311..f8045977 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.8 +// swift-tools-version: 5.9 //===----------------------------------------------------------------------===// // // This source file is part of the SwiftOpenAPIGenerator open source project @@ -15,15 +15,11 @@ import PackageDescription // General Swift-settings for all targets. -var swiftSettings: [SwiftSetting] = [] - -#if swift(>=5.9) -swiftSettings.append( +let swiftSettings: [SwiftSetting] = [ // https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md // Require `any` for existential types. .enableUpcomingFeature("ExistentialAny") -) -#endif +] let package = Package( name: "swift-openapi-runtime", diff --git a/docker/Dockerfile b/docker/Dockerfile index 76bb652d..5040ad9c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG swift_version=5.8 +ARG swift_version=5.9 ARG ubuntu_version=jammy ARG base_image=swift:${swift_version}-${ubuntu_version} diff --git a/docker/docker-compose.2204.58.yaml b/docker/docker-compose.2204.58.yaml deleted file mode 100644 index 071d15ba..00000000 --- a/docker/docker-compose.2204.58.yaml +++ /dev/null @@ -1,19 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.8 - build: - args: - ubuntu_version: "jammy" - swift_version: "5.8" - - 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.yaml b/docker/docker-compose.yaml index 000a5ad0..c63046bb 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -4,7 +4,7 @@ # # % docker-compose \ # -f docker/docker-compose.yaml \ -# -f docker/docker-compose.2204.58.yaml \ +# -f docker/docker-compose.2204.59.yaml \ # run test # version: "3" From 336b77ce633f753284fa6bf4ceec385ae78c1095 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 27 Nov 2023 17:21:00 +0100 Subject: [PATCH 19/79] Remove deprecated code (#81) ### Motivation Preparing for 1.0.0-alpha.1, remove deprecated code. ### Modifications Removed deprecated code. ### Result No more deprecated code. ### Test Plan All tests pass, no warnings. --- .../Base/Base64EncodedData.swift | 6 - .../Deprecated/Deprecated.swift | 201 ------------------ .../Conversion/Test_Converter+Common.swift | 20 -- 3 files changed, 227 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index 408e9f90..7af50f72 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -54,12 +54,6 @@ public struct Base64EncodedData: Sendable, Hashable { /// A container of the raw bytes. public var data: ArraySlice - /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. - /// - Parameter data: The underlying bytes to wrap. - @available(*, deprecated, renamed: "init(_:)") - - public init(data: ArraySlice) { self.data = data } - /// Initializes an instance of ``Base64EncodedData`` wrapping the provided slice of bytes. /// - Parameter data: The underlying bytes to wrap. public init(_ data: ArraySlice) { self.data = data } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 5dfee0b0..39cd0951 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -15,204 +15,3 @@ import Foundation import HTTPTypes // MARK: - Functionality to be removed in the future - -extension ClientError { - /// Creates a new error. - /// - Parameters: - /// - operationID: The OpenAPI operation identifier. - /// - operationInput: The operation-specific Input value. - /// - request: The HTTP request created during the operation. - /// - requestBody: The HTTP request body created during the operation. - /// - baseURL: The base URL for HTTP requests. - /// - response: The HTTP response received during the operation. - /// - responseBody: The HTTP response body received during the operation. - /// - underlyingError: The underlying error that caused the operation - /// to fail. - @available( - *, - deprecated, - renamed: - "ClientError.init(operationID:operationInput:request:requestBody:baseURL:response:responseBody:causeDescription:underlyingError:)", - message: "Use the initializer with a causeDescription parameter." - ) public init( - operationID: String, - operationInput: any Sendable, - request: HTTPRequest? = nil, - requestBody: HTTPBody? = nil, - baseURL: URL? = nil, - response: HTTPResponse? = nil, - responseBody: HTTPBody? = nil, - underlyingError: any Error - ) { - self.init( - operationID: operationID, - operationInput: operationInput, - request: request, - requestBody: requestBody, - baseURL: baseURL, - response: response, - responseBody: responseBody, - causeDescription: "Legacy error without a causeDescription.", - underlyingError: underlyingError - ) - } -} - -extension ServerError { - /// Creates a new error. - /// - Parameters: - /// - operationID: The OpenAPI operation identifier. - /// - request: The HTTP request provided to the server. - /// - requestBody: The HTTP request body provided to the server. - /// - requestMetadata: The request metadata extracted by the server. - /// - operationInput: An operation-specific Input value. - /// - operationOutput: An operation-specific Output value. - /// - underlyingError: The underlying error that caused the operation - /// to fail. - @available( - *, - deprecated, - renamed: - "ServerError.init(operationID:request:requestBody:requestMetadata:operationInput:operationOutput:causeDescription:underlyingError:)", - message: "Use the initializer with a causeDescription parameter." - ) public init( - operationID: String, - request: HTTPRequest, - requestBody: HTTPBody?, - requestMetadata: ServerRequestMetadata, - operationInput: (any Sendable)? = nil, - operationOutput: (any Sendable)? = nil, - underlyingError: any Error - ) { - self.init( - operationID: operationID, - request: request, - requestBody: requestBody, - requestMetadata: requestMetadata, - operationInput: operationInput, - operationOutput: operationOutput, - causeDescription: "Legacy error without a causeDescription.", - underlyingError: underlyingError - ) - } -} - -extension Converter { - /// Returns an error to be thrown when an unexpected content type is - /// received. - /// - Parameter contentType: The content type that was received. - /// - Returns: An error representing an unexpected content type. - @available(*, deprecated) public func makeUnexpectedContentTypeError(contentType: OpenAPIMIMEType?) -> any Error { - RuntimeError.unexpectedContentTypeHeader(contentType?.description ?? "") - } - - /// Checks whether a concrete content type matches an expected content type. - /// - /// The concrete content type can contain parameters, such as `charset`, but - /// they are ignored in the equality comparison. - /// - /// The expected content type can contain wildcards, such as */* and text/*. - /// - Parameters: - /// - received: The concrete content type to validate against the other. - /// - expectedRaw: The expected content type, can contain wildcards. - /// - Throws: A `RuntimeError` when `expectedRaw` is not a valid content type. - /// - Returns: A Boolean value representing whether the concrete content - /// type matches the expected one. - @available(*, deprecated) public func isMatchingContentType(received: OpenAPIMIMEType?, expectedRaw: String) throws - -> Bool - { - guard let received else { return false } - guard case let .concrete(type: receivedType, subtype: receivedSubtype) = received.kind else { return false } - guard let expectedContentType = OpenAPIMIMEType(expectedRaw) else { - throw RuntimeError.invalidExpectedContentType(expectedRaw) - } - switch expectedContentType.kind { - case .any: return true - case .anySubtype(let expectedType): return receivedType.lowercased() == expectedType.lowercased() - case .concrete(let expectedType, let expectedSubtype): - return receivedType.lowercased() == expectedType.lowercased() - && receivedSubtype.lowercased() == expectedSubtype.lowercased() - } - } -} - -extension DecodingError { - /// Returns a decoding error used by the oneOf decoder when not a single - /// child schema decodes the received payload. - /// - Parameters: - /// - type: The type representing the oneOf schema in which the decoding - /// occurred. - /// - codingPath: The coding path to the decoder that attempted to decode - /// the type. - /// - Returns: A decoding error. - @_spi(Generated) @available(*, deprecated) public static func failedToDecodeOneOfSchema( - type: Any.Type, - codingPath: [any CodingKey] - ) -> Self { - DecodingError.valueNotFound( - type, - DecodingError.Context.init( - codingPath: codingPath, - debugDescription: "The oneOf structure did not decode into any child schema." - ) - ) - } - - /// Returns a decoding error used by the anyOf decoder when not a single - /// child schema decodes the received payload. - /// - Parameters: - /// - type: The type representing the anyOf schema in which the decoding - /// occurred. - /// - codingPath: The coding path to the decoder that attempted to decode - /// the type. - /// - Returns: A decoding error. - @available(*, deprecated) static func failedToDecodeAnySchema(type: Any.Type, codingPath: [any CodingKey]) -> Self { - DecodingError.valueNotFound( - type, - DecodingError.Context.init( - codingPath: codingPath, - debugDescription: "The anyOf structure did not decode into any child schema." - ) - ) - } - - /// Verifies that the anyOf decoder successfully decoded at least one - /// child schema, and throws an error otherwise. - /// - Parameters: - /// - values: An array of optional values to check. - /// - type: The type representing the anyOf schema in which the decoding - /// occurred. - /// - codingPath: The coding path to the decoder that attempted to decode - /// the type. - /// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded. - @_spi(Generated) @available(*, deprecated) public static func verifyAtLeastOneSchemaIsNotNil( - _ values: [Any?], - type: Any.Type, - codingPath: [any CodingKey] - ) throws { - guard values.contains(where: { $0 != nil }) else { - throw DecodingError.failedToDecodeAnySchema(type: type, codingPath: codingPath) - } - } -} - -extension Configuration { - /// Creates a new configuration with the specified values. - /// - /// - Parameter dateTranscoder: The transcoder to use when converting between date - /// and string values. - @available(*, deprecated, renamed: "init(dateTranscoder:multipartBoundaryGenerator:)") @_disfavoredOverload - public init(dateTranscoder: any DateTranscoder) { - self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: .random) - } -} - -extension HTTPBody { - /// Describes how many times the provided sequence can be iterated. - @available( - *, - deprecated, - renamed: "IterationBehavior", - message: "Use the top level IterationBehavior directly instead of HTTPBody.IterationBehavior." - ) public typealias IterationBehavior = OpenAPIRuntime.IterationBehavior -} diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index bca29837..925ebf4f 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -21,26 +21,6 @@ final class Test_CommonConverterExtensions: Test_Runtime { // MARK: Miscs - @available(*, deprecated) func testContentTypeMatching() throws { - let cases: [(received: String, expected: String, isMatch: Bool)] = [ - ("application/json", "application/json", true), ("APPLICATION/JSON", "application/json", true), - ("application/json", "application/*", true), ("application/json", "*/*", true), - ("application/json", "text/*", false), ("application/json", "application/xml", false), - ("application/json", "text/plain", false), - - ("text/plain; charset=UTF-8", "text/plain", true), ("TEXT/PLAIN; CHARSET=UTF-8", "text/plain", true), - ("text/plain; charset=UTF-8", "text/*", true), ("text/plain; charset=UTF-8", "*/*", true), - ("text/plain; charset=UTF-8", "application/*", false), ("text/plain; charset=UTF-8", "text/html", false), - ] - for testCase in cases { - XCTAssertEqual( - try converter.isMatchingContentType(received: .init(testCase.received), expectedRaw: testCase.expected), - testCase.isMatch, - "Wrong result for (\(testCase.received), \(testCase.expected), \(testCase.isMatch))" - ) - } - } - func testBestContentType() throws { func testCase( received: String?, From 7d1644b125c02be1bedcfe1a69b58fae881b2393 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 27 Nov 2023 18:15:01 +0100 Subject: [PATCH 20/79] Bump version docs to 1.0.0-alpha.1 (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump version docs to 1.0.0-alpha.1 ### Motivation _[Explain here the context, and why you're making that change. What is the problem you're trying to solve.]_ ### Modifications _[Describe the modifications you've made.]_ ### Result _[After your change, what will change.]_ ### Test Plan _[Describe the steps you took, or will take, to qualify the change - such as adjusting tests and manual testing.]_ Reviewed by: simonjbeaumont Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.9) - 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/82 --- README.md | 2 +- Sources/OpenAPIRuntime/Documentation.docc/Documentation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5fd6a69a..b74f3b9a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add the package dependency in your `Package.swift`: ```swift .package( url: "https://github.com/apple/swift-openapi-runtime", - .upToNextMinor(from: "0.3.0") + exact: "1.0.0-alpha.1" ), ``` diff --git a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md index f124e7d8..1d1fec26 100644 --- a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md +++ b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md @@ -19,7 +19,7 @@ Add the package dependency in your `Package.swift`: ```swift .package( url: "https://github.com/apple/swift-openapi-runtime", - .upToNextMinor(from: "0.3.0") + exact: "1.0.0-alpha.1" ), ``` From 32cef1a271e8edc41a637a2527b299a03832adfe Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 30 Nov 2023 09:48:40 +0100 Subject: [PATCH 21/79] Add visionOS platform support (#85) ### Motivation While this isn't technically necessary, as all versions of a platform not explicitly mentioned are assumed to be supported, it's better to be explicit here. ### Modifications Add `visionOS(.v1)` to the list of supported platforms. ### Result Clearer support matrix. ### Test Plan N/A, this is basically just a documentation change. --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f8045977..fcdf464f 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), + .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) ], products: [ .library( From 3300cc48eb3563339dbdb5dd0fb21d9327940d4d Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Thu, 30 Nov 2023 20:23:12 +0000 Subject: [PATCH 22/79] Add Docker Compose file for Swift 5.9.0 (#86) --- docker/docker-compose.2204.590.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docker/docker-compose.2204.590.yaml diff --git a/docker/docker-compose.2204.590.yaml b/docker/docker-compose.2204.590.yaml new file mode 100644 index 00000000..dacf9e3c --- /dev/null +++ b/docker/docker-compose.2204.590.yaml @@ -0,0 +1,19 @@ +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 From 3452f2b837c634f48d07909fa819224bcacde5c6 Mon Sep 17 00:00:00 2001 From: Mahdi Bahrami Date: Fri, 1 Dec 2023 12:33:15 +0330 Subject: [PATCH 23/79] Remove no-longer-needed `@preconcurrency`s (#83) See https://github.com/apple/swift-openapi-generator/pull/396. --------- Co-authored-by: Honza Dvorsky --- Sources/OpenAPIRuntime/Conversion/Converter.swift | 4 ++-- Sources/OpenAPIRuntime/Errors/ClientError.swift | 4 ++-- Sources/OpenAPIRuntime/Interface/ClientTransport.swift | 4 ---- Sources/OpenAPIRuntime/Interface/UniversalClient.swift | 2 +- Sources/OpenAPIRuntime/Interface/UniversalServer.swift | 4 +--- .../URICoder/Test_URICodingRoundtrip.swift | 8 +++++--- 6 files changed, 11 insertions(+), 15 deletions(-) diff --git a/Sources/OpenAPIRuntime/Conversion/Converter.swift b/Sources/OpenAPIRuntime/Conversion/Converter.swift index bd7566b9..69223da5 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter.swift @@ -12,11 +12,11 @@ // //===----------------------------------------------------------------------===// #if canImport(Darwin) -import Foundation +import class Foundation.JSONEncoder #else @preconcurrency import class Foundation.JSONEncoder -@preconcurrency import class Foundation.JSONDecoder #endif +import class Foundation.JSONDecoder /// Converter between generated and HTTP currency types. @_spi(Generated) public struct Converter: Sendable { diff --git a/Sources/OpenAPIRuntime/Errors/ClientError.swift b/Sources/OpenAPIRuntime/Errors/ClientError.swift index 5a20f224..90481bff 100644 --- a/Sources/OpenAPIRuntime/Errors/ClientError.swift +++ b/Sources/OpenAPIRuntime/Errors/ClientError.swift @@ -14,11 +14,11 @@ import HTTPTypes #if canImport(Darwin) -import Foundation +import struct Foundation.URL #else @preconcurrency import struct Foundation.URL -@preconcurrency import protocol Foundation.LocalizedError #endif +import protocol Foundation.LocalizedError /// An error thrown by a client performing an OpenAPI operation. /// diff --git a/Sources/OpenAPIRuntime/Interface/ClientTransport.swift b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift index 3786bcea..200520ca 100644 --- a/Sources/OpenAPIRuntime/Interface/ClientTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift @@ -13,11 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes -#if canImport(Darwin) import struct Foundation.URL -#else -@preconcurrency import struct Foundation.URL -#endif /// A type that performs HTTP operations. /// diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 431fb8af..5afff2b1 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes #if canImport(Darwin) -import Foundation +import struct Foundation.URL #else @preconcurrency import struct Foundation.URL #endif diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 80d69e25..4608dafe 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -13,14 +13,12 @@ //===----------------------------------------------------------------------===// import HTTPTypes - #if canImport(Darwin) import struct Foundation.URL -import struct Foundation.URLComponents #else @preconcurrency import struct Foundation.URL -@preconcurrency import struct Foundation.URLComponents #endif +import struct Foundation.URLComponents /// OpenAPI document-agnostic HTTP server used by OpenAPI document-specific, /// generated servers to perform request deserialization, middleware and handler diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index 0487c756..ccfe52c4 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -12,10 +12,12 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) @testable import OpenAPIRuntime -#if os(Linux) -@preconcurrency import Foundation +#if canImport(Darwin) || swift(>=5.9.1) +import struct Foundation.Date +#else +@preconcurrency import struct Foundation.Date #endif +@_spi(Generated) @testable import OpenAPIRuntime final class Test_URICodingRoundtrip: Test_Runtime { From 860a3ed237c952c0ddf3f9814ed20dcc1b36363a Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 5 Dec 2023 17:54:38 +0100 Subject: [PATCH 24/79] [Docs] Make sure all symbols are curated (#88) ### Motivation Ensure all symbols are curated. ### Modifications Added symbols that were showing up in automatic sections to existing curated sections. ### Result All symbols are curated. ### Test Plan Built docs locally. --- .../Documentation.docc/Documentation.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md index 1d1fec26..2e6f3399 100644 --- a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md +++ b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md @@ -68,6 +68,18 @@ Please report any issues related to this library in the [swift-openapi-generator - ``Configuration`` - ``DateTranscoder`` - ``ISO8601DateTranscoder`` +- ``MultipartBoundaryGenerator`` +- ``RandomMultipartBoundaryGenerator`` +- ``ConstantMultipartBoundaryGenerator`` +- ``IterationBehavior`` + +### Content types +- ``HTTPBody`` +- ``Base64EncodedData`` +- ``MultipartBody`` +- ``MultipartRawPart`` +- ``MultipartPart`` +- ``MultipartDynamicallyNamedPart`` ### Errors - ``ClientError`` @@ -77,6 +89,9 @@ Please report any issues related to this library in the [swift-openapi-generator ### HTTP Currency Types - ``HTTPBody`` - ``ServerRequestMetadata`` +- ``AcceptableProtocol`` +- ``AcceptHeaderContentType`` +- ``QualityValue`` ### Dynamic Payloads - ``OpenAPIValueContainer`` From 160ff92214cf5a1aa9503ca3092220c2c4ff4f1a Mon Sep 17 00:00:00 2001 From: Si Beaumont Date: Mon, 11 Dec 2023 09:53:58 +0000 Subject: [PATCH 25/79] Add issue template, redirecting to swift-openapi-generator issues --- .github/ISSUE_TEMPLATE/config.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..548a1a83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: 🐞 Open an issue on the Swift OpenAPI Generator repository + url: https://github.com/apple/swift-openapi-generator/issues + about: > + Issues for all repositories in the Swift OpenAPI Generator project are centralized in the swift-openapi-generator repository. From bc1023f948f0093e2d79244feedb2203379d148a Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 11 Dec 2023 11:52:19 +0100 Subject: [PATCH 26/79] [Docs] Prep 1.0.0 (#89) ### Motivation Prep docs for 1.0. ### Modifications See above. ### Result Ready to tag 1.0. ### Test Plan Manual inspection. --- README.md | 28 +++++++--------- .../Base/Base64EncodedData.swift | 2 +- .../Documentation.docc/Documentation.md | 21 ++++-------- .../Interface/ClientTransport.swift | 33 ++++--------------- .../Interface/ServerTransport.swift | 15 ++++----- 5 files changed, 34 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index b74f3b9a..75c8720f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Swift OpenAPI Generator Runtime [![](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation) +[![](https://img.shields.io/github/v/release/apple/swift-openapi-runtime)](https://github.com/apple/swift-openapi-runtime/releases) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fapple%2Fswift-openapi-runtime%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/apple/swift-openapi-runtime) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fapple%2Fswift-openapi-runtime%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/apple/swift-openapi-runtime) @@ -10,27 +11,26 @@ This library provides common abstractions and helper functions used by the clien It contains: - Common types used in the code generated by the `swift-openapi-generator` package plugin. -- Protocol definitions for pluggable layers, including `ClientTransport`, `ServerTransport`, and middleware. +- Protocol definitions for pluggable layers, including [`ClientTransport`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clienttransport), [`ServerTransport`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/servertransport), [`ClientMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware), and [`ServerMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/servermiddleware). + +Many of the HTTP currency types used are defined in the [Swift HTTP Types](https://github.com/apple/swift-http-types) library. + +> Tip: Check out the [example projects](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/checking-out-an-example-project) focused on middlewares. ## Usage Add the package dependency in your `Package.swift`: ```swift -.package( - url: "https://github.com/apple/swift-openapi-runtime", - exact: "1.0.0-alpha.1" -), +.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), ``` -Note that this repository does not have a 1.0 tag yet, so the API is not stable. - Next, in your target, add `OpenAPIRuntime` to your dependencies: ```swift .target(name: "MyTarget", dependencies: [ .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), -], +]), ``` The next step depends on your use case. @@ -45,17 +45,13 @@ Swift OpenAPI Generator generates client and server code that is designed to be Implement a new transport or middleware by providing a type that adopts one of the protocols from the runtime library: -* `ClientTransport` -* `ClientMiddleware` -* `ServerTransport` -* `ServerMiddleware` +* [`ClientTransport`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clienttransport) +* [`ClientMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/clientmiddleware) +* [`ServerTransport`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/servertransport) +* [`ServerMiddleware`](https://swiftpackageindex.com/apple/swift-openapi-runtime/documentation/openapiruntime/servermiddleware) You can also publish your transport or middleware as a Swift package to allow others to use it with their generated code. -## Reporting issues - -Please report any issues related to this library in the [swift-openapi-generator](https://github.com/apple/swift-openapi-generator/issues) repository. - ## Documentation To learn more, check out the full [documentation][2]. diff --git a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift index 7af50f72..ce3c43fa 100644 --- a/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift +++ b/Sources/OpenAPIRuntime/Base/Base64EncodedData.swift @@ -14,7 +14,7 @@ import Foundation -/// Provides a route to encode or decode base64-encoded data +/// A type for converting data as a base64 string. /// /// This type holds raw, unencoded, data as a slice of bytes. It can be used to encode that /// data to a provided `Encoder` as base64-encoded data or to decode from base64 encoding when diff --git a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md index 2e6f3399..8b16cf53 100644 --- a/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md +++ b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md @@ -8,29 +8,26 @@ This library provides common abstractions and helper functions used by the clien It contains: - Common types used in the code generated by the `swift-openapi-generator` package plugin. -- Protocol definitions for pluggable layers, including ``ClientTransport``, ``ServerTransport``, and middleware. +- Protocol definitions for pluggable layers, including ``ClientTransport``, ``ServerTransport``, ``ClientMiddleware``, and ``ServerMiddleware``. Many of the HTTP currency types used are defined in the [Swift HTTP Types](https://github.com/apple/swift-http-types) library. +> Tip: Check out the [example projects](https://swiftpackageindex.com/apple/swift-openapi-generator/documentation/swift-openapi-generator/checking-out-an-example-project) focused on middlewares. + ### Usage Add the package dependency in your `Package.swift`: ```swift -.package( - url: "https://github.com/apple/swift-openapi-runtime", - exact: "1.0.0-alpha.1" -), +.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), ``` -Note that this repository does not have a 1.0 tag yet, so the API is not stable. - Next, in your target, add `OpenAPIRuntime` to your dependencies: ```swift .target(name: "MyTarget", dependencies: [ .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), -], +]), ``` The next step depends on your use case. @@ -52,19 +49,15 @@ Implement a new transport or middleware by providing a type that adopts one of t You can also publish your transport or middleware as a Swift package to allow others to use it with their generated code. -### Reporting issues - -Please report any issues related to this library in the [swift-openapi-generator](https://github.com/apple/swift-openapi-generator/issues) repository. - ## Topics ### Essentials - ``ClientTransport`` - ``ServerTransport`` - -### Customization - ``ClientMiddleware`` - ``ServerMiddleware`` + +### Customization - ``Configuration`` - ``DateTranscoder`` - ``ISO8601DateTranscoder`` diff --git a/Sources/OpenAPIRuntime/Interface/ClientTransport.swift b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift index 200520ca..cb20c651 100644 --- a/Sources/OpenAPIRuntime/Interface/ClientTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift @@ -41,17 +41,11 @@ import struct Foundation.URL /// /// let transport = URLSessionTransport() /// -/// Create the base URL of the server to call using your client. If the server -/// URL was defined in the OpenAPI document, you find a generated method for it -/// on the `Servers` type, for example: -/// -/// let serverURL = try Servers.server1() -/// /// Instantiate the `Client` type generated by the Swift OpenAPI Generator for /// your provided OpenAPI document. For example: /// /// let client = Client( -/// serverURL: serverURL, +/// serverURL: URL(string: "https://example.com")!, /// transport: transport /// ) /// @@ -59,16 +53,7 @@ import struct Foundation.URL /// example, if the OpenAPI document contains an HTTP operation with /// the identifier `checkHealth`, call it from Swift with: /// -/// let response = try await client.checkHealth(.init()) -/// switch response { -/// case .ok(let okPayload): -/// // ... -/// -/// // Handle any HTTP status code not documented in -/// // your OpenAPI document. -/// case .undocumented(let statusCode, _): -/// // ... -/// } +/// let response = try await client.checkHealth() /// /// The generated operation method takes an `Input` type unique to /// the operation, and returns an `Output` type unique to the operation. @@ -107,16 +92,13 @@ import struct Foundation.URL /// Then in your test code, instantiate and provide the test transport to your /// generated client instead: /// -/// let transport = TestTransport() +/// var transport = TestTransport() /// transport.isHealthy = true // for HTTP status code 200 (success) -/// transport.isHealthy = false // for HTTP status code 500 (failure) -/// let serverURL = try Servers.server1() /// let client = Client( -/// serverURL: serverURL, +/// serverURL: URL(string: "https://example.com")!, /// transport: transport /// ) -/// let response = try await client.checkHealth(.init()) -/// // ... +/// let response = try await client.checkHealth() /// /// Implementing a test client transport is just one way to help test your /// code that integrates with a generated client. Another is to implement @@ -172,7 +154,7 @@ public protocol ClientTransport: Sendable { /// the middleware to the initializer of the generated `Client` type: /// /// let client = Client( -/// serverURL: serverURL, +/// serverURL: URL(string: "https://example.com")!, /// transport: transport, /// middlewares: [ /// loggingMiddleware, @@ -181,8 +163,7 @@ public protocol ClientTransport: Sendable { /// /// Then make a call to one of the generated client methods: /// -/// let response = try await client.checkHealth(.init()) -/// // ... +/// let response = try await client.checkHealth() /// /// As part of the invocation of `checkHealth`, the client first invokes /// the middlewares in the order you provided them, and then passes the request diff --git a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift index 2ee147bc..40e16e8f 100644 --- a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift @@ -71,20 +71,19 @@ import HTTPTypes /// /// Create the URL where the server will run. The path of the URL is extracted /// by the transport to create a common prefix (such as `/api/v1`) that might -/// be expected by the clients. If the server URL is defined in the OpenAPI -/// document, find the generated method for it on the `Servers` type, -/// for example: -/// -/// let serverURL = try Servers.server1() +/// be expected by the clients. /// /// Register the generated request handlers by calling the method generated /// on the `APIProtocol` protocol: /// -/// try handler.registerHandlers(on: transport, serverURL: serverURL) +/// try handler.registerHandlers( +/// on: transport, +/// serverURL: URL(string: "/api/v1")! +/// ) /// /// Start the server by following the documentation of your chosen transport: /// -/// try app.run() +/// try await app.execute() /// /// ### Implement a custom server transport /// @@ -161,7 +160,7 @@ public protocol ServerTransport { /// /// try handler.registerHandlers( /// on: transport, -/// serverURL: serverURL, +/// serverURL: URL(string: "/api/v1")!, /// middlewares: [ /// loggingMiddleware, /// ] From fd101c320185313bbf9c5a45b827b17eda9ff18a Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Fri, 15 Dec 2023 13:58:50 +0100 Subject: [PATCH 27/79] [Runtime] Add headerFields and body to UndocumentedPayload (#90) ### Motivation The runtime changes for https://github.com/apple/swift-openapi-generator/issues/299. ### Modifications Added `headerFields` and `body` properties to `UndocumentedPayload`, allowing adopters to access the full request information when an undocumented response is returned. ### Result Easier access to the raw payload when an undocumented response is returned. ### Test Plan These are just the runtime changes, tested together with generated changes. --- ...yloads.swift => UndocumentedPayload.swift} | 19 +++++++++++++++++-- .../Deprecated/Deprecated.swift | 7 +++++++ 2 files changed, 24 insertions(+), 2 deletions(-) rename Sources/OpenAPIRuntime/Base/{CommonOutputPayloads.swift => UndocumentedPayload.swift} (64%) diff --git a/Sources/OpenAPIRuntime/Base/CommonOutputPayloads.swift b/Sources/OpenAPIRuntime/Base/UndocumentedPayload.swift similarity index 64% rename from Sources/OpenAPIRuntime/Base/CommonOutputPayloads.swift rename to Sources/OpenAPIRuntime/Base/UndocumentedPayload.swift index c4cac144..399256de 100644 --- a/Sources/OpenAPIRuntime/Base/CommonOutputPayloads.swift +++ b/Sources/OpenAPIRuntime/Base/UndocumentedPayload.swift @@ -12,6 +12,8 @@ // //===----------------------------------------------------------------------===// +import HTTPTypes + /// A payload value used by undocumented operation responses. /// /// Each operation's `Output` enum type needs to exhaustively @@ -20,6 +22,19 @@ /// `undocumented` enum case is used when such a status code is /// detected. public struct UndocumentedPayload: Sendable, Hashable { - /// Creates a new payload. - public init() {} + + /// The header fields contained in the response. + public var headerFields: HTTPFields + + /// The body stream of this part, if present. + public var body: HTTPBody? + + /// Creates a new part. + /// - Parameters: + /// - headerFields: The header fields contained in the response. + /// - body: The body stream of this part, if present. + public init(headerFields: HTTPFields = [:], body: HTTPBody? = nil) { + self.headerFields = headerFields + self.body = body + } } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 39cd0951..bf030d1c 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -15,3 +15,10 @@ import Foundation import HTTPTypes // MARK: - Functionality to be removed in the future + +extension UndocumentedPayload { + /// Creates a new payload. + @available(*, deprecated, renamed: "init(headerFields:body:)") @_disfavoredOverload public init() { + self.init(headerFields: [:], body: nil) + } +} From 6be221fe0ae025d01a49cd733df746db0e3e8fc9 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Mon, 8 Jan 2024 14:06:57 +0000 Subject: [PATCH 28/79] [Runtime] SOAR-0010: Event streams sequences (#91) ### Motivation Land changes approved in https://github.com/apple/swift-openapi-generator/pull/495. ### Modifications Introduced the new APIs. ### Result Easy use of event streams. ### Test Plan Added unit tests for all. --------- Co-authored-by: Si Beaumont --- .../{Multipart => Base}/ByteUtilities.swift | 34 ++ .../EventStreams/JSONLinesDecoding.swift | 181 +++++++ .../EventStreams/JSONLinesEncoding.swift | 156 ++++++ .../EventStreams/JSONSequenceDecoding.swift | 236 +++++++++ .../EventStreams/JSONSequenceEncoding.swift | 157 ++++++ .../EventStreams/ServerSentEvents.swift | 87 ++++ .../ServerSentEventsDecoding.swift | 451 ++++++++++++++++++ .../ServerSentEventsEncoding.swift | 190 ++++++++ .../MultipartFramesToBytesSequence.swift | 1 + .../EventStreams/Test_JSONLinesDecoding.swift | 33 ++ .../EventStreams/Test_JSONLinesEncoding.swift | 29 ++ .../Test_JSONSequenceDecoding.swift | 32 ++ .../Test_JSONSequenceEncoding.swift | 30 ++ .../Test_ServerSentEventsDecoding.swift | 144 ++++++ .../Test_ServerSentEventsEncoding.swift | 103 ++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 59 ++- 16 files changed, 1915 insertions(+), 8 deletions(-) rename Sources/OpenAPIRuntime/{Multipart => Base}/ByteUtilities.swift (81%) create mode 100644 Sources/OpenAPIRuntime/EventStreams/JSONLinesDecoding.swift create mode 100644 Sources/OpenAPIRuntime/EventStreams/JSONLinesEncoding.swift create mode 100644 Sources/OpenAPIRuntime/EventStreams/JSONSequenceDecoding.swift create mode 100644 Sources/OpenAPIRuntime/EventStreams/JSONSequenceEncoding.swift create mode 100644 Sources/OpenAPIRuntime/EventStreams/ServerSentEvents.swift create mode 100644 Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift create mode 100644 Sources/OpenAPIRuntime/EventStreams/ServerSentEventsEncoding.swift create mode 100644 Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesDecoding.swift create mode 100644 Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesEncoding.swift create mode 100644 Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceDecoding.swift create mode 100644 Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceEncoding.swift create mode 100644 Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift create mode 100644 Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift diff --git a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift b/Sources/OpenAPIRuntime/Base/ByteUtilities.swift similarity index 81% rename from Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift rename to Sources/OpenAPIRuntime/Base/ByteUtilities.swift index 9ae1c6a5..039c03f2 100644 --- a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift +++ b/Sources/OpenAPIRuntime/Base/ByteUtilities.swift @@ -24,6 +24,9 @@ enum ASCII { /// The line feed `` character. static let lf: UInt8 = 0x0a + /// The record separator `` character. + static let rs: UInt8 = 0x1e + /// The colon `:` character. static let colon: UInt8 = 0x3a @@ -122,3 +125,34 @@ extension RandomAccessCollection where Element: Equatable { return .noMatch } } + +/// A value returned by the `matchOfOneOf` method. +enum MatchOfOneOfResult { + + /// No match found at any position in self. + case noMatch + + /// The first option matched. + case first(C.Index) + + /// The second option matched. + case second(C.Index) +} + +extension RandomAccessCollection where Element: Equatable { + /// Returns the index of the first match of one of two elements. + /// - Parameters: + /// - first: The first element to match. + /// - second: The second element to match. + /// - Returns: The result. + func matchOfOneOf(first: Element, second: Element) -> MatchOfOneOfResult { + var index = startIndex + while index < endIndex { + let element = self[index] + if element == first { return .first(index) } + if element == second { return .second(index) } + formIndex(after: &index) + } + return .noMatch + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/JSONLinesDecoding.swift b/Sources/OpenAPIRuntime/EventStreams/JSONLinesDecoding.swift new file mode 100644 index 00000000..eed9acdc --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/JSONLinesDecoding.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import class Foundation.JSONDecoder +#else +@preconcurrency import class Foundation.JSONDecoder +#endif +import struct Foundation.Data + +/// A sequence that parses arbitrary byte chunks into lines using the JSON Lines format. +public struct JSONLinesDeserializationSequence: Sendable +where Upstream.Element == ArraySlice { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension JSONLinesDeserializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ArraySlice + + /// The iterator of `JSONLinesDeserializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == Element { + + /// The upstream iterator of arbitrary byte chunks. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ArraySlice? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitLine(let line): return line + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .emitLine(let line): return line + case .noop: continue + } + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +extension AsyncSequence where Element == ArraySlice { + + /// Returns another sequence that decodes each JSON Lines event as the provided type using the provided decoder. + /// - Parameters: + /// - eventType: The type to decode the JSON event into. + /// - decoder: The JSON decoder to use. + /// - Returns: A sequence that provides the decoded JSON events. + public func asDecodedJSONLines( + of eventType: Event.Type = Event.self, + decoder: JSONDecoder = .init() + ) -> AsyncThrowingMapSequence, Event> { + JSONLinesDeserializationSequence(upstream: self) + .map { line in try decoder.decode(Event.self, from: Data(line)) } + } +} + +extension JSONLinesDeserializationSequence.Iterator { + + /// A state machine representing the JSON Lines deserializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Is waiting for the end of line. + case waitingForDelimiter(buffer: [UInt8]) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .waitingForDelimiter(buffer: []) } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit a full line. + case emitLine(ArraySlice) + + /// The line is not complete yet, needs more bytes. + case needsMore + } + + /// Read the next line parsed from upstream bytes. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .waitingForDelimiter(var buffer): + state = .mutating + guard let indexOfNewline = buffer.firstIndex(of: ASCII.lf) else { + state = .waitingForDelimiter(buffer: buffer) + return .needsMore + } + let line = buffer[..) + + /// No action, rerun the parsing loop. + case noop + } + + /// Ingest the provided bytes. + /// - Parameter value: A new byte chunk. If `nil`, then the source of bytes is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { + switch state { + case .waitingForDelimiter(var buffer): + if let value { + state = .mutating + buffer.append(contentsOf: value) + state = .waitingForDelimiter(buffer: buffer) + return .noop + } else { + let line = ArraySlice(buffer) + buffer = [] + state = .finished + if line.isEmpty { return .returnNil } else { return .emitLine(line) } + } + case .finished, .mutating: preconditionFailure("Invalid state") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/JSONLinesEncoding.swift b/Sources/OpenAPIRuntime/EventStreams/JSONLinesEncoding.swift new file mode 100644 index 00000000..f1d9b9b8 --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/JSONLinesEncoding.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import class Foundation.JSONEncoder +#else +@preconcurrency import class Foundation.JSONEncoder +#endif + +/// A sequence that serializes lines by concatenating them using the JSON Lines format. +public struct JSONLinesSerializationSequence: Sendable +where Upstream.Element == ArraySlice { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of lines. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension JSONLinesSerializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ArraySlice + + /// The iterator of `JSONLinesSerializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == Element { + + /// The upstream iterator of lines. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ArraySlice? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .emitBytes(let bytes): return bytes + } + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +extension AsyncSequence where Element: Encodable & Sendable, Self: Sendable { + + /// Returns another sequence that encodes the events using the provided encoder into JSON Lines. + /// - Parameter encoder: The JSON encoder to use. + /// - Returns: A sequence that provides the serialized JSON Lines. + public func asEncodedJSONLines( + encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + ) -> JSONLinesSerializationSequence>> { + .init(upstream: map { event in try ArraySlice(encoder.encode(event)) }) + } +} + +extension JSONLinesSerializationSequence.Iterator { + + /// A state machine representing the JSON Lines serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State { + + /// Is emitting serialized JSON Lines events. + case running + + /// Finished, the terminal state. + case finished + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .running } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Needs more bytes. + case needsMore + } + + /// Read the next byte chunk serialized from upstream lines. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .running: return .needsMore + case .finished: return .returnNil + } + } + + /// An action returned by the `receivedValue` method. + enum ReceivedValueAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit the provided bytes. + case emitBytes(ArraySlice) + } + + /// Ingest the provided line. + /// - Parameter value: A new line. If `nil`, then the source of line is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { + switch state { + case .running: + if let value { + var buffer = value + buffer.append(ASCII.lf) + return .emitBytes(ArraySlice(buffer)) + } else { + state = .finished + return .returnNil + } + case .finished: preconditionFailure("Invalid state") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/JSONSequenceDecoding.swift b/Sources/OpenAPIRuntime/EventStreams/JSONSequenceDecoding.swift new file mode 100644 index 00000000..4b34658c --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/JSONSequenceDecoding.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import class Foundation.JSONDecoder +#else +@preconcurrency import class Foundation.JSONDecoder +#endif +import protocol Foundation.LocalizedError +import struct Foundation.Data + +/// A sequence that parses arbitrary byte chunks into lines using the JSON Sequence format. +public struct JSONSequenceDeserializationSequence: Sendable +where Upstream.Element == ArraySlice { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension JSONSequenceDeserializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ArraySlice + + /// An error thrown by the deserializer. + struct DeserializerError: Swift.Error, CustomStringConvertible, + LocalizedError + where UpstreamIterator.Element == Element { + + /// The underlying error emitted by the state machine. + let error: Iterator.StateMachine.ActionError + + var description: String { + switch error { + case .missingInitialRS: return "Missing an initial character, the bytes might not be a JSON Sequence." + } + } + + var errorDescription: String? { description } + } + + /// The iterator of `JSONSequenceDeserializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == Element { + + /// The upstream iterator of arbitrary byte chunks. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ArraySlice? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitLine(let line): return line + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .emitLine(let line): return line + case .noop: continue + } + case .emitError(let error): throw DeserializerError(error: error) + case .noop: continue + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +extension AsyncSequence where Element == ArraySlice { + + /// Returns another sequence that decodes each JSON Sequence event as the provided type using the provided decoder. + /// - Parameters: + /// - eventType: The type to decode the JSON event into. + /// - decoder: The JSON decoder to use. + /// - Returns: A sequence that provides the decoded JSON events. + public func asDecodedJSONSequence( + of eventType: Event.Type = Event.self, + decoder: JSONDecoder = .init() + ) -> AsyncThrowingMapSequence, Event> { + JSONSequenceDeserializationSequence(upstream: self) + .map { line in try decoder.decode(Event.self, from: Data(line)) } + } +} + +extension JSONSequenceDeserializationSequence.Iterator { + + /// A state machine representing the JSON Lines deserializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Has not yet fully parsed the initial boundary. + case initial(buffer: [UInt8]) + + /// Is parsing a line, waiting for the end newline. + case parsingLine(buffer: [UInt8]) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .initial(buffer: []) } + + /// An error returned by the state machine. + enum ActionError { + + /// The initial boundary `` was not found. + case missingInitialRS + } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit a full line. + case emitLine(ArraySlice) + + /// Emit an error. + case emitError(ActionError) + + /// The line is not complete yet, needs more bytes. + case needsMore + + /// Rerun the parsing loop. + case noop + } + + /// Read the next line parsed from upstream bytes. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .initial(var buffer): + guard !buffer.isEmpty else { return .needsMore } + guard buffer.first! == ASCII.rs else { return .emitError(.missingInitialRS) } + state = .mutating + buffer.removeFirst() + state = .parsingLine(buffer: buffer) + return .noop + case .parsingLine(var buffer): + state = .mutating + guard let indexOfRecordSeparator = buffer.firstIndex(of: ASCII.rs) else { + state = .parsingLine(buffer: buffer) + return .needsMore + } + let line = buffer[..) + + /// No action, rerun the parsing loop. + case noop + } + + /// Ingest the provided bytes. + /// - Parameter value: A new byte chunk. If `nil`, then the source of bytes is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { + switch state { + case .initial(var buffer): + if let value { + state = .mutating + buffer.append(contentsOf: value) + state = .initial(buffer: buffer) + return .noop + } else { + let line = ArraySlice(buffer) + buffer = [] + state = .finished + if line.isEmpty { return .returnNil } else { return .emitLine(line) } + } + case .parsingLine(var buffer): + if let value { + state = .mutating + buffer.append(contentsOf: value) + state = .parsingLine(buffer: buffer) + return .noop + } else { + let line = ArraySlice(buffer) + buffer = [] + state = .finished + if line.isEmpty { return .returnNil } else { return .emitLine(line) } + } + case .finished, .mutating: preconditionFailure("Invalid state") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/JSONSequenceEncoding.swift b/Sources/OpenAPIRuntime/EventStreams/JSONSequenceEncoding.swift new file mode 100644 index 00000000..a6ffe940 --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/JSONSequenceEncoding.swift @@ -0,0 +1,157 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import class Foundation.JSONEncoder +#else +@preconcurrency import class Foundation.JSONEncoder +#endif + +/// A sequence that serializes lines by concatenating them using the JSON Sequence format. +public struct JSONSequenceSerializationSequence: Sendable +where Upstream.Element == ArraySlice { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of lines. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension JSONSequenceSerializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ArraySlice + + /// The iterator of `JSONSequenceSerializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == Element { + + /// The upstream iterator of lines. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ArraySlice? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .emitBytes(let bytes): return bytes + } + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +extension AsyncSequence where Element: Encodable & Sendable, Self: Sendable { + + /// Returns another sequence that encodes the events using the provided encoder into a JSON Sequence. + /// - Parameter encoder: The JSON encoder to use. + /// - Returns: A sequence that provides the serialized JSON Sequence. + public func asEncodedJSONSequence( + encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + ) -> JSONSequenceSerializationSequence>> { + .init(upstream: map { event in try ArraySlice(encoder.encode(event)) }) + } +} + +extension JSONSequenceSerializationSequence.Iterator { + + /// A state machine representing the JSON Sequence serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State { + + /// Is emitting serialized JSON Sequence events. + case running + + /// Finished, the terminal state. + case finished + } + + /// The current state of the state machine. + private(set) var state: State + /// Creates a new state machine. + init() { self.state = .running } + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Needs more bytes. + case needsMore + } + + /// Read the next byte chunk serialized from upstream lines. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .running: return .needsMore + case .finished: return .returnNil + } + } + + /// An action returned by the `receivedValue` method. + enum ReceivedValueAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit the provided bytes. + case emitBytes(ArraySlice) + } + + /// Ingest the provided line. + /// - Parameter value: A new line. If `nil`, then the source of line is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { + switch state { + case .running: + if let value { + var buffer: [UInt8] = [] + buffer.reserveCapacity(value.count + 2) + buffer.append(ASCII.rs) + buffer.append(contentsOf: value) + buffer.append(ASCII.lf) + return .emitBytes(ArraySlice(buffer)) + } else { + state = .finished + return .returnNil + } + case .finished: preconditionFailure("Invalid state") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/ServerSentEvents.swift b/Sources/OpenAPIRuntime/EventStreams/ServerSentEvents.swift new file mode 100644 index 00000000..bced26f9 --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/ServerSentEvents.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// An event sent by the server that has a JSON payload in the data field. +/// +/// https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation +public struct ServerSentEventWithJSONData: Sendable, Hashable { + + /// A type of the event, helps inform how to interpret the data. + public var event: String? + + /// The payload of the event. + public var data: JSONDataType? + + /// A unique identifier of the event, can be used to resume an interrupted stream by + /// making a new request with the `Last-Event-ID` header field set to this value. + /// + /// https://html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header + public var id: String? + + /// The amount of time, in milliseconds, the client should wait before reconnecting in case + /// of an interruption. + /// + /// https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface + public var retry: Int64? + + /// Creates a new event. + /// - Parameters: + /// - event: A type of the event, helps inform how to interpret the data. + /// - data: The payload of the event. + /// - id: A unique identifier of the event. + /// - retry: The amount of time, in milliseconds, to wait before retrying. + public init(event: String? = nil, data: JSONDataType? = nil, id: String? = nil, retry: Int64? = nil) { + self.event = event + self.data = data + self.id = id + self.retry = retry + } +} + +/// An event sent by the server. +/// +/// https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation +public struct ServerSentEvent: Sendable, Hashable { + + /// A unique identifier of the event, can be used to resume an interrupted stream by + /// making a new request with the `Last-Event-ID` header field set to this value. + /// + /// https://html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header + public var id: String? + + /// A type of the event, helps inform how to interpret the data. + public var event: String? + + /// The payload of the event. + public var data: String? + + /// The amount of time, in milliseconds, the client should wait before reconnecting in case + /// of an interruption. + /// + /// https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface + public var retry: Int64? + + /// Creates a new event. + /// - Parameters: + /// - id: A unique identifier of the event. + /// - event: A type of the event, helps inform how to interpret the data. + /// - data: The payload of the event. + /// - retry: The amount of time, in milliseconds, to wait before retrying. + public init(id: String? = nil, event: String? = nil, data: String? = nil, retry: Int64? = nil) { + self.id = id + self.event = event + self.data = data + self.retry = retry + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift new file mode 100644 index 00000000..421e5319 --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift @@ -0,0 +1,451 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import class Foundation.JSONDecoder +#else +@preconcurrency import class Foundation.JSONDecoder +#endif +import struct Foundation.Data + +/// A sequence that parses arbitrary byte chunks into events using the Server-sent Events format. +/// +/// https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events +public struct ServerSentEventsDeserializationSequence: Sendable +where Upstream.Element == ArraySlice { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension ServerSentEventsDeserializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ServerSentEvent + + /// The iterator of `ServerSentEventsDeserializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == ArraySlice { + + /// The upstream iterator of arbitrary byte chunks. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ServerSentEvent? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitEvent(let event): return event + case .noop: continue + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .noop: continue + } + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +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. + public func asDecodedServerSentEvents() -> ServerSentEventsDeserializationSequence< + ServerSentEventsLineDeserializationSequence + > { .init(upstream: ServerSentEventsLineDeserializationSequence(upstream: self)) } + + /// 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. + public func asDecodedServerSentEventsWithJSONData( + of dataType: JSONDataType.Type = JSONDataType.self, + decoder: JSONDecoder = .init() + ) -> AsyncThrowingMapSequence< + ServerSentEventsDeserializationSequence>, + ServerSentEventWithJSONData + > { + asDecodedServerSentEvents() + .map { event in + ServerSentEventWithJSONData( + event: event.event, + data: try event.data.flatMap { stringData in + try decoder.decode(JSONDataType.self, from: Data(stringData.utf8)) + }, + id: event.id, + retry: event.retry + ) + } + } +} + +extension ServerSentEventsDeserializationSequence.Iterator { + + /// A state machine representing the Server-sent Events deserializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State: Hashable { + + /// Accumulating an event, which hasn't been emitted yet. + case accumulatingEvent(ServerSentEvent, buffer: [ArraySlice]) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .accumulatingEvent(.init(), buffer: []) } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit a completed event. + case emitEvent(ServerSentEvent) + + /// The line is not complete yet, needs more bytes. + case needsMore + + /// Rerun the parsing loop. + case noop + } + + /// Read the next line parsed from upstream bytes. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .accumulatingEvent(var event, var buffer): + 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() } + return .emitEvent(event) + } + if line.first! == ASCII.colon { + // A comment, skip this line. + state = .accumulatingEvent(event, buffer: buffer) + return .noop + } + // Parse the field name and value. + let field: String + let value: String? + if let indexOfFirstColon = line.firstIndex(of: ASCII.colon) { + field = String(decoding: line[.. + if valueBytes.isEmpty { + resolvedValueBytes = [] + } else if valueBytes.first! == ASCII.space { + resolvedValueBytes = valueBytes.dropFirst() + } else { + resolvedValueBytes = valueBytes + } + value = String(decoding: resolvedValueBytes, as: UTF8.self) + } else { + field = String(decoding: line, as: UTF8.self) + value = nil + } + guard let value else { + // An unknown type of event, skip. + state = .accumulatingEvent(event, buffer: buffer) + return .noop + } + // Process the field. + switch field { + case "event": event.event = value + case "data": + var data = event.data ?? "" + data.append(value) + data.append("\n") + event.data = data + case "id": event.id = value + case "retry": + if let retry = Int64(value) { + event.retry = retry + } else { + // Skip this line. + fallthrough + } + default: + // An unknown or invalid field, skip. + state = .accumulatingEvent(event, buffer: buffer) + return .noop + } + // Processed the field, continue. + state = .accumulatingEvent(event, buffer: buffer) + return .noop + case .finished: return .returnNil + case .mutating: preconditionFailure("Invalid state") + } + } + + /// An action returned by the `receivedValue` method. + enum ReceivedValueAction { + + /// Return nil to the caller, no more lines. + case returnNil + + /// No action, rerun the parsing loop. + case noop + } + + /// Ingest the provided bytes. + /// - Parameter value: A new byte chunk. If `nil`, then the source of bytes is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { + switch state { + case .accumulatingEvent(let event, var buffer): + if let value { + state = .mutating + buffer.append(value) + state = .accumulatingEvent(event, buffer: buffer) + return .noop + } else { + // If no value is received, drop the existing event on the floor. + // The specification explicitly states this. + // > Once the end of the file is reached, any pending data must be discarded. + // > (If the file ends in the middle of an event, before the final empty line, + // > the incomplete event is not dispatched.) + // Source: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + state = .finished + return .returnNil + } + case .finished, .mutating: preconditionFailure("Invalid state") + } + } + } +} + +/// A sequence that parses arbitrary byte chunks into lines using the Server-sent Events format. +public struct ServerSentEventsLineDeserializationSequence: Sendable +where Upstream.Element == ArraySlice { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension ServerSentEventsLineDeserializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ArraySlice + + /// The iterator of `ServerSentEventsLineDeserializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == Element { + + /// The upstream iterator of arbitrary byte chunks. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ArraySlice? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .emitLine(let line): return line + case .noop: continue + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .emitLine(let line): return line + case .noop: continue + } + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +extension ServerSentEventsLineDeserializationSequence.Iterator { + + /// A state machine for parsing lines in Server-Sent Events. + /// + /// https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream + /// + /// This is not trivial to do with a streaming parser, as the end of line can be: + /// - LF + /// - CR + /// - CRLF + /// + /// So when we get CR, but have no more data, we want to be able to emit the previous line, + /// however we need to discard a LF if one comes. + struct StateMachine { + + /// A state machine representing the Server-sent Events deserializer. + enum State { + + /// Is waiting for the end of line. + case waitingForEndOfLine(buffer: [UInt8]) + + /// Consumed a `` character, so possibly the end of line. + case consumedCR(buffer: [UInt8]) + + /// Finished, the terminal state. + case finished + + /// Helper state to avoid copy-on-write copies. + case mutating + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .waitingForEndOfLine(buffer: []) } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit a full line. + case emitLine(ArraySlice) + + /// The line is not complete yet, needs more bytes. + case needsMore + + /// No action, rerun the parsing loop. + case noop + } + + mutating func next() -> NextAction { + switch state { + case .waitingForEndOfLine(var buffer): + switch buffer.matchOfOneOf(first: ASCII.lf, second: ASCII.cr) { + case .noMatch: return .needsMore + case .first(let index): + // Just a LF, so consume the line and move onto the next line. + state = .mutating + let line = buffer[..) + + /// No action, rerun the parsing loop. + case noop + } + + /// Ingest the provided bytes. + /// - Parameter value: A new byte chunk. If `nil`, then the source of bytes is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ArraySlice?) -> ReceivedValueAction { + switch state { + case .waitingForEndOfLine(var buffer): + if let value { + state = .mutating + buffer.append(contentsOf: value) + state = .waitingForEndOfLine(buffer: buffer) + return .noop + } else { + let line = ArraySlice(buffer) + buffer = [] + state = .finished + if line.isEmpty { return .returnNil } else { return .emitLine(line) } + } + case .consumedCR(var buffer): + if let value { + state = .mutating + buffer.append(contentsOf: value) + state = .consumedCR(buffer: buffer) + return .noop + } else { + let line = ArraySlice(buffer) + buffer = [] + state = .finished + if line.isEmpty { return .returnNil } else { return .emitLine(line) } + } + case .finished, .mutating: preconditionFailure("Invalid state") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsEncoding.swift b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsEncoding.swift new file mode 100644 index 00000000..853d76d2 --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsEncoding.swift @@ -0,0 +1,190 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import class Foundation.JSONEncoder +#else +@preconcurrency import class Foundation.JSONEncoder +#endif + +/// A sequence that serializes Server-sent Events. +public struct ServerSentEventsSerializationSequence: Sendable +where Upstream.Element == ServerSentEvent { + + /// The upstream sequence. + private let upstream: Upstream + + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of events. + public init(upstream: Upstream) { self.upstream = upstream } +} + +extension ServerSentEventsSerializationSequence: AsyncSequence { + + /// The type of element produced by this asynchronous sequence. + public typealias Element = ArraySlice + + /// The iterator of `ServerSentEventsSerializationSequence`. + public struct Iterator: AsyncIteratorProtocol + where UpstreamIterator.Element == ServerSentEvent { + + /// The upstream iterator of lines. + var upstream: UpstreamIterator + + /// The state machine of the iterator. + var stateMachine: StateMachine = .init() + + /// Asynchronously advances to the next element and returns it, or ends the + /// sequence if there is no next element. + public mutating func next() async throws -> ArraySlice? { + while true { + switch stateMachine.next() { + case .returnNil: return nil + case .needsMore: + let value = try await upstream.next() + switch stateMachine.receivedValue(value) { + case .returnNil: return nil + case .returnBytes(let bytes): return bytes + } + } + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + public func makeAsyncIterator() -> Iterator { + Iterator(upstream: upstream.makeAsyncIterator()) + } +} + +extension AsyncSequence { + + /// Returns another sequence that encodes Server-sent Events with generic data in the data field. + /// - Returns: A sequence that provides the serialized Server-sent Events. + public func asEncodedServerSentEvents() -> ServerSentEventsSerializationSequence + where Element == ServerSentEvent { .init(upstream: self) } + + /// Returns another sequence that encodes Server-sent Events that have a JSON value in the data field. + /// - Parameter encoder: The JSON encoder to use. + /// - Returns: A sequence that provides the serialized Server-sent Events. + public func asEncodedServerSentEventsWithJSONData( + encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + ) -> ServerSentEventsSerializationSequence> + where Element == ServerSentEventWithJSONData { + ServerSentEventsSerializationSequence( + upstream: map { event in + ServerSentEvent( + id: event.id, + event: event.event, + data: try event.data.flatMap { try String(decoding: encoder.encode($0), as: UTF8.self) }, + retry: event.retry + ) + } + ) + } +} + +extension ServerSentEventsSerializationSequence.Iterator { + + /// A state machine representing the JSON Lines serializer. + struct StateMachine { + + /// The possible states of the state machine. + enum State { + + /// Is emitting serialized JSON Lines events. + case running + + /// Finished, the terminal state. + case finished + } + + /// The current state of the state machine. + private(set) var state: State + + /// Creates a new state machine. + init() { self.state = .running } + + /// An action returned by the `next` method. + enum NextAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Needs more bytes. + case needsMore + } + + /// Read the next byte chunk serialized from upstream lines. + /// - Returns: An action to perform. + mutating func next() -> NextAction { + switch state { + case .running: return .needsMore + case .finished: return .returnNil + } + } + + /// An action returned by the `receivedValue` method. + enum ReceivedValueAction { + + /// Return nil to the caller, no more bytes. + case returnNil + + /// Emit the provided bytes. + case returnBytes(ArraySlice) + } + + /// Ingest the provided event. + /// - Parameter value: A new event. If `nil`, then the source of events is finished. + /// - Returns: An action to perform. + mutating func receivedValue(_ value: ServerSentEvent?) -> ReceivedValueAction { + switch state { + case .running: + if let value { + var buffer: [UInt8] = [] + func encodeField(name: String, value: some StringProtocol) { + buffer.append(contentsOf: name.utf8) + buffer.append(ASCII.colon) + buffer.append(ASCII.space) + buffer.append(contentsOf: value.utf8) + buffer.append(ASCII.lf) + } + if let id = value.id { encodeField(name: "id", value: id) } + if let event = value.event { encodeField(name: "event", value: event) } + if let retry = value.retry { encodeField(name: "retry", value: String(retry)) } + if let data = value.data { + // Normalize the data section by replacing CRLF and CR with just LF. + // Then split the section into individual field/value pairs. + let lines = data.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + for line in lines { encodeField(name: "data", value: line) } + } + // End the event. + buffer.append(ASCII.lf) + return .returnBytes(ArraySlice(buffer)) + } else { + state = .finished + return .returnNil + } + case .finished: preconditionFailure("Invalid state") + } + } + } +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift index 441c85fd..07538233 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift @@ -90,6 +90,7 @@ struct MultipartSerializer { self.stateMachine = .init() self.outBuffer = [] } + /// Requests the next byte chunk. /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. /// - Returns: A byte chunk. diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesDecoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesDecoding.swift new file mode 100644 index 00000000..a841dc67 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesDecoding.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// 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_JSONLinesDecoding: Test_Runtime { + func testParsed() async throws { + let upstream = asOneBytePerElementSequence(ArraySlice("hello\nworld\n".utf8)) + let sequence = JSONLinesDeserializationSequence(upstream: upstream) + let lines = try await [ArraySlice](collecting: sequence) + XCTAssertEqual(lines.count, 2) + XCTAssertEqualData(lines[0], "hello".utf8) + XCTAssertEqualData(lines[1], "world".utf8) + } + + func testTyped() async throws { + let sequence = testJSONLinesOneBytePerElementSequence.asDecodedJSONLines(of: TestPet.self) + let events = try await [TestPet](collecting: sequence) + XCTAssertEqual(events, testEvents) + } +} diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesEncoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesEncoding.swift new file mode 100644 index 00000000..d7a44319 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONLinesEncoding.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// 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_JSONLinesEncoding: Test_Runtime { + func testSerialized() async throws { + let upstream = WrappedSyncSequence(sequence: [ArraySlice("hello".utf8), ArraySlice("world".utf8)]) + let sequence = JSONLinesSerializationSequence(upstream: upstream) + try await XCTAssertEqualAsyncData(sequence, "hello\nworld\n".utf8) + } + + func testTyped() async throws { + let sequence = testEventsAsyncSequence.asEncodedJSONLines() + try await XCTAssertEqualAsyncData(sequence, testJSONLinesBytes) + } +} diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceDecoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceDecoding.swift new file mode 100644 index 00000000..ab967e1e --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceDecoding.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// 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_JSONSequenceDecoding: Test_Runtime { + func testParsed() async throws { + let upstream = testJSONSequenceOneBytePerElementSequence + let sequence = JSONSequenceDeserializationSequence(upstream: upstream) + let events = try await [ArraySlice](collecting: sequence) + XCTAssertEqual(events.count, 2) + XCTAssertEqualData(events[0], "{\"name\":\"Rover\"}\n".utf8) + XCTAssertEqualData(events[1], "{\"name\":\"Pancake\"}\n".utf8) + } + func testTyped() async throws { + let sequence = testJSONSequenceOneBytePerElementSequence.asDecodedJSONSequence(of: TestPet.self) + let events = try await [TestPet](collecting: sequence) + XCTAssertEqual(events, testEvents) + } +} diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceEncoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceEncoding.swift new file mode 100644 index 00000000..f03e00cd --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_JSONSequenceEncoding.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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_JSONSequenceEncoding: Test_Runtime { + func testSerialized() async throws { + let upstream = WrappedSyncSequence(sequence: [ + ArraySlice(#"{"name":"Rover"}"#.utf8), ArraySlice(#"{"name":"Pancake"}"#.utf8), + ]) + let sequence = JSONSequenceSerializationSequence(upstream: upstream) + try await XCTAssertEqualAsyncData(sequence, testJSONSequenceBytes) + } + func testTyped() async throws { + let sequence = testEventsAsyncSequence.asEncodedJSONSequence() + try await XCTAssertEqualAsyncData(sequence, testJSONSequenceBytes) + } +} diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift new file mode 100644 index 00000000..be98e6f1 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// +// 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_ServerSentEventsDecoding: Test_Runtime { + func _test(input: String, output: [ServerSentEvent], file: StaticString = #file, 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) + for (index, linePair) in zip(events, output).enumerated() { + let (actualEvent, expectedEvent) = linePair + XCTAssertEqual(actualEvent, expectedEvent, "Event: \(index)", file: file, line: line) + } + } + func test() async throws { + // Simple event. + try await _test( + input: #""" + data: hello + data: world + + + """#, + output: [.init(data: "hello\nworld")] + ) + // Two simple events. + try await _test( + input: #""" + data: hello + data: world + + data: hello2 + data: world2 + + + """#, + output: [.init(data: "hello\nworld"), .init(data: "hello2\nworld2")] + ) + // Incomplete event is not emitted. + try await _test( + input: #""" + data: hello + """#, + output: [] + ) + // A few events. + try await _test( + input: #""" + retry: 5000 + + data: This is the first message. + + data: This is the second + data: message. + + event: customEvent + data: This is a custom event message. + + id: 123 + data: This is a message with an ID. + + + """#, + output: [ + .init(retry: 5000), .init(data: "This is the first message."), + .init(data: "This is the second\nmessage."), + .init(event: "customEvent", data: "This is a custom event message."), + .init(id: "123", data: "This is a message with an ID."), + ] + ) + } + func _testJSONData( + input: String, + output: [ServerSentEventWithJSONData], + file: StaticString = #file, + line: UInt = #line + ) async throws { + let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)) + .asDecodedServerSentEventsWithJSONData(of: JSONType.self) + let events = try await [ServerSentEventWithJSONData](collecting: sequence) + XCTAssertEqual(events.count, output.count, file: file, line: line) + for (index, linePair) in zip(events, output).enumerated() { + let (actualEvent, expectedEvent) = linePair + XCTAssertEqual(actualEvent, expectedEvent, "Event: \(index)", file: file, line: line) + } + } + struct TestEvent: Decodable, Hashable, Sendable { var index: Int } + func testJSONData() async throws { + // Simple event. + try await _testJSONData( + input: #""" + event: event1 + id: 1 + data: {"index":1} + + event: event2 + id: 2 + data: { + data: "index": 2 + data: } + + + """#, + output: [ + .init(event: "event1", data: TestEvent(index: 1), id: "1"), + .init(event: "event2", data: TestEvent(index: 2), id: "2"), + ] + ) + } +} + +final class Test_ServerSentEventsDecoding_Lines: Test_Runtime { + func _test(input: String, output: [String], file: StaticString = #file, line: UInt = #line) async throws { + let upstream = asOneBytePerElementSequence(ArraySlice(input.utf8)) + let sequence = ServerSentEventsLineDeserializationSequence(upstream: upstream) + let lines = try await [ArraySlice](collecting: sequence) + XCTAssertEqual(lines.count, output.count, file: file, line: line) + for (index, linePair) in zip(lines, output).enumerated() { + let (actualLine, expectedLine) = linePair + XCTAssertEqualData(actualLine, expectedLine.utf8, "Line: \(index)", file: file, line: line) + } + } + func test() async throws { + // LF + try await _test(input: "hello\nworld\n", output: ["hello", "world"]) + // CR + try await _test(input: "hello\rworld\r", output: ["hello", "world"]) + // CRLF + try await _test(input: "hello\r\nworld\r\n", output: ["hello", "world"]) + } +} diff --git a/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift new file mode 100644 index 00000000..db88cd60 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// 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_ServerSentEventsEncoding: Test_Runtime { + func _test(input: [ServerSentEvent], output: String, file: StaticString = #file, line: UInt = #line) async throws { + let sequence = WrappedSyncSequence(sequence: input).asEncodedServerSentEvents() + try await XCTAssertEqualAsyncData(sequence, output.utf8, file: file, line: line) + } + func test() async throws { + // Simple event. + try await _test( + input: [.init(data: "hello\nworld")], + output: #""" + data: hello + data: world + + + """# + ) + // Two simple events. + try await _test( + input: [.init(data: "hello\nworld"), .init(data: "hello2\nworld2")], + output: #""" + data: hello + data: world + + data: hello2 + data: world2 + + + """# + ) + // A few events. + try await _test( + input: [ + .init(retry: 5000), .init(data: "This is the first message."), + .init(data: "This is the second\nmessage."), + .init(event: "customEvent", data: "This is a custom event message."), + .init(id: "123", data: "This is a message with an ID."), + ], + output: #""" + retry: 5000 + + data: This is the first message. + + data: This is the second + data: message. + + event: customEvent + data: This is a custom event message. + + id: 123 + data: This is a message with an ID. + + + """# + ) + } + func _testJSONData( + input: [ServerSentEventWithJSONData], + output: String, + file: StaticString = #file, + line: UInt = #line + ) async throws { + let sequence = WrappedSyncSequence(sequence: input).asEncodedServerSentEventsWithJSONData() + try await XCTAssertEqualAsyncData(sequence, output.utf8, file: file, line: line) + } + struct TestEvent: Encodable, Hashable, Sendable { var index: Int } + func testJSONData() async throws { + // Simple event. + try await _testJSONData( + input: [ + .init(event: "event1", data: TestEvent(index: 1), id: "1"), + .init(event: "event2", data: TestEvent(index: 2), id: "2"), + ], + output: #""" + id: 1 + event: event1 + data: {"index":1} + + id: 2 + event: event2 + data: {"index":2} + + + """# + ) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 0d7d108e..7f1f2255 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -114,6 +114,29 @@ class Test_Runtime: XCTestCase { var testStructURLFormData: Data { Data(testStructURLFormString.utf8) } + var testEvents: [TestPet] { [.init(name: "Rover"), .init(name: "Pancake")] } + var testEventsAsyncSequence: WrappedSyncSequence<[TestPet]> { WrappedSyncSequence(sequence: testEvents) } + + var testJSONLinesBytes: ArraySlice { + let encoder = JSONEncoder() + let bytes = try! testEvents.map { try encoder.encode($0) + [ASCII.lf] }.joined() + return ArraySlice(bytes) + } + var testJSONSequenceBytes: ArraySlice { + let encoder = JSONEncoder() + let bytes = try! testEvents.map { try [ASCII.rs] + encoder.encode($0) + [ASCII.lf] }.joined() + return ArraySlice(bytes) + } + + func asOneBytePerElementSequence(_ source: ArraySlice) -> HTTPBody { + HTTPBody( + WrappedSyncSequence(sequence: source).map { ArraySlice([$0]) }, + length: .known(Int64(source.count)), + iterationBehavior: .multiple + ) + } + var testJSONLinesOneBytePerElementSequence: HTTPBody { asOneBytePerElementSequence(testJSONLinesBytes) } + var testJSONSequenceOneBytePerElementSequence: HTTPBody { asOneBytePerElementSequence(testJSONSequenceBytes) } @discardableResult func _testPrettyEncoded(_ value: Value, expectedJSON: String) throws -> String { let encoder = JSONEncoder() @@ -147,7 +170,7 @@ func chunkFromStringLines(_ strings: [String], addExtraCRLFs: Int = 0) -> ArrayS func chunkFromString(_ string: String, addCRLFs: Int = 0) -> ArraySlice { var slice = ArraySlice(string.utf8) - for _ in 0.. [UInt8] { Array(string.utf8) } extension ArraySlice { mutating func append(_ string: String) { append(contentsOf: chunkFromString(string)) } - mutating func appendCRLF() { append(contentsOf: [0x0d, 0x0a]) } + mutating func appendCRLF() { append(contentsOf: ASCII.crlf) } } struct TestError: Error, Equatable {} @@ -338,8 +361,9 @@ fileprivate extension UInt8 { var asHex: String { let original: String switch self { - case 0x0d: original = "CR" - case 0x0a: original = "LF" + case ASCII.cr: original = "CR" + case ASCII.lf: original = "LF" + case ASCII.rs: original = "RS" default: original = "\(UnicodeScalar(self)) " } return String(format: "%02x \(original)", self) @@ -395,17 +419,36 @@ public func XCTAssertEqualData( } /// Asserts that the data matches the expected value. -public func XCTAssertEqualData( - _ expression1: @autoclosure () throws -> HTTPBody?, +public func XCTAssertEqualAsyncData( + _ expression1: @autoclosure () throws -> AS?, _ expression2: @autoclosure () throws -> C, _ message: @autoclosure () -> String = "Data doesn't match.", file: StaticString = #filePath, line: UInt = #line -) async throws where C.Element == UInt8 { +) async throws where C.Element == UInt8, AS.Element == ArraySlice { guard let actualBytesBody = try expression1() else { XCTFail("First value is nil", file: file, line: line) return } - let actualBytes = try await [UInt8](collecting: actualBytesBody, upTo: .max) + let actualBytes = try await [ArraySlice](collecting: actualBytesBody).flatMap { $0 } XCTAssertEqualData(actualBytes, try expression2(), file: file, line: line) } + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> C, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) async throws where C.Element == UInt8 { + try await XCTAssertEqualAsyncData(try expression1(), try expression2(), file: file, line: line) +} + +extension Array { + init(collecting source: Source) async throws where Source.Element == Element { + var elements: [Element] = [] + for try await element in source { elements.append(element) } + self = elements + } +} From 7f86e4a103bcc1732cddcd197ac924c178b440f3 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Tue, 16 Jan 2024 16:33:54 +0100 Subject: [PATCH 29/79] Make the ISO8601 date transcoder configurable (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the ISO8601 date transcoder configurable ### Motivation Fixes https://github.com/apple/swift-openapi-generator/issues/389. ### Modifications Add a way to configure the existing `ISO8601DateTranscoder`. ### Result Adopters can more easily use e.g. fractional seconds-based transcoder. ### 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/94 --- .../Conversion/Configuration.swift | 34 +++++++++++++++++-- .../Conversion/Test_Configuration.swift | 30 ++++++++++++++++ Tests/OpenAPIRuntimeTests/Test_Runtime.swift | 4 +++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 6cff9130..e0b593a5 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -25,14 +25,37 @@ public protocol DateTranscoder: Sendable { } /// A transcoder for dates encoded as an ISO-8601 string (in RFC 3339 format). -public struct ISO8601DateTranscoder: DateTranscoder { +public struct ISO8601DateTranscoder: DateTranscoder, @unchecked Sendable { + + /// The lock protecting the formatter. + private let lock: NSLock + + /// The underlying date formatter. + private let locked_formatter: ISO8601DateFormatter + + /// Creates a new transcoder with the provided options. + /// - Parameter options: Options to override the default ones. If you provide nil here, the default options + /// are used. + public init(options: ISO8601DateFormatter.Options? = nil) { + let formatter = ISO8601DateFormatter() + if let options { formatter.formatOptions = options } + lock = NSLock() + lock.name = "com.apple.swift-openapi-generator.runtime.ISO8601DateTranscoder" + locked_formatter = formatter + } /// Creates and returns an ISO 8601 formatted string representation of the specified date. - public func encode(_ date: Date) throws -> String { ISO8601DateFormatter().string(from: date) } + public func encode(_ date: Date) throws -> String { + lock.lock() + defer { lock.unlock() } + return locked_formatter.string(from: date) + } /// Creates and returns a date object from the specified ISO 8601 formatted string representation. public func decode(_ dateString: String) throws -> Date { - guard let date = ISO8601DateFormatter().date(from: dateString) else { + lock.lock() + defer { lock.unlock() } + guard let date = locked_formatter.date(from: dateString) else { throw DecodingError.dataCorrupted( .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.") ) @@ -44,6 +67,11 @@ public struct ISO8601DateTranscoder: DateTranscoder { extension DateTranscoder where Self == ISO8601DateTranscoder { /// A transcoder that transcodes dates as ISO-8601–formatted string (in RFC 3339 format). public static var iso8601: Self { ISO8601DateTranscoder() } + + /// A transcoder that transcodes dates as ISO-8601–formatted string (in RFC 3339 format) with fractional seconds. + public static var iso8601WithFractionalSeconds: Self { + ISO8601DateTranscoder(options: [.withInternetDateTime, .withFractionalSeconds]) + } } extension JSONEncoder.DateEncodingStrategy { diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift new file mode 100644 index 00000000..6027c423 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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) import OpenAPIRuntime + +final class Test_Configuration: Test_Runtime { + + func testDateTranscoder_iso8601() throws { + let transcoder: any DateTranscoder = .iso8601 + XCTAssertEqual(try transcoder.encode(testDate), testDateString) + XCTAssertEqual(testDate, try transcoder.decode(testDateString)) + } + + func testDateTranscoder_iso8601WithFractionalSeconds() throws { + let transcoder: any DateTranscoder = .iso8601WithFractionalSeconds + XCTAssertEqual(try transcoder.encode(testDateWithFractionalSeconds), testDateWithFractionalSecondsString) + XCTAssertEqual(testDateWithFractionalSeconds, try transcoder.decode(testDateWithFractionalSecondsString)) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 7f1f2255..9de89902 100644 --- a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift +++ b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift @@ -42,6 +42,10 @@ class Test_Runtime: XCTestCase { var testDateString: String { "2023-01-18T10:04:11Z" } + var testDateWithFractionalSeconds: Date { Date(timeIntervalSince1970: 1_674_036_251.123) } + + var testDateWithFractionalSecondsString: String { "2023-01-18T10:04:11.123Z" } + var testDateEscapedString: String { "2023-01-18T10%3A04%3A11Z" } var testDateStringData: Data { Data(testDateString.utf8) } 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 30/79] 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 31/79] 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 32/79] 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 33/79] 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 34/79] 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 35/79] 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 36/79] [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 37/79] 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 38/79] 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 39/79] 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 40/79] 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 41/79] 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 42/79] 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 43/79] 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 44/79] 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 45/79] 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 46/79] 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 47/79] 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 48/79] 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 49/79] 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 50/79] [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 51/79] 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 52/79] 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 53/79] 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 54/79] [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 55/79] 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 56/79] 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 57/79] 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 58/79] 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 59/79] 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 60/79] 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 61/79] 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 62/79] [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 63/79] 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 64/79] 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 65/79] 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 66/79] 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 67/79] 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 68/79] 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 69/79] 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 70/79] 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 71/79] 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 72/79] 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 73/79] 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 74/79] 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 75/79] 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 76/79] 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 77/79] 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 78/79] 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 79/79] 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