From 25de572b9a03871e4515c710e1040dc281c892c7 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Fri, 1 Dec 2023 08:25:50 -0600 Subject: [PATCH] Upgrade everything to Swift 5.9 and Sendable correctness, use SwiftHTTPTypes (#10) * Upgrade everything to Swift 5.9 and Sendable correctness, also use SwiftHTTPTypes. What a zoo. * Remove BackgroundTaskHandler entirely. * Heavily improve CI. Revise README. Improve behavior of the URLSessionTransport vis a vis locking. * Create dependabot.yml * Add code coverage uploads, CodeQL scanning, and dependency graph submission --- .github/dependabot.yml | 23 ++ .github/workflows/test.yml | 162 +++++++---- Package.swift | 30 +- README.md | 32 ++- .../HTTP/HTTPStatusCode+Conformances.swift | 256 ------------------ .../HTTP/HTTPStatusCode.swift | 92 ------- .../Handlers/AddHTTPHeadersHandler.swift | 8 +- .../Handlers/BackgroundTaskHandler.swift | 90 ------ .../Handlers/TokenAuthenticationHandler.swift | 60 ++-- .../NetworkClient+AsyncAwait.swift | 4 - .../NetworkClient+Combine.swift | 67 ----- .../StructuredAPIClient/NetworkClient.swift | 29 +- .../StructuredAPIClient/NetworkRequest.swift | 9 +- .../Transport/Transport+Combine.swift | 33 --- .../Transport/Transport.swift | 15 +- .../Transport/TransportFailure.swift | 5 +- .../URLSessionTransport+AsyncAwait.swift | 8 +- .../URLSessionTransport+Combine.swift | 40 --- .../Transport/URLSessionTransport.swift | 69 +++-- .../TestTokenProvider.swift | 12 +- .../TestTransport.swift | 46 +++- .../HTTPStatusCodeTests.swift | 44 --- .../NetworkClientTests.swift | 46 ++-- .../NetworkClientWithAsyncAwaitTests.swift | 12 +- .../NetworkClientWithCombineTests.swift | 100 ------- 25 files changed, 385 insertions(+), 907 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 Sources/StructuredAPIClient/HTTP/HTTPStatusCode+Conformances.swift delete mode 100644 Sources/StructuredAPIClient/HTTP/HTTPStatusCode.swift delete mode 100644 Sources/StructuredAPIClient/Handlers/BackgroundTaskHandler.swift delete mode 100644 Sources/StructuredAPIClient/NetworkClient+Combine.swift delete mode 100644 Sources/StructuredAPIClient/Transport/Transport+Combine.swift delete mode 100644 Sources/StructuredAPIClient/Transport/URLSessionTransport+Combine.swift delete mode 100644 Tests/StructuredAPIClientTests/HTTPStatusCodeTests.swift delete mode 100644 Tests/StructuredAPIClientTests/NetworkClientWithCombineTests.swift diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d2faa2f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + allow: + - dependency-type: all + groups: + dependencies: + patterns: + - "*" + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "weekly" + allow: + - dependency-type: all + groups: + all-dependencies: + patterns: + - "*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78f8c42..307ad0c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,66 +1,134 @@ -name: test +name: Tests +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true on: - push: - branches: - - main - pull_request: + pull_request: { types: [opened, reopened, synchronize, ready_for_review] } + push: { branches: [ main ] } +env: + LOG_LEVEL: info jobs: appleos: + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: xcode: - latest - - latest-stable - destination: - - 'platform=macOS,arch=x86_64' - #- 'platform=macOS,arch=arm64' - - 'platform=iOS Simulator,OS=latest,name=iPhone 11 Pro' - - 'platform=tvOS Simulator,OS=latest,name=Apple TV 4K' - - 'platform=watchOS Simulator,OS=latest,name=Apple Watch Series 6 - 44mm' - runs-on: macos-11.0 + #- latest-stable + platform: + - 'macOS' + - 'iOS Simulator' + - 'tvOS Simulator' + - 'watchOS Simulator' + include: + - platform: 'macOS' + destination: 'arch=x86_64' + - platform: 'iOS Simulator' + destination: 'OS=latest,name=iPhone 15 Pro' + - platform: 'tvOS Simulator' + destination: 'OS=latest,name=Apple TV 4K (3rd generation)' + - platform: 'watchOS Simulator' + destination: 'OS=latest,name=Apple Watch Series 9 (45mm)' + name: ${{ matrix.platform }} Tests + runs-on: macos-13 steps: - - name: Select latest available Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.xcode }} - - name: Checkout - uses: actions/checkout@v2 - - name: Run tests for ${{ matrix.destination }} - run: xcodebuild test -scheme StructuredAPIClient-Package -enableThreadSanitizer YES -destination '${{ matrix.destination }}' + - name: Select latest available Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode }} + - name: Install xcbeautify + run: brew install xcbeautify + - name: Checkout code + uses: actions/checkout@v4 + - name: Run tests + env: + DESTINATION: ${{ format('platform={0},{1}', matrix.platform, matrix.destination) }} + run: | + set -o pipefail && \ + xcodebuild test -scheme StructuredAPIClient-Package \ + -enableThreadSanitizer YES \ + -enableCodeCoverage YES \ + -disablePackageRepositoryCache \ + -resultBundlePath "${GITHUB_WORKSPACE}/results.resultBundle" \ + -destination "${DESTINATION}" | + xcbeautify --is-ci --quiet --renderer github-actions + - name: Upload coverage data + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + swift: true + verbose: true + xcode: true + xcode_archive_path: ${{ github.workspace }}/results.resultBundle linux: - runs-on: ubuntu-latest + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: - ver: - - swift:5.3 - - swift:5.4 - - swift:5.5 - - swiftlang/swift:nightly-main - os: - - bionic - - focal - - amazonlinux2 + swift-image: + - swift:5.9-jammy + - swiftlang/swift:nightly-5.10-jammy + - swiftlang/swift:nightly-main-jammy + name: Linux ${{ matrix.swift-image }} Tests + runs-on: ubuntu-latest + container: ${{ matrix.swift-image }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install xcbeautify + run: | + DEBIAN_FRONTEND=noninteractive apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y curl + curl -fsSLO 'https://github.com/tuist/xcbeautify/releases/download/1.0.1/xcbeautify-1.0.1-x86_64-unknown-linux-gnu.tar.xz' + tar -x -J -f xcbeautify-1.0.1-x86_64-unknown-linux-gnu.tar.xz + - name: Run tests + shell: bash + run: | + set -o pipefail && \ + swift test --sanitize=thread --enable-code-coverage | + ./xcbeautify --is-ci --quiet --renderer github-actions + - name: Upload coverage data + uses: vapor/swift-codecov-action@v0.2 + with: + cc_token: ${{ secrets.CODECOV_TOKEN }} + cc_verbose: true + + codeql: + if: ${{ !(github.event.pull_request.draft || false) }} + name: CodeQL Analysis + runs-on: ubuntu-latest container: - image: ${{ matrix.ver }}-${{ matrix.os }} + image: swift:5.9-jammy + permissions: { actions: write, contents: read, security-events: write } steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Run tests for ${{ matrix.runner }} - run: swift test --enable-test-discovery --sanitize=thread + - name: Checkout code + uses: actions/checkout@v4 + - name: Mark repo safe + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: { languages: swift } + - name: Perform build + run: swift build + - name: Run CodeQL analyze + uses: github/codeql-action/analyze@v2 - windows: - runs-on: windows-latest + dependency-graph: + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + container: swift:jammy + permissions: + contents: write steps: - - name: Install Swift toolchain - uses: compnerd/gha-setup-swift@main - with: - branch: swift-5.5-release - tag: 5.5-RELEASE - - name: Checkout - uses: actions/checkout@v2 - - name: Run tests - run: swift test + - name: Check out code + uses: actions/checkout@v4 + - name: Set up dependencies + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + apt-get update && apt-get install -y curl + - name: Submit dependency graph + uses: vapor-community/swift-dependency-submission@v0.1 diff --git a/Package.swift b/Package.swift index 942c7d4..46d0e2f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 //===----------------------------------------------------------------------===// // // This source file is part of the StructuredAPIClient open source project @@ -14,6 +14,14 @@ import PackageDescription +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] + let package = Package( name: "StructuredAPIClient", products: [ @@ -22,20 +30,32 @@ let package = Package( ], dependencies: [ // Swift logging API - .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.4.0")), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), ], targets: [ .target( name: "StructuredAPIClient", - dependencies: [.product(name: "Logging", package: "swift-log")]), + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "HTTPTypes", package: "swift-http-types"), + ], + swiftSettings: swiftSettings + ), .target( name: "StructuredAPIClientTestSupport", - dependencies: [.target(name: "StructuredAPIClient")]), + dependencies: [ + .target(name: "StructuredAPIClient"), + ], + swiftSettings: swiftSettings + ), .testTarget( name: "StructuredAPIClientTests", dependencies: [ .target(name: "StructuredAPIClient"), .target(name: "StructuredAPIClientTestSupport"), - ]), + ], + swiftSettings: swiftSettings + ), ] ) diff --git a/README.md b/README.md index dc584d5..4ddbb26 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ -# StructuredAPIClient +#
StructuredAPIClient
+ +

- MIT License +MIT License - - Swift 5.3 + + +CI - - CI + + + + +Swift 5.9 + + +

+ A testable and composable network client. -### Supported Platforms +For more information, browse the API documentation (_coming soon_). + +## Supported Platforms StructuredAPIClient is tested on macOS, iOS, tvOS, Linux, and Windows, and is known to support the following operating system versions: -* Ubuntu 16.04+ +* Ubuntu 18.04+ * AmazonLinux2 * macOS 10.12+ * iOS 12+ @@ -28,10 +40,10 @@ To integrate the package: ```swift dependencies: [ - .package(url: "https://github.com/stairtree/StructuredAPIClient.git", from: "1.1.1") + .package(url: "https://github.com/stairtree/StructuredAPIClient.git", from: "2.0.0") ] ``` --- -Inspired by blog posts by [Rob Napier](https://robnapier.net) and [Soroush Khanlou](http://khanlou.com), as well as the [Testing Tips & Tricks](https://developer.apple.com/videos/play/wwdc2018/417/) WWDC talk. +Inspired by blog posts by [Rob Napier](https://robnapier.net) and [Soroush Khanlou](http://khanlou.com), as well as the [Testing Tips & Tricks](https://developer.apple.com/videos/play/wwdc2018/417/) WWDC talk. Version 2.0 revised for full Concurrency support by @gwynne. diff --git a/Sources/StructuredAPIClient/HTTP/HTTPStatusCode+Conformances.swift b/Sources/StructuredAPIClient/HTTP/HTTPStatusCode+Conformances.swift deleted file mode 100644 index f99d492..0000000 --- a/Sources/StructuredAPIClient/HTTP/HTTPStatusCode+Conformances.swift +++ /dev/null @@ -1,256 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -extension HTTPStatusCode: RawRepresentable { - /// See `RawRepresentable.RawValue`. - public typealias RawValue = Int - - /// See `RawRepresentable.init?(rawValue:)`. - public init?(rawValue: Int) { - switch rawValue { - case 100: self = .continue - case 101: self = .switchingProtocols - case 102: self = .processing - case 103: self = .earlyHints - case 200: self = .ok - case 201: self = .created - case 202: self = .accepted - case 203: self = .nonAuthoritativeInformation - case 204: self = .noContent - case 205: self = .resetContent - case 206: self = .partialContent - case 207: self = .multiStatus - case 208: self = .alreadyReported - case 226: self = .imUsed - case 300: self = .multipleChoices - case 301: self = .movedPermanently - case 302: self = .found - case 303: self = .seeOther - case 304: self = .notModified - case 305: self = .useProxy - case 307: self = .temporaryRedirect - case 308: self = .permanentRedirect - case 400: self = .badRequest - case 401: self = .unauthorized - case 402: self = .paymentRequired - case 403: self = .forbidden - case 404: self = .notFound - case 405: self = .methodNotAllowed - case 406: self = .notAcceptable - case 407: self = .proxyAuthenticationRequired - case 408: self = .requestTimeout - case 409: self = .conflict - case 410: self = .gone - case 411: self = .lengthRequired - case 412: self = .preconditionFailed - case 413: self = .payloadTooLarge - case 414: self = .uriTooLong - case 415: self = .unsupportedMediaType - case 416: self = .rangeNotSatisfiable - case 417: self = .expectationFailed - case 418: self = .imATeapot - case 421: self = .misdirectedRequest - case 422: self = .unprocessableEntity - case 423: self = .locked - case 424: self = .failedDependency - case 426: self = .upgradeRequired - case 428: self = .preconditionRequired - case 429: self = .tooManyRequests - case 431: self = .requestHeaderFieldsTooLarge - case 451: self = .unavailableForLegalReasons - case 500: self = .internalServerError - case 501: self = .notImplemented - case 502: self = .badGateway - case 503: self = .serviceUnavailable - case 504: self = .gatewayTimeout - case 505: self = .httpVersionNotSupported - case 506: self = .variantAlsoNegotiates - case 507: self = .insufficientStorage - case 508: self = .loopDetected - case 510: self = .notExtended - case 511: self = .networkAuthenticationRequired - case (100..<600): self = .custom(rawValue) - default: return nil - } - } - - /// See `RawRepresentable.rawValue`. - public var rawValue: Int { - // The mapping dictionary can't be used to shorthand this accessor because it inevitably leads to an infinite - // recursion (both comparison and hashing are done by raw value). - switch self { - case .continue: return 100 - case .switchingProtocols: return 101 - case .processing: return 102 - case .earlyHints: return 103 - case .ok: return 200 - case .created: return 201 - case .accepted: return 202 - case .nonAuthoritativeInformation: return 203 - case .noContent: return 204 - case .resetContent: return 205 - case .partialContent: return 206 - case .multiStatus: return 207 - case .alreadyReported: return 208 - case .imUsed: return 226 - case .multipleChoices: return 300 - case .movedPermanently: return 301 - case .found: return 302 - case .seeOther: return 303 - case .notModified: return 304 - case .useProxy: return 305 - case .temporaryRedirect: return 307 - case .permanentRedirect: return 308 - case .badRequest: return 400 - case .unauthorized: return 401 - case .paymentRequired: return 402 - case .forbidden: return 403 - case .notFound: return 404 - case .methodNotAllowed: return 405 - case .notAcceptable: return 406 - case .proxyAuthenticationRequired: return 407 - case .requestTimeout: return 408 - case .conflict: return 409 - case .gone: return 410 - case .lengthRequired: return 411 - case .preconditionFailed: return 412 - case .payloadTooLarge: return 413 - case .uriTooLong: return 414 - case .unsupportedMediaType: return 415 - case .rangeNotSatisfiable: return 416 - case .expectationFailed: return 417 - case .imATeapot: return 418 - case .misdirectedRequest: return 421 - case .unprocessableEntity: return 422 - case .locked: return 423 - case .failedDependency: return 424 - case .upgradeRequired: return 426 - case .preconditionRequired: return 428 - case .tooManyRequests: return 429 - case .requestHeaderFieldsTooLarge: return 431 - case .unavailableForLegalReasons: return 451 - case .internalServerError: return 500 - case .notImplemented: return 501 - case .badGateway: return 502 - case .serviceUnavailable: return 503 - case .gatewayTimeout: return 504 - case .httpVersionNotSupported: return 505 - case .variantAlsoNegotiates: return 506 - case .insufficientStorage: return 507 - case .loopDetected: return 508 - case .notExtended: return 510 - case .networkAuthenticationRequired: return 511 - case .custom(let value): return value - } - } -} - -extension HTTPStatusCode: CaseIterable { - /// See `CaseIterable.allCases`. - public static var allCases: [HTTPStatusCode] { - return (100..<600) - .compactMap { Self.init(rawValue: $0) } - .filter { $0 != Self.custom($0.rawValue) } - } -} - -extension HTTPStatusCode: Equatable { - /// See `Equatable.==(_:_:)`. - public static func ==(_ lhs: Self, _ rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.custom(let lvalue), .custom(let rvalue)): - return lvalue == rvalue - case (.custom(_), _), (_, .custom(_)): - return false - case (let lvalue, let rvalue): - return lvalue.rawValue == rvalue.rawValue - } - } -} - -extension HTTPStatusCode: Hashable { - /// See `Hashable.hash(into:)`. - public func hash(into hasher: inout Hasher) { - hasher.combine(self.rawValue) - } -} - -extension HTTPStatusCode: CustomStringConvertible { - /// See `CustomStringConvertible.description`. - public var description: String { - switch self { - case .continue: return "Continue" - case .switchingProtocols: return "Switching Protocols" - case .processing: return "Processing" - case .earlyHints: return "Early Hints" - case .ok: return "OK" - case .created: return "Created" - case .accepted: return "Accepted" - case .nonAuthoritativeInformation: return "Non-Authoritative Information" - case .noContent: return "No Content" - case .resetContent: return "Reset Content" - case .partialContent: return "Partial Content" - case .multiStatus: return "Multi-Status" - case .alreadyReported: return "Already Reported" - case .imUsed: return "IM Used" - case .multipleChoices: return "Multiple Choices" - case .movedPermanently: return "Moved Permanently" - case .found: return "Found" - case .seeOther: return "See Other" - case .notModified: return "Not Modified" - case .useProxy: return "Use Proxy" - case .temporaryRedirect: return "Temporary Redirect" - case .permanentRedirect: return "Permanent Redirect" - case .badRequest: return "Bad Request" - case .unauthorized: return "Unauthorized" - case .paymentRequired: return "Payment Required" - case .forbidden: return "Forbidden" - case .notFound: return "Not Found" - case .methodNotAllowed: return "Method Not Allowed" - case .notAcceptable: return "Not Acceptable" - case .proxyAuthenticationRequired: return "Proxy Authentication Required" - case .requestTimeout: return "Request Timeout" - case .conflict: return "Conflict" - case .gone: return "Gone" - case .lengthRequired: return "Length Required" - case .preconditionFailed: return "Precondition Failed" - case .payloadTooLarge: return "Payload Too Large" - case .uriTooLong: return "URI Too Long" - case .unsupportedMediaType: return "Unsupported Media Type" - case .rangeNotSatisfiable: return "Range Not Satisfiable" - case .expectationFailed: return "Expectation Failed" - case .imATeapot: return "I'm a teapot" - case .misdirectedRequest: return "Misdirected Request" - case .unprocessableEntity: return "Unprocessable Entity" - case .locked: return "Locked" - case .failedDependency: return "Failed Dependency" - case .upgradeRequired: return "Upgrade Required" - case .preconditionRequired: return "Precondition Required" - case .tooManyRequests: return "Too Many Requests" - case .requestHeaderFieldsTooLarge: return "Request Header Fields Too Large" - case .unavailableForLegalReasons: return "Unavailable For Legal Reasons" - case .internalServerError: return "Internal Server Error" - case .notImplemented: return "Not Implemented" - case .badGateway: return "Bad Gateway" - case .serviceUnavailable: return "Service Unavailable" - case .gatewayTimeout: return "Gateway Timeout" - case .httpVersionNotSupported: return "HTTP Version Not Supported" - case .variantAlsoNegotiates: return "Variant Also Negotiates" - case .insufficientStorage: return "Insufficient Storage" - case .loopDetected: return "Loop Detected" - case .notExtended: return "Not Extended" - case .networkAuthenticationRequired: return "Network Authentication Required" - case .custom(let value): return "\(value)" - } - } -} diff --git a/Sources/StructuredAPIClient/HTTP/HTTPStatusCode.swift b/Sources/StructuredAPIClient/HTTP/HTTPStatusCode.swift deleted file mode 100644 index a36baed..0000000 --- a/Sources/StructuredAPIClient/HTTP/HTTPStatusCode.swift +++ /dev/null @@ -1,92 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -/// As complete an enumeration of official HTTP status codes as is reasonably possible, plus the option to specify -/// custom status codes numerically, with full conformance to all the usual protocols. -/// -/// Yes, 418 is official. Yes, really. -public enum HTTPStatusCode { - // 1xx - case `continue` - case switchingProtocols - case processing - case earlyHints - - // 2xx - case ok - case created - case accepted - case nonAuthoritativeInformation - case noContent - case resetContent - case partialContent - case multiStatus - case alreadyReported - case imUsed - - // 3xx - case multipleChoices - case movedPermanently - case found - case seeOther - case notModified - case useProxy - case temporaryRedirect - case permanentRedirect - - // 4xx - case badRequest - case unauthorized - case paymentRequired - case forbidden - case notFound - case methodNotAllowed - case notAcceptable - case proxyAuthenticationRequired - case requestTimeout - case conflict - case gone - case lengthRequired - case preconditionFailed - case payloadTooLarge - case uriTooLong - case unsupportedMediaType - case rangeNotSatisfiable - case expectationFailed - case imATeapot - case misdirectedRequest - case unprocessableEntity - case locked - case failedDependency - case upgradeRequired - case preconditionRequired - case tooManyRequests - case requestHeaderFieldsTooLarge - case unavailableForLegalReasons - - // 5xx - case internalServerError - case notImplemented - case badGateway - case serviceUnavailable - case gatewayTimeout - case httpVersionNotSupported - case variantAlsoNegotiates - case insufficientStorage - case loopDetected - case notExtended - case networkAuthenticationRequired - - // other - case custom(Int) -} diff --git a/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift b/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift index e80855c..a1df7f5 100644 --- a/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift +++ b/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift @@ -20,7 +20,7 @@ import FoundationNetworking public final class AddHTTPHeadersHandler: Transport { /// An enumeration of the possible modes for working with headers. - public enum Mode: CaseIterable { + public enum Mode: CaseIterable, Sendable { /// Accumulating behavior - if a given header is already specified by a request, the transport's value is /// appended, as per `URLRequest.addValue(_:forHTTPHeaderField:)`. /// @@ -42,7 +42,7 @@ public final class AddHTTPHeadersHandler: Transport { /// The base `Transport` to extend with extra headers. /// /// - Note: Never `nil` in practice for this transport. - public let next: Transport? + public let next: (any Transport)? /// Additional headers that will be applied to the request upon sending. private let headers: [String: String] @@ -55,13 +55,13 @@ public final class AddHTTPHeadersHandler: Transport { /// - base: The base `Transport` that will have the headers applied /// - headers: Headers to apply to the base `Transport` /// - mode: The mode to use for resolving conflicts between a request's headers and the transport's headers. - public init(base: Transport, headers: [String: String], mode: Mode = .add) { + public init(base: any Transport, headers: [String: String], mode: Mode = .add) { self.next = base self.headers = headers self.mode = mode } - public func send(request: URLRequest, completion: @escaping (Result) -> Void) { + public func send(request: URLRequest, completion: @escaping @Sendable (Result) -> Void) { var newRequest = request for (key, value) in self.headers { diff --git a/Sources/StructuredAPIClient/Handlers/BackgroundTaskHandler.swift b/Sources/StructuredAPIClient/Handlers/BackgroundTaskHandler.swift deleted file mode 100644 index 99f8274..0000000 --- a/Sources/StructuredAPIClient/Handlers/BackgroundTaskHandler.swift +++ /dev/null @@ -1,90 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -import Logging - -#if !os(macOS) && canImport(UIKit) -import UIKit - -public final class BackgroundExtendingHandler: Transport { - - /// The base `Transport` to extend. - public let next: Transport? - - /// A debug name for the background task. It will be suffixed with the request's url. - private let name: String? - - /// Called synchronously on the main thread shortly before the app is suspended. - private let expirationHandler: (() -> Void)? - - private var started: Bool = false - - public init(base: Transport, name: String?, expirationHandler: (() -> Void)?) { - self.next = base - self.name = name - self.expirationHandler = expirationHandler - } - - public func send(request: URLRequest, completion: @escaping (Result) -> Void) { - if #available( - iOSApplicationExtension 9, - tvOSApplicationExtension 9, - macCatalystApplicationExtension 13, - watchOS 2, - iOS 999, tvOS 999, macCatalyst 999, * - ) { - let reason = request.debugString - - ProcessInfo().performExpiringActivity(withReason: reason, using: { expired in - // Being called with `expired` without being `started` means - // the background assertion was not granted. - if expired && !self.started { - self.cancel() - return completion(.failure(TransportFailure.cancelled)) - } - - self.started = true - guard !expired else { - return self.cancel() - } - - self.next!.send(request: request, completion: completion) - }) - } else { - #if !os(watchOS) - let reason = "\(name.map { "\($0)-" } ?? "")\(request.debugString)" - var identifier: UIBackgroundTaskIdentifier! - - identifier = UIApplication.shared.beginBackgroundTask(withName: reason, expirationHandler: { [weak self] in - self?.expirationHandler?() - UIApplication.shared.endBackgroundTask(identifier) - }) - - guard identifier != .invalid else { - self.cancel() - return completion(.failure(TransportFailure.cancelled)) - } - - self.next!.send(request: request) { response in - completion(response) - UIApplication.shared.endBackgroundTask(identifier) - } - #endif - } - } -} -#endif diff --git a/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift b/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift index 1482813..595ad36 100644 --- a/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift +++ b/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift @@ -13,23 +13,23 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking +@preconcurrency import FoundationNetworking #endif import Logging // Handle token auth and add appropriate auth headers to an existing transport. public final class TokenAuthenticationHandler: Transport { - public let next: Transport? + public let next: (any Transport)? private let logger: Logger private let auth: AuthState - public init(base: Transport, accessToken: Token? = nil, refreshToken: Token? = nil, tokenProvider: TokenProvider, logger: Logger? = nil) { + public init(base: any Transport, accessToken: (any Token)? = nil, refreshToken: (any Token)? = nil, tokenProvider: any TokenProvider, logger: Logger? = nil) { self.next = base self.logger = logger ?? Logger(label: "TokenAuth") self.auth = AuthState(accessToken: accessToken, refreshToken: refreshToken, provider: tokenProvider, logger: logger) } - public func send(request: URLRequest, completion: @escaping (Result) -> Void) { + public func send(request: URLRequest, completion: @escaping @Sendable (Result) -> Void) { self.auth.token { result in switch result { case let .failure(error): @@ -46,42 +46,56 @@ public final class TokenAuthenticationHandler: Transport { public protocol TokenProvider { // Get access token and refresh token - func fetchToken(completion: @escaping (Result<(Token, Token), Error>) -> Void) + func fetchToken(completion: @escaping @Sendable (Result<(any Token, any Token), any Error>) -> Void) // Refreh the current token - func refreshToken(withRefreshToken refreshToken: Token, completion: @escaping (Result) -> Void) + func refreshToken(withRefreshToken refreshToken: any Token, completion: @escaping @Sendable (Result) -> Void) } -public protocol Token { +public protocol Token: Sendable { var raw: String { get } var expiresAt: Date? { get } } -final class AuthState { - var accessToken: Token? = nil - var refreshToken: Token? = nil - - let provider: TokenProvider +final class AuthState: @unchecked Sendable { + private final class LockedTokens: @unchecked Sendable { + private let lock = NSLock() + private var accessToken: (any Token)? + private var refreshToken: (any Token)? + + init(accessToken: (any Token)?, refreshToken: (any Token)?) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + func withLock(_ closure: @escaping @Sendable (inout (any Token)?, inout (any Token)?) throws -> R) rethrows -> R { + try self.lock.withLock { + try closure(&self.accessToken, &self.refreshToken) + } + } + } + + private let tokens: LockedTokens + let provider: any TokenProvider let logger: Logger - internal init(accessToken: Token? = nil, refreshToken: Token? = nil, provider: TokenProvider, logger: Logger? = nil) { - self.accessToken = accessToken - self.refreshToken = refreshToken + internal init(accessToken: (any Token)? = nil, refreshToken: (any Token)? = nil, provider: any TokenProvider, logger: Logger? = nil) { + self.tokens = .init(accessToken: accessToken, refreshToken: refreshToken) self.provider = provider self.logger = logger ?? Logger(label: "AuthState") } - func token(_ completion: @escaping (Result) -> Void) { - if let access = self.accessToken, (access.expiresAt ?? Date.distantFuture) > Date() { - return completion(.success(access.raw)) - } else if let refresh = self.refreshToken, (refresh.expiresAt ?? Date.distantFuture) > Date() { + func token(_ completion: @escaping @Sendable (Result) -> Void) { + if let raw = self.tokens.withLock({ token, _ in token.flatMap { ($0.expiresAt ?? Date.distantFuture) > Date() ? $0.raw : nil } }) { + return completion(.success(raw)) + } else if let refresh = self.tokens.withLock({ _, token in token.flatMap { ($0.expiresAt ?? Date.distantFuture) > Date() ? $0 : nil } }) { logger.trace("Refreshing token") self.provider.refreshToken(withRefreshToken: refresh, completion: { result in switch result { case let .failure(error): return completion(.failure(error)) case let .success(access): - self.accessToken = access + self.tokens.withLock { token, _ in token = access } return completion(.success(access.raw)) } }) @@ -92,8 +106,10 @@ final class AuthState { case let .failure(error): return completion(.failure(error)) case let .success((access, refresh)): - self.accessToken = access - self.refreshToken = refresh + self.tokens.withLock { accessToken, refreshToken in + accessToken = access + refreshToken = refresh + } return completion(.success(access.raw)) } }) diff --git a/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift b/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift index cc16f2b..edcf30e 100644 --- a/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift +++ b/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift @@ -16,8 +16,6 @@ import Foundation import FoundationNetworking #endif -#if compiler(>=5.5) && canImport(_Concurrency) && canImport(Darwin) - @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) extension NetworkClient { public func load(_ req: Request) async throws -> Request.ResponseDataType { @@ -29,5 +27,3 @@ extension NetworkClient { } } } - -#endif diff --git a/Sources/StructuredAPIClient/NetworkClient+Combine.swift b/Sources/StructuredAPIClient/NetworkClient+Combine.swift deleted file mode 100644 index 2c617e4..0000000 --- a/Sources/StructuredAPIClient/NetworkClient+Combine.swift +++ /dev/null @@ -1,67 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -import Logging -#if canImport(Combine) -import Combine - -@available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) -extension NetworkClient { - /// Provides a Combine `Publisher` which emits the result of sending a given `NetworkRequest`. - public func requestPublisher(for req: Request) -> AnyPublisher { - var start: DispatchTime = .distantFuture - - // Jump into the Combine universe by wrapping the URLRequest creation in a Result's publisher. - return Result { - try req.makeRequest(baseURL: self.baseURL) - }.publisher - - // Start the request timing from the point where a subscription is made. See below for why we do this here. - // Log the URL request. - .handleEvents( - receiveSubscription: { _ in start = .now() }, - receiveOutput: { self.logger.trace(Logger.Message(stringLiteral: $0.debugString)) } - ) - - // Invoke our Transport's publisher to send the request, but use Deferred to avoid performing the actual send - // until someting downstream has subscribed to this pipeline. - .map { urlRequest in - Deferred { - return self.transport().publisher(forRequest: urlRequest) - } - - // Log the time between the request send and the receipt of a response. Must be chained to the Deferred - // publisher directly instead of the outer pipeline in order to have access to the URLRequest. - .handleEvents(receiveOutput: { _ in - self.logger.trace("Request '\(urlRequest.debugString)' took \(String(format: "%.4f", (.now() - start).milliseconds))ms") - }) - } - - // "Flatten" the upstream publisher so we see the transport's result instead of just the publisher. - .switchToLatest() - - // Parse the response returned by the transport as success or API error according to status code. - .tryMap { response in - try req.parseResponse(response) - } - - // Type-erase the pipeline. - .eraseToAnyPublisher() - } -} - -#endif // canImport(Combine) diff --git a/Sources/StructuredAPIClient/NetworkClient.swift b/Sources/StructuredAPIClient/NetworkClient.swift index 7bf2b73..6b3f462 100644 --- a/Sources/StructuredAPIClient/NetworkClient.swift +++ b/Sources/StructuredAPIClient/NetworkClient.swift @@ -13,34 +13,35 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking +@preconcurrency import FoundationNetworking #endif import Logging public final class NetworkClient { public let baseURL: URL - let transport: () -> Transport + let transport: () -> any Transport let logger: Logger /// Create a new `NetworkClient` from a base URL, a `Transport`, and an optional `Logger`. - public init(baseURL: URL, transport: @escaping @autoclosure () -> Transport = URLSessionTransport(.shared), logger: Logger? = nil) { + public init(baseURL: URL, transport: @escaping @Sendable @autoclosure () -> any Transport = URLSessionTransport(.shared), logger: Logger? = nil) { self.baseURL = baseURL self.transport = transport self.logger = logger ?? Logger(label: "NetworkClient") } /// Fetch any `NetworkRequest` type and return the response asynchronously. - public func load(_ req: Request, completion: @escaping (Result) -> Void) { + public func load(_ req: Request, completion: @escaping @Sendable (Result) -> Void) { let start = DispatchTime.now() // Construct the URLRequest do { - let urlRequest = try req.makeRequest(baseURL: baseURL) - logger.trace(Logger.Message(stringLiteral: urlRequest.debugString)) + let logger = self.logger + let urlRequest = try req.makeRequest(baseURL: baseURL) + logger.trace("\(urlRequest.debugString)") // Send it to the transport transport().send(request: urlRequest) { result in // TODO: Deliver a more accurate split of the different phases of the request - defer { self.logger.trace("Request '\(urlRequest.debugString)' took \(String(format: "%.4f", (.now() - start).milliseconds))ms") } + defer { logger.trace("Request '\(urlRequest.debugString)' took \(String(format: "%.4f", (.now() - start).milliseconds))ms") } completion(result.flatMap { resp in .init { try req.parseResponse(resp) } }) } @@ -52,10 +53,20 @@ public final class NetworkClient { internal extension DispatchTime { static func -(lhs: Self, rhs: Self) -> Self { - return .init(uptimeNanoseconds: lhs.uptimeNanoseconds - rhs.uptimeNanoseconds) + .init(uptimeNanoseconds: lhs.uptimeNanoseconds - rhs.uptimeNanoseconds) } var milliseconds: Double { - return Double(self.uptimeNanoseconds) / 1_000_000 + Double(self.uptimeNanoseconds) / 1_000_000 } } + +#if !canImport(Darwin) +extension NSLocking { + package func withLock(_ body: @Sendable () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try body() + } +} +#endif diff --git a/Sources/StructuredAPIClient/NetworkRequest.swift b/Sources/StructuredAPIClient/NetworkRequest.swift index 8ae742b..3c8a67d 100644 --- a/Sources/StructuredAPIClient/NetworkRequest.swift +++ b/Sources/StructuredAPIClient/NetworkRequest.swift @@ -15,11 +15,12 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import HTTPTypes /// Any request that can be sent as a `URLRequest` with a `NetworkClient`, and returns a response. -public protocol NetworkRequest { +public protocol NetworkRequest: Sendable { /// The decoded data type that represents the response. - associatedtype ResponseDataType + associatedtype ResponseDataType: Sendable /// Returns a request based on the given base URL. /// - Parameter baseURL: The `NetworkClient`'s base URL. @@ -35,10 +36,10 @@ public protocol NetworkRequest { /// A convenient error type to use for handling non-2xx HTTP status codes. public struct APIError: Error { - public let status: HTTPStatusCode + public let status: HTTPResponse.Status public let body: Data - public init(status: HTTPStatusCode, body: Data) { + public init(status: HTTPResponse.Status, body: Data) { self.status = status self.body = body } diff --git a/Sources/StructuredAPIClient/Transport/Transport+Combine.swift b/Sources/StructuredAPIClient/Transport/Transport+Combine.swift deleted file mode 100644 index d2faf97..0000000 --- a/Sources/StructuredAPIClient/Transport/Transport+Combine.swift +++ /dev/null @@ -1,33 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -#if canImport(Combine) -import Combine - -@available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) -extension Transport { - /// Provide a default implementation of `Transport.publisher(forRequest:)` for "traditional" transports. As per - /// standard practice, this simply wraps `Transport.send(request:completion:)` with the `Future` published. - /// - /// Combine-aware `Transport`s may, if they choose, provide their own implementation of this method. - public func publisher(forRequest request: URLRequest) -> AnyPublisher { - return Future { self.send(request: request, completion: $0) }.eraseToAnyPublisher() - } -} - -#endif // canImport(Combine) diff --git a/Sources/StructuredAPIClient/Transport/Transport.swift b/Sources/StructuredAPIClient/Transport/Transport.swift index b17007a..881e71e 100644 --- a/Sources/StructuredAPIClient/Transport/Transport.swift +++ b/Sources/StructuredAPIClient/Transport/Transport.swift @@ -15,20 +15,21 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +import HTTPTypes /// A successful response from a `Transport`. -public struct TransportResponse { +public struct TransportResponse: Sendable { /// The received HTTP status code. - public let status: HTTPStatusCode + public let status: HTTPResponse.Status /// Any received HTTP headers, if the transport in use provides them. - public let headers: [String: String] + public let headers: HTTPFields /// The raw HTTP response body. If there was no response body, this will have a zero length. public let body: Data /// Create a new `TransportResponse`. Intended for use by `Transport` implementations. - public init(status: HTTPStatusCode, headers: [String: String], body: Data) { + public init(status: HTTPResponse.Status, headers: HTTPFields, body: Data) { self.status = status self.headers = headers self.body = body @@ -36,7 +37,7 @@ public struct TransportResponse { } /// A `Transport` maps a `URLRequest` to a `Status` and `Data` pair asynchronously. -public protocol Transport { +public protocol Transport: Sendable { /// Sends the request and delivers the response asynchronously to a completion handler. /// /// Transports should make an effort to provide the most specific errors possible for failures. In particular, the @@ -46,12 +47,12 @@ public protocol Transport { /// - request: The request to be sent. /// - completion: The completion handler that is called after the response is received. /// - response: The received response from the server, or an error indicating a transport-level failure. - func send(request: URLRequest, completion: @escaping (_ result: Result) -> Void) + func send(request: URLRequest, completion: @escaping @Sendable (_ result: Result) -> Void) /// The next Transport that the request is being forwarded to. /// /// If `nil`, this should be the final `Transport`. - var next: Transport? { get } + var next: (any Transport)? { get } /// Cancel the request. /// diff --git a/Sources/StructuredAPIClient/Transport/TransportFailure.swift b/Sources/StructuredAPIClient/Transport/TransportFailure.swift index b434ddd..55d4dbb 100644 --- a/Sources/StructuredAPIClient/Transport/TransportFailure.swift +++ b/Sources/StructuredAPIClient/Transport/TransportFailure.swift @@ -11,6 +11,9 @@ // //===----------------------------------------------------------------------===// +#if !canImport(Darwin) +@preconcurrency +#endif import Foundation #if canImport(FoundationNetworking) import FoundationNetworking @@ -20,7 +23,7 @@ public enum TransportFailure: Error, Equatable { case invalidRequest(baseURL: URL, components: URLComponents?) case network(URLError) case cancelled - case unknown(Error) + case unknown(any Error) public static func ==(lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { diff --git a/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.swift b/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.swift index b79da51..25be42e 100644 --- a/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.swift +++ b/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.swift @@ -16,7 +16,7 @@ import Foundation import FoundationNetworking #endif -#if compiler(>=5.5) && canImport(_Concurrency) && canImport(Darwin) +#if canImport(Darwin) @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) extension URLSessionTransport { @@ -31,14 +31,10 @@ extension URLSessionTransport { throw TransportFailure.network(URLError(.unsupportedURL)) } return httpResponse.asTransportResponse(withData: data) - } catch let netError as URLError { - if netError.code == .cancelled { throw TransportFailure.cancelled } - throw TransportFailure.network(netError) - + throw netError.asTransportFailure } catch let error as TransportFailure { throw error - } catch { throw TransportFailure.unknown(error) } diff --git a/Sources/StructuredAPIClient/Transport/URLSessionTransport+Combine.swift b/Sources/StructuredAPIClient/Transport/URLSessionTransport+Combine.swift deleted file mode 100644 index d0a5b1d..0000000 --- a/Sources/StructuredAPIClient/Transport/URLSessionTransport+Combine.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -#if canImport(Combine) -import Combine - -@available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) -extension URLSessionTransport { - public func publisher(forRequest request: URLRequest) -> AnyPublisher { - return self.session.dataTaskPublisher(for: request) - .mapError { netError -> Error in - if netError.code == .cancelled { return TransportFailure.cancelled } - else { return TransportFailure.network(netError) } - } - .tryMap { output -> TransportResponse in - guard let response = output.response as? HTTPURLResponse else { - throw TransportFailure.network(URLError(.unsupportedURL)) - } - return response.asTransportResponse(withData: output.data) - } - .eraseToAnyPublisher() - } -} -#endif - diff --git a/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift b/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift index a0584c9..569029c 100644 --- a/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift +++ b/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift @@ -13,18 +13,39 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking +@preconcurrency import FoundationNetworking #endif +import HTTPTypes public final class URLSessionTransport: Transport { /// The actual `URLSession` instance used to create request tasks. public let session: URLSession /// See `Transport.next`. - public var next: Transport? { nil } + public var next: (any Transport)? { nil } + + private final class LockedURLSessionDataTask: @unchecked Sendable { + let lock = NSLock() + var task: URLSessionDataTask? + + func setAndResume(_ newTask: URLSessionDataTask) { + self.lock.withLock { + assert(self.task == nil) + self.task = newTask + newTask.resume() + } + } + + func cancelAndClear() { + self.lock.withLock { + self.task?.cancel() + self.task = nil + } + } + } /// An in-progress data task representing a request in flight - private var task: URLSessionDataTask! + private let task = LockedURLSessionDataTask() public init(_ session: URLSession) { self.session = session @@ -35,15 +56,13 @@ public final class URLSessionTransport: Transport { /// - request: The configured request to send /// - completion: The completion handler that is called after the response is received. /// - response: The received response from the server. - public func send(request: URLRequest, completion: @escaping (Result) -> Void) { - self.task = session.dataTask(with: request) { (data, response, error) in - switch error.map({ $0 as? URLError }) { - case .some(.some(let netError)) where netError.code == .cancelled: return completion(.failure(TransportFailure.cancelled)) - case .some(.some(let netError)): return completion(.failure(TransportFailure.network(netError))) - case .some(.none): return completion(.failure(TransportFailure.unknown(error!))) - case .none: break // no error + public func send(request: URLRequest, completion: @escaping @Sendable (Result) -> Void) { + self.task.setAndResume(session.dataTask(with: request) { (data, response, error) in + if let error { + return completion(.failure((error as? URLError)?.asTransportFailure ?? .unknown(error))) } - guard let response = response else { + + guard let response else { return completion(.failure(TransportFailure.network(URLError(.unknown)))) } guard let httpResponse = response as? HTTPURLResponse else { @@ -51,13 +70,20 @@ public final class URLSessionTransport: Transport { } completion(.success(httpResponse.asTransportResponse(withData: data))) - } - self.task.resume() + }) } public func cancel() { - self.task.cancel() - self.task = nil + self.task.cancelAndClear() + } +} + +extension URLError { + var asTransportFailure: TransportFailure { + switch self.code { + case .cancelled: .cancelled + default: .network(self) + } } } @@ -69,11 +95,14 @@ extension URLRequest { extension HTTPURLResponse { func asTransportResponse(withData data: Data?) -> TransportResponse { - return TransportResponse( - status: HTTPStatusCode(rawValue: self.statusCode) ?? .internalServerError, - headers: .init(uniqueKeysWithValues: self.allHeaderFields.compactMap { k, v in - guard let name = k.base as? String, let value = v as? String else { return nil } - return (name, value) + TransportResponse( + status: HTTPResponse.Status(code: self.statusCode), + headers: HTTPFields(self.allHeaderFields.compactMap { k, v in + guard let name = (k.base as? String).flatMap(HTTPField.Name.init(_:)), + let value = v as? String + else { return nil } + + return HTTPField(name: name, value: value) }), body: data ?? .init() ) diff --git a/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift b/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift index 774cbc6..297d4da 100644 --- a/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift +++ b/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift @@ -19,20 +19,20 @@ import StructuredAPIClient /// A `TokenProvider` that returns a given accessToken and refreshToken for the respective requests. -public final class TestTokenProvider: TokenProvider { - let accessToken: Token - let refreshToken: Token +public final class TestTokenProvider: TokenProvider, Sendable { + let accessToken: any Token + let refreshToken: any Token - public init(accessToken: Token, refreshToken: Token) { + public init(accessToken: any Token, refreshToken: any Token) { self.accessToken = accessToken self.refreshToken = refreshToken } - public func fetchToken(completion: (Result<(Token, Token), Error>) -> Void) { + public func fetchToken(completion: (Result<(any Token, any Token), any Error>) -> Void) { completion(.success((accessToken, refreshToken))) } - public func refreshToken(withRefreshToken: Token, completion: (Result) -> Void) { + public func refreshToken(withRefreshToken: any Token, completion: (Result) -> Void) { completion(.success(accessToken)) } } diff --git a/Sources/StructuredAPIClientTestSupport/TestTransport.swift b/Sources/StructuredAPIClientTestSupport/TestTransport.swift index ffeb123..c756881 100644 --- a/Sources/StructuredAPIClientTestSupport/TestTransport.swift +++ b/Sources/StructuredAPIClientTestSupport/TestTransport.swift @@ -13,30 +13,48 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking +@preconcurrency import FoundationNetworking #endif import StructuredAPIClient +private final class TestTransportData: @unchecked Sendable { + let lock = NSLock() + var history: [URLRequest] + var responses: [Result] + + init(history: [URLRequest], responses: [Result]) { + self.history = history + self.responses = responses + } + + func withLock(_ closure: @escaping @Sendable (inout [URLRequest], inout [Result]) throws -> R) rethrows -> R { + try self.lock.withLock { + try closure(&self.history, &self.responses) + } + } +} + // A `Transport` that synchronously returns static values for tests public final class TestTransport: Transport { - var history: [URLRequest] = [] - var responses: [Result] - var assertRequest: (URLRequest) -> Void + private let data: TestTransportData + let assertRequest: @Sendable (URLRequest) -> Void - public init(responses: [Result], assertRequest: @escaping (URLRequest) -> Void = { _ in }) { - self.responses = responses + public init(responses: [Result], assertRequest: @escaping @Sendable (URLRequest) -> Void = { _ in }) { + self.data = .init(history: [], responses: responses) self.assertRequest = assertRequest } - public func send(request: URLRequest, completion: @escaping (Result) -> Void) { - assertRequest(request) - history.append(request) - if !responses.isEmpty { - completion(responses.removeFirst()) - } else { - completion(.failure(APIError(status: .tooManyRequests, body: Data()))) + public func send(request: URLRequest, completion: @escaping @Sendable (Result) -> Void) { + self.assertRequest(request) + self.data.withLock { history, responses in + history.append(request) + if !responses.isEmpty { + completion(responses.removeFirst()) + } else { + completion(.failure(APIError(status: .tooManyRequests, body: Data()))) + } } } - public var next: Transport? { nil } + public var next: (any Transport)? { nil } } diff --git a/Tests/StructuredAPIClientTests/HTTPStatusCodeTests.swift b/Tests/StructuredAPIClientTests/HTTPStatusCodeTests.swift deleted file mode 100644 index 6965c32..0000000 --- a/Tests/StructuredAPIClientTests/HTTPStatusCodeTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -import XCTest -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -@testable import StructuredAPIClient -import StructuredAPIClientTestSupport - -final class HTTPStatusCodeTests: XCTestCase { - - func testRejectsInvalidNumbers() { - XCTAssertNil(HTTPStatusCode.init(rawValue: 0)) - XCTAssertNil(HTTPStatusCode.init(rawValue: 99)) - XCTAssertNil(HTTPStatusCode.init(rawValue: 600)) - XCTAssertNil(HTTPStatusCode.init(rawValue: -1)) - XCTAssertNil(HTTPStatusCode.init(rawValue: .min)) - XCTAssertNil(HTTPStatusCode.init(rawValue: .max)) - } - - func testUsesKnownCasesWhenAvailable() { - XCTAssertEqual(HTTPStatusCode.init(rawValue: 200), HTTPStatusCode.ok) - XCTAssertEqual(HTTPStatusCode.init(rawValue: 418), HTTPStatusCode.imATeapot) - } - - func testCustomCaseEqualityRules() { - // N.B.: Creating custom cases for well-known status codes this way is considered invalid in its own right. - XCTAssertNotEqual(HTTPStatusCode.custom(200), HTTPStatusCode.ok) - XCTAssertNotEqual(HTTPStatusCode.custom(500), HTTPStatusCode.internalServerError) - XCTAssertEqual(HTTPStatusCode.custom(209), HTTPStatusCode.init(rawValue: 209)) - } - -} diff --git a/Tests/StructuredAPIClientTests/NetworkClientTests.swift b/Tests/StructuredAPIClientTests/NetworkClientTests.swift index a2a7e3e..deef1b8 100644 --- a/Tests/StructuredAPIClientTests/NetworkClientTests.swift +++ b/Tests/StructuredAPIClientTests/NetworkClientTests.swift @@ -18,26 +18,36 @@ import FoundationNetworking @testable import StructuredAPIClient import StructuredAPIClientTestSupport +final class LockedResult: @unchecked Sendable { + let lock = NSLock() + var result: Result? + + var value: Result? { + get { self.lock.withLock { self.result } } + set { self.lock.withLock { self.result = newValue } } + } +} + final class NetworkClientTests: XCTestCase { private static let baseTestURL = URL(string: "https://test.somewhere.com")! private func _runTest( request: R, client: NetworkClient, file: StaticString = #filePath, line: UInt = #line - ) throws -> Result { + ) throws -> Result { let expectation = XCTestExpectation(description: "network client completion expectation") - var rawResult: Result? + let rawResult = LockedResult() XCTAssertEqual(client.baseURL, Self.baseTestURL) client.load(request) { actualResult in - rawResult = actualResult + rawResult.value = actualResult expectation.fulfill() } XCTAssertEqual( XCTWaiter().wait(for: [expectation], timeout: 5.0), XCTWaiter.Result.completed, "Test network request timeout", file: file, line: line ) - return try XCTUnwrap(rawResult, "No result after network request completed", file: file, line: line) + return try XCTUnwrap(rawResult.value, "No result after network request completed", file: file, line: line) } private func runTest( @@ -66,8 +76,8 @@ final class NetworkClientTests: XCTestCase { } func testNetworkClient() throws { - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) } let client = NetworkClient(baseURL: Self.baseTestURL, transport: TestTransport(responses: [response], assertRequest: requestAssertions)) @@ -79,8 +89,8 @@ final class NetworkClientTests: XCTestCase { let accessToken = TestToken(raw: "abc", expiresAt: Date()) let refreshToken = TestToken(raw: "def", expiresAt: Date()) let tokenProvider = TestTokenProvider(accessToken: accessToken, refreshToken: refreshToken) - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["Authorization"], "Bearer abc") } @@ -95,8 +105,8 @@ final class NetworkClientTests: XCTestCase { } func testStackingHeaders() throws { - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["H1"], "1") XCTAssertEqual($0.allHTTPHeaderFields?["H2"], "2") @@ -110,8 +120,8 @@ final class NetworkClientTests: XCTestCase { } func testConflictingHeaderDefaultMode() throws { - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["H1"], "1-3") XCTAssertEqual($0.allHTTPHeaderFields?["H2"], "2") @@ -126,8 +136,8 @@ final class NetworkClientTests: XCTestCase { } func testConflictingHeaderAddMode() throws { - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["H1"], "1-3") XCTAssertEqual($0.allHTTPHeaderFields?["H2"], "2") @@ -142,8 +152,8 @@ final class NetworkClientTests: XCTestCase { } func testConflictingHeaderAppendMode() throws { - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["H1"], "1-3,1-2,1-1") XCTAssertEqual($0.allHTTPHeaderFields?["H2"], "2") @@ -158,8 +168,8 @@ final class NetworkClientTests: XCTestCase { } func testConflictingHeaderReplaceMode() throws { - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["H1"], "1-1") XCTAssertEqual($0.allHTTPHeaderFields?["H2"], "2") diff --git a/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift b/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift index 8740ceb..1fb01d1 100644 --- a/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift +++ b/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift @@ -18,8 +18,6 @@ import FoundationNetworking @testable import StructuredAPIClient import StructuredAPIClientTestSupport -#if compiler(>=5.5) && canImport(_Concurrency) && canImport(Darwin) - @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) final class NetworkClientWithAsyncAwaitTests: XCTestCase { @@ -29,9 +27,9 @@ final class NetworkClientWithAsyncAwaitTests: XCTestCase { func parseResponse(_ response: TransportResponse) throws -> String { .init(decoding: response.body, as: UTF8.self) } } - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) } @@ -54,9 +52,9 @@ final class NetworkClientWithAsyncAwaitTests: XCTestCase { let tokenProvider = TestTokenProvider(accessToken: accessToken, refreshToken: refreshToken) - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) + let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - let requestAssertions: (URLRequest) -> Void = { + let requestAssertions: @Sendable (URLRequest) -> Void = { XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) XCTAssertEqual($0.allHTTPHeaderFields?["Authorization"], "Bearer abc") } @@ -75,5 +73,3 @@ final class NetworkClientWithAsyncAwaitTests: XCTestCase { XCTAssertEqual(client.baseURL.absoluteString, "https://test.somewhere.com") } } - -#endif diff --git a/Tests/StructuredAPIClientTests/NetworkClientWithCombineTests.swift b/Tests/StructuredAPIClientTests/NetworkClientWithCombineTests.swift deleted file mode 100644 index 01082b5..0000000 --- a/Tests/StructuredAPIClientTests/NetworkClientWithCombineTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the StructuredAPIClient open source project -// -// Copyright (c) Stairtree GmbH -// Licensed under the MIT license -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: MIT -// -//===----------------------------------------------------------------------===// - -import XCTest -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -@testable import StructuredAPIClient -import StructuredAPIClientTestSupport -#if canImport(Combine) -import Combine - -@available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) -final class NetworkClientWithCombineTests: XCTestCase { - var sink: AnyCancellable! - - override func setUp() { - super.setUp() - sink = nil - } - - func testNetworkClientWithCombine() throws { - struct TestRequest: NetworkRequest { - func makeRequest(baseURL: URL) throws -> URLRequest { URLRequest(url: baseURL) } - func parseResponse(_ response: TransportResponse) throws -> String { .init(decoding: response.body, as: UTF8.self) } - } - - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - - let requestAssertions: (URLRequest) -> Void = { - XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) - } - - let client = NetworkClient(baseURL: URL(string: "https://test.somewhere.com")!, transport: TestTransport(responses: [response], assertRequest: requestAssertions)) - - let exp = expectation(description: "Result") - - sink = client.requestPublisher(for: TestRequest()) - .sink (receiveCompletion: { _ in - exp.fulfill() - }, receiveValue: { value in - XCTAssertEqual(value, "Test") - }) - - wait(for: [exp], timeout: 2) - - XCTAssertEqual(client.baseURL.absoluteString, "https://test.somewhere.com") - } - - func testTokenAuthWithCombine() { - struct TestRequest: NetworkRequest { - func makeRequest(baseURL: URL) throws -> URLRequest { URLRequest(url: baseURL) } - func parseResponse(_ response: TransportResponse) throws -> String { .init(decoding: response.body, as: UTF8.self) } - } - - let accessToken = TestToken(raw: "abc", expiresAt: Date()) - let refreshToken = TestToken(raw: "def", expiresAt: Date()) - - let tokenProvider = TestTokenProvider(accessToken: accessToken, refreshToken: refreshToken) - - let response: Result = .success(.init(status: .ok, headers: [:], body: Data("Test".utf8))) - - let requestAssertions: (URLRequest) -> Void = { - XCTAssertEqual($0.url, URL(string: "https://test.somewhere.com")!) - XCTAssertEqual($0.allHTTPHeaderFields?["Authorization"], "Bearer abc") - } - - let client = NetworkClient( - baseURL: URL(string: "https://test.somewhere.com")!, - transport: TokenAuthenticationHandler( - base: TestTransport(responses: [response], assertRequest: requestAssertions), - tokenProvider: tokenProvider - ) - ) - - let exp = expectation(description: "Result") - - sink = client.requestPublisher(for: TestRequest()) - .sink (receiveCompletion: { _ in - exp.fulfill() - }, receiveValue: { value in - XCTAssertEqual(value, "Test") - }) - - wait(for: [exp], timeout: 2) - - XCTAssertEqual(client.baseURL.absoluteString, "https://test.somewhere.com") - } -} -#endif