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/.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. 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 - - "*" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..6db1fc5d --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,77 @@ +name: Main + +on: + push: + branches: [main] + schedule: + - cron: "0 8,20 * * *" + +jobs: + 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_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" + + 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 + needs: construct-integration-test-matrix + uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main + with: + name: "Integration test" + matrix_string: '${{ needs.construct-integration-test-matrix.outputs.integration-test-matrix }}' + + static-sdk: + 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: 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_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 new file mode 100644 index 00000000..f3ab7a89 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,81 @@ +name: PR + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + 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_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_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" + + 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 + needs: construct-integration-test-matrix + uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main + with: + name: "Integration test" + matrix_string: '${{ needs.construct-integration-test-matrix.outputs.integration-test-matrix }}' + + static-sdk: + 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 + + 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_6_2_enabled: true + windows_nightly_next_enabled: true + windows_nightly_main_enabled: true 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 diff --git a/.gitignore b/.gitignore index f6f5465e..c4deea75 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +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 new file mode 100644 index 00000000..9869feaf --- /dev/null +++ b/.licenseignore @@ -0,0 +1,11 @@ +.gitignore +.licenseignore +.swiftformatignore +.spi.yml +.swift-format +.github/ +**.md +**.txt +**Package.swift +docker/* +.editorconfig 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/.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/.swiftformatignore b/.swiftformatignore new file mode 100644 index 00000000..ef0b696a --- /dev/null +++ b/.swiftformatignore @@ -0,0 +1 @@ +**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") + ] + ), + ] +) 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/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/Package.swift b/Package.swift index 960e3311..58fc1d4d 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,20 +15,16 @@ 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", platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), + .macOS(.v10_15), .macCatalyst(.v13), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) ], products: [ .library( @@ -38,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( @@ -55,3 +50,17 @@ let package = Package( ), ] ) + +// --- STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // +for target in package.targets { + 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 --- // diff --git a/README.md b/README.md index d54d870b..75c8720f 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,36 @@ # 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) + This library provides common abstractions and helper functions used by the client and server code generated by [Swift OpenAPI Generator][0]. ## Overview 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", - .upToNextMinor(from: "0.3.0") -), +.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. @@ -41,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/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/ diff --git a/Sources/OpenAPIRuntime/Base/Acceptable.swift b/Sources/OpenAPIRuntime/Base/Acceptable.swift index 217d9f7f..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 {} @@ -29,9 +30,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 +45,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 +53,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 +79,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 +118,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 +148,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..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 @@ -56,9 +56,17 @@ 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 } + + /// 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 { @@ -76,7 +84,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/Sources/OpenAPIRuntime/Base/ByteUtilities.swift b/Sources/OpenAPIRuntime/Base/ByteUtilities.swift new file mode 100644 index 00000000..039c03f2 --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/ByteUtilities.swift @@ -0,0 +1,158 @@ +//===----------------------------------------------------------------------===// +// +// 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 record separator `` character. + static let rs: UInt8 = 0x1e + + /// 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 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] + + /// 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.. { + + /// 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/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift new file mode 100644 index 00000000..11a43dc0 --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 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/Base/CopyOnWriteBox.swift b/Sources/OpenAPIRuntime/Base/CopyOnWriteBox.swift index cbfc8f01..f876666e 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 - } + @usableFromInline 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) - } + 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..3d7adef8 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -14,11 +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 @@ -38,15 +40,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 +73,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 +90,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 +117,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 +141,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 +183,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 +205,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 +236,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..83da78c1 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift @@ -12,6 +12,18 @@ // //===----------------------------------------------------------------------===// +#if canImport(Foundation) +#if canImport(Darwin) +import class Foundation.NSNull +#else +@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. /// /// Contains an untyped JSON value. In some cases, the structure of the data @@ -41,9 +53,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 +73,16 @@ 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 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 } 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 +91,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 } } @@ -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,31 +143,94 @@ 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: + 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") ) } } + #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. + /// - 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)" + ) + ) + } + } + } + #endif // MARK: Equatable @@ -169,47 +242,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 +276,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 +296,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 +356,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,64 +379,49 @@ 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 - /// 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 - 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 } // 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) @@ -439,14 +459,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 +482,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 @@ -476,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 @@ -488,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 @@ -503,9 +527,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 +540,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/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/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..e91954a2 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,26 @@ 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 { 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 +114,15 @@ 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 { 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 +131,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) @@ -189,26 +142,16 @@ extension Encoder { } /// 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) - } + 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..2ee7ab00 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -25,21 +25,39 @@ 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) + 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." - ) + .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.") ) } return date @@ -48,15 +66,18 @@ 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() } + + /// 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 { /// 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 +88,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) @@ -75,19 +96,79 @@ 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 +} + +/// 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. 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 + + /// Custom XML coder for encoding and decoding xml bodies. + public var xmlCoder: (any CustomCoder)? + /// 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. + /// - 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 + 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+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 4c6950b2..28abbdb2 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) } ) } @@ -142,19 +127,18 @@ extension Converter { convert: convertBodyCodableToJSON ) } - - /// Sets an optional request body as binary in the specified header fields and returns an `HTTPBody`. + /// Sets an optional request body as XML in the specified header fields and returns an `HTTPBody`. /// /// - Parameters: - /// - value: The optional `HTTPBody` to be set as the request body. + /// - 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 binary request body, or `nil` if the `value` is `nil`. + /// - 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 binary fails. - public func setOptionalRequestBodyAsBinary( - _ value: HTTPBody?, + /// - Throws: An error if setting the request body as XML fails. + public func setOptionalRequestBodyAsXML( + _ value: T?, headerFields: inout HTTPFields, contentType: String ) throws -> HTTPBody? { @@ -162,22 +146,21 @@ extension Converter { value, headerFields: &headerFields, contentType: contentType, - convert: { $0 } + convert: convertBodyCodableToXML ) } - - /// Sets a required request body as binary in the specified header fields and returns an `HTTPBody`. + /// Sets a required request body as XML in the specified header fields and returns an `HTTPBody`. /// /// - Parameters: - /// - value: The `HTTPBody` to be set as the request body. + /// - 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 binary request body. + /// - Returns: An `HTTPBody` representing the XML-encoded request body. /// - /// - Throws: An error if setting the request body as binary fails. - public func setRequiredRequestBodyAsBinary( - _ value: HTTPBody, + /// - Throws: An error if setting the request body as XML fails. + public func setRequiredRequestBodyAsXML( + _ value: T, headerFields: inout HTTPFields, contentType: String ) throws -> HTTPBody { @@ -185,10 +168,38 @@ extension Converter { value, headerFields: &headerFields, contentType: contentType, - convert: { $0 } + convert: convertBodyCodableToXML ) } + /// Sets an optional request body as binary in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The optional `HTTPBody` 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 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? + { setOptionalRequestBody(value, headerFields: &headerFields, contentType: contentType, convert: { $0 }) } + + /// Sets a required request body as binary in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The `HTTPBody` 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 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 + { 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`. /// /// - Parameters: @@ -235,6 +246,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: @@ -250,9 +311,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, @@ -260,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. /// @@ -276,14 +358,51 @@ 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 }) + } + /// 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 a9c27f9c..73f8fecb 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,16 +58,41 @@ 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) + throw RuntimeError.unexpectedContentTypeHeader( + expected: bestContentType, + received: String(describing: received) + ) } 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. @@ -85,27 +102,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 +119,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 +132,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 +154,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 +176,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 +189,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..a3088bd3 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,42 +46,31 @@ 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.isEmpty { return } + guard let parsedSubstring = OpenAPIMIMEType(substring) else { + throw RuntimeError.invalidAcceptSubstring(substring) } - if acceptValues.contains("*/*") { - return - } - if acceptValues.contains("\(substring.split(separator: "/")[0].lowercased())/*") { - return - } - if acceptValues.contains(where: { $0.localizedCaseInsensitiveContains(substring) }) { - return + // 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: @@ -114,11 +97,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 +135,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 +173,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 } ) @@ -250,6 +221,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: @@ -262,14 +274,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 +288,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. /// @@ -334,6 +332,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: @@ -342,11 +385,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, @@ -354,6 +395,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. /// @@ -363,16 +422,81 @@ 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, + public func setResponseBodyAsBinary(_ value: HTTPBody, headerFields: inout HTTPFields, contentType: String) throws + -> HTTPBody + { 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 + contentType: String, + allowsUnknownParts: Bool, + requiredExactlyOncePartNames: Set, + requiredAtLeastOncePartNames: Set, + atMostOncePartNames: Set, + zeroOrMoreTimesPartNames: Set, + encoding encode: @escaping @Sendable (Part) throws -> MultipartRawPart ) throws -> HTTPBody { - try setResponseBody( + let boundary = configuration.multipartBoundaryGenerator.makeBoundary() + let contentTypeWithBoundary = contentType + "; boundary=\(boundary)" + return setResponseBody( value, headerFields: &headerFields, - contentType: contentType, - convert: { $0 } + contentType: contentTypeWithBoundary, + convert: { value in + convertMultipartToBytes( + value, + requirements: .init( + allowsUnknownParts: allowsUnknownParts, + requiredExactlyOncePartNames: requiredExactlyOncePartNames, + requiredAtLeastOncePartNames: requiredAtLeastOncePartNames, + atMostOncePartNames: atMostOncePartNames, + zeroOrMoreTimesPartNames: zeroOrMoreTimesPartNames + ), + boundary: boundary, + encode: encode + ) + } ) } } + +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/Conversion/Converter.swift b/Sources/OpenAPIRuntime/Conversion/Converter.swift index 2cf78d99..606790f7 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter.swift @@ -12,15 +12,14 @@ // //===----------------------------------------------------------------------===// #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 { +@_spi(Generated) public struct Converter: Sendable { /// Configuration used to set up the converter. public let configuration: Configuration @@ -35,13 +34,11 @@ 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() - self.encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + self.encoder.outputFormatting = .init(configuration.jsonEncodingOptions) self.encoder.dateEncodingStrategy = .from(dateTranscoder: configuration.dateTranscoder) self.headerFieldEncoder = JSONEncoder() @@ -52,3 +49,14 @@ public struct Converter: Sendable { 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/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index 3b7a7d41..fc50b2a1 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -24,14 +24,14 @@ 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 { + switch resolvedStyle { + case .form, .deepObject: break + default: throw RuntimeError.unsupportedParameterStyle( name: name, location: .query, @@ -49,9 +49,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 +59,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 +77,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 +97,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 +123,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 +132,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,20 +141,42 @@ 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) } + /// 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. /// - 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 +194,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, @@ -217,13 +207,57 @@ 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. /// - 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 +267,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 +281,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 +295,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 +313,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 +332,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 +356,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 +408,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 +445,7 @@ extension Converter { as: type, convert: convert ) - else { - throw RuntimeError.missingRequiredQueryParameter(name) - } + else { throw RuntimeError.missingRequiredQueryParameter(name) } return value } @@ -456,15 +457,16 @@ 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 { + 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. @@ -475,16 +477,13 @@ 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? { - guard let value else { - return nil - } + ) rethrows -> HTTPBody? { + guard let value else { return nil } return try setRequiredRequestBody( value, headerFields: &headerFields, @@ -507,9 +506,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 +532,7 @@ extension Converter { transforming: transform, convert: convert ) - else { - throw RuntimeError.missingRequiredRequestBody - } + else { throw RuntimeError.missingRequiredRequestBody } return body } @@ -555,9 +550,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 +569,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 @@ -635,15 +621,16 @@ 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 { + 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. @@ -660,9 +647,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..b8e32edc 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,19 @@ 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)" } 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 + if let first = errors.first { + return "Multiple errors encountered, first one: \(first.localizedDescription)." + } else { + return "No errors" + } } } 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..31dda63c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -14,9 +14,8 @@ /// 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 { +/// 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. /// @@ -27,6 +26,11 @@ public enum ParameterStyle: Sendable { /// /// Details: https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2 case simple + + /// The deepObject style. + /// + /// Details: https://spec.openapis.org/oas/v3.0.4.html#style-values + case deepObject } extension ParameterStyle { @@ -46,18 +50,15 @@ 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 + case .deepObject: self = .deepObject } } } 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..2ce41750 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -16,201 +16,80 @@ import HTTPTypes // MARK: - Functionality to be removed in the future -extension ClientError { - /// Creates a new error. +extension UndocumentedPayload { + /// Creates a new payload. + @available(*, deprecated, renamed: "init(headerFields:body:)") @_disfavoredOverload public init() { + self.init(headerFields: [:], body: nil) + } +} + +extension Configuration { + /// Creates a new configuration with the specified values. + /// /// - 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." - ) + /// - 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( - operationID: String, - operationInput: any Sendable, - request: HTTPRequest? = nil, - requestBody: HTTPBody? = nil, - baseURL: URL? = nil, - response: HTTPResponse? = nil, - responseBody: HTTPBody? = nil, - underlyingError: any Error + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random ) { - self.init( - operationID: operationID, - operationInput: operationInput, - request: request, - requestBody: requestBody, - baseURL: baseURL, - response: response, - responseBody: responseBody, - causeDescription: "Legacy error without a causeDescription.", - underlyingError: underlyingError - ) + self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: multipartBoundaryGenerator, xmlCoder: nil) } -} -extension ServerError { - /// Creates a new error. + /// Creates a new configuration with the specified values. + /// /// - 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 + /// - 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( - operationID: operationID, - request: request, - requestBody: requestBody, - requestMetadata: requestMetadata, - operationInput: operationInput, - operationOutput: operationOutput, - causeDescription: "Legacy error without a causeDescription.", - underlyingError: underlyingError + dateTranscoder: dateTranscoder, + jsonEncodingOptions: [.sortedKeys, .prettyPrinted], + multipartBoundaryGenerator: multipartBoundaryGenerator, + xmlCoder: xmlCoder ) } } -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. +extension AsyncSequence where Element == ArraySlice, Self: Sendable { + /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. /// - /// The concrete content type can contain parameters, such as `charset`, but - /// they are ignored in the equality comparison. + /// 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. /// - /// The expected content type can contain wildcards, such as */* and text/*. + /// Use this method if the event's `data` field is JSON. /// - 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() - } - } + /// - 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 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 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/Documentation.docc/Documentation.md b/Sources/OpenAPIRuntime/Documentation.docc/Documentation.md index f124e7d8..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", - .upToNextMinor(from: "0.3.0") -), +.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,22 +49,30 @@ 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`` +- ``MultipartBoundaryGenerator`` +- ``RandomMultipartBoundaryGenerator`` +- ``ConstantMultipartBoundaryGenerator`` +- ``IterationBehavior`` + +### Content types +- ``HTTPBody`` +- ``Base64EncodedData`` +- ``MultipartBody`` +- ``MultipartRawPart`` +- ``MultipartPart`` +- ``MultipartDynamicallyNamedPart`` ### Errors - ``ClientError`` @@ -77,6 +82,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`` diff --git a/Sources/OpenAPIRuntime/Errors/ClientError.swift b/Sources/OpenAPIRuntime/Errors/ClientError.swift index b820bd4a..eb0c8005 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. /// @@ -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 } } @@ -134,6 +132,6 @@ extension ClientError: LocalizedError { /// /// - Returns: A localized string describing the client error. public var errorDescription: String? { - description + "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 c31291d7..12bdb42c 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: \(self)" } return "DecodingError: \(output)" } @@ -35,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)" } ?? ""))" } } @@ -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: \(self)" } return "EncodingError: \(output)" } @@ -55,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 4fbab419..2c3260ac 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 { @@ -21,26 +22,27 @@ 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) // Data conversion case failedToDecodeStringConvertibleValue(type: String) + case missingCoderForCustomContentType(contentType: String) enum ParameterLocation: String, CustomStringConvertible { case query - var description: String { - rawValue - } + var description: String { rawValue } } case unsupportedParameterStyle(name: String, location: ParameterLocation, style: ParameterStyle, explode: Bool) // Headers case missingRequiredHeaderField(String) - case unexpectedContentTypeHeader(String) + case unexpectedContentTypeHeader(expected: String, received: String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) + case missingOrMalformedContentDispositionName // Path case missingRequiredPathParameter(String) @@ -53,6 +55,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) @@ -65,69 +71,60 @@ 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 .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))'" - 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 .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)" - 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(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: + 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." case .unexpectedResponseStatus(let expectedStatus, let response): return "Unexpected response, expected status code: \(expectedStatus), response: \(response)" case .unexpectedResponseBody(let expectedContentType, let body): 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. @@ -136,10 +133,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 +143,28 @@ 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) } + +/// 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/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 7595a890..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 } } @@ -107,6 +105,6 @@ extension ServerError: LocalizedError { /// /// - Returns: A localized string describing the server error. public var errorDescription: String? { - description + "Server encountered an error handling the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." } } 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..34f51b21 --- /dev/null +++ b/Sources/OpenAPIRuntime/EventStreams/ServerSentEventsDecoding.swift @@ -0,0 +1,481 @@ +//===----------------------------------------------------------------------===// +// +// 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 + + /// A closure that determines whether the given byte chunk should be forwarded to the consumer. + /// + /// 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. + /// - 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 { + + /// 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 + + /// 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. + 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(), while: predicate) + } +} + +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 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 } + ) -> 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(), + while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } + ) -> AsyncThrowingMapSequence< + ServerSentEventsDeserializationSequence>, + ServerSentEventWithJSONData + > { + asDecodedServerSentEvents(while: predicate) + .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 { + + /// Accumulating an event, which hasn't been emitted yet. + case accumulatingEvent(ServerSentEvent, buffer: [ArraySlice], predicate: (ArraySlice) -> Bool) + + /// 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(while predicate: @escaping (ArraySlice) -> Bool) { + self.state = .accumulatingEvent(.init(), buffer: [], predicate: predicate) + } + + /// 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, let predicate): + guard let line = buffer.first else { return .needsMore } + state = .mutating + buffer.removeFirst() + if line.isEmpty { + // Dispatch the accumulated event. + // 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, predicate: predicate) + 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, predicate: predicate) + 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, predicate: predicate) + return .noop + } + // Processed the field, continue. + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) + 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, let predicate): + if let value { + state = .mutating + buffer.append(value) + state = .accumulatingEvent(event, buffer: buffer, predicate: predicate) + 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/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/ClientTransport.swift b/Sources/OpenAPIRuntime/Interface/ClientTransport.swift index 5d66ff6b..cb20c651 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. /// @@ -45,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 /// ) /// @@ -63,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. @@ -111,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 @@ -137,12 +115,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. @@ -179,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, @@ -188,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/CurrencyTypes.swift b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift index 477c5b93..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. @@ -24,11 +25,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 +35,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/ErrorHandlingMiddleware.swift b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift new file mode 100644 index 00000000..48c3cabc --- /dev/null +++ b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// 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/Sources/OpenAPIRuntime/Interface/HTTPBody.swift b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift index cb7e53f1..59292ec9 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -121,41 +121,25 @@ 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. + /// 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. - private let sequence: BodySequence + private let sequence: AnySequence /// A lock for shared mutable state. private let lock: NSLock = { @@ -171,28 +155,10 @@ 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 } - /// 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. @@ -202,12 +168,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. @@ -218,9 +180,9 @@ public final class HTTPBody: @unchecked Sendable { /// - iterationBehavior: The sequence's iteration behavior, which /// indicates whether the sequence can be iterated multiple times. @usableFromInline init( - _ sequence: BodySequence, + _ sequence: AnySequence, length: Length, - iterationBehavior: IterationBehavior + iterationBehavior: OpenAPIRuntime.IterationBehavior ) { self.sequence = sequence self.length = length @@ -236,7 +198,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)), @@ -255,21 +217,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,30 +233,21 @@ 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 - ) { - self.init([bytes], length: .known(bytes.count), iterationBehavior: .multiple) + @inlinable public convenience init(_ bytes: ByteChunk) { + self.init([bytes], length: .known(Int64(bytes.count)), iterationBehavior: .multiple) } /// Creates a new body with the provided byte sequence. @@ -313,66 +259,37 @@ extension HTTPBody { @inlinable public convenience init( _ bytes: some Sequence & Sendable, length: Length, - iterationBehavior: IterationBehavior - ) { - self.init( - [ArraySlice(bytes)], - length: length, - iterationBehavior: iterationBehavior - ) - } + iterationBehavior: OpenAPIRuntime.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 - ) { - self.init(bytes, length: .known(bytes.count)) + @inlinable public convenience init(_ bytes: some Collection & Sendable) { + self.init(bytes, length: .known(Int64(bytes.count))) } /// Creates a new body with the provided async throwing stream. /// - 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. @@ -384,13 +301,9 @@ 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 - ) + self.init(.init(sequence), length: length, iterationBehavior: iterationBehavior) } /// Creates a new body with the provided async sequence of byte sequences. @@ -402,13 +315,9 @@ 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 - ) + self.init(sequence.map { ArraySlice($0) }, length: length, iterationBehavior: iterationBehavior) } } @@ -422,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 sequence.makeAsyncIterator() + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } } } @@ -438,13 +349,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 +362,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 @@ -468,23 +373,15 @@ 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) - } + 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 +424,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 +439,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. @@ -584,13 +458,9 @@ 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 - ) + self.init(.init(sequence.map { ByteChunk.init($0) }), length: length, iterationBehavior: iterationBehavior) } } @@ -598,9 +468,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 +481,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 +491,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 +507,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,115 +546,19 @@ 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() } } + /// 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. /// - 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() - } - } -} - -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() - } + public mutating func next() async throws -> Element? { try await produceNext() } } } diff --git a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift index 2ee147bc..0984ed21 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 /// @@ -115,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 @@ -161,7 +161,7 @@ public protocol ServerTransport { /// /// try handler.registerHandlers( /// on: transport, -/// serverURL: serverURL, +/// serverURL: URL(string: "/api/v1")!, /// middlewares: [ /// loggingMiddleware, /// ] @@ -198,7 +198,7 @@ public protocol ServerTransport { /// print("<<<: \(response.status.code)") /// return (response, responseBody) /// } catch { -/// print("!!!: \(error.localizedDescription)") +/// print("!!!: \(error)") /// throw error /// } /// } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 6c75a3ce..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 @@ -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..4fb6bc82 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 @@ -43,12 +41,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 @@ -98,27 +91,20 @@ 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 -> 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 +131,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 +142,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 +155,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 +165,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 +176,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 +184,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/Multipart/MultipartBoundaryGenerator.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift new file mode 100644 index 00000000..c3397ba2 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 +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() } } + } +} + +/// 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 new file mode 100644 index 00000000..07538233 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift @@ -0,0 +1,317 @@ +//===----------------------------------------------------------------------===// +// +// 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 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() } + } + } +} + +/// 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..8734b6aa --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift @@ -0,0 +1,398 @@ +//===----------------------------------------------------------------------===// +// +// 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(Int64(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: + let frame: Upstream.AsyncIterator.Element? + var upstream = upstream + #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 + 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: + let frame: Upstream.AsyncIterator.Element? + var upstream = upstream + #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 + 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/URICoder/Common/URIParsedNode.swift b/Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift similarity index 55% rename from Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift rename to Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift index 51ecbe2a..49e57b9f 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartInternalTypes.swift @@ -12,16 +12,15 @@ // //===----------------------------------------------------------------------===// -import Foundation +import HTTPTypes -/// The type used for keys by `URIParser`. -typealias URIParsedKey = String.SubSequence +/// A frame of a multipart message, either the whole header fields +/// section or a chunk of the body bytes. +enum MultipartFrame: Sendable, Hashable { -/// The type used for values by `URIParser`. -typealias URIParsedValue = String.SubSequence + /// The header fields section. + case headerFields(HTTPFields) -/// 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] + /// One byte chunk of the part's body. + case bodyChunk(ArraySlice) +} diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift new file mode 100644 index 00000000..6db356c3 --- /dev/null +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -0,0 +1,360 @@ +//===----------------------------------------------------------------------===// +// +// 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 + } +} + +/// A wrapper of a typed part with a statically known name that adds other +/// dynamic `content-disposition` parameter values, such as `filename`. +public struct MultipartPart: 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 } + } + + /// 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. + /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. + public func makeAsyncIterator() -> AsyncIterator { + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } + } +} + +// 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() } + } + + /// 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. + /// - 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/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/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/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/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/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift index bfb42c48..3f7b380e 100644 --- a/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift +++ b/Sources/OpenAPIRuntime/URICoder/Common/URICoderConfiguration.swift @@ -17,14 +17,23 @@ 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 } /// A character used to escape the space character. @@ -41,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 4dc882a4..d2f9edbb 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 { @@ -63,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 @@ -77,12 +91,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 +104,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 +117,30 @@ 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 + } + } + + /// 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 } } @@ -140,10 +153,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/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 138d60cc..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 @@ -53,19 +60,13 @@ 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 { /// 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 @@ -73,22 +74,13 @@ 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 { + 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 @@ -96,93 +88,10 @@ 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) - } - } - - /// 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() + func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws + -> T? + { + 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 6590be92..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,11 +29,8 @@ 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 { - throw DecodingError.keyNotFound( - key, - .init(codingPath: codingPath, debugDescription: "Key not found.") - ) + guard let value = try decoder.nestedElementInCurrentDictionary(forKey: key.stringValue) else { + throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: "Key not found.")) } return value } @@ -49,17 +43,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 +61,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 +79,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 +94,77 @@ 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 - } + 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() - } + decoder.push(.init(key)) + 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..2207bd84 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift @@ -24,11 +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. @@ -36,18 +37,23 @@ 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 { - guard let double = try Double(value) else { - throw DecodingError.typeMismatch( + private func _decodeBinaryFloatingPoint(_: T.Type = T.self) throws -> T { + guard let value = try value else { + throw DecodingError.valueNotFound( T.self, - .init( + DecodingError.Context( codingPath: codingPath, - debugDescription: "Failed to convert to Double." + 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.") + ) + } return T(double) } @@ -57,18 +63,23 @@ 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 { - guard let parsedValue = try T(value) else { - throw DecodingError.typeMismatch( + private func _decodeFixedWidthInteger(_: T.Type = T.self) throws -> T { + guard let value = try value else { + throw DecodingError.valueNotFound( T.self, - .init( + DecodingError.Context.init( codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." + 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.") + ) + } return parsedValue } @@ -78,122 +89,102 @@ 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 { - guard let parsedValue = try T(String(value)) else { - throw DecodingError.typeMismatch( + private func _decodeLosslessStringConvertible(_: T.Type = T.self) throws -> T { + guard let value = try value else { + throw DecodingError.valueNotFound( T.self, - .init( + DecodingError.Context.init( codingPath: codingPath, - debugDescription: "Failed to convert to the requested type." + 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.") + ) + } return parsedValue } } extension URISingleValueDecodingContainer: SingleValueDecodingContainer { - var codingPath: [any CodingKey] { - decoder.codingPath - } + 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: Bool.Type) throws -> Bool { try _decodeLosslessStringConvertible() } func decode(_ type: String.Type) throws -> String { - try String(value) + 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() - } + 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 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: + 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) + 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..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 } } @@ -45,12 +38,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 { currentIndex += 1 } return try work() } @@ -59,9 +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. @@ -70,16 +57,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 +73,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 +89,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 +104,74 @@ 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 - } + 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() - } + decoder.push(.init(intValue: currentIndex)) + 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..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 key of the root value in the node. - private let rootKey: URIParsedKey + /// The cached parsing state of the decoder. + private struct ParsingCache { - /// The variable expansion style. - private let style: URICoderConfiguration.Style + /// The cached result of parsing the string as a primitive value. + var primitive: Result? - /// The explode parameter of the expansion style. - private let explode: Bool + /// The cached result of parsing the string as an array. + var array: Result<[URIParsedValue], any Error>? - /// The stack of nested values within the root node. - private var codingStack: [CodingStackEntry] + /// The cached result of parsing the string as a dictionary. + var dictionary: Result<[URIParsedKeyComponent: [URIParsedValue]], any Error>? + } + + /// A cache holding the parsed intermediate representation. + private var cache: ParsingCache + + /// 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 = [] } @@ -63,31 +71,34 @@ 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 } - /// 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) } @@ -104,276 +115,260 @@ 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. - 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. - /// - 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) + /// 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) }) } - /// 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 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) - } + // MARK: - decoding utilities - /// 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) - } + /// 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] } - /// 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) - } + // MARK: - withCurrent methods - /// 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) - } - guard array.count == 1 else { - if style == .simple { - return Substring(array.joined(separator: ",")) + /// 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.") } - let reason = array.isEmpty ? "an empty node" : "a node with multiple values" - try throwMismatch("Cannot parse a value from \(reason).") + } else { + // Top level primitive. + return try work(parsedRootAsPrimitive()) } - 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 + /// 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()) } - return values[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 + /// 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()) } - return value } -} -extension URIValueFromNodeDecoder: Decoder { + // MARK: - metadata and data accessors - var codingPath: [any CodingKey] { - codingStack.map(\.key) + /// 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] } } - var userInfo: [CodingUserInfoKey: Any] { - [:] + /// 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) } } - func container( - keyedBy type: Key.Type - ) throws -> KeyedDecodingContainer where Key: CodingKey { - let values = try currentElementAsDictionary() - return .init( - URIKeyedDecodingContainer( - decoder: self, - values: values - ) - ) + /// 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 } - func unkeyedContainer() throws -> any UnkeyedDecodingContainer { - let values = try currentElementAsArray() - return URIUnkeyedDecodingContainer( - decoder: self, - values: values - ) + /// 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 } + + var userInfo: [CodingUserInfoKey: Any] { [:] } + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { + KeyedDecodingContainer(URIKeyedDecodingContainer(decoder: self)) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { URIUnkeyedDecodingContainer(decoder: self) } 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..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. @@ -53,16 +60,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 +83,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 +104,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..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 { @@ -26,16 +33,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 +60,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..9e1da427 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -14,204 +14,302 @@ 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 } } /// 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 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 ["": [""]] + /// 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, _): + 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): + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } + } + + /// 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): - return try parseExplodedFormRoot() + 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 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) + } + } + return items case (.form, false): - return try parseUnexplodedFormRoot() - case (.simple, true): - return try parseExplodedSimpleRoot() - case (.simple, false): - return try parseUnexplodedSimpleRoot() + 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 { + 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) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + 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 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 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 ) - 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 key = URIParsedKey([unescapeValue(firstValue)]) + items.append(.init(key: key, value: unescapeValue(secondValue))) + 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 { + 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) } - 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) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, values) } - } - } - - /// 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 + 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 + 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))) + } + return items + case (.deepObject, true): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let nestedKeyStart: Character = "[" + let nestedKeyEnd: Character = "]" + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator ) - appendPair(.init(), [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") } } } @@ -220,36 +318,11 @@ 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. 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 +336,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 +362,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 +411,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..838ca9b1 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 } @@ -70,10 +65,37 @@ extension CharacterSet { extension URISerializer { /// A serializer error. - private enum SerializationError: Swift.Error { - + enum SerializationError: Swift.Error, Hashable, CustomStringConvertible, LocalizedError { /// 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) + + /// 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. @@ -83,10 +105,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 +119,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,16 +130,21 @@ 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 } + 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. @@ -130,47 +152,28 @@ 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 + case .deepObject: throw SerializationError.deepObjectsWithPrimitiveValuesNotSupported } - 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(unwrapPrimitiveOrArrayOfPrimitives), 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 +204,7 @@ 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 { let keyAndValueSeparator: String? let pairSeparator: String switch (configuration.style, configuration.explode) { @@ -220,14 +217,12 @@ extension URISerializer { case (.simple, _): keyAndValueSeparator = nil 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 - ) + try serializePrimitiveKeyValuePair(element, forKey: key, separator: keyAndValueSeparator) } else { try serializePrimitiveValue(element) } @@ -240,9 +235,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. @@ -252,18 +245,13 @@ extension URISerializer { /// style and explode parameters in the configuration). /// - Throws: An error if serialization of the dictionary fails. private mutating func serializeDictionary( - _ dictionary: [String: URIEncodedNode.Primitive], + _ dictionary: [String: URIEncodedNode.PrimitiveOrArrayOfPrimitives], forKey key: String ) throws { - guard !dictionary.isEmpty else { - return + 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 @@ -280,25 +268,41 @@ 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 serializeNext(_ element: URIEncodedNode.Primitive, forKey elementKey: String) throws { - try serializePrimitiveKeyValuePair( - element, - forKey: elementKey, - separator: keyAndValueSeparator - ) + func serializeNestedKey(_ elementKey: String, forKey rootKey: String) -> String { + guard case .deepObject = configuration.style else { return elementKey } + return rootKey + "[" + elementKey + "]" + } + 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)) 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) + try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key)) } } } @@ -310,10 +314,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_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift new file mode 100644 index 00000000..121c5fdd --- /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 = #filePath, + 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/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..a28ec47d 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 { @@ -114,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( @@ -135,16 +109,13 @@ 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( 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..419c1d7d 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift @@ -12,6 +12,10 @@ // //===----------------------------------------------------------------------===// import XCTest +import Foundation +#if canImport(CoreFoundation) +import CoreFoundation +#endif @_spi(Generated) @testable import OpenAPIRuntime final class Test_OpenAPIValue: Test_Runtime { @@ -22,6 +26,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"]) @@ -36,18 +44,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 = #""" @@ -70,7 +68,69 @@ 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) + } + #if canImport(CoreFoundation) + 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 + #endif func testEncoding_container_failure() throws { struct Foobar: Equatable {} XCTAssertThrowsError(try OpenAPIValueContainer(unvalidatedValue: Foobar())) { error in @@ -127,12 +187,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 = #""" { @@ -161,11 +216,84 @@ 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 values: [(any Sendable)?] = ["one", ["two": 2]] let container = try OpenAPIArrayContainer(unvalidatedValue: values) let expectedString = #""" [ @@ -194,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 @@ -203,17 +365,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: #""" @@ -268,29 +421,52 @@ 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=")! - XCTAssertEqual( - try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoded), - encodedData - ) + XCTAssertEqual(try JSONDecoder().decode(Base64EncodedData.self, from: JSONEncoded), encodedData) } 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 ) } } + +struct MyAnyOf2: Codable, Hashable, + Sendable +{ + var value1: Value1? + var value2: Value2? + init(value1: Value1? = nil, value2: Value2? = nil) { + self.value1 = value1 + self.value2 = value2 + } + 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 + ) + } + func encode(to encoder: any Encoder) throws { + try self.value1?.encode(to: encoder) + try self.value2?.encode(to: encoder) + } +} 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_Configuration.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift new file mode 100644 index 00000000..e4e3ff03 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Configuration.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// 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) 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)) + } + + 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/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 250642ce..e223ea53 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", .contentLength: "23"]) } 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", .contentLength: "7"]) } // | client | set | request body | JSON | required | setRequiredRequestBodyAsJSON | @@ -161,12 +118,29 @@ 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 | 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 | @@ -184,12 +158,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 | @@ -201,12 +170,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 | @@ -218,12 +182,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 | @@ -235,11 +194,28 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(body, testString) + XCTAssertEqual(headerFields, [.contentType: "application/octet-stream", .contentLength: "5"]) + } + + // | 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: "application/octet-stream" - ] + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] ) } @@ -252,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 { @@ -262,6 +247,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. @@ -282,7 +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/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 4819e912..85d04f25 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -12,114 +12,38 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@testable @_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 { - 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?, 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 - ) + 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" - ) + 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: [ - "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 +51,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,68 +59,76 @@ 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: "-") + ) { 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 { + 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 = #filePath, + 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 @@ -207,140 +136,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 +202,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 +211,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 +234,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..3d956bb2 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,63 +32,48 @@ 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 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), + (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), - (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), + + // 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 { 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 +85,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!") } } @@ -216,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( @@ -240,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( @@ -292,6 +289,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 { @@ -333,6 +348,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 = [:] @@ -342,12 +376,18 @@ 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 | 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 | @@ -359,11 +399,28 @@ final class Test_ServerConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(data, testString) + XCTAssertEqual(headers, [.contentType: "application/octet-stream", .contentLength: "5"]) + } + + // | 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( - headers, - [ - .contentType: "application/octet-stream" - ] + headerFields, + [.contentType: "multipart/form-data; boundary=__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__"] ) } } 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/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 new file mode 100644 index 00000000..ec76c0e0 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// 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) + } + + 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 { + case testError(String) + /// HTTP status code for error cases + public var httpStatus: HTTPTypes.HTTPResponse.Status { .badGateway } +} 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/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..2a15b932 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsDecoding.swift @@ -0,0 +1,196 @@ +//===----------------------------------------------------------------------===// +// +// 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 = #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() { + 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."), + ] + ) + + 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, + while predicate: @escaping @Sendable (ArraySlice) -> Bool = { _ in true } + ) async throws { + let sequence = asOneBytePerElementSequence(ArraySlice(input.utf8)) + .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() { + 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"), + ] + ) + + 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)) } + ) + } +} + +final class Test_ServerSentEventsDecoding_Lines: Test_Runtime { + 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) + 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..ac8922da --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/EventStreams/Test_ServerSentEventsEncoding.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// 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 = #filePath, 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 = #filePath, + 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/Interface/Test_ErrorHandlingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift new file mode 100644 index 00000000..eceb59e0 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// +// 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"])) diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift index bc6cc623..1ae395f7 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) @@ -222,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 { @@ -231,9 +187,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 +195,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) } @@ -280,7 +232,7 @@ extension Test_Body { func _testConsume( _ body: HTTPBody, expected: HTTPBody.ByteChunk, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let output = try await ArraySlice(collecting: body, upTo: .max) @@ -290,7 +242,7 @@ extension Test_Body { func _testConsume( _ body: HTTPBody, expected: some StringProtocol, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) async throws { let output = try await String(collecting: body, upTo: .max) 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.. (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/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__")) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift new file mode 100644 index 00000000..36eb2301 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartBytesToFramesSequence.swift @@ -0,0 +1,182 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +final class Test_MultipartBytesToFramesSequence: Test_Runtime { + func test() async throws { + let chunk = chunkFromStringLines([ + "--__abcd__", #"Content-Disposition: form-data; name="name""#, "", "24", "--__abcd__", + #"Content-Disposition: form-data; name="info""#, "", "{}", "--__abcd__--", + ]) + 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) } + XCTAssertEqual( + frames, + [ + .headerFields([.contentDisposition: #"form-data; name="name""#]), .bodyChunk(chunkFromString("2")), + .bodyChunk(chunkFromString("4")), .headerFields([.contentDisposition: #"form-data; name="info""#]), + .bodyChunk(chunkFromString("{")), .bodyChunk(chunkFromString("}")), + ] + ) + } +} + +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 new file mode 100644 index 00000000..da87af47 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToBytesSequence.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +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) + } +} + +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..143993a7 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartFramesToRawPartsSequence.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +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_MultipartRawPartsToFramesSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift new file mode 100644 index 00000000..826ef34f --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartRawPartsToFramesSequence.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +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 + } +} diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift new file mode 100644 index 00000000..0343966a --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -0,0 +1,284 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import HTTPTypes + +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"))) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Test_Runtime.swift b/Tests/OpenAPIRuntimeTests/Test_Runtime.swift index 704b1ef6..37184d58 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 { @@ -24,19 +24,12 @@ 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 customCoder: any CustomCoder { MockCustomCoder() } + var configuration: Configuration { .init(multipartBoundaryGenerator: .constant, xmlCoder: customCoder) } - var converter: Converter { - .init(configuration: configuration) - } + var converter: Converter { .init(configuration: configuration) } var testComponents: URLComponents { var components = URLComponents() @@ -44,57 +37,63 @@ 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 testDateWithFractionalSeconds: Date { Date(timeIntervalSince1970: 1_674_036_251.123) } - var testDateStringData: Data { - Data(testDateString.utf8) - } + var testDateWithFractionalSecondsString: String { "2023-01-18T10:04:11.123Z" } - var testDateEscapedStringData: Data { - Data(testDateEscapedString.utf8) - } + var testDateEscapedString: String { "2023-01-18T10%3A04%3A11Z" } - var testString: String { - "hello" - } + var testDateStringData: Data { Data(testDateString.utf8) } - var testStringData: Data { - Data(testString.utf8) - } + var testDateEscapedStringData: Data { Data(testDateEscapedString.utf8) } - var testQuotedString: String { - "\"hello\"" - } + var testString: String { "hello" } - var testQuotedStringData: Data { - Data(testQuotedString.utf8) - } + var testStringData: Data { Data(testString.utf8) } - var testStruct: TestPet { - .init(name: "Fluffz") - } + var testMultipartString: String { "hello" } - var testStructDetailed: TestPetDetailed { - .init(name: "Rover!", type: "Golden Retriever", age: "3") + 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 testStructString: String { - #"{"name":"Fluffz"}"# - } + var testQuotedString: String { "\"hello\"" } + + var testQuotedStringData: Data { Data(testQuotedString.utf8) } + + var testStruct: TestPet { .init(name: "Fluffz") } + + var testStructDetailed: TestPetDetailed { .init(name: "Rover!", type: "Golden Retriever", age: "3") } + + var testStructString: String { #"{"name":"Fluffz"}"# } var testStructPrettyString: String { #""" @@ -104,36 +103,69 @@ 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 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 testStructPrettyData: Data { - Data(testStructPrettyString.utf8) + var testPetWithPathPrettifiedWithoutEscapingSlashes: String { + #""" + { + "name" : "Fluffz", + "path" : "/land/forest" + } + """# } - var testStructURLFormData: Data { - Data(testStructURLFormString.utf8) + 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) } - @discardableResult - func _testPrettyEncoded(_ value: Value, expectedJSON: String) throws -> String { + 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() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(value) @@ -155,8 +187,34 @@ 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: ASCII.crlf) } +} + 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 @@ -172,13 +230,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,17 +243,20 @@ 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) } } +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: @@ -207,7 +264,7 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware { /// - 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 @@ -215,8 +272,11 @@ public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticStri XCTAssertEqual(lhs.absoluteString, rhs, file: file, line: line) } -struct TestPet: Codable, Equatable { +struct TestPet: Codable, Equatable { var name: String } + +struct TestPetWithPath: Codable, Equatable { var name: String + var path: URL } struct TestPetDetailed: Codable, Equatable { @@ -231,6 +291,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 { @@ -265,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 } } @@ -293,9 +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. @@ -314,10 +397,102 @@ 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) } + +fileprivate extension UInt8 { + var asHex: String { + let original: String + switch self { + 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) + } +} + +/// 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)", file: file, line: line) } +} + +/// Asserts that the data matches the expected value. +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, AS.Element == ArraySlice { + guard let actualBytesBody = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + 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 + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift index a98e91b5..0e611789 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift @@ -16,17 +16,76 @@ import XCTest final class Test_URIDecoder: Test_Runtime { - func testDecoding() throws { - struct Foo: Decodable, Equatable { - var bar: String + 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] } - let decoder = URIDecoder(configuration: .formDataExplode) - let decodedValue = try decoder.decode( - Foo.self, - forKey: "", - from: "bar=hello+world" + _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 } + _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" + ) ) - XCTAssertEqual(decodedValue, Foo(bar: "hello world")) } func testDecoding_structWithOptionalProperty() throws { @@ -36,47 +95,42 @@ 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") + XCTAssertEqual(decodedValue, Foo(baz: 1)) + } + } + + func testDecoding_freeformObject() throws { + let decoder = URIDecoder(configuration: .formDataExplode) do { let decodedValue = try decoder.decode( - Foo.self, + OpenAPIObjectContainer.self, forKey: "", - from: "baz=1" + from: "baz=1&bar=hello+world&bar=goodbye+world" + ) + XCTAssertEqual( + decodedValue, + try .init(unvalidatedValue: ["bar": ["hello world", "goodbye world"], "baz": 1]) ) - XCTAssertEqual(decodedValue, Foo(baz: 1)) } } 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 +139,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,12 +148,58 @@ 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") } } } + +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 8a67ac0e..6ce7037b 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 @@ -30,128 +36,75 @@ 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") + + // A struct with an array property. try test( - ["foo": ["bar"]], - SimpleStruct(foo: "bar"), + "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, explode: Bool = true, - file: StaticString = #file, + file: StaticString = #filePath, 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 - ) + 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..fd5cdd20 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift @@ -17,15 +17,17 @@ 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") } + 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/Encoding/Test_URIValueToNodeEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift index d6967014..80759c62 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,134 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { var val: SimpleEnum? } - struct NestedStruct: Encodable { - var simple: SimpleStruct + struct StructWithArray: Encodable { + var foo: String + var bar: [Int]? + var val: [String] } + 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"))]) + ), + + // A struct with an array property. + makeCase( + StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]), .dictionary([ "foo": .primitive(.string("bar")), - "val": .primitive(.string("foo")), + "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")), - .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..678cf794 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -14,215 +14,623 @@ 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, - ] - func testParsing() throws { - let cases: [Case] = [ - makeCase( - .init( - formExplode: "empty=", - formUnexplode: "empty=", - simpleExplode: .custom("", value: ["": [""]]), - simpleUnexplode: .custom("", value: ["": [""]]), - formDataExplode: "empty=", - formDataUnexplode: "empty=" - ), - value: [ - "empty": [""] - ] - ), - makeCase( - .init( - formExplode: "", - formUnexplode: "", - simpleExplode: .custom("", value: ["": [""]]), - simpleUnexplode: .custom("", value: ["": [""]]), - formDataExplode: "", - formDataUnexplode: "" - ), - 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" - ), - 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" - ), - 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" - ), - value: [ - "list": ["red", "green", "blue"] - ] - ), - makeCase( - .init( - formExplode: "comma=%2C&dot=.&semi=%3B", - formUnexplode: .custom( - "keys=comma,%2C,dot,.,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] - ), - simpleExplode: "comma=%2C,dot=.,semi=%3B", - simpleUnexplode: .custom( - "comma,%2C,dot,.,semi,%3B", - value: ["": ["comma", ",", "dot", ".", "semi", ";"]] - ), - formDataExplode: "comma=%2C&dot=.&semi=%3B", - formDataUnexplode: .custom( - "keys=comma,%2C,dot,.,semi,%3B", - value: ["keys": ["comma", ",", "dot", ".", "semi", ";"]] - ) + // 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"), + ] ), - 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[...] - ) - let parsedNode = try parser.parseRoot() - XCTAssertEqual( - parsedNode, - input.valueOverride ?? testCase.value, - "Failed for config: \(variant.name)", - file: testCase.file, - line: testCase.line + dictionary: .assert( + "R=100&G=200&G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] ) - } - 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) - } + ), + 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 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) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } - struct Variants { - - struct Input: ExpressibleByStringLiteral { - var string: String - var valueOverride: URIParsedNode? - - init(string: String, valueOverride: URIParsedNode? = nil) { - self.string = string - self.valueOverride = valueOverride - } - - static func custom(_ string: String, value: URIParsedNode) -> Self { - .init(string: string, valueOverride: value) - } - - init(stringLiteral value: String) { - self.string = value - self.valueOverride = nil - } + struct RootInput { + var string: String + enum ExpectedResult { + case success(RootType) + case failure((ParsingError) -> Void) } + var result: ExpectedResult + 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 var simpleUnexplode: Input var formDataExplode: Input var formDataUnexplode: Input + var deepObjectExplode: Input } var variants: Variants - var value: URIParsedNode var file: StaticString = #file var line: UInt = #line } - func makeCase( - _ variants: Case.Variants, - value: URIParsedNode, + + 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 - ) -> Case { - .init( - variants: variants, - value: value, + ) 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 1e25109b..a42c9913 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -16,15 +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( @@ -36,7 +27,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "empty=", - formDataUnexplode: "empty=" + formDataUnexplode: "empty=", + deepObjectExplode: .custom("empty=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -48,7 +40,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( @@ -60,7 +53,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( @@ -72,7 +66,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( @@ -84,7 +79,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( @@ -96,15 +95,15 @@ 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( - 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", @@ -112,40 +111,59 @@ 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( value: .dictionary([ - "semi": .primitive(.string(";")), - "dot": .primitive(.string(".")), + "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" + 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" ) ), ] 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) @@ -153,6 +171,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) } } } @@ -163,38 +182,37 @@ 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) + 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 @@ -208,13 +226,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..ac1fc00f 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 { @@ -30,9 +32,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 +54,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, @@ -102,7 +96,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "root=", - formDataUnexplode: "root=" + formDataUnexplode: "root=", + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -116,7 +111,8 @@ 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("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -130,7 +126,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "red", simpleUnexplode: "red", formDataExplode: "root=red", - formDataUnexplode: "root=red" + formDataUnexplode: "root=red", + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -144,7 +141,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "1234", simpleUnexplode: "1234", formDataExplode: "root=1234", - formDataUnexplode: "root=1234" + formDataUnexplode: "root=1234", + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -158,7 +156,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("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -172,7 +171,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "true", simpleUnexplode: "true", formDataExplode: "root=true", - formDataUnexplode: "root=true" + formDataUnexplode: "root=true", + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -186,7 +186,8 @@ 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("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -200,16 +201,14 @@ 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("", expectedError: .deepObjectsArrayNotSupported) ) ) // 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", @@ -217,7 +216,8 @@ 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("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -228,10 +228,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: [""]), - simpleUnexplode: .custom("", value: [""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -245,19 +246,14 @@ 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("", expectedError: .deepObjectsArrayNotSupported) ) ) // 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", @@ -265,16 +261,16 @@ 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" ) ) // 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", @@ -282,13 +278,12 @@ 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("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( - AnyOf( - value2: .green - ), + AnyOf(value2: .green), key: "root", .init( formExplode: "root=green", @@ -296,13 +291,12 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "green", simpleUnexplode: "green", formDataExplode: "root=green", - formDataUnexplode: "root=green" + formDataUnexplode: "root=green", + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) try _test( - AnyOf( - value3: .init(foo: "bar") - ), + AnyOf(value3: .init(foo: "bar")), key: "root", .init( formExplode: "foo=bar", @@ -310,7 +304,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" ) ) @@ -325,7 +320,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) @@ -339,7 +335,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" ) ) @@ -350,10 +347,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: ["": ""]), - simpleUnexplode: .custom("", value: ["": ""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) } @@ -362,48 +360,33 @@ 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) + 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) + .init(string: string, customValue: value, expectedError: nil) + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, customValue: nil, expectedError: expectedError) } } @@ -413,73 +396,54 @@ final class Test_URICodingRoundtrip: Test_Runtime { var simpleUnexplode: Input var formDataExplode: Input var formDataUnexplode: Input + var deepObjectExplode: Input } func _test( _ 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 { + 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 - ) - 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, 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..38462a6a 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift @@ -59,4 +59,19 @@ extension URICoderConfiguration { spaceEscapingCharacter: .plus, dateTranscoder: defaultDateTranscoder ) + + static let deepObjectExplode: Self = .init( + style: .deepObject, + explode: true, + spaceEscapingCharacter: .percentEncoded, + 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[...] }) } } diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 76bb652d..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -ARG swift_version=5.8 -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 d031df5a..00000000 --- a/docker/docker-compose.2204.510.yaml +++ /dev/null @@ -1,18 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.10 - build: - args: - base_image: "swiftlang/swift:nightly-5.10-jammy" - - 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.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.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.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 000a5ad0..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.58.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: /bin/bash -xcl "swift -version && uname -a && ./scripts/soundness.sh" - - test: - <<: *common - command: /bin/bash -xcl "swift $${SWIFT_TEST_VERB-test} $${WARN_AS_ERROR_ARG-} $${SANITIZER_ARG-} $${IMPORT_CHECK_ARG-} $${STRICT_CONCURRENCY_ARG-}" - - shell: - <<: *common - entrypoint: /bin/bash - - integration-test: - <<: *common - command: /bin/bash -xcl "swift -version && uname -a && bash ./scripts/run-integration-test.sh" - - docc-test: - <<: *common - command: /bin/bash -xcl "swift -version && uname -a && bash ./scripts/check-for-docc-warnings.sh" - 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 d2ce9812..00000000 --- a/scripts/check-for-breaking-api-changes.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env 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 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." diff --git a/scripts/check-for-broken-symlinks.sh b/scripts/check-for-broken-symlinks.sh deleted file mode 100644 index 4df8c92b..00000000 --- a/scripts/check-for-broken-symlinks.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env 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 88215d49..00000000 --- a/scripts/check-for-docc-warnings.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env 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 94f79dfc..00000000 --- a/scripts/check-for-unacceptable-language.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env 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 f4d2ae3d..00000000 --- a/scripts/check-license-headers.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env 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)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 '#!/usr/bin/env 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 99f072e0..00000000 --- a/scripts/generate-contributors-list.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env 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-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 deleted file mode 100755 index eefa5850..00000000 --- a/scripts/run-swift-format.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env 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 45ee0f30..00000000 --- a/scripts/soundness.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env 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