diff --git a/Package.swift b/Package.swift index 46d0e2f..32d8233 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,12 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "StructuredAPIClient", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], products: [ .library(name: "StructuredAPIClient", targets: ["StructuredAPIClient"]), .library(name: "StructuredAPIClientTestSupport", targets: ["StructuredAPIClientTestSupport"]), @@ -31,7 +37,8 @@ let package = Package( dependencies: [ // Swift logging API .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"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.2"), + .package(url: "https://github.com/stairtree/async-helpers.git", from: "0.2.0"), ], targets: [ .target( @@ -39,6 +46,7 @@ let package = Package( dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "AsyncHelpers", package: "async-helpers"), ], swiftSettings: swiftSettings ), diff --git a/README.md b/README.md index 55ce18d..fa34ce2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - + diff --git a/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift b/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift index a1df7f5..06036aa 100644 --- a/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift +++ b/Sources/StructuredAPIClient/Handlers/AddHTTPHeadersHandler.swift @@ -16,30 +16,30 @@ import Foundation import FoundationNetworking #endif -/// Add headers to an existing `Transport`. +/// Add headers to all requests made by an existing ``Transport``. public final class AddHTTPHeadersHandler: Transport { /// An enumeration of the possible modes for working with headers. - 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:)`. + public enum Mode: CaseIterable, Sendable, Hashable { + /// Accumulating behavior - if a given header is already specified by a request, the transport's + /// value is appended, as per `URLRequest.addValue(_:forHTTPHeaderField:)`. /// /// - Warning: This is rarely what you want. case append - /// Overwriting behavior - if a given header is already specified by a request, the transport's value for the - /// header replaces it, as per `URLRequest.setValue(_:forHTTPHeaderField:)`. In this mode, a request's header - /// value is always overwritten. + /// Overwriting behavior - if a given header is already specified by a request, the transport's + /// value for the header replaces it, as per `URLRequest.setValue(_:forHTTPHeaderField:)`. In this + /// mode, a request's header value is always overwritten. case replace - /// Polyfill behavior - if a given header is already specified by a request, it is left untouched, and the - /// transport's value is ignored. + /// Polyfill behavior - if a given header is already specified by a request, it is left untouched, + /// and the transport's value is ignored. /// /// This behavior is the default. case add } - /// The base `Transport` to extend with extra headers. + /// The base ``Transport`` to extend with extra headers. /// /// - Note: Never `nil` in practice for this transport. public let next: (any Transport)? @@ -47,21 +47,27 @@ public final class AddHTTPHeadersHandler: Transport { /// Additional headers that will be applied to the request upon sending. private let headers: [String: String] - /// The mode used to add additional headers. Defaults to `.append` for legacy compatibility. + /// The mode used to add additional headers. Defaults to ``Mode/append`` for legacy compatibility. private let mode: Mode - /// Create a `Transport` that adds headers to the base `Transport` + /// Create a ``Transport`` that adds headers to the given base ``Transport`` /// - Parameters: - /// - 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. + /// - 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: any Transport, headers: [String: String], mode: Mode = .add) { self.next = base self.headers = headers self.mode = mode } + // See `Transport.send(request:completion:)` public func send(request: URLRequest, completion: @escaping @Sendable (Result) -> Void) { + guard !self.headers.isEmpty else { + return self.next?.send(request: request, completion: completion) ?? () + } + var newRequest = request for (key, value) in self.headers { diff --git a/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift b/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift index 595ad36..e68324c 100644 --- a/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift +++ b/Sources/StructuredAPIClient/Handlers/TokenAuthenticationHandler.swift @@ -16,8 +16,9 @@ import Foundation @preconcurrency import FoundationNetworking #endif import Logging +import AsyncHelpers -// Handle token auth and add appropriate auth headers to an existing transport. +// Handle token auth and add appropriate auth headers to requests sent by an existing ``Transport``. public final class TokenAuthenticationHandler: Transport { public let next: (any Transport)? private let logger: Logger @@ -58,58 +59,40 @@ public protocol Token: Sendable { } 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 + private let tokens: Locking.LockedValueBox<(accessToken: (any Token)?, refreshToken: (any Token)?)> let provider: any TokenProvider let logger: Logger internal init(accessToken: (any Token)? = nil, refreshToken: (any Token)? = nil, provider: any TokenProvider, logger: Logger? = nil) { - self.tokens = .init(accessToken: accessToken, refreshToken: refreshToken) + self.tokens = .init((accessToken: accessToken, refreshToken: refreshToken)) self.provider = provider self.logger = logger ?? Logger(label: "AuthState") } 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 } }) { + let (latestAccessToken, latestRefreshToken) = self.tokens.withLockedValue { ($0.accessToken, $0.refreshToken) } + + if let raw = latestAccessToken.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") + } else if let refresh = latestRefreshToken.flatMap({ ($0.expiresAt ?? Date.distantFuture) > Date() ? $0 : nil }) { + self.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.tokens.withLock { token, _ in token = access } + self.tokens.withLockedValue { $0.accessToken = access } return completion(.success(access.raw)) } }) } else { - logger.trace("Fetching initial tokens") + self.logger.trace("Fetching initial tokens") self.provider.fetchToken(completion: { result in switch result { case let .failure(error): return completion(.failure(error)) case let .success((access, refresh)): - self.tokens.withLock { accessToken, refreshToken in - accessToken = access - refreshToken = refresh - } + self.tokens.withLockedValue { $0 = (accessToken: access, refreshToken: refresh) } return completion(.success(access.raw)) } }) diff --git a/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift b/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift deleted file mode 100644 index edcf30e..0000000 --- a/Sources/StructuredAPIClient/NetworkClient+AsyncAwait.swift +++ /dev/null @@ -1,29 +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 - -@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) -extension NetworkClient { - public func load(_ req: Request) async throws -> Request.ResponseDataType { - try await withCheckedThrowingContinuation { continuation in - self.load(req) { switch $0 { - case .success(let value): continuation.resume(returning: value) - case .failure(let error): continuation.resume(throwing: error) - } } - } - } -} diff --git a/Sources/StructuredAPIClient/NetworkClient.swift b/Sources/StructuredAPIClient/NetworkClient.swift index 6b3f462..4f93ef5 100644 --- a/Sources/StructuredAPIClient/NetworkClient.swift +++ b/Sources/StructuredAPIClient/NetworkClient.swift @@ -29,19 +29,22 @@ public final class NetworkClient { self.logger = logger ?? Logger(label: "NetworkClient") } - /// Fetch any `NetworkRequest` type and return the response asynchronously. + /// Fetch any ``NetworkRequest`` type and return the response asynchronously. public func load(_ req: Request, completion: @escaping @Sendable (Result) -> Void) { - let start = DispatchTime.now() + let start = Date() // Construct the URLRequest do { let logger = self.logger - let urlRequest = try req.makeRequest(baseURL: baseURL) - logger.trace("\(urlRequest.debugString)") + let urlRequest = try req.makeRequest(baseURL: self.baseURL) // 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 { logger.trace("Request '\(urlRequest.debugString)' took \(String(format: "%.4f", (.now() - start).milliseconds))ms") } + self.transport().send(request: urlRequest) { result in + let middle = Date() + logger.trace("Request '\(urlRequest.debugString)' received response in \(start.millisecondsBeforeNowFormatted)ms") + defer { + logger.trace("Request '\(urlRequest.debugString)' was processed in \(middle.millisecondsBeforeNowFormatted)ms") + logger.trace("Request '\(urlRequest.debugString)' took a total of \(start.millisecondsBeforeNowFormatted)ms") + } completion(result.flatMap { resp in .init { try req.parseResponse(resp) } }) } @@ -51,22 +54,33 @@ public final class NetworkClient { } } -internal extension DispatchTime { - static func -(lhs: Self, rhs: Self) -> Self { - .init(uptimeNanoseconds: lhs.uptimeNanoseconds - rhs.uptimeNanoseconds) +extension NetworkClient { + /// Fetch any ``NetworkRequest`` type asynchronously and return the response. + public func load(_ req: Request) async throws -> Request.ResponseDataType { + try await withCheckedThrowingContinuation { continuation in + self.load(req) { + continuation.resume(with: $0) + } + } } - - var milliseconds: Double { - Double(self.uptimeNanoseconds) / 1_000_000 +} + +private extension Date { + var millisecondsBeforeNowFormatted: String { + #if canImport(Darwin) + if #available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) { + return (self.timeIntervalSinceNow * -1000.0).formatted(.number.precision(.fractionLength(4...4)).grouping(.never)) + } + #endif + + // This is both easier and much faster than using NumberFormatter + let msInterval = (self.timeIntervalSinceNow * -10_000_000.0).rounded(.toNearestOrEven) / 10_000.0 + return "\(Int(msInterval))\("\(msInterval)0000".drop(while: { $0 != "." }).prefix(5))" } } -#if !canImport(Darwin) -extension NSLocking { - package func withLock(_ body: @Sendable () throws -> R) rethrows -> R { - self.lock() - defer { self.unlock() } - return try body() +internal extension URLRequest { + var debugString: String { + "\(self.httpMethod.map { "[\($0)] " } ?? "")\(url.map { "\($0) " } ?? "")" } } -#endif diff --git a/Sources/StructuredAPIClient/NetworkRequest.swift b/Sources/StructuredAPIClient/NetworkRequest.swift index 3c8a67d..ad6918f 100644 --- a/Sources/StructuredAPIClient/NetworkRequest.swift +++ b/Sources/StructuredAPIClient/NetworkRequest.swift @@ -17,20 +17,20 @@ import FoundationNetworking #endif import HTTPTypes -/// Any request that can be sent as a `URLRequest` with a `NetworkClient`, and returns a response. +/// Any request that can be sent as a `URLRequest` with a ``NetworkClient``, and returns a response. public protocol NetworkRequest: Sendable { /// The decoded data type that represents the response. associatedtype ResponseDataType: Sendable /// Returns a request based on the given base URL. - /// - Parameter baseURL: The `NetworkClient`'s base URL. + /// - Parameter baseURL: The ``NetworkClient``'s base URL. func makeRequest(baseURL: URL) throws -> URLRequest - /// Processes a response returned from the transport and either returns the associated response type or throws an - /// application-specific error. + /// Processes a response returned from the transport and either returns the associated response type + /// or throws an application-specific error. /// /// - Parameters: - /// - response: A `TransportResponse` containing the response to a request. + /// - response: A ``TransportResponse`` containing the response to a request. func parseResponse(_ response: TransportResponse) throws -> ResponseDataType } diff --git a/Sources/StructuredAPIClient/Transport/Transport.swift b/Sources/StructuredAPIClient/Transport/Transport.swift index 881e71e..1636e21 100644 --- a/Sources/StructuredAPIClient/Transport/Transport.swift +++ b/Sources/StructuredAPIClient/Transport/Transport.swift @@ -17,7 +17,7 @@ import FoundationNetworking #endif import HTTPTypes -/// A successful response from a `Transport`. +/// A successful response from a ``Transport``. public struct TransportResponse: Sendable { /// The received HTTP status code. public let status: HTTPResponse.Status @@ -28,7 +28,7 @@ public struct TransportResponse: Sendable { /// 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. + /// Create a new ``TransportResponse``. Intended for use by ``Transport`` implementations. public init(status: HTTPResponse.Status, headers: HTTPFields, body: Data) { self.status = status self.headers = headers @@ -36,12 +36,13 @@ public struct TransportResponse: Sendable { } } -/// A `Transport` maps a `URLRequest` to a `Status` and `Data` pair asynchronously. +/// A ``Transport`` asynchronously maps a `URLRequest` to a ``TransportResponse``. 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 - /// `TransportFailure` enumeration is intended to encapsulate some of the most common failure modes. + /// Transports should make an effort to provide the most specific errors possible for failures. + /// In particular, the ``TransportFailure`` enumeration is intended to encapsulate some of the + /// most common failure modes. /// /// - Parameters: /// - request: The request to be sent. @@ -49,20 +50,42 @@ public protocol Transport: Sendable { /// - response: The received response from the server, or an error indicating a transport-level failure. func send(request: URLRequest, completion: @escaping @Sendable (_ result: Result) -> Void) - /// The next Transport that the request is being forwarded to. + /// The next ``Transport`` that the request is being forwarded to. /// - /// If `nil`, this should be the final `Transport`. + /// If `nil`, this ``Transport`` is the end of the chain. var next: (any Transport)? { get } /// Cancel the request. /// - /// - Note: Any `Tranport` forwarding the request must call `cancel()` on the next `Transport`. + /// - Note: Any ``Tranport`` forwarding the request must call `cancel()` on the next ``Transport``. func cancel() } extension Transport { - /// If there is no special handling of cancellation, the default implementation just forwards to the next `Transport`. + /// If there is no special handling of cancellation, the default implementation just forwards to + /// the next ``Transport``. /// - /// - Note: You must call `cancel()` on the next `Transport` if you customize this method. - public func cancel() { next?.cancel() } + /// - Note: Implementations which override this method must ensure that they forward the + /// cancellation to the next ``Transport`` in the chain, if any. + public func cancel() { self.next?.cancel() } +} + +extension Transport { + /// Sends the request asynchronously and returns the response. + /// + /// Transports should make an effort to provide the most specific errors possible for failures. + /// In particular, the ``TransportFailure`` enumeration is intended to encapsulate some of the + /// most common failure modes. + /// + /// - Parameters: + /// - request: The request to be sent. + /// - Returns: The received response from the server. + /// - Throws: An error indicating a transport-level failure. + public func send(request: URLRequest) async throws -> TransportResponse { + try await withCheckedThrowingContinuation { continuation in + self.send(request: request, completion: { + continuation.resume(with: $0) + }) + } + } } diff --git a/Sources/StructuredAPIClient/Transport/TransportFailure.swift b/Sources/StructuredAPIClient/Transport/TransportFailure.swift index 55d4dbb..4cb6005 100644 --- a/Sources/StructuredAPIClient/Transport/TransportFailure.swift +++ b/Sources/StructuredAPIClient/Transport/TransportFailure.swift @@ -25,13 +25,18 @@ public enum TransportFailure: Error, Equatable { case cancelled case unknown(any Error) - public static func ==(lhs: Self, rhs: Self) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { - case let (.invalidRequest(lurl, lcomp), .invalidRequest(rurl, rcomp)): return lurl == rurl && lcomp == rcomp - case let (.network(lerror), .network(rerror)): return lerror == rerror - case (.cancelled, .cancelled): return true - case let (.unknown(lerror), .unknown(rerror)): return (lerror as NSError) == (rerror as NSError) - default: return false + case let (.invalidRequest(lurl, lcomp), .invalidRequest(rurl, rcomp)): + lurl == rurl && lcomp == rcomp + case let (.network(lerror), .network(rerror)): + lerror == rerror + case (.cancelled, .cancelled): + true + case let (.unknown(lerror), .unknown(rerror)): + (lerror as NSError) == (rerror as NSError) + default: + false } } } diff --git a/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.swift b/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.swift deleted file mode 100644 index 25be42e..0000000 --- a/Sources/StructuredAPIClient/Transport/URLSessionTransport+AsyncAwait.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 Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -#if canImport(Darwin) - -@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) -extension URLSessionTransport { - - /// Sends the request using a `URLSessionDataTask` - /// - Parameter request: The configured request to send. - /// - Returns: The received response from the server. - public func send(request: URLRequest) async throws -> TransportResponse { - do { - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw TransportFailure.network(URLError(.unsupportedURL)) - } - return httpResponse.asTransportResponse(withData: data) - } catch let netError as URLError { - throw netError.asTransportFailure - } catch let error as TransportFailure { - throw error - } catch { - throw TransportFailure.unknown(error) - } - } -} - -#endif diff --git a/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift b/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift index 569029c..47dfb3b 100644 --- a/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift +++ b/Sources/StructuredAPIClient/Transport/URLSessionTransport.swift @@ -16,16 +16,17 @@ import Foundation @preconcurrency import FoundationNetworking #endif import HTTPTypes +import AsyncHelpers public final class URLSessionTransport: Transport { /// The actual `URLSession` instance used to create request tasks. public let session: URLSession - /// See `Transport.next`. + // See `Transport.next`. public var next: (any Transport)? { nil } private final class LockedURLSessionDataTask: @unchecked Sendable { - let lock = NSLock() + let lock = Locking.FastLock() var task: URLSessionDataTask? func setAndResume(_ newTask: URLSessionDataTask) { @@ -57,7 +58,7 @@ public final class URLSessionTransport: Transport { /// - 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 @Sendable (Result) -> Void) { - self.task.setAndResume(session.dataTask(with: request) { (data, response, error) in + self.task.setAndResume(self.session.dataTask(with: request) { (data, response, error) in if let error { return completion(.failure((error as? URLError)?.asTransportFailure ?? .unknown(error))) } @@ -81,26 +82,18 @@ public final class URLSessionTransport: Transport { extension URLError { var asTransportFailure: TransportFailure { switch self.code { - case .cancelled: .cancelled - default: .network(self) + case .cancelled: .cancelled + default: .network(self) } } } -extension URLRequest { - var debugString: String { - "\(httpMethod.map { "[\($0)] " } ?? "")\(url.map { "\($0) " } ?? "")" - } -} - extension HTTPURLResponse { func asTransportResponse(withData data: Data?) -> TransportResponse { 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 } + 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) }), diff --git a/Sources/StructuredAPIClientTestSupport/TestRequest.swift b/Sources/StructuredAPIClientTestSupport/TestRequest.swift index f4295f5..613192f 100644 --- a/Sources/StructuredAPIClientTestSupport/TestRequest.swift +++ b/Sources/StructuredAPIClientTestSupport/TestRequest.swift @@ -17,8 +17,8 @@ import FoundationNetworking #endif import StructuredAPIClient -/// A `NetworkRequest` that simply requests the base URL (optionally with additional headers added) and expects UTF-8 -/// `String`s as responses. +/// A ``NetworkRequest`` that simply requests the base URL (optionally with additional headers added) +/// and expects UTF-8 `String`s as responses. public struct TestRequest: NetworkRequest { private let extraHeaders: [String: String] @@ -27,7 +27,7 @@ public struct TestRequest: NetworkRequest { } public func parseResponse(_ response: TransportResponse) throws -> String { - return String(decoding: response.body, as: UTF8.self) + String(decoding: response.body, as: UTF8.self) } public func makeRequest(baseURL: URL) throws -> URLRequest { diff --git a/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift b/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift index 297d4da..3b281cc 100644 --- a/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift +++ b/Sources/StructuredAPIClientTestSupport/TestTokenProvider.swift @@ -17,8 +17,7 @@ import FoundationNetworking #endif import StructuredAPIClient - -/// A `TokenProvider` that returns a given accessToken and refreshToken for the respective requests. +/// A ``TokenProvider`` that returns a given access token and refresh token for the respective requests. public final class TestTokenProvider: TokenProvider, Sendable { let accessToken: any Token let refreshToken: any Token @@ -37,10 +36,11 @@ public final class TestTokenProvider: TokenProvider, Sendable { } } -/// A sample `Token` that contains the raw String and an expiry date. +/// A sample ``Token`` that contains the raw `String` and an expiration date. public struct TestToken: Token { public let raw: String public let expiresAt: Date? + public init(raw: String, expiresAt: Date?) { self.raw = raw self.expiresAt = expiresAt diff --git a/Sources/StructuredAPIClientTestSupport/TestTransport.swift b/Sources/StructuredAPIClientTestSupport/TestTransport.swift index c756881..19cf0a0 100644 --- a/Sources/StructuredAPIClientTestSupport/TestTransport.swift +++ b/Sources/StructuredAPIClientTestSupport/TestTransport.swift @@ -16,9 +16,10 @@ import Foundation @preconcurrency import FoundationNetworking #endif import StructuredAPIClient +import AsyncHelpers private final class TestTransportData: @unchecked Sendable { - let lock = NSLock() + let lock = Locking.FastLock() var history: [URLRequest] var responses: [Result] @@ -34,7 +35,7 @@ private final class TestTransportData: @unchecked Sendable { } } -// A `Transport` that synchronously returns static values for tests +/// A ``Transport`` that synchronously returns static values for tests public final class TestTransport: Transport { private let data: TestTransportData let assertRequest: @Sendable (URLRequest) -> Void diff --git a/Tests/StructuredAPIClientTests/NetworkClientTests.swift b/Tests/StructuredAPIClientTests/NetworkClientTests.swift index deef1b8..3148447 100644 --- a/Tests/StructuredAPIClientTests/NetworkClientTests.swift +++ b/Tests/StructuredAPIClientTests/NetworkClientTests.swift @@ -17,9 +17,11 @@ import FoundationNetworking #endif @testable import StructuredAPIClient import StructuredAPIClientTestSupport +import AsyncHelpers +import Logging final class LockedResult: @unchecked Sendable { - let lock = NSLock() + let lock = Locking.FastLock() var result: Result? var value: Result? { @@ -31,6 +33,10 @@ final class LockedResult: @unchecked Sendable { final class NetworkClientTests: XCTestCase { private static let baseTestURL = URL(string: "https://test.somewhere.com")! + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + private func _runTest( request: R, client: NetworkClient, file: StaticString = #filePath, line: UInt = #line @@ -183,3 +189,16 @@ final class NetworkClientTests: XCTestCase { try self.runTest(request: TestRequest(extraHeaders: ["H1": "1-3"]), client: client, expecting: "Test") } } + +func env(_ name: String) -> String? { + ProcessInfo.processInfo.environment[name] +} + +let isLoggingConfigured: Bool = { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .trace + return handler + } + return true +}() diff --git a/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift b/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift index 1fb01d1..4b621b3 100644 --- a/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift +++ b/Tests/StructuredAPIClientTests/NetworkClientWithAsyncAwaitTests.swift @@ -18,9 +18,11 @@ import FoundationNetworking @testable import StructuredAPIClient import StructuredAPIClientTestSupport -@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) final class NetworkClientWithAsyncAwaitTests: XCTestCase { - + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + func testNetworkClientWithAsyncAwait() async throws { struct TestRequest: NetworkRequest { func makeRequest(baseURL: URL) throws -> URLRequest { URLRequest(url: baseURL) }