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/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/.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/Package.swift b/Package.swift index fcdf464f..58fc1d4d 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "swift-openapi-runtime", platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) + .macOS(.v10_15), .macCatalyst(.v13), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) ], products: [ .library( @@ -34,7 +34,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-http-types", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: [ .target( @@ -51,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/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 fb19799f..2f1b5e21 100644 --- a/Sources/OpenAPIRuntime/Base/Acceptable.swift +++ b/Sources/OpenAPIRuntime/Base/Acceptable.swift @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import Foundation /// The protocol that all generated `AcceptableContentType` enums conform to. public protocol AcceptableProtocol: RawRepresentable, Sendable, Hashable, CaseIterable where RawValue == String {} diff --git a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift b/Sources/OpenAPIRuntime/Base/ByteUtilities.swift similarity index 81% rename from Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift rename to Sources/OpenAPIRuntime/Base/ByteUtilities.swift index 9ae1c6a5..039c03f2 100644 --- a/Sources/OpenAPIRuntime/Multipart/ByteUtilities.swift +++ b/Sources/OpenAPIRuntime/Base/ByteUtilities.swift @@ -24,6 +24,9 @@ enum ASCII { /// The line feed `` character. static let lf: UInt8 = 0x0a + /// The record separator `` character. + static let rs: UInt8 = 0x1e + /// The colon `:` character. static let colon: UInt8 = 0x3a @@ -122,3 +125,34 @@ extension RandomAccessCollection where Element: Equatable { return .noMatch } } + +/// A value returned by the `matchOfOneOf` method. +enum MatchOfOneOfResult { + + /// No match found at any position in self. + case noMatch + + /// The first option matched. + case first(C.Index) + + /// The second option matched. + case second(C.Index) +} + +extension RandomAccessCollection where Element: Equatable { + /// Returns the index of the first match of one of two elements. + /// - Parameters: + /// - first: The first element to match. + /// - second: The second element to match. + /// - Returns: The result. + func matchOfOneOf(first: Element, second: Element) -> MatchOfOneOfResult { + var index = startIndex + while index < endIndex { + let element = self[index] + if element == first { return .first(index) } + if element == second { return .second(index) } + formIndex(after: &index) + } + return .noMatch + } +} diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index c0b25074..11a43dc0 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import Foundation /// A parsed representation of the `content-disposition` header described by RFC 6266 containing only /// the features relevant to OpenAPI multipart bodies. diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index 6dc2a730..3d7adef8 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -14,10 +14,13 @@ import Foundation /// A container for a parsed, valid MIME type. -@_spi(Generated) public struct OpenAPIMIMEType: Equatable { +@_spi(Generated) public struct OpenAPIMIMEType: Equatable, Sendable { + + /// XML MIME type + public static let xml: OpenAPIMIMEType = .init(kind: .concrete(type: "application", subtype: "xml")) /// The kind of the MIME type. - public enum Kind: Equatable { + public enum Kind: Equatable, Sendable { /// Any, spelled as `*/*`. case any diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift b/Sources/OpenAPIRuntime/Base/OpenAPIValue.swift index 0d39a6c4..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 @@ -62,6 +74,9 @@ public struct OpenAPIValueContainer: Codable, Hashable, Sendable { /// - Throws: When the value is not supported. static func tryCast(_ value: (any Sendable)?) throws -> (any Sendable)? { guard let value = value else { return nil } + #if canImport(Foundation) + if value is NSNull { return value } + #endif if let array = value as? [(any Sendable)?] { return try array.map(tryCast(_:)) } if let dictionary = value as? [String: (any Sendable)?] { return try dictionary.mapValues(tryCast(_:)) } if let value = tryCastPrimitiveType(value) { return value } @@ -99,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, @@ -118,27 +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: try container.encode(value) - case let value as Int: try container.encode(value) - case let value as Double: try container.encode(value) - case let value as String: try container.encode(value) + case let value as Bool: + var container = encoder.singleValueContainer() + try container.encode(value) + case let value as Int: + var container = encoder.singleValueContainer() + try container.encode(value) + case let value as Double: + var container = encoder.singleValueContainer() + try container.encode(value) + case let value as String: + var container = encoder.singleValueContainer() + try container.encode(value) case let value as [(any Sendable)?]: - try container.encode(value.map(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.unkeyedContainer() + for item in value { + let containerItem = OpenAPIValueContainer(validatedValue: item) + try container.encode(containerItem) + } case let value as [String: (any Sendable)?]: - try container.encode(value.mapValues(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.container(keyedBy: StringKey.self) + for (itemKey, itemValue) in value { + try container.encode(OpenAPIValueContainer(validatedValue: itemValue), forKey: StringKey(itemKey)) + } default: throw EncodingError.invalidValue( value, - .init(codingPath: container.codingPath, debugDescription: "OpenAPIValueContainer cannot be encoded") + .init(codingPath: encoder.codingPath, debugDescription: "OpenAPIValueContainer cannot be encoded") ) } } + #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 @@ -292,36 +384,29 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { // MARK: Decodable - /// Creates an `OpenAPIValueContainer` by decoding it from a single-value container in a given decoder. - /// - /// - Parameter decoder: The decoder used to decode the container. - /// - Throws: An error if the decoding process encounters an issue or if the data does not match the expected format. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let item = try container.decode([String: OpenAPIValueContainer].self) - self.init(validatedValue: item.mapValues(\.value)) + let container = try decoder.container(keyedBy: StringKey.self) + let keyValuePairs = try container.allKeys.map { key -> (String, (any Sendable)?) in + let item = try container.decode(OpenAPIValueContainer.self, forKey: key) + return (key.stringValue, item.value) + } + self.init(validatedValue: Dictionary(uniqueKeysWithValues: keyValuePairs)) } // MARK: Encodable - /// Encodes the `OpenAPIValueContainer` into a format that can be stored or transmitted via the given encoder. - /// - /// - Parameter encoder: The encoder used to perform the encoding. - /// - Throws: An error if the encoding process encounters an issue or if the data does not match the expected format. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(value.mapValues(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.container(keyedBy: StringKey.self) + for (itemKey, itemValue) in value { + try container.encode(OpenAPIValueContainer(validatedValue: itemValue), forKey: StringKey(itemKey)) + } } // MARK: Equatable - /// Compares two `OpenAPIObjectContainer` instances for equality by comparing their inner key-value dictionaries. - /// - /// - Parameters: - /// - lhs: The left-hand side `OpenAPIObjectContainer` to compare. - /// - rhs: The right-hand side `OpenAPIObjectContainer` to compare. - /// - /// - Returns: `true` if the `OpenAPIObjectContainer` instances are equal, `false` otherwise. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public static func == (lhs: OpenAPIObjectContainer, rhs: OpenAPIObjectContainer) -> Bool { let lv = lhs.value let rv = rhs.value @@ -336,9 +421,7 @@ public struct OpenAPIObjectContainer: Codable, Hashable, Sendable { // MARK: Hashable - /// Hashes the `OpenAPIObjectContainer` instance into the provided `Hasher`. - /// - /// - Parameter hasher: The `Hasher` into which the hash value is combined. + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation public func hash(into hasher: inout Hasher) { for (key, itemValue) in value { hasher.combine(key) @@ -409,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 @@ -421,8 +509,11 @@ public struct OpenAPIArrayContainer: Codable, Hashable, Sendable { /// - Parameter encoder: The encoder to use for encoding the array of values. /// - Throws: An error if the encoding process fails. public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(value.map(OpenAPIValueContainer.init(validatedValue:))) + var container = encoder.unkeyedContainer() + for item in value { + let containerItem = OpenAPIValueContainer(validatedValue: item) + try container.encode(containerItem) + } } // MARK: Equatable diff --git a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift index 5aa893bf..e91954a2 100644 --- a/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CodableExtensions.swift @@ -102,7 +102,6 @@ /// - Parameter additionalProperties: A container of additional properties. /// - Throws: An error if there are issues with encoding the additional properties. public func encodeAdditionalProperties(_ additionalProperties: OpenAPIObjectContainer) throws { - guard !additionalProperties.value.isEmpty else { return } var container = container(keyedBy: StringKey.self) for (key, value) in additionalProperties.value { try container.encode(OpenAPIValueContainer(unvalidatedValue: value), forKey: .init(key)) @@ -116,7 +115,6 @@ /// - Parameter additionalProperties: A container of additional properties. /// - Throws: An error if there are issues with encoding the additional properties. public func encodeAdditionalProperties(_ additionalProperties: [String: T]) throws { - guard !additionalProperties.isEmpty else { return } var container = container(keyedBy: StringKey.self) for (key, value) in additionalProperties { try container.encode(value, forKey: .init(key)) } } @@ -144,7 +142,7 @@ } /// A freeform String coding key for decoding undocumented values. -private struct StringKey: CodingKey, Hashable, Comparable { +internal struct StringKey: CodingKey, Hashable, Comparable { var stringValue: String var intValue: Int? { Int(stringValue) } diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 6cff9130..2ee7ab00 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -25,14 +25,37 @@ public protocol DateTranscoder: Sendable { } /// A transcoder for dates encoded as an ISO-8601 string (in RFC 3339 format). -public struct ISO8601DateTranscoder: DateTranscoder { +public struct ISO8601DateTranscoder: DateTranscoder, @unchecked Sendable { + + /// The lock protecting the formatter. + private let lock: NSLock + + /// The underlying date formatter. + private let locked_formatter: ISO8601DateFormatter + + /// Creates a new transcoder with the provided options. + /// - Parameter options: Options to override the default ones. If you provide nil here, the default options + /// are used. + public init(options: ISO8601DateFormatter.Options? = nil) { + let formatter = ISO8601DateFormatter() + if let options { formatter.formatOptions = options } + lock = NSLock() + lock.name = "com.apple.swift-openapi-generator.runtime.ISO8601DateTranscoder" + locked_formatter = formatter + } /// Creates and returns an ISO 8601 formatted string representation of the specified date. - public func encode(_ date: Date) throws -> String { ISO8601DateFormatter().string(from: date) } + public func encode(_ date: Date) throws -> String { + lock.lock() + defer { lock.unlock() } + return locked_formatter.string(from: date) + } /// Creates and returns a date object from the specified ISO 8601 formatted string representation. public func decode(_ dateString: String) throws -> Date { - guard let date = ISO8601DateFormatter().date(from: dateString) else { + lock.lock() + defer { lock.unlock() } + guard let date = locked_formatter.date(from: dateString) else { throw DecodingError.dataCorrupted( .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.") ) @@ -44,6 +67,11 @@ public struct ISO8601DateTranscoder: DateTranscoder { extension DateTranscoder where Self == ISO8601DateTranscoder { /// A transcoder that transcodes dates as ISO-8601–formatted string (in RFC 3339 format). public static var iso8601: Self { ISO8601DateTranscoder() } + + /// A transcoder that transcodes dates as ISO-8601–formatted string (in RFC 3339 format) with fractional seconds. + public static var iso8601WithFractionalSeconds: Self { + ISO8601DateTranscoder(options: [.withInternetDateTime, .withFractionalSeconds]) + } } extension JSONEncoder.DateEncodingStrategy { @@ -68,26 +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. /// /// - 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, - multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + 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 ea575002..28abbdb2 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -127,6 +127,50 @@ extension Converter { convert: convertBodyCodableToJSON ) } + /// Sets an optional request body as XML in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The optional value to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// + /// - Returns: An `HTTPBody` representing the XML-encoded request body, or `nil` if the `value` is `nil`. + /// + /// - Throws: An error if setting the request body as XML fails. + public func setOptionalRequestBodyAsXML( + _ value: T?, + headerFields: inout HTTPFields, + contentType: String + ) throws -> HTTPBody? { + try setOptionalRequestBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } + /// Sets a required request body as XML in the specified header fields and returns an `HTTPBody`. + /// + /// - Parameters: + /// - value: The value to be set as the request body. + /// - headerFields: The header fields in which to set the content type. + /// - contentType: The content type to be set in the header fields. + /// + /// - Returns: An `HTTPBody` representing the XML-encoded request body. + /// + /// - Throws: An error if setting the request body as XML fails. + public func setRequiredRequestBodyAsXML( + _ value: T, + headerFields: inout HTTPFields, + contentType: String + ) throws -> HTTPBody { + try setRequiredRequestBody( + value, + headerFields: &headerFields, + contentType: contentType, + convert: convertBodyCodableToXML + ) + } /// Sets an optional request body as binary in the specified header fields and returns an `HTTPBody`. /// @@ -275,6 +319,29 @@ extension Converter { convert: convertJSONToBodyCodable ) } + /// Retrieves the response body as XML and transforms it into a specified type. + /// + /// - Parameters: + /// - type: The type to decode the XML into. + /// - data: The HTTP body data containing the XML. + /// - transform: A transformation function to apply to the decoded XML. + /// + /// - Returns: The transformed result of type `C`. + /// + /// - Throws: An error if retrieving or transforming the response body fails. + public func getResponseBodyAsXML( + _ type: T.Type, + from data: HTTPBody?, + transforming transform: (T) -> C + ) async throws -> C { + guard let data else { throw RuntimeError.missingRequiredResponseBody } + return try await getBufferingResponseBody( + type, + from: data, + transforming: transform, + convert: convertXMLToBodyCodable + ) + } /// Retrieves the response body as binary data and transforms it into a specified type. /// diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift index dc908e75..73f8fecb 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Common.swift @@ -61,7 +61,12 @@ extension Converter { // The force unwrap is safe, we only get here if the array is not empty. let bestOption = evaluatedOptions.max { a, b in a.match.score < b.match.score }! let bestContentType = bestOption.contentType - if case .incompatible = bestOption.match { throw RuntimeError.unexpectedContentTypeHeader(bestContentType) } + if case .incompatible = bestOption.match { + throw RuntimeError.unexpectedContentTypeHeader( + expected: bestContentType, + received: String(describing: received) + ) + } return bestContentType } diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index e8f36306..a3088bd3 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -56,14 +56,21 @@ extension Converter { // Drop everything after the optional semicolon (q, extensions, ...) value.split(separator: ";")[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - if acceptValues.isEmpty { return } - if acceptValues.contains("*/*") { return } - if acceptValues.contains("\(substring.split(separator: "/")[0].lowercased())/*") { return } - if acceptValues.contains(where: { $0.localizedCaseInsensitiveContains(substring) }) { return } + guard let parsedSubstring = OpenAPIMIMEType(substring) else { + throw RuntimeError.invalidAcceptSubstring(substring) + } + // Look for the first match. + for acceptValue in acceptValues { + // Fast path. + if acceptValue == substring { return } + guard let parsedAcceptValue = OpenAPIMIMEType(acceptValue) else { + throw RuntimeError.invalidExpectedContentType(acceptValue) + } + if parsedSubstring.satisfies(acceptValue: parsedAcceptValue) { return } + } throw RuntimeError.unexpectedAcceptHeader(acceptHeader) } - /// Retrieves and decodes a path parameter as a URI-encoded value of the specified type. /// /// - Parameters: @@ -214,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: @@ -347,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. /// @@ -410,3 +476,27 @@ extension Converter { ) } } + +fileprivate extension OpenAPIMIMEType { + /// Checks if the type satisfies the provided Accept header value. + /// - Parameter acceptValue: A parsed Accept header MIME type. + /// - Returns: `true` if it satisfies the Accept header, `false` otherwise. + func satisfies(acceptValue: OpenAPIMIMEType) -> Bool { + switch (acceptValue.kind, self.kind) { + case (.concrete, .any), (.concrete, .anySubtype), (.anySubtype, .any): + // The response content-type must be at least as specific as the accept header. + return false + case (.any, _): + // Accept: */* -- Any content-type satisfies the accept header. + return true + case (.anySubtype(let acceptType), .anySubtype(let substringType)), + (.anySubtype(let acceptType), .concrete(let substringType, _)): + // Accept: type/* -- The content-type should match the partially-specified accept header. + return acceptType.lowercased() == substringType.lowercased() + case (.concrete(let acceptType, let acceptSubtype), .concrete(let substringType, let substringSubtype)): + // Accept: type/subtype -- The content-type should match the concrete type. + return acceptType.lowercased() == substringType.lowercased() + && acceptSubtype.lowercased() == substringSubtype.lowercased() + } + } +} diff --git a/Sources/OpenAPIRuntime/Conversion/Converter.swift b/Sources/OpenAPIRuntime/Conversion/Converter.swift index 69223da5..606790f7 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter.swift @@ -38,7 +38,7 @@ import class Foundation.JSONDecoder self.configuration = configuration self.encoder = JSONEncoder() - self.encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + self.encoder.outputFormatting = .init(configuration.jsonEncodingOptions) self.encoder.dateEncodingStrategy = .from(dateTranscoder: configuration.dateTranscoder) self.headerFieldEncoder = JSONEncoder() @@ -49,3 +49,14 @@ import class Foundation.JSONDecoder self.decoder.dateDecodingStrategy = .from(dateTranscoder: configuration.dateTranscoder) } } + +extension JSONEncoder.OutputFormatting { + /// Creates a new value. + /// - Parameter options: The JSON encoding options to represent. + init(_ options: JSONEncodingOptions) { + self.init() + if options.contains(.prettyPrinted) { formUnion(.prettyPrinted) } + if options.contains(.sortedKeys) { formUnion(.sortedKeys) } + if options.contains(.withoutEscapingSlashes) { formUnion(.withoutEscapingSlashes) } + } +} diff --git a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift index df6caf04..fc50b2a1 100644 --- a/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/CurrencyExtensions.swift @@ -29,7 +29,9 @@ extension ParameterStyle { ) { let resolvedStyle = style ?? .defaultForQueryItems let resolvedExplode = explode ?? ParameterStyle.defaultExplodeFor(forStyle: resolvedStyle) - guard resolvedStyle == .form else { + switch resolvedStyle { + case .form, .deepObject: break + default: throw RuntimeError.unsupportedParameterStyle( name: name, location: .query, @@ -144,6 +146,32 @@ extension Converter { return HTTPBody(data) } + /// Returns a value decoded from a XML body. + /// - Parameter body: The body containing the raw XML bytes. + /// - Returns: A decoded value. + /// - Throws: An error if decoding from the body fails. + /// - Throws: An error if no custom coder is present for XML coding. + func convertXMLToBodyCodable(_ body: HTTPBody) async throws -> T { + guard let coder = configuration.xmlCoder else { + throw RuntimeError.missingCoderForCustomContentType(contentType: OpenAPIMIMEType.xml.description) + } + let data = try await Data(collecting: body, upTo: .max) + return try coder.customDecode(T.self, from: data) + } + + /// Returns a XML body for the provided encodable value. + /// - Parameter value: The value to encode as XML. + /// - Returns: The raw XML body. + /// - Throws: An error if encoding to XML fails. + /// - Throws: An error if no custom coder is present for XML coding. + func convertBodyCodableToXML(_ value: T) throws -> HTTPBody { + guard let coder = configuration.xmlCoder else { + throw RuntimeError.missingCoderForCustomContentType(contentType: OpenAPIMIMEType.xml.description) + } + let data = try coder.customEncode(value) + return HTTPBody(data) + } + /// Returns a value decoded from a URL-encoded form body. /// - Parameter body: The body containing the raw URL-encoded form bytes. /// - Returns: A decoded value. @@ -435,8 +463,10 @@ extension Converter { contentType: String, convert: (T) throws -> HTTPBody ) rethrows -> HTTPBody { + let body = try convert(value) headerFields[.contentType] = contentType - return try convert(value) + if case let .known(length) = body.length { headerFields[.contentLength] = String(length) } + return body } /// Sets the provided request body and the appropriate content type. @@ -597,8 +627,10 @@ extension Converter { contentType: String, convert: (T) throws -> HTTPBody ) rethrows -> HTTPBody { + let body = try convert(value) headerFields[.contentType] = contentType - return try convert(value) + if case let .known(length) = body.length { headerFields[.contentLength] = String(length) } + return body } /// Returns a decoded value for the provided path parameter. diff --git a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift index 9d21513c..b8e32edc 100644 --- a/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift @@ -114,12 +114,18 @@ struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible { var description: String { let combinedDescription = errors.map { error in - guard let error = error as? (any PrettyStringConvertible) else { return error.localizedDescription } + guard let error = error as? (any PrettyStringConvertible) else { return "\(error)" } return error.prettyDescription } .enumerated().map { ($0.offset + 1, $0.element) }.map { "Error \($0.0): [\($0.1)]" }.joined(separator: ", ") return "MultiError (contains \(errors.count) error\(errors.count == 1 ? "" : "s")): \(combinedDescription)" } - var errorDescription: String? { description } + var errorDescription: String? { + if let first = errors.first { + return "Multiple errors encountered, first one: \(first.localizedDescription)." + } else { + return "No errors" + } + } } diff --git a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift index fb95bce7..31dda63c 100644 --- a/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift +++ b/Sources/OpenAPIRuntime/Conversion/ParameterStyles.swift @@ -14,7 +14,7 @@ /// The serialization style used by a parameter. /// -/// Details: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields-10 +/// Details: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#fixed-fields-10 @_spi(Generated) public enum ParameterStyle: Sendable { /// The form style. @@ -26,6 +26,11 @@ /// /// 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 { @@ -53,6 +58,7 @@ extension URICoderConfiguration.Style { switch style { case .form: self = .form case .simple: self = .simple + case .deepObject: self = .deepObject } } } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index bf030d1c..2ce41750 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -22,3 +22,74 @@ extension UndocumentedPayload { self.init(headerFields: [:], body: nil) } } + +extension Configuration { + /// Creates a new configuration with the specified values. + /// + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date + /// and string values. + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + @available(*, deprecated, renamed: "init(dateTranscoder:multipartBoundaryGenerator:xmlCoder:)") @_disfavoredOverload + public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random + ) { + self.init(dateTranscoder: dateTranscoder, multipartBoundaryGenerator: multipartBoundaryGenerator, xmlCoder: nil) + } + + /// Creates a new configuration with the specified values. + /// + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date + /// and string values. + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. + @available(*, deprecated, renamed: "init(dateTranscoder:jsonEncodingOptions:multipartBoundaryGenerator:xmlCoder:)") + @_disfavoredOverload public init( + dateTranscoder: any DateTranscoder = .iso8601, + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, + xmlCoder: (any CustomCoder)? = nil + ) { + self.init( + dateTranscoder: dateTranscoder, + jsonEncodingOptions: [.sortedKeys, .prettyPrinted], + multipartBoundaryGenerator: multipartBoundaryGenerator, + xmlCoder: xmlCoder + ) + } +} + +extension AsyncSequence where Element == ArraySlice, Self: Sendable { + /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. + /// + /// Use this method if the event's `data` field is not JSON, or if you don't want to parse it using `asDecodedServerSentEventsWithJSONData`. + /// - Returns: A sequence that provides the events. + @available(*, deprecated, renamed: "asDecodedServerSentEvents(while:)") @_disfavoredOverload + public func asDecodedServerSentEvents() -> ServerSentEventsDeserializationSequence< + ServerSentEventsLineDeserializationSequence + > { asDecodedServerSentEvents(while: { _ in true }) } + /// Returns another sequence that decodes each event's data as the provided type using the provided decoder. + /// + /// Use this method if the event's `data` field is JSON. + /// - Parameters: + /// - dataType: The type to decode the JSON data into. + /// - decoder: The JSON decoder to use. + /// - Returns: A sequence that provides the events with the decoded JSON data. + @available(*, deprecated, renamed: "asDecodedServerSentEventsWithJSONData(of:decoder:while:)") @_disfavoredOverload + public func asDecodedServerSentEventsWithJSONData( + of dataType: JSONDataType.Type = JSONDataType.self, + decoder: JSONDecoder = .init() + ) -> AsyncThrowingMapSequence< + ServerSentEventsDeserializationSequence>, + ServerSentEventWithJSONData + > { asDecodedServerSentEventsWithJSONData(of: dataType, decoder: decoder, while: { _ in true }) } +} + +extension ServerSentEventsDeserializationSequence { + /// Creates a new sequence. + /// - Parameter upstream: The upstream sequence of arbitrary byte chunks. + @available(*, deprecated, renamed: "init(upstream:while:)") @_disfavoredOverload public init(upstream: Upstream) { + self.init(upstream: upstream, while: { _ in true }) + } +} diff --git a/Sources/OpenAPIRuntime/Errors/ClientError.swift b/Sources/OpenAPIRuntime/Errors/ClientError.swift index 90481bff..eb0c8005 100644 --- a/Sources/OpenAPIRuntime/Errors/ClientError.swift +++ b/Sources/OpenAPIRuntime/Errors/ClientError.swift @@ -109,9 +109,7 @@ public struct ClientError: Error { // MARK: Private fileprivate var underlyingErrorDescription: String { - guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { - return underlyingError.localizedDescription - } + guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { return "\(underlyingError)" } return prettyError.prettyDescription } } @@ -133,5 +131,7 @@ extension ClientError: LocalizedError { /// This computed property provides a localized human-readable description of the client error, which is suitable for displaying to users. /// /// - Returns: A localized string describing the client error. - public var errorDescription: String? { description } + public var errorDescription: String? { + "Client encountered an error invoking the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." + } } diff --git a/Sources/OpenAPIRuntime/Errors/CodingErrors.swift b/Sources/OpenAPIRuntime/Errors/CodingErrors.swift index 30a04c4c..12bdb42c 100644 --- a/Sources/OpenAPIRuntime/Errors/CodingErrors.swift +++ b/Sources/OpenAPIRuntime/Errors/CodingErrors.swift @@ -21,7 +21,7 @@ extension DecodingError: PrettyStringConvertible { case .keyNotFound(let key, let context): output = "keyNotFound \(key) - \(context.prettyDescription)" case .typeMismatch(let type, let context): output = "typeMismatch \(type) - \(context.prettyDescription)" case .valueNotFound(let type, let context): output = "valueNotFound \(type) - \(context.prettyDescription)" - @unknown default: output = "unknown: \(localizedDescription)" + @unknown default: output = "unknown: \(self)" } return "DecodingError: \(output)" } @@ -30,7 +30,7 @@ extension DecodingError: PrettyStringConvertible { extension DecodingError.Context: PrettyStringConvertible { var prettyDescription: String { let path = codingPath.map(\.description).joined(separator: "/") - return "at \(path): \(debugDescription) (underlying error: \(underlyingError?.localizedDescription ?? ""))" + return "at \(path): \(debugDescription) (underlying error: \(underlyingError.map { "\($0)" } ?? ""))" } } @@ -39,7 +39,7 @@ extension EncodingError: PrettyStringConvertible { let output: String switch self { case .invalidValue(let value, let context): output = "invalidValue \(value) - \(context.prettyDescription)" - @unknown default: output = "unknown: \(localizedDescription)" + @unknown default: output = "unknown: \(self)" } return "EncodingError: \(output)" } @@ -48,6 +48,6 @@ extension EncodingError: PrettyStringConvertible { extension EncodingError.Context: PrettyStringConvertible { var prettyDescription: String { let path = codingPath.map(\.description).joined(separator: "/") - return "at \(path): \(debugDescription) (underlying error: \(underlyingError?.localizedDescription ?? ""))" + return "at \(path): \(debugDescription) (underlying error: \(underlyingError.map { "\($0)" } ?? ""))" } } diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 150b804c..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,11 +22,13 @@ 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 @@ -36,7 +39,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Headers case missingRequiredHeaderField(String) - case unexpectedContentTypeHeader(String) + case unexpectedContentTypeHeader(expected: String, received: String) case unexpectedAcceptHeader(String) case malformedAcceptHeader(String) case missingOrMalformedContentDispositionName @@ -84,15 +87,19 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Invalid server variable named: '\(name)', which has the value: '\(value)', but the only allowed values are: \(allowedValues.map { "'\($0)'" }.joined(separator: ", "))" case .invalidExpectedContentType(let string): return "Invalid expected content type: '\(string)'" + case .invalidAcceptSubstring(let string): return "Invalid Accept header content type: '\(string)'" case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'" case .invalidBase64String(let string): return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'" 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 .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: @@ -114,6 +121,10 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Unexpected response body, expected content type: \(expectedContentType), body: \(body)" } } + + // MARK: - LocalizedError + + var errorDescription: String? { description } } /// Throws an error to indicate an unexpected HTTP response status. @@ -135,3 +146,25 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret @_spi(Generated) public func throwUnexpectedResponseBody(expectedContent: String, body: any Sendable) throws -> Never { throw RuntimeError.unexpectedResponseBody(expectedContent: expectedContent, body: body) } + +/// HTTP Response status definition for ``RuntimeError``. +extension RuntimeError: HTTPResponseConvertible { + /// HTTP Status code corresponding to each error case + public var httpStatus: HTTPTypes.HTTPResponse.Status { + switch self { + case .invalidServerURL, .invalidServerVariableValue, .pathUnset: .notFound + case .invalidExpectedContentType, .unexpectedContentTypeHeader: .unsupportedMediaType + case .missingCoderForCustomContentType: .unprocessableContent + case .unexpectedAcceptHeader: .notAcceptable + case .failedToDecodeStringConvertibleValue, .invalidAcceptSubstring, .invalidBase64String, + .invalidHeaderFieldName, .malformedAcceptHeader, .missingMultipartBoundaryContentTypeParameter, + .missingOrMalformedContentDispositionName, .missingRequiredHeaderField, + .missingRequiredMultipartFormDataContentType, .missingRequiredQueryParameter, .missingRequiredPathParameter, + .missingRequiredRequestBody, .unsupportedParameterStyle: + .badRequest + case .handlerFailed, .middlewareFailed, .missingRequiredResponseBody, .transportFailed, + .unexpectedResponseStatus, .unexpectedResponseBody: + .internalServerError + } + } +} diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 92d0552e..13288a9c 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -82,9 +82,7 @@ public struct ServerError: Error { // MARK: Private fileprivate var underlyingErrorDescription: String { - guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { - return underlyingError.localizedDescription - } + guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { return "\(underlyingError)" } return prettyError.prettyDescription } } @@ -106,5 +104,7 @@ extension ServerError: LocalizedError { /// This computed property provides a localized human-readable description of the server error, which is suitable for displaying to users. /// /// - Returns: A localized string describing the server error. - public var errorDescription: String? { description } + public var errorDescription: String? { + "Server encountered an error handling the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." + } } diff --git a/Sources/OpenAPIRuntime/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/CurrencyTypes.swift b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift index 7093bd75..9e3e4542 100644 --- a/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift +++ b/Sources/OpenAPIRuntime/Interface/CurrencyTypes.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import HTTPTypes +import Foundation /// A container for request metadata already parsed and validated /// by the server transport. diff --git a/Sources/OpenAPIRuntime/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 648c504a..59292ec9 100644 --- a/Sources/OpenAPIRuntime/Interface/HTTPBody.swift +++ b/Sources/OpenAPIRuntime/Interface/HTTPBody.swift @@ -159,16 +159,6 @@ public final class HTTPBody: @unchecked Sendable { return locked_iteratorCreated } - /// Verifying that creating another iterator is allowed based on - /// the values of `iterationBehavior` and `locked_iteratorCreated`. - /// - Throws: If another iterator is not allowed to be created. - private func checkIfCanCreateIterator() throws { - lock.lock() - defer { lock.unlock() } - guard iterationBehavior == .single else { return } - if locked_iteratorCreated { throw TooManyIterationsError() } - } - /// Tries to mark an iterator as created, verifying that it is allowed /// based on the values of `iterationBehavior` and `locked_iteratorCreated`. /// - Throws: If another iterator is not allowed to be created. @@ -341,10 +331,12 @@ extension HTTPBody: AsyncSequence { /// Creates and returns an asynchronous iterator /// /// - Returns: An asynchronous iterator for byte chunks. + /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. public func makeAsyncIterator() -> AsyncIterator { - // The crash on error is intentional here. - try! tryToMarkIteratorCreated() - return .init(sequence.makeAsyncIterator()) + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } } } @@ -381,10 +373,6 @@ extension HTTPBody { /// than `maxBytes`. /// - Returns: A byte chunk containing all the accumulated bytes. fileprivate func collect(upTo maxBytes: Int) async throws -> ByteChunk { - - // Check that we're allowed to iterate again. - try checkIfCanCreateIterator() - // If the length is known, verify it's within the limit. if case .known(let knownBytes) = length { guard knownBytes <= maxBytes else { throw TooManyBytesError(maxBytes: maxBytes) } @@ -563,6 +551,9 @@ extension HTTPBody { var iterator = iterator self.produceNext = { try await iterator.next() } } + /// Creates an iterator throwing the given error when iterated. + /// - Parameter error: The error to throw on iteration. + fileprivate init(throwing error: any Error) { self.produceNext = { throw error } } /// Advances the iterator to the next element and returns it asynchronously. /// diff --git a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift index 40e16e8f..0984ed21 100644 --- a/Sources/OpenAPIRuntime/Interface/ServerTransport.swift +++ b/Sources/OpenAPIRuntime/Interface/ServerTransport.swift @@ -114,9 +114,10 @@ public protocol ServerTransport { /// - Important: The `path` can have mixed components, such /// as `/file/{name}.zip`. func register( - _ handler: @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> ( - HTTPResponse, HTTPBody? - ), + _ handler: + @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> ( + HTTPResponse, HTTPBody? + ), method: HTTPRequest.Method, path: String ) throws @@ -197,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/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 4608dafe..4fb6bc82 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -91,8 +91,8 @@ import struct Foundation.URLComponents metadata: ServerRequestMetadata, forOperation operationID: String, using handlerMethod: @Sendable @escaping (APIHandler) -> ((OperationInput) async throws -> OperationOutput), - deserializer: @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> - OperationInput, + deserializer: + @Sendable @escaping (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> OperationInput, serializer: @Sendable @escaping (OperationOutput, HTTPRequest) throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) where OperationInput: Sendable, OperationOutput: Sendable { @Sendable func wrappingErrors(work: () async throws -> R, mapError: (any Error) -> any Error) async throws diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift index 39bc9d21..c3397ba2 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartBoundaryGenerator.swift @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import Foundation /// A generator of a new boundary string used by multipart messages to separate parts. public protocol MultipartBoundaryGenerator: Sendable { diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift index 441c85fd..07538233 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToBytesSequence.swift @@ -90,6 +90,7 @@ struct MultipartSerializer { self.stateMachine = .init() self.outBuffer = [] } + /// Requests the next byte chunk. /// - Parameter fetchFrame: A closure that is called when the serializer is ready to serialize the next frame. /// - Returns: A byte chunk. diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift index c5823ec2..8734b6aa 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartFramesToRawPartsSequence.swift @@ -342,8 +342,17 @@ extension MultipartFramesToRawPartsSequence { switch stateMachine.nextFromPartSequence() { case .returnNil: return nil case .fetchFrame: + let frame: Upstream.AsyncIterator.Element? var upstream = upstream - let frame = try await upstream.next() + #if compiler(>=6.0) + if #available(macOS 15, iOS 18.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) { + frame = try await upstream.next(isolation: self) + } else { + frame = try await upstream.next() + } + #else + frame = try await upstream.next() + #endif self.upstream = upstream switch stateMachine.partReceivedFrame(frame) { case .returnNil: return nil @@ -365,8 +374,17 @@ extension MultipartFramesToRawPartsSequence { switch stateMachine.nextFromBodySubsequence() { case .returnNil: return nil case .fetchFrame: + let frame: Upstream.AsyncIterator.Element? var upstream = upstream - let frame = try await upstream.next() + #if compiler(>=6.0) + if #available(macOS 15, iOS 18.0, tvOS 18.0, watchOS 11.0, macCatalyst 18.0, visionOS 2.0, *) { + frame = try await upstream.next(isolation: self) + } else { + frame = try await upstream.next() + } + #else + frame = try await upstream.next() + #endif self.upstream = upstream switch stateMachine.bodyReceivedFrame(frame) { case .returnNil: return nil diff --git a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift index 2e4234e6..6db356c3 100644 --- a/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift +++ b/Sources/OpenAPIRuntime/Multipart/MultipartPublicTypes.swift @@ -209,16 +209,6 @@ public final class MultipartBody: @unchecked Sendable { var errorDescription: String? { description } } - /// Verifying that creating another iterator is allowed based on the values of `iterationBehavior` - /// and `locked_iteratorCreated`. - /// - Throws: If another iterator is not allowed to be created. - internal func checkIfCanCreateIterator() throws { - lock.lock() - defer { lock.unlock() } - guard iterationBehavior == .single else { return } - if locked_iteratorCreated { throw TooManyIterationsError() } - } - /// Tries to mark an iterator as created, verifying that it is allowed based on the values /// of `iterationBehavior` and `locked_iteratorCreated`. /// - Throws: If another iterator is not allowed to be created. @@ -331,10 +321,12 @@ extension MultipartBody: AsyncSequence { /// Creates and returns an asynchronous iterator /// /// - Returns: An asynchronous iterator for parts. + /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. public func makeAsyncIterator() -> AsyncIterator { - // The crash on error is intentional here. - try! tryToMarkIteratorCreated() - return .init(sequence.makeAsyncIterator()) + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } } } @@ -355,6 +347,10 @@ extension MultipartBody { self.produceNext = { try await iterator.next() } } + /// Creates an iterator throwing the given error when iterated. + /// - Parameter error: The error to throw on iteration. + fileprivate init(throwing error: any Error) { self.produceNext = { throw error } } + /// Advances the iterator to the next element and returns it asynchronously. /// /// - Returns: The next element in the sequence, or `nil` if there are no more elements. diff --git a/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 985b7715..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 @@ -118,6 +132,18 @@ extension URIEncodedNode { } } + /// Marks the node as an array, starting as empty. + /// - Throws: If the node is already set to be anything else but an array. + mutating func markAsArray() throws { + switch self { + case .array: + // Already an array. + break + case .unset: self = .array([]) + default: throw InsertionError.markingExistingNonArrayContainerAsArray + } + } + /// Appends a value to the array node. /// - Parameter childValue: The node to append to the underlying array. /// - Throws: If the node is already set to be anything else but an array. diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift deleted file mode 100644 index 51ecbe2a..00000000 --- a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedNode.swift +++ /dev/null @@ -1,27 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation - -/// The type used for keys by `URIParser`. -typealias URIParsedKey = String.SubSequence - -/// The type used for values by `URIParser`. -typealias URIParsedValue = String.SubSequence - -/// The type used for an array of values by `URIParser`. -typealias URIParsedValueArray = [URIParsedValue] - -/// The type used for a node and a dictionary by `URIParser`. -typealias URIParsedNode = [URIParsedKey: URIParsedValueArray] diff --git a/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift new file mode 100644 index 00000000..23d54e65 --- /dev/null +++ b/Sources/OpenAPIRuntime/URICoder/Common/URIParsedTypes.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// A component of a `URIParsedKey`. +typealias URIParsedKeyComponent = String.SubSequence + +/// A parsed key for a parsed value. +/// +/// For example, `foo=bar` in a `form` string would parse the key as `foo` (single component). +/// In an unexploded `form` string `root=foo,bar`, the key would be `root/foo` (two components). +/// In a `simple` string `bar`, the key would be empty (0 components). +struct URIParsedKey: Hashable { + + /// The individual string components. + let components: [URIParsedKeyComponent] + + /// Creates a new parsed key. + /// - Parameter components: The key components. + init(_ components: [URIParsedKeyComponent]) { self.components = components } + + /// A new empty key. + static var empty: Self { .init([]) } +} + +/// A primitive value produced by `URIParser`. +typealias URIParsedValue = String.SubSequence + +/// A key-value produced by `URIParser`. +struct URIParsedPair: Equatable { + + /// The key of the pair. + /// + /// In `foo=bar`, `foo` is the key. + var key: URIParsedKey + + /// The value of the pair. + /// + /// In `foo=bar`, `bar` is the value. + var value: URIParsedValue +} + +// MARK: - Extensions + +extension URIParsedKey: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + var description: String { + if components.isEmpty { return "" } + return components.joined(separator: "/") + } +} diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift index 72cc077f..3be1dce1 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIDecoder.swift @@ -15,7 +15,7 @@ import Foundation /// A type that decodes a `Decodable` value from an URI-encoded string -/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.3, depending on +/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.4, depending on /// the configuration. /// /// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) @@ -45,6 +45,13 @@ import Foundation /// | `{list\*}` | `red,green,blue` | /// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | /// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +/// +/// [OpenAPI 3.0.4 - Deep object expansion.](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#style-examples) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------------------------------| +/// | `{?keys\*}` | `?keys%5Bsemi%5D=%3B&keys%5Bdot%5D=.&keys%5Bcomma%5D=%2C` | +/// struct URIDecoder: Sendable { /// The configuration instructing the decoder how to interpret the raw @@ -60,10 +67,6 @@ extension URIDecoder { /// Attempt to decode an object from an URI string. /// - /// Under the hood, `URIDecoder` first parses the string into a - /// `URIParsedNode` using `URIParser`, and then uses - /// `URIValueFromNodeDecoder` to decode the `Decodable` value. - /// /// - Parameters: /// - type: The type to decode. /// - key: The key of the decoded value. Only used with certain styles @@ -72,15 +75,12 @@ extension URIDecoder { /// - Returns: The decoded value. /// - Throws: An error if decoding fails, for example, due to incompatible data or key. func decode(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws -> T { - try withCachedParser(from: data) { decoder in try decoder.decode(type, forKey: key) } + let decoder = URIValueFromNodeDecoder(data: data, rootKey: key[...], configuration: configuration) + return try decoder.decodeRoot(type) } /// Attempt to decode an object from an URI string, if present. /// - /// Under the hood, `URIDecoder` first parses the string into a - /// `URIParsedNode` using `URIParser`, and then uses - /// `URIValueFromNodeDecoder` to decode the `Decodable` value. - /// /// - Parameters: /// - type: The type to decode. /// - key: The key of the decoded value. Only used with certain styles @@ -90,76 +90,8 @@ extension URIDecoder { /// - Throws: An error if decoding fails, for example, due to incompatible data or key. func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "", from data: Substring) throws -> T? - { try withCachedParser(from: data) { decoder in try decoder.decodeIfPresent(type, forKey: key) } } - - /// Make multiple decode calls on the parsed URI. - /// - /// Use to avoid repeatedly reparsing the raw string. - /// - Parameters: - /// - data: The URI-encoded string. - /// - calls: The closure that contains 0 or more calls to - /// the `decode` method on `URICachedDecoder`. - /// - Returns: The result of the closure invocation. - /// - Throws: An error if parsing or decoding fails. - func withCachedParser(from data: Substring, calls: (URICachedDecoder) throws -> R) throws -> R { - var parser = URIParser(configuration: configuration, data: data) - let parsedNode = try parser.parseRoot() - let decoder = URICachedDecoder(configuration: configuration, node: parsedNode) - return try calls(decoder) - } -} - -struct URICachedDecoder { - - /// The configuration used by the decoder. - fileprivate let configuration: URICoderConfiguration - - /// The node from which to decode a value on demand. - fileprivate let node: URIParsedNode - - /// Attempt to decode an object from an URI-encoded string. - /// - /// Under the hood, `URICachedDecoder` already has a pre-parsed - /// `URIParsedNode` and uses `URIValueFromNodeDecoder` to decode - /// the `Decodable` value. - /// - /// - Parameters: - /// - type: The type to decode. - /// - key: The key of the decoded value. Only used with certain styles - /// and explode options, ignored otherwise. - /// - Returns: The decoded value. - /// - Throws: An error if decoding fails. - func decode(_ type: T.Type = T.self, forKey key: String = "") throws -> T { - let decoder = URIValueFromNodeDecoder( - node: node, - rootKey: key[...], - style: configuration.style, - explode: configuration.explode, - dateTranscoder: configuration.dateTranscoder - ) - return try decoder.decodeRoot() - } - - /// Attempt to decode an object from an URI-encoded string, if present. - /// - /// Under the hood, `URICachedDecoder` already has a pre-parsed - /// `URIParsedNode` and uses `URIValueFromNodeDecoder` to decode - /// the `Decodable` value. - /// - /// - Parameters: - /// - type: The type to decode. - /// - key: The key of the decoded value. Only used with certain styles - /// and explode options, ignored otherwise. - /// - Returns: The decoded value. - /// - Throws: An error if decoding fails. - func decodeIfPresent(_ type: T.Type = T.self, forKey key: String = "") throws -> T? { - let decoder = URIValueFromNodeDecoder( - node: node, - rootKey: key[...], - style: configuration.style, - explode: configuration.explode, - dateTranscoder: configuration.dateTranscoder - ) - return try decoder.decodeRootIfPresent() + { + let decoder = URIValueFromNodeDecoder(data: data, rootKey: key[...], configuration: configuration) + return try decoder.decodeRootIfPresent(type) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift index fd47d462..3e3990f6 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Keyed.swift @@ -19,9 +19,6 @@ struct URIKeyedDecodingContainer { /// The associated decoder. let decoder: URIValueFromNodeDecoder - - /// The underlying dictionary. - let values: URIParsedNode } extension URIKeyedDecodingContainer { @@ -32,7 +29,7 @@ extension URIKeyedDecodingContainer { /// - Returns: The value found for the provided key. /// - Throws: An error if no value for the key was found. private func _decodeValue(forKey key: Key) throws -> URIParsedValue { - guard let value = values[key.stringValue[...]]?.first else { + guard let value = try decoder.nestedElementInCurrentDictionary(forKey: key.stringValue) else { throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: "Key not found.")) } return value @@ -97,9 +94,9 @@ extension URIKeyedDecodingContainer { extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { - var allKeys: [Key] { values.keys.map { key in Key.init(stringValue: String(key))! } } + var allKeys: [Key] { decoder.elementKeysInCurrentDictionary().compactMap { .init(stringValue: $0) } } - func contains(_ key: Key) -> Bool { values[key.stringValue[...]] != nil } + func contains(_ key: Key) -> Bool { decoder.containsElementInCurrentDictionary(forKey: key.stringValue) } var codingPath: [any CodingKey] { decoder.codingPath } @@ -153,7 +150,7 @@ extension URIKeyedDecodingContainer: KeyedDecodingContainerProtocol { case is UInt64.Type: return try decode(UInt64.self, forKey: key) as! T case is Date.Type: return try decoder.dateTranscoder.decode(String(_decodeValue(forKey: key))) as! T default: - try decoder.push(.init(key)) + decoder.push(.init(key)) defer { decoder.pop() } return try type.init(from: decoder) } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift index 3c829873..2207bd84 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Single.swift @@ -24,7 +24,12 @@ struct URISingleValueDecodingContainer { extension URISingleValueDecodingContainer { /// The underlying value as a single value. - var value: URIParsedValue { get throws { try decoder.currentElementAsSingleValue() } } + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + var value: URIParsedValue? { get throws { try decoder.currentElementAsSingleValue() } } /// Returns the value found in the underlying node converted to /// the provided type. @@ -33,7 +38,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeBinaryFloatingPoint(_: T.Type = T.self) throws -> T { - guard let double = try Double(value) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let double = Double(value) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to Double.") @@ -49,7 +64,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeFixedWidthInteger(_: T.Type = T.self) throws -> T { - guard let parsedValue = try T(value) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let parsedValue = T(value) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") @@ -65,7 +90,17 @@ extension URISingleValueDecodingContainer { /// - Returns: The converted value found. /// - Throws: An error if the conversion failed. private func _decodeLosslessStringConvertible(_: T.Type = T.self) throws -> T { - guard let parsedValue = try T(String(value)) else { + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + guard let parsedValue = T(String(value)) else { throw DecodingError.typeMismatch( T.self, .init(codingPath: codingPath, debugDescription: "Failed to convert to the requested type.") @@ -79,11 +114,23 @@ extension URISingleValueDecodingContainer: SingleValueDecodingContainer { var codingPath: [any CodingKey] { decoder.codingPath } - func decodeNil() -> Bool { false } + func decodeNil() -> Bool { do { return try value == nil } catch { return false } } func decode(_ type: Bool.Type) throws -> Bool { try _decodeLosslessStringConvertible() } - func decode(_ type: String.Type) throws -> String { try String(value) } + func decode(_ type: String.Type) throws -> String { + guard let value = try value else { + throw DecodingError.valueNotFound( + String.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + return String(value) + } func decode(_ type: Double.Type) throws -> Double { try _decodeBinaryFloatingPoint() } @@ -125,7 +172,18 @@ extension URISingleValueDecodingContainer: SingleValueDecodingContainer { case is UInt16.Type: return try decode(UInt16.self) as! T case is UInt32.Type: return try decode(UInt32.self) as! T case is UInt64.Type: return try decode(UInt64.self) as! T - case is Date.Type: return try decoder.dateTranscoder.decode(String(value)) as! T + case is Date.Type: + guard let value = try value else { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context.init( + codingPath: codingPath, + debugDescription: "Value not found.", + underlyingError: nil + ) + ) + } + return try decoder.dateTranscoder.decode(String(value)) as! T default: return try T.init(from: decoder) } } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift index 44a5cd28..72fe4739 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder+Unkeyed.swift @@ -16,24 +16,17 @@ import Foundation /// An unkeyed container used by `URIValueFromNodeDecoder`. struct URIUnkeyedDecodingContainer { - /// The associated decoder. let decoder: URIValueFromNodeDecoder - /// The underlying array. - let values: URIParsedValueArray - - /// The index of the item being currently decoded. - private var index: Int + /// The index of the next item to be decoded. + private(set) var currentIndex: Int /// Creates a new unkeyed container ready to decode the first key. - /// - Parameters: - /// - decoder: The underlying decoder. - /// - values: The underlying array. - init(decoder: URIValueFromNodeDecoder, values: URIParsedValueArray) { + /// - Parameter decoder: The underlying decoder. + init(decoder: URIValueFromNodeDecoder) { self.decoder = decoder - self.values = values - self.index = values.startIndex + self.currentIndex = 0 } } @@ -46,7 +39,7 @@ extension URIUnkeyedDecodingContainer { /// - Throws: An error if the container ran out of items. private mutating func _decodingNext(in work: () throws -> R) throws -> R { guard !isAtEnd else { throw URIValueFromNodeDecoder.GeneralError.reachedEndOfUnkeyedContainer } - defer { values.formIndex(after: &index) } + defer { currentIndex += 1 } return try work() } @@ -55,7 +48,7 @@ extension URIUnkeyedDecodingContainer { /// - Returns: The next value found. /// - Throws: An error if the container ran out of items. private mutating func _decodeNext() throws -> URIParsedValue { - try _decodingNext { [values, index] in values[index] } + try _decodingNext { [decoder, currentIndex] in try decoder.nestedElementInCurrentArray(atIndex: currentIndex) } } /// Returns the next value converted to the provided type. @@ -111,11 +104,9 @@ extension URIUnkeyedDecodingContainer { extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { - var count: Int? { values.count } - - var isAtEnd: Bool { index == values.endIndex } + var count: Int? { try? decoder.countOfCurrentArray() } - var currentIndex: Int { index } + var isAtEnd: Bool { currentIndex == count } var codingPath: [any CodingKey] { decoder.codingPath } @@ -168,7 +159,7 @@ extension URIUnkeyedDecodingContainer: UnkeyedDecodingContainer { case is Date.Type: return try decoder.dateTranscoder.decode(String(_decodeNext())) as! T default: return try _decodingNext { [decoder, currentIndex] in - try decoder.push(.init(intValue: currentIndex)) + decoder.push(.init(intValue: currentIndex)) defer { decoder.pop() } return try type.init(from: decoder) } diff --git a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift index 55982755..83c77c90 100644 --- a/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Decoding/URIValueFromNodeDecoder.swift @@ -14,46 +14,54 @@ import Foundation -/// A type that allows decoding `Decodable` values from a `URIParsedNode`. +/// A type that allows decoding `Decodable` values from a URI-encoded string. final class URIValueFromNodeDecoder { - /// The coder used for serializing Date values. + /// The key of the root object. + let rootKey: URIParsedKeyComponent + + /// The date transcoder used for decoding the `Date` type. let dateTranscoder: any DateTranscoder - /// The underlying root node. - private let node: URIParsedNode + /// The coder configuration. + private let configuration: URICoderConfiguration + + /// The URIParser used to parse the provided URI-encoded string into + /// an intermediate representation. + private let parser: URIParser + + /// The cached parsing state of the decoder. + private struct ParsingCache { + + /// The cached result of parsing the string as a primitive value. + var primitive: Result? - /// The key of the root value in the node. - private let rootKey: URIParsedKey + /// The cached result of parsing the string as an array. + var array: Result<[URIParsedValue], any Error>? - /// The variable expansion style. - private let style: URICoderConfiguration.Style + /// The cached result of parsing the string as a dictionary. + var dictionary: Result<[URIParsedKeyComponent: [URIParsedValue]], any Error>? + } - /// The explode parameter of the expansion style. - private let explode: Bool + /// A cache holding the parsed intermediate representation. + private var cache: ParsingCache - /// The stack of nested values within the root node. - private var codingStack: [CodingStackEntry] + /// The stack of nested keys within the root node. + /// + /// Represents the currently parsed container. + private var codingStack: [URICoderCodingKey] /// Creates a new decoder. /// - Parameters: - /// - node: The underlying root node. - /// - rootKey: The key of the root value in the node. - /// - style: The variable expansion style. - /// - explode: The explode parameter of the expansion style. - /// - dateTranscoder: The coder used for serializing Date values. - init( - node: URIParsedNode, - rootKey: URIParsedKey, - style: URICoderConfiguration.Style, - explode: Bool, - dateTranscoder: any DateTranscoder - ) { - self.node = node + /// - data: The data to parse. + /// - rootKey: The key of the root object. + /// - configuration: The configuration of the decoder. + init(data: Substring, rootKey: URIParsedKeyComponent, configuration: URICoderConfiguration) { self.rootKey = rootKey - self.style = style - self.explode = explode - self.dateTranscoder = dateTranscoder + self.dateTranscoder = configuration.dateTranscoder + self.configuration = configuration + self.parser = .init(configuration: configuration, data: data) + self.cache = .init() self.codingStack = [] } @@ -76,13 +84,22 @@ final class URIValueFromNodeDecoder { return value } - /// Decodes the provided type from the root node. + /// Decodes the provided type from the root node, if it's present. /// - Parameter type: The type to decode from the decoder. - /// - Returns: The decoded value. + /// - Returns: The decoded value, or nil if not found. /// - Throws: When a decoding error occurs. func decodeRootIfPresent(_ type: T.Type = T.self) throws -> T? { - // The root is only nil if the node is empty. - if try currentElementAsArray().isEmpty { return nil } + switch configuration.style { + case .simple: + // Root is never nil, empty data just means an element with an empty string. + break + case .form: + // Try to parse as an array, check the number of elements. + if try parsedRootAsArray().count == 0 { return nil } + case .deepObject: + // Try to parse as a dictionary, check the number of elements. + if try parsedRootAsDictionary().count == 0 { return nil } + } return try decodeRoot(type) } } @@ -98,62 +115,13 @@ extension URIValueFromNodeDecoder { /// The decoder was asked for more items, but it was already at the /// end of the unkeyed container. case reachedEndOfUnkeyedContainer - - /// The provided coding key does not have a valid integer value, but - /// it is being used for accessing items in an unkeyed container. - case codingKeyNotInt - - /// The provided coding key is out of bounds of the unkeyed container. - case codingKeyOutOfBounds - - /// The coding key is of a value not found in the keyed container. - case codingKeyNotFound } - /// A node materialized by the decoder. - private enum URIDecodedNode { - - /// A single value. - case single(URIParsedValue) - - /// An array of values. - case array(URIParsedValueArray) - - /// A dictionary of values. - case dictionary(URIParsedNode) - } - - /// An entry in the coding stack for `URIValueFromNodeDecoder`. - /// - /// This is used to keep track of where we are in the decode. - private struct CodingStackEntry { - - /// The key at which the entry was found. - var key: URICoderCodingKey - - /// The node at the key inside its parent. - var element: URIDecodedNode - } - - /// The element at the current head of the coding stack. - private var currentElement: URIDecodedNode { codingStack.last?.element ?? .dictionary(node) } - /// Pushes a new container on top of the current stack, nesting into the /// value at the provided key. /// - Parameter codingKey: The coding key for the value that is then put /// at the top of the stack. - /// - Throws: An error if an issue occurs during the container push operation. - func push(_ codingKey: URICoderCodingKey) throws { - let nextElement: URIDecodedNode - if let intValue = codingKey.intValue { - let value = try nestedValueInCurrentElementAsArray(at: intValue) - nextElement = .single(value) - } else { - let values = try nestedValuesInCurrentElementAsDictionary(forKey: codingKey.stringValue) - nextElement = .array(values) - } - codingStack.append(CodingStackEntry(key: codingKey, element: nextElement)) - } + func push(_ codingKey: URICoderCodingKey) { codingStack.append(codingKey) } /// Pops the top container from the stack and restores the previously top /// container to be the current top container. @@ -167,153 +135,238 @@ extension URIValueFromNodeDecoder { throw DecodingError.typeMismatch(String.self, .init(codingPath: codingPath, debugDescription: message)) } - /// Extracts the root value of the provided node using the root key. - /// - Parameter node: The node which to expect for the root key. - /// - Returns: The value found at the root key in the provided node. - /// - Throws: A `DecodingError` if the value is not found at the root key - private func rootValue(in node: URIParsedNode) throws -> URIParsedValueArray { - guard let value = node[rootKey] else { - if style == .simple, let valueForFallbackKey = node[""] { - // The simple style doesn't encode the key, so single values - // get encoded as a value only, and parsed under the empty - // string key. - return valueForFallbackKey + // MARK: - withParsed methods + + /// Parse the root parsed as a specific type, with automatic caching. + /// - Parameters: + /// - valueKeyPath: A key path to the parsing cache for storing the cached value. + /// - parsingClosure: Gets the value from the parser. + /// - Returns: The parsed value. + /// - Throws: If the parsing closure fails. + private func cachedRoot( + as valueKeyPath: WritableKeyPath?>, + parse parsingClosure: (URIParser) throws -> ValueType + ) throws -> ValueType { + let value: ValueType + if let cached = cache[keyPath: valueKeyPath] { + value = try cached.get() + } else { + let result: Result + do { + value = try parsingClosure(parser) + result = .success(value) + } catch { + result = .failure(error) + throw error } - return [] + cache[keyPath: valueKeyPath] = result } return value } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as a dictionary. - /// - Returns: The value if it can be treated as a dictionary. - /// - Throws: An error if the current element cannot be treated as a dictionary. - private func currentElementAsDictionary() throws -> URIParsedNode { try nodeAsDictionary(currentElement) } - - /// Checks if the provided node can be treated as a dictionary, and returns - /// it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as a dictionary. - /// - Throws: An error if the node cannot be treated as a valid dictionary. - private func nodeAsDictionary(_ node: URIDecodedNode) throws -> URIParsedNode { - // There are multiple ways a valid dictionary is represented in a node, - // depends on the explode parameter. - // 1. exploded: Key-value pairs in the node: ["R":["100"]] - // 2. unexploded form: Flattened key-value pairs in the only top level - // key's value array: ["":["R","100"]] - // To simplify the code, when asked for a keyed container here and explode - // is false, we convert (2) to (1), and then treat everything as (1). - // The conversion only works if the number of values is even, including 0. - if explode { - guard case let .dictionary(values) = node else { - try throwMismatch("Cannot treat a single value or an array as a dictionary.") + /// Parse the root as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsPrimitive() throws -> URIParsedValue? { + try cachedRoot(as: \.primitive, parse: { try $0.parseRootAsPrimitive(rootKey: rootKey)?.value }) + } + + /// Parse the root as an array. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsArray() throws -> [URIParsedValue] { + try cachedRoot(as: \.array, parse: { try $0.parseRootAsArray(rootKey: rootKey).map(\.value) }) + } + + /// Parse the root as a dictionary. + /// - Returns: The parsed value. + /// - Throws: When parsing the root fails. + private func parsedRootAsDictionary() throws -> [URIParsedKeyComponent: [URIParsedValue]] { + try cachedRoot( + as: \.dictionary, + parse: { parser in + func normalizedDictionaryKey(_ key: URIParsedKey) throws -> Substring { + func validateComponentCount(_ count: Int) throws { + guard key.components.count == count else { + try throwMismatch( + "Decoding a dictionary key encountered an unexpected number of components (expected: \(count), got: \(key.components.count)." + ) + } + } + switch (configuration.style, configuration.explode) { + case (.form, true), (.simple, _): + try validateComponentCount(1) + return key.components[0] + case (.form, false), (.deepObject, true): + try validateComponentCount(2) + return key.components[1] + case (.deepObject, false): try throwMismatch("Decoding deepObject + unexplode is not supported.") + } + } + let tuples = try parser.parseRootAsDictionary(rootKey: rootKey) + let normalizedTuples: [(Substring, [URIParsedValue])] = try tuples.map { pair in + try (normalizedDictionaryKey(pair.key), [pair.value]) + } + return Dictionary(normalizedTuples, uniquingKeysWith: +) } - return values - } - let values = try nodeAsArray(node) - if values == [""] && style == .simple { - // An unexploded simple combination produces a ["":[""]] for an - // empty string. It should be parsed as an empty dictionary. - return ["": [""]] - } - guard values.count % 2 == 0 else { - try throwMismatch("Cannot parse an unexploded dictionary an odd number of elements.") - } - let pairs = stride(from: values.startIndex, to: values.endIndex, by: 2) - .map { firstIndex in (values[firstIndex], [values[firstIndex + 1]]) } - let convertedNode = Dictionary(pairs, uniquingKeysWith: { $0 + $1 }) - return convertedNode + ) } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as an array. - /// - Returns: The value if it can be treated as an array. - /// - Throws: An error if the node cannot be treated as an array. - private func currentElementAsArray() throws -> URIParsedValueArray { try nodeAsArray(currentElement) } - - /// Checks if the provided node can be treated as an array, and returns - /// it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as an array. - /// - Throws: An error if the node cannot be treated as a valid array. - private func nodeAsArray(_ node: URIDecodedNode) throws -> URIParsedValueArray { - switch node { - case .single(let value): return [value] - case .array(let values): return values - case .dictionary(let values): return try rootValue(in: values) + // MARK: - decoding utilities + + /// Returns a dictionary value. + /// - Parameters: + /// - key: The key for which to return the value. + /// - dictionary: The dictionary in which to find the value. + /// - Returns: The value in the dictionary, or nil if not found. + /// - Throws: When multiple values are found for the key. + func primitiveValue(forKey key: String, in dictionary: [URIParsedKeyComponent: [URIParsedValue]]) throws + -> URIParsedValue? + { + let values = dictionary[key[...], default: []] + if values.isEmpty { return nil } + if values.count > 1 { try throwMismatch("Dictionary value contains multiple values.") } + return values[0] + } + + // MARK: - withCurrent methods + + /// Use the current top of the stack as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails. + private func withCurrentPrimitiveElement(_ work: (URIParsedValue?) throws -> R) throws -> R { + if !codingStack.isEmpty { + // Nesting is involved. + // There are exactly three scenarios we support: + // - primitive in a top level array + // - primitive in a top level dictionary + // - primitive in a nested array inside a top level dictionary + if codingStack.count == 1 { + let key = codingStack[0] + if let intKey = key.intValue { + // Top level array. + return try work(parsedRootAsArray()[intKey]) + } else { + // Top level dictionary. + return try work(primitiveValue(forKey: key.stringValue, in: parsedRootAsDictionary())) + } + } else if codingStack.count == 2 { + // Nested array within a top level dictionary. + let dictionaryKey = codingStack[0].stringValue[...] + guard let nestedArrayKey = codingStack[1].intValue else { + try throwMismatch("Nested coding key is not an integer, hinting at unsupported nesting.") + } + return try work(parsedRootAsDictionary()[dictionaryKey, default: []][nestedArrayKey]) + } else { + try throwMismatch("Arbitrary nesting of containers is not supported.") + } + } else { + // Top level primitive. + return try work(parsedRootAsPrimitive()) } } - /// Extracts the node at the top of the coding stack and tries to treat it - /// as a primitive value. - /// - Returns: The value if it can be treated as a primitive value. - /// - Throws: An error if the node cannot be treated as a primitive value. - func currentElementAsSingleValue() throws -> URIParsedValue { try nodeAsSingleValue(currentElement) } - - /// Checks if the provided node can be treated as a primitive value, and - /// returns it if so. - /// - Parameter node: The node to check. - /// - Returns: The value if it can be treated as a primitive value. - /// - Throws: An error if the node cannot be treated as a primitive value. - private func nodeAsSingleValue(_ node: URIDecodedNode) throws -> URIParsedValue { - // A single value can be parsed from a node that: - // 1. Has a single key-value pair - // 2. The value array has a single element. - let array: URIParsedValueArray - switch node { - case .single(let value): return value - case .array(let values): array = values - case .dictionary(let values): array = try rootValue(in: values) + /// Use the current top of the stack as an array. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails. + private func withCurrentArrayElements(_ work: ([URIParsedValue]) throws -> R) throws -> R { + if let nestedArrayParentKey = codingStack.first { + // Top level is dictionary, first level nesting is array. + // Get all the elements that match this rootKey + nested key path. + return try work(parsedRootAsDictionary()[nestedArrayParentKey.stringValue[...]] ?? []) + } else { + // Top level array. + return try work(parsedRootAsArray()) } - guard array.count == 1 else { - if style == .simple { return Substring(array.joined(separator: ",")) } - let reason = array.isEmpty ? "an empty node" : "a node with multiple values" - try throwMismatch("Cannot parse a value from \(reason).") + } + + /// Use the current top of the stack as a dictionary. + /// - Parameter work: The closure in which to use the value. + /// - Returns: Any value returned from the closure. + /// - Throws: When parsing the root fails or if there is unsupported extra nesting of containers. + private func withCurrentDictionaryElements(_ work: ([URIParsedKeyComponent: [URIParsedValue]]) throws -> R) + throws -> R + { + if !codingStack.isEmpty { + try throwMismatch("Nesting a dictionary inside another container is not supported.") + } else { + // Top level dictionary. + return try work(parsedRootAsDictionary()) } - let value = array[0] - return value } - /// Returns the nested value at the provided index inside the node at the - /// top of the coding stack. - /// - Parameter index: The index of the nested value. - /// - Returns: The nested value. - /// - Throws: An error if the current node is not a valid array, or if the - /// index is out of bounds. - private func nestedValueInCurrentElementAsArray(at index: Int) throws -> URIParsedValue { - let values = try currentElementAsArray() - guard index < values.count else { throw GeneralError.codingKeyOutOfBounds } - return values[index] + // MARK: - metadata and data accessors + + /// Returns the current top-of-stack as a primitive value. + /// + /// Can be nil if the underlying URI is valid, just doesn't contain any value. + /// + /// For example, an empty string input into an exploded form decoder (expecting pairs in the form `key=value`) + /// would result in a nil returned value. + /// - Returns: The primitive value, or nil if not found. + /// - Throws: When parsing the root fails. + func currentElementAsSingleValue() throws -> URIParsedValue? { try withCurrentPrimitiveElement { $0 } } + + /// Returns the count of elements in the current top-of-stack array. + /// - Returns: The number of elements. + /// - Throws: When parsing the root fails. + func countOfCurrentArray() throws -> Int { try withCurrentArrayElements { $0.count } } + + /// Returns an element from the current top-of-stack array. + /// - Parameter index: The position in the array to return. + /// - Returns: The primitive value from the array. + /// - Throws: When parsing the root fails. + func nestedElementInCurrentArray(atIndex index: Int) throws -> URIParsedValue { + try withCurrentArrayElements { $0[index] } } - /// Returns the nested value at the provided key inside the node at the - /// top of the coding stack. - /// - Parameter key: The key of the nested value. - /// - Returns: The nested value. - /// - Throws: An error if the current node is not a valid dictionary, or - /// if no value exists for the key. - private func nestedValuesInCurrentElementAsDictionary(forKey key: String) throws -> URIParsedValueArray { - let values = try currentElementAsDictionary() - guard let value = values[key[...]] else { throw GeneralError.codingKeyNotFound } - return value + /// Returns an element from the current top-of-stack dictionary. + /// - Parameter key: The key to find a value for. + /// - Returns: The value for the key, or nil if not found. + /// - Throws: When parsing the root fails. + func nestedElementInCurrentDictionary(forKey key: String) throws -> URIParsedValue? { + try withCurrentDictionaryElements { dictionary in try primitiveValue(forKey: key, in: dictionary) } + } + + /// Returns a Boolean value that represents whether the current top-of-stack dictionary + /// contains a value for the provided key. + /// - Parameter key: The key for which to look for a value. + /// - Returns: `true` if a value was found, `false` otherwise. + func containsElementInCurrentDictionary(forKey key: String) -> Bool { + (try? withCurrentDictionaryElements({ dictionary in dictionary[key[...]] != nil })) ?? false + } + + /// Returns a list of keys found in the current top-of-stack dictionary. + /// - Returns: A list of keys from the dictionary. + /// - Throws: When parsing the root fails. + func elementKeysInCurrentDictionary() -> [String] { + (try? withCurrentDictionaryElements { dictionary in dictionary.keys.map(String.init) }) ?? [] } } extension URIValueFromNodeDecoder: Decoder { - var codingPath: [any CodingKey] { codingStack.map(\.key) } + var codingPath: [any CodingKey] { codingStack } var userInfo: [CodingUserInfoKey: Any] { [:] } func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { - let values = try currentElementAsDictionary() - return .init(URIKeyedDecodingContainer(decoder: self, values: values)) + KeyedDecodingContainer(URIKeyedDecodingContainer(decoder: self)) } - func unkeyedContainer() throws -> any UnkeyedDecodingContainer { - let values = try currentElementAsArray() - return URIUnkeyedDecodingContainer(decoder: self, values: values) - } + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { URIUnkeyedDecodingContainer(decoder: self) } func singleValueContainer() throws -> any SingleValueDecodingContainer { URISingleValueDecodingContainer(decoder: self) diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift index 21028207..06103408 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIEncoder.swift @@ -15,7 +15,7 @@ import Foundation /// A type that encodes an `Encodable` value to an URI-encoded string -/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.3, depending on +/// using the rules from RFC 6570, RFC 1866, and OpenAPI 3.0.4, depending on /// the configuration. /// /// [RFC 6570 - Form-style query expansion.](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) @@ -45,6 +45,13 @@ import Foundation /// | `{list\*}` | `red,green,blue` | /// | `{keys}` | `semi,%3B,dot,.,comma,%2C` | /// | `{keys\*}` | `semi=%3B,dot=.,comma=%2C` | +/// +/// [OpenAPI 3.0.4 - Deep object expansion.](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.4.md#style-examples) +/// +/// | Example Template | Expansion | +/// | ---------------- | ----------------------------------------------------------| +/// | `{?keys\*}` | `?keys%5Bsemi%5D=%3B&keys%5Bdot%5D=.&keys%5Bcomma%5D=%2C` | +/// struct URIEncoder: Sendable { /// The serializer used to turn `URIEncodedNode` values to a string. diff --git a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift index 35f71884..5d892151 100644 --- a/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift +++ b/Sources/OpenAPIRuntime/URICoder/Encoding/URIValueToNodeEncoder+Unkeyed.swift @@ -19,6 +19,13 @@ struct URIUnkeyedEncodingContainer { /// The associated encoder. let encoder: URIValueToNodeEncoder + + /// Creates a new encoder. + /// - Parameter encoder: The associated encoder. + init(encoder: URIValueToNodeEncoder) { + self.encoder = encoder + try? encoder.currentStackEntry.storage.markAsArray() + } } extension URIUnkeyedEncodingContainer { diff --git a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift index 3be75420..9e1da427 100644 --- a/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift +++ b/Sources/OpenAPIRuntime/URICoder/Parsing/URIParser.swift @@ -14,195 +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, true): return try parseExplodedFormRoot() - case (.form, false): return try parseUnexplodedFormRoot() - case (.simple, true): return try parseExplodedSimpleRoot() - case (.simple, false): return try parseUnexplodedSimpleRoot() - } - } - - /// 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 + case (.form, _): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let value: Raw switch firstResult { case .foundFirst: - // Hit the key/value separator, so a value will follow. - let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - key = firstValue - value = secondValue - case .foundSecondOrEnd: - // No key/value separator, treat the string as the key. - key = firstValue - value = .init() + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapedKey]) + 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) } - appendPair(key, [value]) } + 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 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 + /// Parses the string as an array. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed array. + /// - Throws: When parsing the root fails. + func parseRootAsArray(rootKey: URIParsedKeyComponent) throws -> [URIParsedPair] { + var data = data + switch (configuration.style, configuration.explode) { + case (.form, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "&" - let valueSeparator: Character = "," - + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let values: [Raw] switch firstResult { case .foundFirst: - // Hit the key/value separator, so one or more values will follow. - var accumulatedValues: [Raw] = [] - valueLoop: while !data.isEmpty { - let (secondResult, secondValue) = data.parseUpToEitherCharacterOrEnd( - first: valueSeparator, - second: pairSeparator - ) - accumulatedValues.append(secondValue) - switch secondResult { - case .foundFirst: - // Hit the value separator, so ended one value and - // another one is coming. - continue - case .foundSecondOrEnd: - // Hit the pair separator or the end, this is the - // last value. - break valueLoop - } + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + let 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) } - if accumulatedValues.isEmpty { - // We hit the key/value separator, so always write - // at least one empty value. - accumulatedValues.append("") + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.form, false): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let arrayElementSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + 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) } - key = firstValue - values = accumulatedValues case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, values) } + return items + case (.simple, _): + let pairSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let value = data.parseUpToCharacterOrEnd(pairSeparator) + items.append(.init(key: .empty, value: unescapeValue(value))) + } + return items + case (.deepObject, true): + throw ParsingError.invalidConfiguration("deepObject does not support array values, only dictionaries") + case (.deepObject, false): + throw ParsingError.invalidConfiguration("deepObject + explode: false is not supported") } } - /// Parses the root node assuming the raw string uses the simple style - /// and the explode parameter is enabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseExplodedSimpleRoot() throws -> URIParsedNode { - try parseGenericRoot { data, appendPair in + /// Parses the string as a dictionary. + /// - Parameter rootKey: The key of the root object, used to filter out unrelated values. + /// - Returns: The parsed key/value pairs as an array. + /// - Throws: When parsing the root fails. + func parseRootAsDictionary(rootKey: URIParsedKeyComponent) throws -> [URIParsedPair] { + var data = data + switch (configuration.style, configuration.explode) { + case (.form, true): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) + let key = URIParsedKey([unescapeValue(firstValue)]) + items.append(.init(key: key, value: unescapeValue(secondValue))) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.form, false): + let keyValueSeparator: Character = "=" + let pairSeparator: Character = "&" + let arrayElementSeparator: Character = "," + var items: [URIParsedPair] = [] + while !data.isEmpty { + let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( + first: keyValueSeparator, + second: pairSeparator + ) + switch firstResult { + case .foundFirst: + let unescapedKey = unescapeValue(firstValue) + if unescapedKey == rootKey { + elementScan: while !data.isEmpty { + let (innerKeyResult, innerKeyValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + switch innerKeyResult { + case .foundFirst: + let (innerValueResult, innerValueValue) = data.parseUpToEitherCharacterOrEnd( + first: arrayElementSeparator, + second: pairSeparator + ) + items.append( + .init( + key: URIParsedKey([unescapedKey, innerKeyValue]), + value: unescapeValue(innerValueValue) + ) + ) + switch innerValueResult { + case .foundFirst: continue elementScan + case .foundSecondOrEnd: break elementScan + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(innerKeyValue) + } + } + } else { + // Ignore the value, skip to the end of the pair. + _ = data.parseUpToCharacterOrEnd(pairSeparator) + } + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) + } + } + return items + case (.simple, true): let keyValueSeparator: Character = "=" let pairSeparator: Character = "," - + var items: [URIParsedPair] = [] while !data.isEmpty { let (firstResult, firstValue) = data.parseUpToEitherCharacterOrEnd( first: keyValueSeparator, second: pairSeparator ) - let key: Raw - let value: Raw + let key: URIParsedKey + let value: URIParsedValue switch firstResult { case .foundFirst: - // Hit the key/value separator, so a value will follow. let secondValue = data.parseUpToCharacterOrEnd(pairSeparator) - key = firstValue + key = URIParsedKey([unescapeValue(firstValue)]) value = secondValue - case .foundSecondOrEnd: - // No key/value separator, treat the string as the value. - key = .init() - value = firstValue + items.append(.init(key: key, value: unescapeValue(value))) + case .foundSecondOrEnd: throw ParsingError.malformedKeyValuePair(firstValue) } - appendPair(key, [value]) } - } - } - - /// Parses the root node assuming the raw string uses the simple style - /// and the explode parameter is disabled. - /// - Returns: The parsed root node. - /// - Throws: An error if parsing fails. - private mutating func parseUnexplodedSimpleRoot() throws -> URIParsedNode { - // Unexploded simple dictionary cannot be told apart from - // an array, so we just accumulate all pairs as standalone - // values and add them to the array. It'll be the higher - // level decoder's responsibility to parse this properly. - - try parseGenericRoot { data, appendPair in + return items + case (.simple, false): let pairSeparator: Character = "," + var items: [URIParsedPair] = [] while !data.isEmpty { - let value = data.parseUpToCharacterOrEnd(pairSeparator) - appendPair(.init(), [value]) + let rawKey = data.parseUpToCharacterOrEnd(pairSeparator) + let value: URIParsedValue + if data.isEmpty { value = "" } else { value = data.parseUpToCharacterOrEnd(pairSeparator) } + let key = URIParsedKey([unescapeValue(rawKey)]) + items.append(.init(key: key, value: unescapeValue(value))) } + 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 + ) + 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") } } } @@ -211,24 +318,6 @@ extension URIParser { extension URIParser { - /// Parses the underlying string using a parser closure. - /// - Parameter parser: A closure that accepts another closure, which should - /// be called 0 or more times, once for each parsed key-value pair. - /// - Returns: The accumulated node. - /// - Throws: An error if parsing using the provided parser closure fails, - private mutating func parseGenericRoot(_ parser: (inout Raw, (Raw, [Raw]) -> Void) throws -> Void) throws - -> URIParsedNode - { - var root = URIParsedNode() - let spaceEscapingCharacter = configuration.spaceEscapingCharacter - let unescapeValue: (Raw) -> Raw = { Self.unescapeValue($0, spaceEscapingCharacter: spaceEscapingCharacter) } - try parser(&data) { key, values in - let newItem = [unescapeValue(key): values.map(unescapeValue)] - root.merge(newItem) { $0 + $1 } - } - return root - } - /// Removes escaping from the provided string. /// - Parameter escapedValue: An escaped string. /// - Returns: The provided string with escaping removed. diff --git a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift index 26071f85..838ca9b1 100644 --- a/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift +++ b/Sources/OpenAPIRuntime/URICoder/Serialization/URISerializer.swift @@ -65,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. @@ -108,6 +135,16 @@ extension URISerializer { guard case let .primitive(primitive) = node else { throw SerializationError.nestedContainersNotSupported } return primitive } + func unwrapPrimitiveOrArrayOfPrimitives(_ node: URIEncodedNode) throws + -> URIEncodedNode.PrimitiveOrArrayOfPrimitives + { + if case let .primitive(primitive) = node { return .primitive(primitive) } + if case let .array(array) = node { + let primitives = try array.map(unwrapPrimitiveValue) + return .arrayOfPrimitives(primitives) + } + throw SerializationError.nestedContainersNotSupported + } switch value { case .unset: // Nothing to serialize. @@ -117,11 +154,12 @@ extension URISerializer { switch configuration.style { case .form: keyAndValueSeparator = "=" case .simple: keyAndValueSeparator = nil + case .deepObject: throw SerializationError.deepObjectsWithPrimitiveValuesNotSupported } try serializePrimitiveKeyValuePair(primitive, forKey: key, separator: keyAndValueSeparator) case .array(let array): try serializeArray(array.map(unwrapPrimitiveValue), forKey: key) case .dictionary(let dictionary): - try serializeDictionary(dictionary.mapValues(unwrapPrimitiveValue), forKey: key) + try serializeDictionary(dictionary.mapValues(unwrapPrimitiveOrArrayOfPrimitives), forKey: key) } } @@ -167,7 +205,6 @@ extension URISerializer { /// style and explode parameters in the configuration). /// - Throws: An error if serialization of the array fails. private mutating func serializeArray(_ array: [URIEncodedNode.Primitive], forKey key: String) throws { - guard !array.isEmpty else { return } let keyAndValueSeparator: String? let pairSeparator: String switch (configuration.style, configuration.explode) { @@ -180,7 +217,9 @@ 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) @@ -205,9 +244,10 @@ extension URISerializer { /// - key: The key to serialize the value under (details depend on the /// style and explode parameters in the configuration). /// - Throws: An error if serialization of the dictionary fails. - private mutating func serializeDictionary(_ dictionary: [String: URIEncodedNode.Primitive], forKey key: String) - throws - { + private mutating func serializeDictionary( + _ dictionary: [String: URIEncodedNode.PrimitiveOrArrayOfPrimitives], + forKey key: String + ) throws { guard !dictionary.isEmpty else { return } let sortedDictionary = dictionary.sorted { a, b in a.key.localizedCaseInsensitiveCompare(b.key) == .orderedAscending @@ -228,20 +268,42 @@ 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) } + if let (elementKey, element) = sortedDictionary.last { + try serializeNext(element, forKey: serializeNestedKey(elementKey, forKey: key)) + } } } diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift index b820929d..121c5fdd 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -21,7 +21,7 @@ final class Test_ContentDisposition: Test_Runtime { input: String, parsed: ContentDisposition?, output: String?, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { let value = ContentDisposition(rawValue: input) diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift index 207d3920..a28ec47d 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIMIMEType.swift @@ -88,7 +88,7 @@ final class Test_OpenAPIMIMEType: Test_Runtime { receivedParameters: [String: String], against option: OpenAPIMIMEType, expected expectedMatch: OpenAPIMIMEType.Match, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { let result = OpenAPIMIMEType.evaluate( @@ -109,7 +109,7 @@ final class Test_OpenAPIMIMEType: Test_Runtime { func testJSONWith2Params( against option: OpenAPIMIMEType, expected expectedMatch: OpenAPIMIMEType.Match, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) { testCase( diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift b/Tests/OpenAPIRuntimeTests/Base/Test_OpenAPIValue.swift index d95ee8c4..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"]) @@ -60,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 @@ -146,6 +216,82 @@ final class Test_OpenAPIValue: Test_Runtime { XCTAssertEqual(value["keyMore"] as? [Bool], [true]) } + func testEncoding_anyOfObjects_success() throws { + let values1: [String: (any Sendable)?] = ["key": "value"] + let values2: [String: (any Sendable)?] = ["keyMore": [true]] + let container = MyAnyOf2( + value1: try OpenAPIObjectContainer(unvalidatedValue: values1), + value2: try OpenAPIObjectContainer(unvalidatedValue: values2) + ) + let expectedString = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + try _testPrettyEncoded(container, expectedJSON: expectedString) + } + + func testDecoding_anyOfObjects_success() throws { + let json = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + let container: MyAnyOf2 = try _getDecoded(json: json) + let value1 = container.value1?.value + XCTAssertEqual(value1?.count, 2) + XCTAssertEqual(value1?["key"] as? String, "value") + XCTAssertEqual(value1?["keyMore"] as? [Bool], [true]) + let value2 = container.value2?.value + XCTAssertEqual(value2?.count, 2) + XCTAssertEqual(value2?["key"] as? String, "value") + XCTAssertEqual(value2?["keyMore"] as? [Bool], [true]) + } + + func testEncoding_anyOfValues_success() throws { + let values1: [String: (any Sendable)?] = ["key": "value"] + let values2: [String: (any Sendable)?] = ["keyMore": [true]] + let container = MyAnyOf2( + value1: try OpenAPIValueContainer(unvalidatedValue: values1), + value2: try OpenAPIValueContainer(unvalidatedValue: values2) + ) + let expectedString = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + try _testPrettyEncoded(container, expectedJSON: expectedString) + } + + func testDecoding_anyOfValues_success() throws { + let json = #""" + { + "key" : "value", + "keyMore" : [ + true + ] + } + """# + let container: MyAnyOf2 = try _getDecoded(json: json) + let value1 = try XCTUnwrap(container.value1?.value as? [String: (any Sendable)?]) + XCTAssertEqual(value1.count, 2) + XCTAssertEqual(value1["key"] as? String, "value") + XCTAssertEqual(value1["keyMore"] as? [Bool], [true]) + let value2 = try XCTUnwrap(container.value2?.value as? [String: (any Sendable)?]) + XCTAssertEqual(value2.count, 2) + XCTAssertEqual(value2["key"] as? String, "value") + XCTAssertEqual(value2["keyMore"] as? [Bool], [true]) + } + func testEncoding_array_success() throws { let values: [(any Sendable)?] = ["one", ["two": 2]] let container = try OpenAPIArrayContainer(unvalidatedValue: values) @@ -176,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 @@ -264,3 +444,29 @@ final class Test_OpenAPIValue: Test_Runtime { ) } } + +struct MyAnyOf2: Codable, Hashable, + Sendable +{ + var value1: Value1? + var value2: Value2? + init(value1: Value1? = nil, value2: Value2? = nil) { + self.value1 = value1 + self.value2 = value2 + } + 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_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 57c11580..e223ea53 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -95,7 +95,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/json" ) try await XCTAssertEqualStringifiedData(body, testStructPrettyString) - XCTAssertEqual(headerFields, [.contentType: "application/json"]) + XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "23"]) } func test_setOptionalRequestBodyAsJSON_codable_string() async throws { @@ -106,7 +106,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/json" ) try await XCTAssertEqualStringifiedData(body, testQuotedString) - XCTAssertEqual(headerFields, [.contentType: "application/json"]) + XCTAssertEqual(headerFields, [.contentType: "application/json", .contentLength: "7"]) } // | client | set | request body | JSON | required | setRequiredRequestBodyAsJSON | @@ -118,7 +118,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 | @@ -136,7 +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 | @@ -148,7 +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 | @@ -160,7 +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 | @@ -172,7 +194,7 @@ final class Test_ClientConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(body, testString) - XCTAssertEqual(headerFields, [.contentType: "application/octet-stream"]) + XCTAssertEqual(headerFields, [.contentType: "application/octet-stream", .contentLength: "5"]) } // | client | set | request body | multipart | required | setRequiredRequestBodyAsMultipart | @@ -206,6 +228,15 @@ final class Test_ClientConverterExtensions: Test_Runtime { ) XCTAssertEqual(value, testStruct) } + // | client | get | response body | XML | required | getResponseBodyAsXML | + func test_getResponseBodyAsXML_codable() async throws { + let value: TestPet = try await converter.getResponseBodyAsXML( + TestPet.self, + from: .init(testStructData), + transforming: { $0 } + ) + XCTAssertEqual(value, testStruct) + } // | client | get | response body | binary | required | getResponseBodyAsBinary | func test_getResponseBodyAsBinary_data() async throws { @@ -254,5 +285,5 @@ public func XCTAssertEqualStringifiedData( do { let actualString = String(decoding: try expression1(), as: UTF8.self) XCTAssertEqual(actualString, try expression2(), file: file, line: line) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } + } catch { XCTFail("\(error)", file: file, line: line) } } diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift index 925ebf4f..85d04f25 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Common.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import XCTest -@_spi(Generated) import OpenAPIRuntime +@testable @_spi(Generated) import OpenAPIRuntime import HTTPTypes extension HTTPField.Name { static var foo: Self { Self("foo")! } } @@ -26,7 +26,7 @@ final class Test_CommonConverterExtensions: Test_Runtime { received: String?, options: [String], expected expectedChoice: String, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line ) throws { let choice = try converter.bestContentType(received: received.map { .init($0)! }, options: options) @@ -84,7 +84,16 @@ final class Test_CommonConverterExtensions: Test_Runtime { try testCase(received: "image/png", options: ["image/*", "*/*"], expected: "image/*") XCTAssertThrowsError( try testCase(received: "text/csv", options: ["text/html", "application/json"], expected: "-") - ) + ) { error in + XCTAssert(error is RuntimeError) + guard let error = error as? RuntimeError, + case .unexpectedContentTypeHeader(expected: let expected, received: let received) = error, + expected == "text/html", received == "text/csv" + else { + XCTFail("Unexpected error: \(error)") + return + } + } } func testVerifyContentTypeIfPresent() throws { @@ -100,9 +109,13 @@ final class Test_CommonConverterExtensions: Test_Runtime { } func testExtractContentDispositionNameAndFilename() throws { - func testCase(value: String?, name: String?, filename: String?, file: StaticString = #file, line: UInt = #line) - throws - { + func testCase( + value: String?, + name: String?, + filename: String?, + file: StaticString = #filePath, + line: UInt = #line + ) throws { let headerFields: HTTPFields if let value { headerFields = [.contentDisposition: value] } else { headerFields = [:] } let (actualName, actualFilename) = try converter.extractContentDispositionNameAndFilename(in: headerFields) diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index d70a58d7..3d956bb2 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -39,18 +39,20 @@ final class Test_ServerConverterExtensions: Test_Runtime { .accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8" ] let multiple: HTTPFields = [.accept: "text/plain, application/json"] + let params: HTTPFields = [.accept: "application/json; foo=bar"] let cases: [(HTTPFields, String, Bool)] = [ // No Accept header, any string validates successfully (emptyHeaders, "foobar", true), - // Accept: */*, any string validates successfully - (wildcard, "foobar", true), + // Accept: */*, any MIME type validates successfully + (wildcard, "foobaz/bar", true), // Accept: text/*, so text/plain succeeds, application/json fails (partialWildcard, "text/plain", true), (partialWildcard, "application/json", false), // Accept: text/plain, text/plain succeeds, application/json fails - (short, "text/plain", true), (short, "application/json", false), + (short, "text/plain", true), (short, "application/json", false), (short, "application/*", false), + (short, "*/*", false), // A bunch of acceptable content types (long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true), @@ -58,6 +60,10 @@ final class Test_ServerConverterExtensions: Test_Runtime { // Multiple values (multiple, "text/plain", true), (multiple, "application/json", true), (multiple, "application/xml", false), + + // Params + (params, "application/json; foo=bar", true), (params, "application/json; charset=utf-8; foo=bar", true), + (params, "application/json", true), (params, "text/plain", false), ] for (headers, contentType, success) in cases { if success { @@ -171,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( @@ -195,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( @@ -247,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 { @@ -316,7 +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 | @@ -328,7 +399,7 @@ final class Test_ServerConverterExtensions: Test_Runtime { contentType: "application/octet-stream" ) try await XCTAssertEqualStringifiedData(data, testString) - XCTAssertEqual(headers, [.contentType: "application/octet-stream"]) + XCTAssertEqual(headers, [.contentType: "application/octet-stream", .contentLength: "5"]) } // | server | set | response body | multipart | required | setResponseBodyAsMultipart | 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 16a684c1..1ae395f7 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_HTTPBody.swift @@ -173,6 +173,11 @@ final class Test_Body: Test_Runtime { _ = try await String(collecting: body, upTo: .max) XCTFail("Expected an error to be thrown") } catch {} + + do { + for try await _ in body {} + XCTFail("Expected an error to be thrown") + } catch {} } func testIterationBehavior_multiple() async throws { @@ -224,16 +229,22 @@ final class Test_Body: Test_Runtime { } extension Test_Body { - func _testConsume(_ body: HTTPBody, expected: HTTPBody.ByteChunk, file: StaticString = #file, line: UInt = #line) - async throws - { + func _testConsume( + _ body: HTTPBody, + expected: HTTPBody.ByteChunk, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { let output = try await ArraySlice(collecting: body, upTo: .max) XCTAssertEqual(output, expected, file: file, line: line) } - func _testConsume(_ body: HTTPBody, expected: some StringProtocol, file: StaticString = #file, line: UInt = #line) - async throws - { + func _testConsume( + _ body: HTTPBody, + expected: some StringProtocol, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { let output = try await String(collecting: body, upTo: .max) XCTAssertEqual(output, expected.description, file: file, line: line) } diff --git a/Tests/OpenAPIRuntimeTests/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.. { WrappedSyncSequence(sequence: testEvents) } + + var testJSONLinesBytes: ArraySlice { + let encoder = JSONEncoder() + let bytes = try! testEvents.map { try encoder.encode($0) + [ASCII.lf] }.joined() + return ArraySlice(bytes) + } + var testJSONSequenceBytes: ArraySlice { + let encoder = JSONEncoder() + let bytes = try! testEvents.map { try [ASCII.rs] + encoder.encode($0) + [ASCII.lf] }.joined() + return ArraySlice(bytes) + } + + func asOneBytePerElementSequence(_ source: ArraySlice) -> HTTPBody { + HTTPBody( + WrappedSyncSequence(sequence: source).map { ArraySlice([$0]) }, + length: .known(Int64(source.count)), + iterationBehavior: .multiple + ) + } + var testJSONLinesOneBytePerElementSequence: HTTPBody { asOneBytePerElementSequence(testJSONLinesBytes) } + var testJSONSequenceOneBytePerElementSequence: HTTPBody { asOneBytePerElementSequence(testJSONSequenceBytes) } @discardableResult func _testPrettyEncoded(_ value: Value, expectedJSON: String) throws -> String { let encoder = JSONEncoder() @@ -147,7 +197,7 @@ func chunkFromStringLines(_ strings: [String], addExtraCRLFs: Int = 0) -> ArrayS func chunkFromString(_ string: String, addCRLFs: Int = 0) -> ArraySlice { var slice = ArraySlice(string.utf8) - for _ in 0.. [UInt8] { Array(string.utf8) } extension ArraySlice { mutating func append(_ string: String) { append(contentsOf: chunkFromString(string)) } - mutating func appendCRLF() { append(contentsOf: [0x0d, 0x0a]) } + mutating func appendCRLF() { append(contentsOf: ASCII.crlf) } } struct TestError: Error, Equatable {} +struct PrintableError: Error, CustomStringConvertible, LocalizedError { + var description: String { "Just description" } + var errorDescription: String? { "Just errorDescription" } +} + struct MockMiddleware: ClientMiddleware, ServerMiddleware { enum FailurePhase { case never @@ -195,6 +250,13 @@ struct MockMiddleware: ClientMiddleware, ServerMiddleware { } } +struct MockCustomCoder: CustomCoder { + func customEncode(_ value: T) throws -> Data where T: Encodable { try JSONEncoder().encode(value) } + func customDecode(_ type: T.Type, from data: Data) throws -> T where T: Decodable { + try JSONDecoder().decode(T.self, from: data) + } +} + /// Asserts that a given URL's absolute string representation is equal to an expected string. /// /// - Parameters: @@ -202,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 @@ -212,6 +274,11 @@ public func XCTAssertEqualURLString(_ lhs: URL?, _ rhs: String, file: StaticStri struct TestPet: Codable, Equatable { var name: String } +struct TestPetWithPath: Codable, Equatable { + var name: String + var path: URL +} + struct TestPetDetailed: Codable, Equatable { var name: String var type: String @@ -283,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 } } @@ -311,7 +378,7 @@ public func XCTAssertEqualStringifiedData( } let actualString = String(decoding: Array(value1), as: UTF8.self) XCTAssertEqual(actualString, try expression2(), file: file, line: line) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } + } catch { XCTFail("\(error)", file: file, line: line) } } /// Asserts that the string representation of binary data in an HTTP body is equal to an expected string. @@ -338,8 +405,9 @@ fileprivate extension UInt8 { var asHex: String { let original: String switch self { - case 0x0d: original = "CR" - case 0x0a: original = "LF" + case ASCII.cr: original = "CR" + case ASCII.lf: original = "LF" + case ASCII.rs: original = "RS" default: original = "\(UnicodeScalar(self)) " } return String(format: "%02x \(original)", self) @@ -391,21 +459,40 @@ public func XCTAssertEqualData( file: file, line: line ) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } + } catch { XCTFail("\(error)", file: file, line: line) } } /// Asserts that the data matches the expected value. -public func XCTAssertEqualData( - _ expression1: @autoclosure () throws -> HTTPBody?, +public func XCTAssertEqualAsyncData( + _ expression1: @autoclosure () throws -> AS?, _ expression2: @autoclosure () throws -> C, _ message: @autoclosure () -> String = "Data doesn't match.", file: StaticString = #filePath, line: UInt = #line -) async throws where C.Element == UInt8 { +) async throws where C.Element == UInt8, AS.Element == ArraySlice { guard let actualBytesBody = try expression1() else { XCTFail("First value is nil", file: file, line: line) return } - let actualBytes = try await [UInt8](collecting: actualBytesBody, upTo: .max) + let actualBytes = try await [ArraySlice](collecting: actualBytesBody).flatMap { $0 } XCTAssertEqualData(actualBytes, try expression2(), file: file, line: line) } + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData( + _ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> C, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line +) async throws where C.Element == UInt8 { + try await XCTAssertEqualAsyncData(try expression1(), try expression2(), file: file, line: line) +} + +extension Array { + init(collecting source: Source) async throws where Source.Element == Element { + var elements: [Element] = [] + for try await element in source { elements.append(element) } + self = elements + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift index c02c83c3..0e611789 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIDecoder.swift @@ -16,11 +16,76 @@ import XCTest final class Test_URIDecoder: Test_Runtime { - func testDecoding() throws { + func testDecoding_string() throws { + _test( + "hello world", + forKey: "message", + from: .init( + formExplode: "message=hello%20world", + formUnexplode: "message=hello%20world", + simpleExplode: "hello%20world", + simpleUnexplode: "hello%20world", + formDataExplode: "message=hello+world", + formDataUnexplode: "message=hello+world", + deepObjectExplode: nil + ) + ) + } + + func testDecoding_maxNesting() throws { + struct Filter: Decodable, Equatable { + enum State: String, Decodable, Equatable { + case enabled + case disabled + } + var state: [State] + } + _test( + Filter(state: [.enabled, .disabled]), + forKey: "filter", + from: .init( + formExplode: "state=enabled&state=disabled", + formUnexplode: "filter=state,enabled,state,disabled", + simpleExplode: "state=enabled,state=disabled", + simpleUnexplode: "state,enabled,state,disabled", + formDataExplode: "state=enabled&state=disabled", + formDataUnexplode: "filter=state,enabled,state,disabled", + deepObjectExplode: "filter%5Bstate%5D=enabled&filter%5Bstate%5D=disabled" + ) + ) + } + + func testDecoding_array() throws { + _test( + ["hello world", "goodbye world"], + forKey: "message", + from: .init( + formExplode: "message=hello%20world&message=goodbye%20world", + formUnexplode: "message=hello%20world,goodbye%20world", + simpleExplode: "hello%20world,goodbye%20world", + simpleUnexplode: "hello%20world,goodbye%20world", + formDataExplode: "message=hello+world&message=goodbye+world", + formDataUnexplode: "message=hello+world,goodbye+world", + deepObjectExplode: nil + ) + ) + } + + func testDecoding_struct() throws { struct Foo: Decodable, Equatable { var bar: String } - let decoder = URIDecoder(configuration: .formDataExplode) - let decodedValue = try decoder.decode(Foo.self, forKey: "", from: "bar=hello+world") - XCTAssertEqual(decodedValue, Foo(bar: "hello world")) + _test( + Foo(bar: "hello world"), + forKey: "message", + from: .init( + formExplode: "bar=hello%20world", + formUnexplode: "message=bar,hello%20world", + simpleExplode: "bar=hello%20world", + simpleUnexplode: "bar,hello%20world", + formDataExplode: "bar=hello+world", + formDataUnexplode: "message=bar,hello+world", + deepObjectExplode: "message%5Bbar%5D=hello%20world" + ) + ) } func testDecoding_structWithOptionalProperty() throws { @@ -39,6 +104,21 @@ final class Test_URIDecoder: Test_Runtime { } } + func testDecoding_freeformObject() throws { + let decoder = URIDecoder(configuration: .formDataExplode) + do { + let decodedValue = try decoder.decode( + OpenAPIObjectContainer.self, + forKey: "", + from: "baz=1&bar=hello+world&bar=goodbye+world" + ) + XCTAssertEqual( + decodedValue, + try .init(unvalidatedValue: ["bar": ["hello world", "goodbye world"], "baz": 1]) + ) + } + } + func testDecoding_rootValue() throws { let decoder = URIDecoder(configuration: .formDataExplode) do { @@ -73,3 +153,53 @@ final class Test_URIDecoder: Test_Runtime { } } } + +extension Test_URIDecoder { + + struct Inputs { + var formExplode: Substring? + var formUnexplode: Substring? + var simpleExplode: Substring? + var simpleUnexplode: Substring? + var formDataExplode: Substring? + var formDataUnexplode: Substring? + var deepObjectExplode: Substring? + } + + func _test( + _ value: T, + forKey key: String, + from inputs: Inputs, + file: StaticString = #file, + line: UInt = #line + ) { + func _run(name: String, configuration: URICoderConfiguration, sourceString: Substring) { + let decoder = URIDecoder(configuration: configuration) + do { + let decodedValue = try decoder.decode(T.self, forKey: key, from: sourceString) + XCTAssertEqual(decodedValue, value, "Failed in \(name)", file: file, line: line) + } catch { XCTFail("Threw an error in \(name): \(error)", file: file, line: line) } + } + if let value = inputs.formExplode { + _run(name: "formExplode", configuration: .formExplode, sourceString: value) + } + if let value = inputs.formUnexplode { + _run(name: "formUnexplode", configuration: .formUnexplode, sourceString: value) + } + if let value = inputs.simpleExplode { + _run(name: "simpleExplode", configuration: .simpleExplode, sourceString: value) + } + if let value = inputs.simpleUnexplode { + _run(name: "simpleUnexplode", configuration: .simpleUnexplode, sourceString: value) + } + if let value = inputs.formDataExplode { + _run(name: "formDataExplode", configuration: .formDataExplode, sourceString: value) + } + if let value = inputs.formDataUnexplode { + _run(name: "formDataUnexplode", configuration: .formDataUnexplode, sourceString: value) + } + if let value = inputs.deepObjectExplode { + _run(name: "deepObjectExplode", configuration: .deepObjectExplode, sourceString: value) + } + } +} diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Decoder/Test_URIValueFromNodeDecoder.swift index bbbf4dae..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,66 +36,72 @@ final class Test_URIValueFromNodeDecoder: Test_Runtime { } // An empty string. - try test(["root": [""]], "", key: "root") + try test("root=", "", key: "root") // An empty string with a simple style. - try test(["root": [""]], "", key: "root", style: .simple) + try test("", "", key: "root", style: .simple) // A string with a space. - try test(["root": ["Hello World"]], "Hello World", key: "root") + try test("root=Hello%20world", "Hello world", key: "root") // An enum. - try test(["root": ["red"]], SimpleEnum.red, key: "root") + try test("root=red", SimpleEnum.red, key: "root") // An integer. - try test(["root": ["1234"]], 1234, key: "root") + try test("root=1234", 1234, key: "root") // A float. - try test(["root": ["12.34"]], 12.34, key: "root") + try test("root=12.34", 12.34, key: "root") // A bool. - try test(["root": ["true"]], true, key: "root") + try test("root=true", true, key: "root") // A simple array of strings. - try test(["root": ["a", "b", "c"]], ["a", "b", "c"], key: "root") + try test("root=a&root=b&root=c", ["a", "b", "c"], key: "root") // A simple array of enums. - try test(["root": ["red", "green", "blue"]], [.red, .green, .blue] as [SimpleEnum], key: "root") + try test("root=red&root=green&root=blue", [.red, .green, .blue] as [SimpleEnum], key: "root") // A struct. - try test(["foo": ["bar"]], SimpleStruct(foo: "bar"), key: "root") + try test("foo=bar", SimpleStruct(foo: "bar"), key: "root") + + // A struct with an array property. + try test( + "foo=bar&bar=1&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) diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift index fe9d445e..fd5cdd20 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIEncoder.swift @@ -23,4 +23,11 @@ final class Test_URIEncoder: Test_Runtime { let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") XCTAssertEqual(encodedString, "bar=hello+world") } + func testNestedEncoding() throws { + struct Foo: Encodable { var bar: String } + let serializer = URISerializer(configuration: .deepObjectExplode) + let encoder = URIEncoder(serializer: serializer) + let encodedString = try encoder.encode(Foo(bar: "hello world"), forKey: "root") + XCTAssertEqual(encodedString, "root%5Bbar%5D=hello%20world") + } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift index 913511b6..80759c62 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Encoding/Test_URIValueToNodeEncoder.swift @@ -41,6 +41,12 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { var val: SimpleEnum? } + struct StructWithArray: Encodable { + var foo: String + var bar: [Int]? + var val: [String] + } + struct NestedStruct: Encodable { var simple: SimpleStruct } let cases: [Case] = [ @@ -89,6 +95,16 @@ final class Test_URIValueToNodeEncoder: Test_Runtime { .dictionary(["foo": .primitive(.string("bar")), "val": .primitive(.string("foo"))]) ), + // A struct with an array property. + makeCase( + StructWithArray(foo: "bar", bar: [1, 2], val: ["baz", "baq"]), + .dictionary([ + "foo": .primitive(.string("bar")), + "bar": .array([.primitive(.integer(1)), .primitive(.integer(2))]), + "val": .array([.primitive(.string("baz")), .primitive(.string("baq"))]), + ]) + ), + // A nested struct. makeCase( NestedStruct(simple: SimpleStruct(foo: "bar")), diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift index 9bd8f3e8..678cf794 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Parsing/Test_URIParser.swift @@ -14,114 +14,495 @@ import XCTest @testable import OpenAPIRuntime +/// Tests for URIParser. +/// +/// Guiding examples: +/// +/// rootKey: "color" +/// +/// form explode: +/// - nil: "" -> nil +/// - empty: "color=" -> ("color/0", "") +/// - primitive: "color=blue" -> ("color/0", "blue") +/// - array: "color=blue&color=black&color=brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary+array: "R=100&G=200&G=150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// form unexplode: +/// - nil: "" -> nil +/// - empty: "color=" -> ("color/0", "") +/// - primitive: "color=blue" -> ("color/0", "blue") +/// - array: "color=blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary: "color=R,100,G,200,G,150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// simple explode: +/// - nil: "" -> ("color/0", "") +/// - empty: "" -> ("color/0", "") +/// - primitive: "blue" -> ("color/0", "blue") +/// - array: "blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary+array: "R=100,G=200,G=150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// simple unexplode: +/// - nil: "" -> ("color/0", "") +/// - empty: "" -> ("color/0", "") +/// - primitive: "blue" -> ("color/0", "blue") +/// - array: "blue,black,brown" -> [("color/0", "blue"), ("color/1", "black"), ("color/2", "brown)] +/// - dictionary: "R,100,G,200,G,150" -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] +/// +/// deepObject unexplode: unsupported +/// +/// deepObject explode: +/// - nil: -> unsupported +/// - empty: -> unsupported +/// - primitive: -> unsupported +/// - array: -> unsupported +/// - dictionary: "color%5BR%5D=100&color%5BG%5D=200&color%5BG%5D=150" +/// -> [("color/R/0", "100"), ("color/G/0", "200"), ("color/G/1", "150")] final class Test_URIParser: Test_Runtime { - let testedVariants: [URICoderConfiguration] = [ - .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, - ] - 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": [","]] + dictionary: .assert( + "R=100&G=200&G=150", + equals: [ + .init(key: "R", value: "100"), .init(key: "G", value: "200"), .init(key: "G", value: "150"), + ] + ) ), - ] - 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 + 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"), + ] ) - } - 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) - } + ), + 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 @@ -133,41 +514,125 @@ extension Test_URIParser { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } - struct Variants { - - struct Input: ExpressibleByStringLiteral { - var string: String - var valueOverride: URIParsedNode? - - 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, file: StaticString = #file, line: UInt = #line) - -> Case - { .init(variants: variants, value: value, file: file, line: line) } + + func testCase(_ variants: Case.Variants, file: StaticString = #file, line: UInt = #line) throws { + let caseValue = Case(variants: variants, file: file, line: line) + func testVariant(_ variant: Case.Variant, _ input: Case.Input) throws { + func testRoot( + rootName: String, + _ root: Case.RootInput, + parse: (URIParser) throws -> RootType + ) throws { + let parser = URIParser(configuration: variant.config, data: root.string[...]) + switch root.result { + case .success(let expectedValue): + let parsedValue = try parse(parser) + XCTAssertEqual( + parsedValue, + expectedValue, + "Failed for config: \(variant.name), root: \(rootName)", + file: caseValue.file, + line: caseValue.line + ) + case .failure(let validateError): + do { + _ = try parse(parser) + XCTFail("Should have thrown an error", file: caseValue.file, line: caseValue.line) + } catch { + guard let parsingError = error as? ParsingError else { + XCTAssert( + false, + "Unexpected error thrown: \(error)", + file: caseValue.file, + line: caseValue.line + ) + return + } + validateError(parsingError) + } + } + } + try testRoot( + rootName: "primitive", + input.primitive, + parse: { try $0.parseRootAsPrimitive(rootKey: input.rootKey) } + ) + try testRoot(rootName: "array", input.array, parse: { try $0.parseRootAsArray(rootKey: input.rootKey) }) + try testRoot( + rootName: "dictionary", + input.dictionary, + parse: { try $0.parseRootAsDictionary(rootKey: input.rootKey) } + ) + } + let variants = caseValue.variants + try testVariant(.formExplode, variants.formExplode) + try testVariant(.formUnexplode, variants.formUnexplode) + try testVariant(.simpleExplode, variants.simpleExplode) + try testVariant(.simpleUnexplode, variants.simpleUnexplode) + try testVariant(.formDataExplode, variants.formDataExplode) + try testVariant(.formDataUnexplode, variants.formDataUnexplode) + try testVariant(.deepObjectExplode, variants.deepObjectExplode) + } + + func testCase( + formExplode: Case.Input, + formUnexplode: Case.Input, + simpleExplode: Case.Input, + simpleUnexplode: Case.Input, + formDataExplode: Case.Input, + formDataUnexplode: Case.Input, + deepObjectExplode: Case.Input, + file: StaticString = #file, + line: UInt = #line + ) throws { + try testCase( + .init( + formExplode: formExplode, + formUnexplode: formUnexplode, + simpleExplode: simpleExplode, + simpleUnexplode: simpleUnexplode, + formDataExplode: formDataExplode, + formDataUnexplode: formDataUnexplode, + deepObjectExplode: deepObjectExplode + ), + file: file, + line: line + ) + } } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift index f93fabed..a42c9913 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Serialization/Test_URISerializer.swift @@ -16,10 +16,6 @@ import XCTest final class Test_URISerializer: Test_Runtime { - let testedVariants: [URICoderConfiguration] = [ - .formExplode, .formUnexplode, .simpleExplode, .simpleUnexplode, .formDataExplode, .formDataUnexplode, - ] - func testSerializing() throws { let cases: [Case] = [ makeCase( @@ -31,7 +27,8 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "empty=", - formDataUnexplode: "empty=" + formDataUnexplode: "empty=", + deepObjectExplode: .custom("empty=", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ), makeCase( @@ -43,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( @@ -55,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( @@ -67,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( @@ -79,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( @@ -91,7 +95,11 @@ final class Test_URISerializer: Test_Runtime { simpleExplode: "Hello%20World", simpleUnexplode: "Hello%20World", formDataExplode: "hello=Hello+World", - formDataUnexplode: "hello=Hello+World" + formDataUnexplode: "hello=Hello+World", + deepObjectExplode: .custom( + "hello=Hello%20World", + expectedError: .deepObjectsWithPrimitiveValuesNotSupported + ) ) ), makeCase( @@ -103,36 +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(".")), "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) @@ -140,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) } } } @@ -156,14 +188,31 @@ extension Test_URISerializer { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", config: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", config: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", config: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", config: .deepObjectExplode) } struct Variants { - var formExplode: String - var formUnexplode: String - var simpleExplode: String - var simpleUnexplode: String - var formDataExplode: String - var formDataUnexplode: String + struct Input: ExpressibleByStringLiteral { + var string: String + var expectedError: URISerializer.SerializationError? + init(string: String, expectedError: URISerializer.SerializationError? = nil) { + self.string = string + self.expectedError = expectedError + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, expectedError: expectedError) + } + init(stringLiteral value: String) { + self.string = value + self.expectedError = nil + } + } + var formExplode: Input + var formUnexplode: Input + var simpleExplode: Input + var simpleUnexplode: Input + var formDataExplode: Input + var formDataUnexplode: Input + var deepObjectExplode: Input } var value: URIEncodedNode var key: String diff --git a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift index ccfe52c4..ac1fc00f 100644 --- a/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift +++ b/Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift @@ -96,7 +96,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "root=", - formDataUnexplode: "root=" + formDataUnexplode: "root=", + deepObjectExplode: .custom("", expectedError: .deepObjectsWithPrimitiveValuesNotSupported) ) ) @@ -110,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) ) ) @@ -124,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) ) ) @@ -138,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) ) ) @@ -152,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) ) ) @@ -166,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) ) ) @@ -180,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) ) ) @@ -194,7 +201,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "a,b,c", simpleUnexplode: "a,b,c", formDataExplode: "list=a&list=b&list=c", - formDataUnexplode: "list=a,b,c" + formDataUnexplode: "list=a,b,c", + deepObjectExplode: .custom("", expectedError: .deepObjectsArrayNotSupported) ) ) @@ -208,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) ) ) @@ -219,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) ) ) @@ -236,7 +246,8 @@ 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) ) ) @@ -250,7 +261,9 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "bar=24,color=red,date=2023-08-25T07%3A34%3A59Z,empty=,foo=hi%21", simpleUnexplode: "bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", formDataExplode: "bar=24&color=red&date=2023-08-25T07%3A34%3A59Z&empty=&foo=hi%21", - formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21" + formDataUnexplode: "keys=bar,24,color,red,date,2023-08-25T07%3A34%3A59Z,empty,,foo,hi%21", + deepObjectExplode: + "keys%5Bbar%5D=24&keys%5Bcolor%5D=red&keys%5Bdate%5D=2023-08-25T07%3A34%3A59Z&keys%5Bempty%5D=&keys%5Bfoo%5D=hi%21" ) ) @@ -265,7 +278,8 @@ 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( @@ -277,7 +291,8 @@ 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( @@ -289,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" ) ) @@ -304,7 +320,8 @@ final class Test_URICodingRoundtrip: Test_Runtime { simpleExplode: "", simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) @@ -318,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" ) ) @@ -329,10 +347,11 @@ final class Test_URICodingRoundtrip: Test_Runtime { .init( formExplode: "", formUnexplode: "", - simpleExplode: .custom("", value: ["": ""]), - simpleUnexplode: .custom("", value: ["": ""]), + simpleExplode: "", + simpleUnexplode: "", formDataExplode: "", - formDataUnexplode: "" + formDataUnexplode: "", + deepObjectExplode: "" ) ) } @@ -347,21 +366,28 @@ final class Test_URICodingRoundtrip: Test_Runtime { static let simpleUnexplode: Self = .init(name: "simpleUnexplode", configuration: .simpleUnexplode) static let formDataExplode: Self = .init(name: "formDataExplode", configuration: .formDataExplode) static let formDataUnexplode: Self = .init(name: "formDataUnexplode", configuration: .formDataUnexplode) + static let deepObjectExplode: Self = .init(name: "deepObjectExplode", configuration: .deepObjectExplode) } struct Variants { struct Input: ExpressibleByStringLiteral { var string: String var customValue: T? - - init(string: String, customValue: T?) { + var expectedError: URISerializer.SerializationError? + init(string: String, customValue: T?, expectedError: URISerializer.SerializationError?) { self.string = string self.customValue = customValue + self.expectedError = expectedError } - init(stringLiteral value: String) { self.init(string: value, customValue: nil) } + init(stringLiteral value: String) { self.init(string: value, customValue: nil, expectedError: nil) } - static func custom(_ string: String, value: T) -> Self { .init(string: string, customValue: value) } + static func custom(_ string: String, value: T) -> Self { + .init(string: string, customValue: value, expectedError: nil) + } + static func custom(_ string: String, expectedError: URISerializer.SerializationError) -> Self { + .init(string: string, customValue: nil, expectedError: expectedError) + } } var formExplode: Input @@ -370,22 +396,39 @@ 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 { let encoder = URIEncoder(configuration: configuration) - let encodedString = try encoder.encode(value, forKey: key) - XCTAssertEqual(encodedString, variant.string, "Variant: \(name)", file: file, line: line) - let decoder = URIDecoder(configuration: configuration) - let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) - XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) + do { + let encodedString = try encoder.encode(value, forKey: key) + XCTAssertEqual(encodedString, variant.string, "Variant: \(name)", file: file, line: line) + let decoder = URIDecoder(configuration: configuration) + let decodedValue = try decoder.decode(T.self, forKey: key, from: encodedString[...]) + XCTAssertEqual(decodedValue, variant.customValue ?? value, "Variant: \(name)", file: file, line: line) + } catch { + guard let expectedError = variant.expectedError, + let serializationError = error as? URISerializer.SerializationError + else { + XCTAssert(false, "Unexpected error thrown: \(error)", file: file, line: line) + return + } + XCTAssertEqual( + expectedError, + serializationError, + "Failed for config: \(variant.string)", + file: file, + line: line + ) + } } try testVariant(name: "formExplode", configuration: .formExplode, variant: variants.formExplode) try testVariant(name: "formUnexplode", configuration: .formUnexplode, variant: variants.formUnexplode) @@ -397,6 +440,10 @@ final class Test_URICodingRoundtrip: Test_Runtime { configuration: .formDataUnexplode, variant: variants.formDataUnexplode ) + try testVariant( + name: "deepObjectExplode", + configuration: .deepObjectExplode, + variant: variants.deepObjectExplode + ) } - } diff --git a/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift b/Tests/OpenAPIRuntimeTests/URICoder/URICoderTestUtils.swift index 375c266a..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 5040ad9c..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -ARG swift_version=5.9 -ARG ubuntu_version=jammy -ARG base_image=swift:${swift_version}-${ubuntu_version} - -FROM ${base_image} -ARG swift_version -ARG ubuntu_version - -# set as UTF-8 -RUN apt-get update && apt-get install -y locales locales-all -ENV LC_ALL en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US.UTF-8 - -# tools -RUN mkdir -p $HOME/.tools -RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile - -# swift-format -ARG swiftformat_version=509.0.0 -RUN git clone --branch $swiftformat_version --depth 1 https://github.com/apple/swift-format $HOME/.tools/swift-format-source -RUN cd $HOME/.tools/swift-format-source && swift build -c release -RUN ln -s $HOME/.tools/swift-format-source/.build/release/swift-format $HOME/.tools/swift-format - -# jq -RUN apt-get install -y jq diff --git a/docker/docker-compose.2204.510.yaml b/docker/docker-compose.2204.510.yaml deleted file mode 100644 index 02e5d46e..00000000 --- a/docker/docker-compose.2204.510.yaml +++ /dev/null @@ -1,20 +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 - # Disabled strict concurrency checking as currently it's not possible to iterate an async sequence - # from inside an actor without warnings. - # - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.2204.59.yaml b/docker/docker-compose.2204.59.yaml deleted file mode 100644 index 891fabef..00000000 --- a/docker/docker-compose.2204.59.yaml +++ /dev/null @@ -1,19 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.9 - build: - args: - ubuntu_version: "jammy" - swift_version: "5.9" - - test: - image: *image - environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.2204.590.yaml b/docker/docker-compose.2204.590.yaml deleted file mode 100644 index dacf9e3c..00000000 --- a/docker/docker-compose.2204.590.yaml +++ /dev/null @@ -1,19 +0,0 @@ -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:22.04-5.9.0 - build: - args: - ubuntu_version: "jammy" - swift_version: "5.9.0" - - test: - image: *image - environment: - - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.2204.main.yaml b/docker/docker-compose.2204.main.yaml deleted file mode 100644 index 75b44630..00000000 --- a/docker/docker-compose.2204.main.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: "3" - -services: - - runtime-setup: - image: &image swift-openapi-runtime:22.04-main - build: - args: - base_image: "swiftlang/swift:nightly-main-jammy" - - test: - image: *image - environment: - # Disable warnings as errors on nightlies as they are still in-development. - # - WARN_AS_ERROR_ARG=-Xswiftc -warnings-as-errors - - IMPORT_CHECK_ARG=--explicit-target-dependency-import-check error - - STRICT_CONCURRENCY_ARG=-Xswiftc -strict-concurrency=complete - - shell: - image: *image diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml deleted file mode 100644 index c63046bb..00000000 --- a/docker/docker-compose.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# NOTE: This file is not designed to be run independently. -# -# Instead, use it with a file for a specific OS and Swift version, for example: -# -# % docker-compose \ -# -f docker/docker-compose.yaml \ -# -f docker/docker-compose.2204.59.yaml \ -# run test -# -version: "3" - -services: - runtime-setup: - image: &image swift-openapi-runtime:default - build: - context: . - dockerfile: Dockerfile - - common: &common - image: *image - depends_on: [runtime-setup] - volumes: - - ~/.ssh:/root/.ssh - - ..:/code:z - working_dir: /code - - soundness: - <<: *common - command: /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