From 6cc678bbf423d5f9b4c383b49b6489c29c9fa398 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 11:15:58 -0300 Subject: [PATCH 01/16] chore: HTTP layer from OpenAPIRuntime --- Package.swift | 5 + .../Base/PrettyStringConvertible.swift | 20 + .../HTTP/HTTPClient/Errors/ClientError.swift | 128 ++ .../HTTP/HTTPClient/Errors/RuntimeError.swift | 88 + Sources/Helpers/HTTP/HTTPClient/Exports.swift | 1 + .../Interface/AsyncSequenceCommon.swift | 123 + .../HTTP/HTTPClient/Interface/Client.swift | 147 ++ .../Interface/ClientTransport.swift | 214 ++ .../HTTPClient/Interface/CurrencyTypes.swift | 39 + .../HTTP/HTTPClient/Interface/HTTPBody.swift | 596 +++++ .../Middlewares/LoggingMiddleware.swift | 32 + .../BufferedStream/BufferedStream.swift | 1973 +++++++++++++++++ .../BufferedStream/Lock.swift | 278 +++ .../HTTP/HTTPClientFoundation/Reexports.swift | 8 + ...rectionalStreamingURLSessionDelegate.swift | 235 ++ .../HTTPBodyOutputStreamBridge.swift | 312 +++ .../URLSession+Extensions.swift | 61 + .../URLSessionTransport.swift | 427 ++++ .../xcshareddata/swiftpm/Package.resolved | 12 +- 19 files changed, 4698 insertions(+), 1 deletion(-) create mode 100644 Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Exports.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift create mode 100644 Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift create mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift diff --git a/Package.swift b/Package.swift index 42cadc4d..66815226 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), @@ -39,6 +41,9 @@ let package = Package( dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + .product(name: "Logging", package: "swift-log"), + .product(name: "DequeModule", package: "swift-collections"), .product(name: "Clocks", package: "swift-clocks"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ] diff --git a/Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift b/Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift new file mode 100644 index 00000000..cfcb75ca --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// A helper protocol for customizing descriptions. +internal protocol PrettyStringConvertible { + + /// A pretty string description. + var prettyDescription: String { get } +} \ No newline at end of file diff --git a/Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift b/Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift new file mode 100644 index 00000000..9f0c4996 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +import protocol Foundation.LocalizedError + +#if canImport(Darwin) + import struct Foundation.URL +#else + @preconcurrency import struct Foundation.URL +#endif + +/// An error thrown by a client performing an OpenAPI operation. +/// +/// Use a `ClientError` to inspect details about the request and response +/// that resulted in an error. +/// +/// You don't create or throw instances of `ClientError` yourself; they are +/// created and thrown on your behalf by the runtime library when a client +/// operation fails. +struct ClientError: Error { + /// The HTTP request created during the operation. + /// + /// Will be nil if the error resulted before the request was generated, + /// for example if generating the request from the Input failed. + var request: HTTPTypes.HTTPRequest? + + /// The HTTP request body created during the operation. + /// + /// Will be nil if the error resulted before the request was generated, + /// for example if generating the request from the Input failed. + var requestBody: HTTPBody? + + /// The base URL for HTTP requests. + /// + /// Will be nil if the error resulted before the request was generated, + /// for example if generating the request from the Input failed. + var baseURL: URL? + + /// The HTTP response received during the operation. + /// + /// Will be nil if the error resulted before the response was received. + var response: HTTPTypes.HTTPResponse? + + /// The HTTP response body received during the operation. + /// + /// Will be nil if the error resulted before the response was received. + var responseBody: HTTPBody? + + /// A user-facing description of what caused the underlying error + /// to be thrown. + var causeDescription: String + + /// The underlying error that caused the operation to fail. + var underlyingError: any Error + + /// Creates a new error. + /// - Parameters: + /// - request: The HTTP request created during the operation. + /// - requestBody: The HTTP request body created during the operation. + /// - baseURL: The base URL for HTTP requests. + /// - response: The HTTP response received during the operation. + /// - responseBody: The HTTP response body received during the operation. + /// - causeDescription: A user-facing description of what caused + /// the underlying error to be thrown. + /// - underlyingError: The underlying error that caused the operation + /// to fail. + init( + request: HTTPTypes.HTTPRequest? = nil, + requestBody: HTTPBody? = nil, + baseURL: URL? = nil, + response: HTTPTypes.HTTPResponse? = nil, + responseBody: HTTPBody? = nil, + causeDescription: String, + underlyingError: any Error + ) { + self.request = request + self.requestBody = requestBody + self.baseURL = baseURL + self.response = response + self.responseBody = responseBody + self.causeDescription = causeDescription + self.underlyingError = underlyingError + } + + // MARK: Private + + fileprivate var underlyingErrorDescription: String { + guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { + return "\(underlyingError)" + } + return prettyError.prettyDescription + } +} + +extension ClientError: CustomStringConvertible { + /// A human-readable description of the client error. + /// + /// This computed property returns a string that includes information about the client error. + /// + /// - Returns: A string describing the client error and its associated details. + var description: String { + "Client error - cause description: '\(causeDescription)', underlying error: \(underlyingErrorDescription), request: \(request?.prettyDescription ?? ""), requestBody: \(requestBody?.prettyDescription ?? ""), baseURL: \(baseURL?.absoluteString ?? ""), response: \(response?.prettyDescription ?? ""), responseBody: \(responseBody?.prettyDescription ?? "")" + } +} + +extension ClientError: LocalizedError { + /// A localized description of the client error. + /// + /// This computed property provides a localized human-readable description of the client error, which is suitable for displaying to users. + /// + /// - Returns: A localized string describing the client error. + var errorDescription: String? { + "Client encountered an error, caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." + } +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift b/Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift new file mode 100644 index 00000000..8ebe172f --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift @@ -0,0 +1,88 @@ +import HTTPTypes + +import struct Foundation.Data +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import protocol Foundation.LocalizedError + +/// Error thrown by generated code. +internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, PrettyStringConvertible +{ + + // Transport/Handler + case transportFailed(any Error) + case middlewareFailed(middlewareType: Any.Type, any Error) + + /// A wrapped root cause error, if one was thrown by other code. + var underlyingError: (any Error)? { + switch self { + case .transportFailed(let error), .middlewareFailed(_, let error): + return error + } + } + + // MARK: CustomStringConvertible + + var description: String { prettyDescription } + + var prettyDescription: String { + switch self { + case .transportFailed: return "Transport threw an error." + case .middlewareFailed(middlewareType: let type, _): + return "Middleware of type '\(type)' threw an error." + } + } + + // MARK: - LocalizedError + + var errorDescription: String? { description } +} + +/// HTTP Response status definition for ``RuntimeError``. +extension RuntimeError: HTTPResponseConvertible { + /// HTTP Status code corresponding to each error case + var httpStatus: HTTPTypes.HTTPResponse.Status { + switch self { + case .middlewareFailed, .transportFailed: + .internalServerError + } + } +} + +/// A value that can be converted to an HTTP response and body. +/// +/// Conform your error type to this protocol to convert it to an `HTTPResponse` and ``HTTPBody``. +/// +/// Used by ``ErrorHandlingMiddleware``. +protocol HTTPResponseConvertible { + + /// An HTTP status to return in the response. + var httpStatus: HTTPTypes.HTTPResponse.Status { get } + + /// The HTTP header fields of the response. + /// This is optional as default values are provided in the extension. + var httpHeaderFields: HTTPTypes.HTTPFields { get } + + /// The body of the HTTP response. + var httpBody: HTTPBody? { get } +} + +extension HTTPResponseConvertible { + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + var httpHeaderFields: HTTPTypes.HTTPFields { [:] } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + var httpBody: HTTPBody? { nil } +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Exports.swift b/Sources/Helpers/HTTP/HTTPClient/Exports.swift new file mode 100644 index 00000000..3d4db08e --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Exports.swift @@ -0,0 +1 @@ +@_exported import HTTPTypes diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift new file mode 100644 index 00000000..16b56977 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Describes how many times the provided sequence can be iterated. +public enum IterationBehavior: Sendable { + + /// The input sequence can only be iterated once. + /// + /// If a retry or a redirect is encountered, fail the call with + /// a descriptive error. + case single + + /// The input sequence can be iterated multiple times. + /// + /// Supports retries and redirects, as a new iterator is created each + /// time. + case multiple +} + +// MARK: - Internal + +/// A type-erasing closure-based iterator. +@usableFromInline struct AnyIterator: AsyncIteratorProtocol { + + /// The closure that produces the next element. + private let produceNext: () async throws -> Element? + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline init(_ iterator: Iterator) + where Iterator.Element == Element { + var iterator = iterator + self.produceNext = { try await iterator.next() } + } + + /// Advances the iterator to the next element and returns it asynchronously. + /// + /// - Returns: The next element in the sequence, or `nil` if there are no more elements. + /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. + public mutating func next() async throws -> Element? { try await produceNext() } +} + +/// A type-erased async sequence that wraps input sequences. +@usableFromInline struct AnySequence: AsyncSequence, Sendable { + + /// The type of the type-erased iterator. + @usableFromInline typealias AsyncIterator = AnyIterator + + /// A closure that produces a new iterator. + @usableFromInline let produceIterator: @Sendable () -> AsyncIterator + + /// Creates a new sequence. + /// - Parameter sequence: The input sequence to type-erase. + @usableFromInline init(_ sequence: Upstream) + where Upstream.Element == Element, Upstream: Sendable { + self.produceIterator = { .init(sequence.makeAsyncIterator()) } + } + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } +} + +/// An async sequence wrapper for a sync sequence. +@usableFromInline struct WrappedSyncSequence: AsyncSequence, Sendable +where Upstream.Element: Sendable { + + /// The type of the iterator. + @usableFromInline typealias AsyncIterator = Iterator + + /// The element type. + @usableFromInline typealias Element = Upstream.Element + + /// An iterator type that wraps a sync sequence iterator. + @usableFromInline struct Iterator: AsyncIteratorProtocol { + + /// The element type. + @usableFromInline typealias Element = IteratorElement + + /// The underlying sync sequence iterator. + var iterator: any IteratorProtocol + + @usableFromInline mutating func next() async throws -> IteratorElement? { iterator.next() } + } + + /// The underlying sync sequence. + @usableFromInline let sequence: Upstream + + /// Creates a new async sequence with the provided sync sequence. + /// - Parameter sequence: The sync sequence to wrap. + @usableFromInline init(sequence: Upstream) { self.sequence = sequence } + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { + Iterator(iterator: sequence.makeIterator()) + } +} + +/// An empty async sequence. +@usableFromInline struct EmptySequence: AsyncSequence, Sendable { + + /// The type of the empty iterator. + @usableFromInline typealias AsyncIterator = EmptyIterator + + /// An async iterator of an empty sequence. + @usableFromInline struct EmptyIterator: AsyncIteratorProtocol { + + @usableFromInline mutating func next() async throws -> IteratorElement? { nil } + } + + /// Creates a new empty async sequence. + @usableFromInline init() {} + + @usableFromInline func makeAsyncIterator() -> AsyncIterator { EmptyIterator() } +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift new file mode 100644 index 00000000..51b9d562 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import HTTPTypes + +#if canImport(Darwin) + import struct Foundation.URL +#else + @preconcurrency import struct Foundation.URL +#endif + +/// A client that can send HTTP requests and receive HTTP responses. +struct Client: Sendable { + + /// The URL of the server, used as the base URL for requests made by the + /// client. + let serverURL: URL + + /// A type capable of sending HTTP requests and receiving HTTP responses. + var transport: any ClientTransport + + /// The middlewares to be invoked before the transport. + var middlewares: [any ClientMiddleware] + + /// Creates a new client. + init( + serverURL: URL, + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.serverURL = serverURL + self.transport = transport + self.middlewares = middlewares + } + + /// Sends the HTTP request and returns the HTTP response. + /// + /// - Parameters: + /// - request: The HTTP request to send. + /// - body: The HTTP request body to send. + /// - Returns: The HTTP response and its body. + /// - Throws: An error if any part of the HTTP operation process fails. + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody? = nil + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + @Sendable func wrappingErrors( + work: () async throws -> R, + mapError: (any Error) -> ClientError + ) async throws -> R { + do { + return try await work() + } catch let error as ClientError { + throw error + } catch { + throw mapError(error) + } + } + let baseURL = serverURL + @Sendable func makeError( + request: HTTPTypes.HTTPRequest? = nil, + requestBody: HTTPBody? = nil, + baseURL: URL? = nil, + response: HTTPTypes.HTTPResponse? = nil, + responseBody: HTTPBody? = nil, + error: any Error + ) -> ClientError { + if var error = error as? ClientError { + error.request = error.request ?? request + error.requestBody = error.requestBody ?? requestBody + error.baseURL = error.baseURL ?? baseURL + error.response = error.response ?? response + error.responseBody = error.responseBody ?? responseBody + return error + } + let causeDescription: String + let underlyingError: any Error + if let runtimeError = error as? RuntimeError { + causeDescription = runtimeError.prettyDescription + underlyingError = runtimeError.underlyingError ?? error + } else { + causeDescription = "Unknown" + underlyingError = error + } + return ClientError( + request: request, + requestBody: requestBody, + baseURL: baseURL, + response: response, + responseBody: responseBody, + causeDescription: causeDescription, + underlyingError: underlyingError + ) + } + var next: @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) = { + (_request, _body, _url) in + try await wrappingErrors { + try await transport.send( + _request, + body: _body, + baseURL: _url + ) + } mapError: { error in + makeError( + request: request, + requestBody: body, + baseURL: baseURL, + error: RuntimeError.transportFailed(error) + ) + } + } + for middleware in middlewares.reversed() { + let tmp = next + next = { (_request, _body, _url) in + try await wrappingErrors { + try await middleware.intercept( + _request, + body: _body, + baseURL: _url, + next: tmp + ) + } mapError: { error in + makeError( + request: request, + requestBody: body, + baseURL: baseURL, + error: RuntimeError.middlewareFailed( + middlewareType: type(of: middleware), + error + ) + ) + } + } + } + return try await next(request, body, baseURL) + } +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift new file mode 100644 index 00000000..7b282d7e --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift @@ -0,0 +1,214 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +import struct Foundation.URL + +/// A type that performs HTTP operations. +/// +/// Decouples an underlying HTTP library from generated client code. +/// +/// ### Choose between a transport and a middleware +/// +/// The ``ClientTransport`` and ``ClientMiddleware`` protocols look similar, +/// however each serves a different purpose. +/// +/// A _transport_ abstracts over the underlying HTTP library that actually +/// performs the HTTP operation by using the network. A ``Client`` +/// requires an exactly one client transport. +/// +/// A _middleware_ intercepts the HTTP request and response, without being +/// responsible for performing the HTTP operation itself. That's why +/// middlewares take the extra `next` parameter, to delegate making the HTTP +/// call to the transport at the top of the middleware stack. +/// +/// ### Use an existing client transport +/// +/// Instantiate the transport using the parameters required by the specific +/// implementation. For example, using the client transport for the +/// `URLSession` HTTP client provided by the Foundation framework: +/// +/// let transport = URLSessionTransport() +/// +/// Instantiate the `Client` type. For example: +/// +/// let client = Client( +/// serverURL: URL(string: "https://example.com")!, +/// transport: transport +/// ) +/// +/// ### Implement a custom client transport +/// +/// If a client transport implementation for your preferred HTTP library doesn't +/// yet exist, or you need to simulate rare network conditions in your tests, +/// consider implementing a custom client transport. +/// +/// For example, to implement a test client transport that allows you +/// to test both a healthy and unhealthy response from a `checkHealth` +/// operation, define a new struct that conforms to the `ClientTransport` +/// protocol: +/// +/// struct TestTransport: ClientTransport { +/// var isHealthy: Bool = true +/// func send( +/// _ request: HTTPRequest, +/// body: HTTPBody?, +/// baseURL: URL, +/// operationID: String +/// ) async throws -> (HTTPResponse, HTTPBody?) { +/// ( +/// HTTPResponse(status: isHealthy ? .ok : .internalServerError), +/// nil +/// ) +/// } +/// } +/// +/// Then in your test code, instantiate and provide the test transport to your +/// generated client instead: +/// +/// var transport = TestTransport() +/// transport.isHealthy = true // for HTTP status code 200 (success) +/// let client = Client( +/// serverURL: URL(string: "https://example.com")!, +/// transport: transport +/// ) +/// let response = try await client.checkHealth() +/// +/// Implementing a test client transport is just one way to help test your +/// code that integrates with a generated client. Another is to implement +/// a type conforming to the generated protocol `APIProtocol`, and to implement +/// a custom ``ClientMiddleware``. +public protocol ClientTransport: Sendable { + + /// Sends the underlying HTTP request and returns the received + /// HTTP response. + /// - Parameters: + /// - request: An HTTP request. + /// - body: An HTTP request body. + /// - baseURL: A server base URL. + /// - operationID: The identifier of the OpenAPI operation. + /// - Returns: An HTTP response and its body. + /// - Throws: An error if sending the request and receiving the response fails. + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) +} + +/// A type that intercepts HTTP requests and responses. +/// +/// It allows you to read and modify the request before it is received by +/// the transport and the response after it is returned by the transport. +/// +/// Appropriate for handling authentication, logging, metrics, tracing, +/// injecting custom headers such as "user-agent", and more. +/// +/// ### Choose between a transport and a middleware +/// +/// The ``ClientTransport`` and ``ClientMiddleware`` protocols look similar, +/// however each serves a different purpose. +/// +/// A _transport_ abstracts over the underlying HTTP library that actually +/// performs the HTTP operation by using the network. A ``Client`` +/// requires an exactly one client transport. +/// +/// A _middleware_ intercepts the HTTP request and response, without being +/// responsible for performing the HTTP operation itself. That's why +/// middlewares take the extra `next` parameter, to delegate making the HTTP +/// call to the transport at the top of the middleware stack. +/// +/// ### Use an existing client middleware +/// +/// Instantiate the middleware using the parameters required by the specific +/// implementation. For example, using a hypothetical existing middleware +/// that logs every request and response: +/// +/// let loggingMiddleware = LoggingMiddleware() +/// +/// Similarly to the process of using an existing ``ClientTransport``, provide +/// the middleware to the initializer of the ``Client`` type: +/// +/// let client = Client( +/// serverURL: URL(string: "https://example.com")!, +/// transport: transport, +/// middlewares: [ +/// loggingMiddleware, +/// ] +/// ) +/// +/// Then make a call to one of the client methods: +/// +/// let response = try await client.checkHealth() +/// +/// As part of the invocation of `checkHealth`, the client first invokes +/// the middlewares in the order you provided them, and then passes the request +/// to the transport. When a response is received, the last middleware handles +/// it first, in the reverse order of the `middlewares` array. +/// +/// ### Implement a custom client middleware +/// +/// If a client middleware implementation with your desired behavior doesn't +/// yet exist, or you need to simulate rare network conditions your tests, +/// consider implementing a custom client middleware. +/// +/// For example, to implement a middleware that injects the "Authorization" +/// header to every outgoing request, define a new struct that conforms to +/// the `ClientMiddleware` protocol: +/// +/// /// Injects an authorization header to every request. +/// struct AuthenticationMiddleware: ClientMiddleware { +/// +/// /// The token value. +/// var bearerToken: String +/// +/// func intercept( +/// _ request: HTTPRequest, +/// body: HTTPBody?, +/// baseURL: URL, +/// operationID: String, +/// next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) +/// ) async throws -> (HTTPResponse, HTTPBody?) { +/// var request = request +/// request.headerFields[.authorization] = "Bearer \(bearerToken)" +/// return try await next(request, body, baseURL) +/// } +/// } +/// +/// An alternative use case for a middleware is to inject random failures +/// when calling a real server, to test your retry and error-handling logic. +/// +/// Implementing a test client middleware is just one way to help test your +/// code that integrates with a generated client. Another is to implement +/// a type conforming to the generated protocol `APIProtocol`, and to implement +/// a custom ``ClientTransport``. +protocol ClientMiddleware: Sendable { + + /// Intercepts an outgoing HTTP request and an incoming HTTP response. + /// - Parameters: + /// - request: An HTTP request. + /// - body: An HTTP request body. + /// - baseURL: A server base URL. + /// - operationID: The identifier of the OpenAPI operation. + /// - next: A closure that calls the next middleware, or the transport. + /// - Returns: An HTTP response and its body. + /// - Throws: An error if interception of the request and response fails. + func intercept( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + next: @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift new file mode 100644 index 00000000..831ab840 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import HTTPTypesFoundation + +extension HTTPFields: PrettyStringConvertible { + var prettyDescription: String { + sorted(by: { + $0.name.canonicalName.localizedCompare($1.name.canonicalName) == .orderedAscending + }) + .map { "\($0.name.canonicalName): \($0.value)" }.joined(separator: "; ") + } +} + +extension HTTPTypes.HTTPRequest: PrettyStringConvertible { + var prettyDescription: String { + "\(method.rawValue) \(url?.absoluteString.removingPercentEncoding ?? "") [\(headerFields.prettyDescription)]" + } +} + +extension HTTPTypes.HTTPResponse: PrettyStringConvertible { + var prettyDescription: String { "\(status.code) \(status.reasonPhrase) [\(headerFields.prettyDescription)]" } +} + +extension HTTPBody: PrettyStringConvertible { + var prettyDescription: String { String(describing: self) } +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift new file mode 100644 index 00000000..640237a2 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift @@ -0,0 +1,596 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.Data // only for convenience initializers +import protocol Foundation.LocalizedError +import class Foundation.NSLock + +/// A body of an HTTP request or HTTP response. +/// +/// Under the hood, it represents an async sequence of byte chunks. +/// +/// ## Creating a body from a buffer +/// There are convenience initializers to create a body from common types, such +/// as `Data`, `[UInt8]`, `ArraySlice`, and `String`. +/// +/// Create an empty body: +/// ```swift +/// let body = HTTPBody() +/// ``` +/// +/// Create a body from a byte chunk: +/// ```swift +/// let bytes: ArraySlice = ... +/// let body = HTTPBody(bytes) +/// ``` +/// +/// Create a body from `Foundation.Data`: +/// ```swift +/// let data: Foundation.Data = ... +/// let body = HTTPBody(data) +/// ``` +/// +/// Create a body from a string: +/// ```swift +/// let body = HTTPBody("Hello, world!") +/// ``` +/// +/// ## Creating a body from an async sequence +/// The body type also supports initialization from an async sequence. +/// +/// ```swift +/// let producingSequence = ... // an AsyncSequence +/// let length: HTTPBody.Length = .known(1024) // or .unknown +/// let body = HTTPBody( +/// producingSequence, +/// length: length, +/// iterationBehavior: .single // or .multiple +/// ) +/// ``` +/// +/// In addition to the async sequence, also provide the total body length, +/// if known (this can be sent in the `content-length` header), and whether +/// the sequence is safe to be iterated multiple times, or can only be iterated +/// once. +/// +/// Sequences that can be iterated multiple times work better when an HTTP +/// request needs to be retried, or if a redirect is encountered. +/// +/// In addition to providing the async sequence, you can also produce the body +/// using an `AsyncStream` or `AsyncThrowingStream`: +/// +/// ```swift +/// let body = HTTPBody( +/// AsyncStream(ArraySlice.self, { continuation in +/// continuation.yield([72, 69]) +/// continuation.yield([76, 76, 79]) +/// continuation.finish() +/// }), +/// length: .known(5) +/// ) +/// ``` +/// +/// ## Consuming a body as an async sequence +/// The `HTTPBody` type conforms to `AsyncSequence` and uses `ArraySlice` +/// as its element type, so it can be consumed in a streaming fashion, without +/// ever buffering the whole body in your process. +/// +/// For example, to get another sequence that contains only the size of each +/// chunk, and print each size, use: +/// +/// ```swift +/// let chunkSizes = body.map { chunk in chunk.count } +/// for try await chunkSize in chunkSizes { +/// print("Chunk size: \(chunkSize)") +/// } +/// ``` +/// +/// ## Consuming a body as a buffer +/// If you need to collect the whole body before processing it, use one of +/// the convenience initializers on the target types that take an `HTTPBody`. +/// +/// To get all the bytes, use the initializer on `ArraySlice` or `[UInt8]`: +/// +/// ```swift +/// let buffer = try await ArraySlice(collecting: body, upTo: 2 * 1024 * 1024) +/// ``` +/// +/// The body type provides more variants of the collecting initializer on commonly +/// used buffers, such as: +/// - `Foundation.Data` +/// - `Swift.String` +/// +/// > Important: You must provide the maximum number of bytes you can buffer in +/// memory, in the example above we provide 2 MB. If more bytes are available, +/// the method throws the `TooManyBytesError` to stop the process running out +/// of memory. While discouraged, you can provide `upTo: .max` to +/// read all the available bytes, without a limit. +public final class HTTPBody: @unchecked Sendable { + + /// The underlying byte chunk type. + public typealias ByteChunk = ArraySlice + + /// The iteration behavior, which controls how many times + /// the input sequence can be iterated. + public let iterationBehavior: IterationBehavior + + /// Describes the total length of the body, in bytes, if known. + public enum Length: Sendable, Equatable { + + /// Total length not known yet. + case unknown + + /// Total length is known. + case known(Int64) + } + + /// The total length of the body, in bytes, if known. + public let length: Length + + /// The underlying type-erased async sequence. + private let sequence: AnySequence + + /// A lock for shared mutable state. + private let lock: NSLock = { + let lock = NSLock() + lock.name = "com.apple.swift-openapi-generator.runtime.body" + return lock + }() + + /// A flag indicating whether an iterator has already been created. + private var locked_iteratorCreated: Bool = false + + /// A flag indicating whether an iterator has already been created, only + /// used for testing. + internal var testing_iteratorCreated: Bool { + lock.lock() + defer { lock.unlock() } + return locked_iteratorCreated + } + + /// Tries to mark an iterator as created, verifying that it is allowed + /// based on the values of `iterationBehavior` and `locked_iteratorCreated`. + /// - Throws: If another iterator is not allowed to be created. + private func tryToMarkIteratorCreated() throws { + lock.lock() + defer { + locked_iteratorCreated = true + lock.unlock() + } + guard iterationBehavior == .single else { return } + if locked_iteratorCreated { throw TooManyIterationsError() } + } + + /// Creates a new body. + /// - Parameters: + /// - sequence: The input sequence providing the byte chunks. + /// - length: The total length of the body, in other words the accumulated + /// length of all the byte chunks. + /// - iterationBehavior: The sequence's iteration behavior, which + /// indicates whether the sequence can be iterated multiple times. + @usableFromInline init( + _ sequence: AnySequence, + length: Length, + iterationBehavior: IterationBehavior + ) { + self.sequence = sequence + self.length = length + self.iterationBehavior = iterationBehavior + } + + /// Creates a new body with the provided sequence of byte chunks. + /// - Parameters: + /// - byteChunks: A sequence of byte chunks. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @usableFromInline convenience init( + _ byteChunks: some Sequence & Sendable, + length: Length, + iterationBehavior: IterationBehavior + ) { + self.init( + .init(WrappedSyncSequence(sequence: byteChunks)), + length: length, + iterationBehavior: iterationBehavior + ) + } +} + +extension HTTPBody: Equatable { + /// Compares two HTTPBody instances for equality by comparing their object identifiers. + /// + /// - Parameters: + /// - lhs: The left-hand side HTTPBody. + /// - rhs: The right-hand side HTTPBody. + /// + /// - Returns: `true` if the object identifiers of the two HTTPBody instances are equal, + /// indicating that they are the same object in memory; otherwise, returns `false`. + public static func == (lhs: HTTPBody, rhs: HTTPBody) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } +} + +extension HTTPBody: Hashable { + /// Hashes the HTTPBody instance by combining its object identifier into the provided hasher. + /// + /// - Parameter hasher: The hasher used to combine the hash value. + public func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } +} + +// MARK: - Creating the HTTPBody + +extension HTTPBody { + + /// Creates a new empty body. + @inlinable public convenience init() { + self.init(.init(EmptySequence()), length: .known(0), iterationBehavior: .multiple) + } + + /// Creates a new body with the provided byte chunk. + /// - Parameters: + /// - bytes: A byte chunk. + /// - length: The total length of the body. + @inlinable public convenience init(_ bytes: ByteChunk, length: Length) { + self.init([bytes], length: length, iterationBehavior: .multiple) + } + + /// Creates a new body with the provided byte chunk. + /// - Parameter bytes: A byte chunk. + @inlinable public convenience init(_ bytes: ByteChunk) { + self.init([bytes], length: .known(Int64(bytes.count)), iterationBehavior: .multiple) + } + + /// Creates a new body with the provided byte sequence. + /// - Parameters: + /// - bytes: A byte chunk. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init( + _ bytes: some Sequence & Sendable, + length: Length, + iterationBehavior: IterationBehavior + ) { self.init([ArraySlice(bytes)], length: length, iterationBehavior: iterationBehavior) } + + /// Creates a new body with the provided byte collection. + /// - Parameters: + /// - bytes: A byte chunk. + /// - length: The total length of the body. + @inlinable public convenience init(_ bytes: some Collection & Sendable, length: Length) { + self.init(ArraySlice(bytes), length: length, iterationBehavior: .multiple) + } + + /// Creates a new body with the provided byte collection. + /// - Parameter bytes: A byte chunk. + @inlinable public convenience init(_ bytes: some Collection & Sendable) { + self.init(bytes, length: .known(Int64(bytes.count))) + } + + /// Creates a new body with the provided async throwing stream. + /// - Parameters: + /// - stream: An async throwing stream that provides the byte chunks. + /// - length: The total length of the body. + @inlinable public convenience init( + _ stream: AsyncThrowingStream, + length: HTTPBody.Length + ) { + self.init(.init(stream), length: length, iterationBehavior: .single) + } + + /// Creates a new body with the provided async stream. + /// - Parameters: + /// - stream: An async stream that provides the byte chunks. + /// - length: The total length of the body. + @inlinable public convenience init(_ stream: AsyncStream, length: HTTPBody.Length) { + self.init(.init(stream), length: length, iterationBehavior: .single) + } + + /// Creates a new body with the provided async sequence. + /// - Parameters: + /// - sequence: An async sequence that provides the byte chunks. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init( + _ sequence: Bytes, + length: HTTPBody.Length, + iterationBehavior: IterationBehavior + ) where Bytes.Element == ByteChunk, Bytes: Sendable { + self.init(.init(sequence), length: length, iterationBehavior: iterationBehavior) + } + + /// Creates a new body with the provided async sequence of byte sequences. + /// - Parameters: + /// - sequence: An async sequence that provides the byte chunks. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init( + _ sequence: Bytes, + length: HTTPBody.Length, + iterationBehavior: IterationBehavior + ) where Bytes: Sendable, Bytes.Element: Sequence & Sendable, Bytes.Element.Element == UInt8 { + self.init( + sequence.map { ArraySlice($0) }, + length: length, + iterationBehavior: iterationBehavior + ) + } +} + +// MARK: - Consuming the body + +extension HTTPBody: AsyncSequence { + /// Represents a single element within an asynchronous sequence + public typealias Element = ByteChunk + /// Represents an asynchronous iterator over a sequence of elements. + public typealias AsyncIterator = Iterator + /// Creates and returns an asynchronous iterator + /// + /// - Returns: An asynchronous iterator for byte chunks. + /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. + public func makeAsyncIterator() -> AsyncIterator { + do { + try tryToMarkIteratorCreated() + return .init(sequence.makeAsyncIterator()) + } catch { return .init(throwing: error) } + } +} + +extension HTTPBody { + + /// An error thrown by the collecting initializer when the body contains more + /// than the maximum allowed number of bytes. + private struct TooManyBytesError: Error, CustomStringConvertible, LocalizedError { + + /// The maximum number of bytes acceptable by the user. + let maxBytes: Int + + var description: String { + "OpenAPIRuntime.HTTPBody contains more than the maximum allowed \(maxBytes) bytes." + } + + var errorDescription: String? { description } + } + + /// An error thrown by the collecting initializer when another iteration of + /// the body is not allowed. + private struct TooManyIterationsError: Error, CustomStringConvertible, LocalizedError { + + var description: String { + "OpenAPIRuntime.HTTPBody attempted to create a second iterator, but the underlying sequence is only safe to be iterated once." + } + + var errorDescription: String? { description } + } + + /// Accumulates the full body in-memory into a single buffer + /// up to the provided maximum number of bytes and returns it. + /// - Parameter maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + /// - Returns: A byte chunk containing all the accumulated bytes. + fileprivate func collect(upTo maxBytes: Int) async throws -> ByteChunk { + // If the length is known, verify it's within the limit. + if case .known(let knownBytes) = length { + guard knownBytes <= maxBytes else { throw TooManyBytesError(maxBytes: maxBytes) } + } + + // Accumulate the byte chunks. + var buffer = ByteChunk() + for try await chunk in self { + guard buffer.count + chunk.count <= maxBytes else { + throw TooManyBytesError(maxBytes: maxBytes) + } + buffer.append(contentsOf: chunk) + } + return buffer + } +} + +extension HTTPBody.ByteChunk { + /// Creates a byte chunk by accumulating the full body in-memory into a single buffer + /// up to the provided maximum number of bytes and returning it. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { + self = try await body.collect(upTo: maxBytes) + } +} + +extension Array where Element == UInt8 { + /// Creates a byte array by accumulating the full body in-memory into a single buffer + /// up to the provided maximum number of bytes and returning it. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { + self = try await Array(body.collect(upTo: maxBytes)) + } +} + +// MARK: - String-based bodies + +extension HTTPBody { + + /// Creates a new body with the provided string encoded as UTF-8 bytes. + /// - Parameters: + /// - string: A string to encode as bytes. + /// - length: The total length of the body. + @inlinable public convenience init(_ string: some StringProtocol & Sendable, length: Length) { + self.init(ByteChunk(string), length: length) + } + + /// Creates a new body with the provided string encoded as UTF-8 bytes. + /// - Parameter string: A string to encode as bytes. + @inlinable public convenience init(_ string: some StringProtocol & Sendable) { + self.init(ByteChunk(string)) + } + + /// Creates a new body with the provided async throwing stream of strings. + /// - Parameters: + /// - stream: An async throwing stream that provides the string chunks. + /// - length: The total length of the body. + @inlinable public convenience init( + _ stream: AsyncThrowingStream, + length: HTTPBody.Length + ) { + self.init( + .init(stream.map { ByteChunk.init($0) }), + length: length, + iterationBehavior: .single + ) + } + + /// Creates a new body with the provided async stream of strings. + /// - Parameters: + /// - stream: An async stream that provides the string chunks. + /// - length: The total length of the body. + @inlinable public convenience init( + _ stream: AsyncStream, + length: HTTPBody.Length + ) { + self.init( + .init(stream.map { ByteChunk.init($0) }), + length: length, + iterationBehavior: .single + ) + } + + /// Creates a new body with the provided async sequence of string chunks. + /// - Parameters: + /// - sequence: An async sequence that provides the string chunks. + /// - length: The total length of the body. + /// - iterationBehavior: The iteration behavior of the sequence, which + /// indicates whether it can be iterated multiple times. + @inlinable public convenience init( + _ sequence: Strings, + length: HTTPBody.Length, + iterationBehavior: IterationBehavior + ) where Strings.Element: StringProtocol & Sendable, Strings: Sendable { + self.init( + .init(sequence.map { ByteChunk.init($0) }), + length: length, + iterationBehavior: iterationBehavior + ) + } +} + +extension HTTPBody.ByteChunk { + + /// Creates a byte chunk compatible with the `HTTPBody` type from the provided string. + /// - Parameter string: The string to encode. + @inlinable init(_ string: some StringProtocol & Sendable) { self = Array(string.utf8)[...] } +} + +extension String { + /// Creates a string by accumulating the full body in-memory into a single buffer up to + /// the provided maximum number of bytes, converting it to string using UTF-8 encoding. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { + self = try await String(decoding: body.collect(upTo: maxBytes), as: UTF8.self) + } +} + +// MARK: - HTTPBody conversions + +extension HTTPBody: ExpressibleByStringLiteral { + /// Initializes an `HTTPBody` instance with the provided string value. + /// + /// - Parameter value: The string literal to use for initializing the `HTTPBody`. + public convenience init(stringLiteral value: String) { self.init(value) } +} + +extension HTTPBody { + + /// Creates a new body from the provided array of bytes. + /// - Parameter bytes: An array of bytes. + @inlinable public convenience init(_ bytes: [UInt8]) { self.init(bytes[...]) } +} + +extension HTTPBody: ExpressibleByArrayLiteral { + /// Element type for array literals. + public typealias ArrayLiteralElement = UInt8 + /// Initializes an `HTTPBody` instance with a sequence of `UInt8` elements. + /// + /// - Parameter elements: A variadic list of `UInt8` elements used to initialize the `HTTPBody`. + public convenience init(arrayLiteral elements: UInt8...) { self.init(elements) } +} + +extension HTTPBody { + + /// Creates a new body from the provided data chunk. + /// - Parameter data: A single data chunk. + public convenience init(_ data: Data) { self.init(ArraySlice(data)) } +} + +extension Data { + /// Creates a Data by accumulating the full body in-memory into a single buffer up to + /// the provided maximum number of bytes and converting it to `Data`. + /// - Parameters: + /// - body: The HTTP body to collect. + /// - maxBytes: The maximum number of bytes this method is allowed + /// to accumulate in memory before it throws an error. + /// - Throws: `TooManyBytesError` if the body contains more + /// than `maxBytes`. + public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { + self = try await Data(body.collect(upTo: maxBytes)) + } +} + +// MARK: - Underlying async sequences + +extension HTTPBody { + + /// An async iterator of both input async sequences and of the body itself. + public struct Iterator: AsyncIteratorProtocol { + + /// The element byte chunk type. + public typealias Element = HTTPBody.ByteChunk + + /// The closure that produces the next element. + private let produceNext: () async throws -> Element? + + /// Creates a new type-erased iterator from the provided iterator. + /// - Parameter iterator: The iterator to type-erase. + @usableFromInline init(_ iterator: Iterator) + where Iterator.Element == Element { + var iterator = iterator + self.produceNext = { try await iterator.next() } + } + /// Creates an iterator throwing the given error when iterated. + /// - Parameter error: The error to throw on iteration. + fileprivate init(throwing error: any Error) { self.produceNext = { throw error } } + + /// Advances the iterator to the next element and returns it asynchronously. + /// + /// - Returns: The next element in the sequence, or `nil` if there are no more elements. + /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. + public mutating func next() async throws -> Element? { try await produceNext() } + } +} diff --git a/Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift b/Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift new file mode 100644 index 00000000..044f7191 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift @@ -0,0 +1,32 @@ +import Logging + +#if canImport(Darwin) + import struct Foundation.URL + import struct Foundation.UUID +#else + @preconcurrency import struct Foundation.URL + @preconcurrency import struct Foundation.UUID +#endif + +struct LoggingMiddleware: ClientMiddleware { + let logger: Logger + + init(logger: Logger) { + self.logger = logger + } + + func intercept( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + next: (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + var logger = logger + logger[metadataKey: "request-id"] = .string(UUID().uuidString) + + logger.trace("⬆️ \(request.prettyDescription)") + let (response, body) = try await next(request, body, baseURL) + logger.trace("⬇️ \(response.prettyDescription)") + return (response, body) + } +} diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift new file mode 100644 index 00000000..6b30d219 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift @@ -0,0 +1,1973 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// swift-format-ignore-file +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2020-2021 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import DequeModule + +/// An asynchronous sequence generated from an error-throwing closure that +/// calls a continuation to produce new elements. +/// +/// `BufferedStream` conforms to `AsyncSequence`, providing a convenient +/// way to create an asynchronous sequence without manually implementing an +/// asynchronous iterator. In particular, an asynchronous stream is well-suited +/// to adapt callback- or delegation-based APIs to participate with +/// `async`-`await`. +/// +/// In contrast to `AsyncStream`, this type can throw an error from the awaited +/// `next()`, which terminates the stream with the thrown error. +/// +/// You initialize an `BufferedStream` with a closure that receives an +/// `BufferedStream.Continuation`. Produce elements in this closure, then +/// provide them to the stream by calling the continuation's `yield(_:)` method. +/// When there are no further elements to produce, call the continuation's +/// `finish()` method. This causes the sequence iterator to produce a `nil`, +/// which terminates the sequence. If an error occurs, call the continuation's +/// `finish(throwing:)` method, which causes the iterator's `next()` method to +/// throw the error to the awaiting call point. The continuation is `Sendable`, +/// which permits calling it from concurrent contexts external to the iteration +/// of the `BufferedStream`. +/// +/// An arbitrary source of elements can produce elements faster than they are +/// consumed by a caller iterating over them. Because of this, `BufferedStream` +/// defines a buffering behavior, allowing the stream to buffer a specific +/// number of oldest or newest elements. By default, the buffer limit is +/// `Int.max`, which means it's unbounded. +/// +/// ### Adapting Existing Code to Use Streams +/// +/// To adapt existing callback code to use `async`-`await`, use the callbacks +/// to provide values to the stream, by using the continuation's `yield(_:)` +/// method. +/// +/// Consider a hypothetical `QuakeMonitor` type that provides callers with +/// `Quake` instances every time it detects an earthquake. To receive callbacks, +/// callers set a custom closure as the value of the monitor's +/// `quakeHandler` property, which the monitor calls back as necessary. Callers +/// can also set an `errorHandler` to receive asynchronous error notifications, +/// such as the monitor service suddenly becoming unavailable. +/// +/// class QuakeMonitor { +/// var quakeHandler: ((Quake) -> Void)? +/// var errorHandler: ((Error) -> Void)? +/// +/// func startMonitoring() {…} +/// func stopMonitoring() {…} +/// } +/// +/// To adapt this to use `async`-`await`, extend the `QuakeMonitor` to add a +/// `quakes` property, of type `BufferedStream`. In the getter for +/// this property, return an `BufferedStream`, whose `build` closure -- +/// called at runtime to create the stream -- uses the continuation to +/// perform the following steps: +/// +/// 1. Creates a `QuakeMonitor` instance. +/// 2. Sets the monitor's `quakeHandler` property to a closure that receives +/// each `Quake` instance and forwards it to the stream by calling the +/// continuation's `yield(_:)` method. +/// 3. Sets the monitor's `errorHandler` property to a closure that receives +/// any error from the monitor and forwards it to the stream by calling the +/// continuation's `finish(throwing:)` method. This causes the stream's +/// iterator to throw the error and terminate the stream. +/// 4. Sets the continuation's `onTermination` property to a closure that +/// calls `stopMonitoring()` on the monitor. +/// 5. Calls `startMonitoring` on the `QuakeMonitor`. +/// +/// ``` +/// extension QuakeMonitor { +/// +/// static var throwingQuakes: BufferedStream { +/// BufferedStream { continuation in +/// let monitor = QuakeMonitor() +/// monitor.quakeHandler = { quake in +/// continuation.yield(quake) +/// } +/// monitor.errorHandler = { error in +/// continuation.finish(throwing: error) +/// } +/// continuation.onTermination = { @Sendable _ in +/// monitor.stopMonitoring() +/// } +/// monitor.startMonitoring() +/// } +/// } +/// } +/// ``` +/// +/// +/// Because the stream is an `AsyncSequence`, the call point uses the +/// `for`-`await`-`in` syntax to process each `Quake` instance as produced by the stream: +/// +/// do { +/// for try await quake in quakeStream { +/// print("Quake: \(quake.date)") +/// } +/// print("Stream done.") +/// } catch { +/// print("Error: \(error)") +/// } +/// +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +@usableFromInline +internal struct BufferedStream { + @usableFromInline + final class _Backing: Sendable { + @usableFromInline + let storage: _BackPressuredStorage + + @usableFromInline + init(storage: _BackPressuredStorage) { + self.storage = storage + } + + deinit { + self.storage.sequenceDeinitialized() + } + } + + @usableFromInline + enum _Implementation: Sendable { + /// This is the implementation with backpressure based on the Source + case backpressured(_Backing) + } + + @usableFromInline + let implementation: _Implementation +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension BufferedStream: AsyncSequence { + /// The asynchronous iterator for iterating an asynchronous stream. + /// + /// This type is not `Sendable`. Don't use it from multiple + /// concurrent contexts. It is a programmer error to invoke `next()` from a + /// concurrent context that contends with another such call, which + /// results in a call to `fatalError()`. + @usableFromInline + internal struct Iterator: AsyncIteratorProtocol { + @usableFromInline + final class _Backing { + @usableFromInline + let storage: _BackPressuredStorage + + @usableFromInline + init(storage: _BackPressuredStorage) { + self.storage = storage + self.storage.iteratorInitialized() + } + + deinit { + self.storage.iteratorDeinitialized() + } + } + + @usableFromInline + enum _Implementation { + /// This is the implementation with backpressure based on the Source + case backpressured(_Backing) + } + + @usableFromInline + var implementation: _Implementation + + @usableFromInline + init(implementation: _Implementation) { + self.implementation = implementation + } + + /// The next value from the asynchronous stream. + /// + /// When `next()` returns `nil`, this signifies the end of the + /// `BufferedStream`. + /// + /// It is a programmer error to invoke `next()` from a concurrent context + /// that contends with another such call, which results in a call to + /// `fatalError()`. + /// + /// If you cancel the task this iterator is running in while `next()` is + /// awaiting a value, the `BufferedStream` terminates. In this case, + /// `next()` may return `nil` immediately, or else return `nil` on + /// subsequent calls. + @inlinable + internal mutating func next() async throws -> Element? { + switch self.implementation { + case .backpressured(let backing): + return try await backing.storage.next() + } + } + } + + /// Creates the asynchronous iterator that produces elements of this + /// asynchronous sequence. + @inlinable + internal func makeAsyncIterator() -> Iterator { + switch self.implementation { + case .backpressured(let backing): + return Iterator(implementation: .backpressured(.init(storage: backing.storage))) + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension BufferedStream: Sendable where Element: Sendable {} + +@usableFromInline +internal struct _ManagedCriticalState: @unchecked Sendable { + @usableFromInline + let lock: LockedValueBox + + @usableFromInline + internal init(_ initial: State) { + self.lock = .init(initial) + } + + @inlinable + internal func withCriticalRegion( + _ critical: (inout State) throws -> R + ) rethrows -> R { + try self.lock.withLockedValue(critical) + } +} + +@usableFromInline +internal struct AlreadyFinishedError: Error { + @usableFromInline + init() {} +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension BufferedStream { + /// A mechanism to interface between producer code and an asynchronous stream. + /// + /// Use this source to provide elements to the stream by calling one of the `write` methods, then terminate the stream normally + /// by calling the `finish()` method. You can also use the source's `finish(throwing:)` method to terminate the stream by + /// throwing an error. + @usableFromInline + internal struct Source: Sendable { + /// A strategy that handles the backpressure of the asynchronous stream. + @usableFromInline + internal struct BackPressureStrategy: Sendable { + /// When the high watermark is reached producers will be suspended. All producers will be resumed again once + /// the low watermark is reached. The current watermark is the number of elements in the buffer. + @inlinable + internal static func watermark(low: Int, high: Int) -> BackPressureStrategy { + BackPressureStrategy( + internalBackPressureStrategy: .watermark(.init(low: low, high: high)) + ) + } + + /// When the high watermark is reached producers will be suspended. All producers will be resumed again once + /// the low watermark is reached. The current watermark is computed using the given closure. + static func customWatermark( + low: Int, + high: Int, + waterLevelForElement: @escaping @Sendable (Element) -> Int + ) -> BackPressureStrategy where Element: RandomAccessCollection { + BackPressureStrategy( + internalBackPressureStrategy: .watermark(.init(low: low, high: high, waterLevelForElement: waterLevelForElement)) + ) + } + + @usableFromInline + init(internalBackPressureStrategy: _InternalBackPressureStrategy) { + self._internalBackPressureStrategy = internalBackPressureStrategy + } + + @usableFromInline + let _internalBackPressureStrategy: _InternalBackPressureStrategy + } + + /// A type that indicates the result of writing elements to the source. + @frozen + @usableFromInline + internal enum WriteResult: Sendable { + /// A token that is returned when the asynchronous stream's backpressure strategy indicated that production should + /// be suspended. Use this token to enqueue a callback by calling the ``enqueueCallback(_:)`` method. + @usableFromInline + internal struct CallbackToken: Sendable { + @usableFromInline + let id: UInt + @usableFromInline + init(id: UInt) { + self.id = id + } + } + + /// Indicates that more elements should be produced and written to the source. + case produceMore + + /// Indicates that a callback should be enqueued. + /// + /// The associated token should be passed to the ``enqueueCallback(_:)`` method. + case enqueueCallback(CallbackToken) + } + + /// Backing class for the source used to hook a deinit. + @usableFromInline + final class _Backing: Sendable { + @usableFromInline + let storage: _BackPressuredStorage + + @usableFromInline + init(storage: _BackPressuredStorage) { + self.storage = storage + } + + deinit { + self.storage.sourceDeinitialized() + } + } + + /// A callback to invoke when the stream finished. + /// + /// The stream finishes and calls this closure in the following cases: + /// - No iterator was created and the sequence was deinited + /// - An iterator was created and deinited + /// - After ``finish(throwing:)`` was called and all elements have been consumed + /// - The consuming task got cancelled + @inlinable + internal var onTermination: (@Sendable () -> Void)? { + set { + self._backing.storage.onTermination = newValue + } + get { + self._backing.storage.onTermination + } + } + + @usableFromInline + var _backing: _Backing + + @usableFromInline + internal init(storage: _BackPressuredStorage) { + self._backing = .init(storage: storage) + } + + /// Writes new elements to the asynchronous stream. + /// + /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the + /// first element of the provided sequence. If the asynchronous stream already terminated then this method will throw an error + /// indicating the failure. + /// + /// - Parameter sequence: The elements to write to the asynchronous stream. + /// - Returns: The result that indicates if more elements should be produced at this time. + @inlinable + internal func write(contentsOf sequence: S) throws -> WriteResult + where Element == S.Element, S: Sequence { + try self._backing.storage.write(contentsOf: sequence) + } + + /// Write the element to the asynchronous stream. + /// + /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the + /// provided element. If the asynchronous stream already terminated then this method will throw an error + /// indicating the failure. + /// + /// - Parameter element: The element to write to the asynchronous stream. + /// - Returns: The result that indicates if more elements should be produced at this time. + @inlinable + internal func write(_ element: Element) throws -> WriteResult { + try self._backing.storage.write(contentsOf: CollectionOfOne(element)) + } + + /// Enqueues a callback that will be invoked once more elements should be produced. + /// + /// Call this method after ``write(contentsOf:)`` or ``write(:)`` returned ``WriteResult/enqueueCallback(_:)``. + /// + /// - Important: Enqueueing the same token multiple times is not allowed. + /// + /// - Parameters: + /// - callbackToken: The callback token. + /// - onProduceMore: The callback which gets invoked once more elements should be produced. + @inlinable + internal func enqueueCallback( + callbackToken: WriteResult.CallbackToken, + onProduceMore: @escaping @Sendable (Result) -> Void + ) { + self._backing.storage.enqueueProducer( + callbackToken: callbackToken, + onProduceMore: onProduceMore + ) + } + + /// Cancel an enqueued callback. + /// + /// Call this method to cancel a callback enqueued by the ``enqueueCallback(callbackToken:onProduceMore:)`` method. + /// + /// - Note: This methods supports being called before ``enqueueCallback(callbackToken:onProduceMore:)`` is called and + /// will mark the passed `callbackToken` as cancelled. + /// + /// - Parameter callbackToken: The callback token. + @inlinable + internal func cancelCallback(callbackToken: WriteResult.CallbackToken) { + self._backing.storage.cancelProducer(callbackToken: callbackToken) + } + + /// Write new elements to the asynchronous stream and provide a callback which will be invoked once more elements should be produced. + /// + /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the + /// first element of the provided sequence. If the asynchronous stream already terminated then `onProduceMore` will be invoked with + /// a `Result.failure`. + /// + /// - Parameters: + /// - sequence: The elements to write to the asynchronous stream. + /// - onProduceMore: The callback which gets invoked once more elements should be produced. This callback might be + /// invoked during the call to ``write(contentsOf:onProduceMore:)``. + @inlinable + internal func write( + contentsOf sequence: S, + onProduceMore: @escaping @Sendable (Result) -> Void + ) where Element == S.Element, S: Sequence { + do { + let writeResult = try self.write(contentsOf: sequence) + + switch writeResult { + case .produceMore: + onProduceMore(Result.success(())) + + case .enqueueCallback(let callbackToken): + self.enqueueCallback(callbackToken: callbackToken, onProduceMore: onProduceMore) + } + } catch { + onProduceMore(.failure(error)) + } + } + + /// Writes the element to the asynchronous stream. + /// + /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the + /// provided element. If the asynchronous stream already terminated then `onProduceMore` will be invoked with + /// a `Result.failure`. + /// + /// - Parameters: + /// - sequence: The element to write to the asynchronous stream. + /// - onProduceMore: The callback which gets invoked once more elements should be produced. This callback might be + /// invoked during the call to ``write(_:onProduceMore:)``. + @inlinable + internal func write( + _ element: Element, + onProduceMore: @escaping @Sendable (Result) -> Void + ) { + self.write(contentsOf: CollectionOfOne(element), onProduceMore: onProduceMore) + } + + /// Write new elements to the asynchronous stream. + /// + /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the + /// first element of the provided sequence. If the asynchronous stream already terminated then this method will throw an error + /// indicating the failure. + /// + /// This method returns once more elements should be produced. + /// + /// - Parameters: + /// - sequence: The elements to write to the asynchronous stream. + @inlinable + internal func write(contentsOf sequence: S) async throws + where Element == S.Element, S: Sequence { + let writeResult = try { try self.write(contentsOf: sequence) }() + + switch writeResult { + case .produceMore: + return + + case .enqueueCallback(let callbackToken): + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.enqueueCallback( + callbackToken: callbackToken, + onProduceMore: { result in + switch result { + case .success(): + continuation.resume(returning: ()) + case .failure(let error): + continuation.resume(throwing: error) + } + } + ) + } + } onCancel: { + self.cancelCallback(callbackToken: callbackToken) + } + } + } + + /// Write new element to the asynchronous stream. + /// + /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the + /// provided element. If the asynchronous stream already terminated then this method will throw an error + /// indicating the failure. + /// + /// This method returns once more elements should be produced. + /// + /// - Parameters: + /// - sequence: The element to write to the asynchronous stream. + @inlinable + internal func write(_ element: Element) async throws { + try await self.write(contentsOf: CollectionOfOne(element)) + } + + /// Write the elements of the asynchronous sequence to the asynchronous stream. + /// + /// This method returns once the provided asynchronous sequence or the the asynchronous stream finished. + /// + /// - Important: This method does not finish the source if consuming the upstream sequence terminated. + /// + /// - Parameters: + /// - sequence: The elements to write to the asynchronous stream. + @inlinable + internal func write(contentsOf sequence: S) async throws + where Element == S.Element, S: AsyncSequence { + for try await element in sequence { + try await self.write(contentsOf: CollectionOfOne(element)) + } + } + + /// Indicates that the production terminated. + /// + /// After all buffered elements are consumed the next iteration point will return `nil` or throw an error. + /// + /// Calling this function more than once has no effect. After calling finish, the stream enters a terminal state and doesn't accept + /// new elements. + /// + /// - Parameters: + /// - error: The error to throw, or `nil`, to finish normally. + @inlinable + internal func finish(throwing error: (any Error)?) { + self._backing.storage.finish(error) + } + } + + /// Initializes a new ``BufferedStream`` and an ``BufferedStream/Source``. + /// + /// - Parameters: + /// - elementType: The element type of the stream. + /// - failureType: The failure type of the stream. + /// - backPressureStrategy: The backpressure strategy that the stream should use. + /// - Returns: A tuple containing the stream and its source. The source should be passed to the + /// producer while the stream should be passed to the consumer. + @inlinable + internal static func makeStream( + of elementType: Element.Type = Element.self, + throwing failureType: any Error.Type = (any Error).self, + backPressureStrategy: Source.BackPressureStrategy + ) -> (`Self`, Source) where any Error == any Error { + let storage = _BackPressuredStorage( + backPressureStrategy: backPressureStrategy._internalBackPressureStrategy + ) + let source = Source(storage: storage) + + return (.init(storage: storage), source) + } + + @usableFromInline + init(storage: _BackPressuredStorage) { + self.implementation = .backpressured(.init(storage: storage)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension BufferedStream { + @usableFromInline + struct _WatermarkBackPressureStrategy: Sendable { + /// The low watermark where demand should start. + @usableFromInline + let _low: Int + /// The high watermark where demand should be stopped. + @usableFromInline + let _high: Int + /// The current watermark. + @usableFromInline + private(set) var _current: Int + /// Function to compute the contribution to the water level for a given element. + @usableFromInline + let _waterLevelForElement: (@Sendable (Element) -> Int)? + + /// Initializes a new ``_WatermarkBackPressureStrategy``. + /// + /// - Parameters: + /// - low: The low watermark where demand should start. + /// - high: The high watermark where demand should be stopped. + /// - waterLevelForElement: Function to compute the contribution to the water level for a given element. + @usableFromInline + init(low: Int, high: Int, waterLevelForElement: (@Sendable (Element) -> Int)? = nil) { + precondition(low <= high) + self._low = low + self._high = high + self._current = 0 + self._waterLevelForElement = waterLevelForElement + } + + @usableFromInline + mutating func didYield(elements: Deque.SubSequence) -> Bool { + if let waterLevelForElement = self._waterLevelForElement { + self._current += elements.reduce(0) { $0 + waterLevelForElement($1) } + } else { + self._current += elements.count + } + precondition(self._current >= 0, "Watermark below zero") + // We are demanding more until we reach the high watermark + return self._current < self._high + } + + @usableFromInline + mutating func didConsume(elements: Deque.SubSequence) -> Bool { + if let waterLevelForElement = self._waterLevelForElement { + self._current -= elements.reduce(0) { $0 + waterLevelForElement($1) } + } else { + self._current -= elements.count + } + precondition(self._current >= 0, "Watermark below zero") + // We start demanding again once we are below the low watermark + return self._current < self._low + } + + @usableFromInline + mutating func didConsume(element: Element) -> Bool { + if let waterLevelForElement = self._waterLevelForElement { + self._current -= waterLevelForElement(element) + } else { + self._current -= 1 + } + precondition(self._current >= 0, "Watermark below zero") + // We start demanding again once we are below the low watermark + return self._current < self._low + } + } + + @usableFromInline + enum _InternalBackPressureStrategy: Sendable { + case watermark(_WatermarkBackPressureStrategy) + + @inlinable + mutating func didYield(elements: Deque.SubSequence) -> Bool { + switch self { + case .watermark(var strategy): + let result = strategy.didYield(elements: elements) + self = .watermark(strategy) + return result + } + } + + @usableFromInline + mutating func didConsume(elements: Deque.SubSequence) -> Bool { + switch self { + case .watermark(var strategy): + let result = strategy.didConsume(elements: elements) + self = .watermark(strategy) + return result + } + } + + @usableFromInline + mutating func didConsume(element: Element) -> Bool { + switch self { + case .watermark(var strategy): + let result = strategy.didConsume(element: element) + self = .watermark(strategy) + return result + } + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension BufferedStream { + // We are unchecked Sendable since we are protecting our state with a lock. + @usableFromInline + final class _BackPressuredStorage: Sendable { + /// The state machine + @usableFromInline + let _stateMachine: _ManagedCriticalState<_StateMachine> + + @usableFromInline + var onTermination: (@Sendable () -> Void)? { + set { + self._stateMachine.withCriticalRegion { + $0._onTermination = newValue + } + } + get { + self._stateMachine.withCriticalRegion { + $0._onTermination + } + } + } + + @usableFromInline + init( + backPressureStrategy: _InternalBackPressureStrategy + ) { + self._stateMachine = .init(.init(backPressureStrategy: backPressureStrategy)) + } + + @inlinable + func sequenceDeinitialized() { + let action = self._stateMachine.withCriticalRegion { + $0.sequenceDeinitialized() + } + + switch action { + case .callOnTermination(let onTermination): + onTermination?() + + case .failProducersAndCallOnTermination(let producerContinuations, let onTermination): + for producerContinuation in producerContinuations { + producerContinuation(.failure(AlreadyFinishedError())) + } + onTermination?() + + case .none: + break + } + } + + @inlinable + func iteratorInitialized() { + self._stateMachine.withCriticalRegion { + $0.iteratorInitialized() + } + } + + @inlinable + func iteratorDeinitialized() { + let action = self._stateMachine.withCriticalRegion { + $0.iteratorDeinitialized() + } + + switch action { + case .callOnTermination(let onTermination): + onTermination?() + + case .failProducersAndCallOnTermination(let producerContinuations, let onTermination): + for producerContinuation in producerContinuations { + producerContinuation(.failure(AlreadyFinishedError())) + } + onTermination?() + + case .none: + break + } + } + + @inlinable + func sourceDeinitialized() { + let action = self._stateMachine.withCriticalRegion { + $0.sourceDeinitialized() + } + + switch action { + case .callOnTermination(let onTermination): + onTermination?() + + case .failProducersAndCallOnTermination( + let consumer, + let producerContinuations, + let onTermination + ): + consumer?.resume(returning: nil) + for producerContinuation in producerContinuations { + producerContinuation(.failure(AlreadyFinishedError())) + } + onTermination?() + + case .failProducers(let producerContinuations): + for producerContinuation in producerContinuations { + producerContinuation(.failure(AlreadyFinishedError())) + } + + case .none: + break + } + } + + @inlinable + func write( + contentsOf sequence: some Sequence + ) throws -> Source.WriteResult { + let action = self._stateMachine.withCriticalRegion { + return $0.write(sequence) + } + + switch action { + case .returnProduceMore: + return .produceMore + + case .returnEnqueue(let callbackToken): + return .enqueueCallback(callbackToken) + + case .resumeConsumerAndReturnProduceMore(let continuation, let element): + continuation.resume(returning: element) + return .produceMore + + case .resumeConsumerAndReturnEnqueue(let continuation, let element, let callbackToken): + continuation.resume(returning: element) + return .enqueueCallback(callbackToken) + + case .throwFinishedError: + throw AlreadyFinishedError() + } + } + + @inlinable + func enqueueProducer( + callbackToken: Source.WriteResult.CallbackToken, + onProduceMore: @escaping @Sendable (Result) -> Void + ) { + let action = self._stateMachine.withCriticalRegion { + $0.enqueueProducer(callbackToken: callbackToken, onProduceMore: onProduceMore) + } + + switch action { + case .resumeProducer(let onProduceMore): + onProduceMore(Result.success(())) + + case .resumeProducerWithError(let onProduceMore, let error): + onProduceMore(Result.failure(error)) + + case .none: + break + } + } + + @inlinable + func cancelProducer(callbackToken: Source.WriteResult.CallbackToken) { + let action = self._stateMachine.withCriticalRegion { + $0.cancelProducer(callbackToken: callbackToken) + } + + switch action { + case .resumeProducerWithCancellationError(let onProduceMore): + onProduceMore(Result.failure(CancellationError())) + + case .none: + break + } + } + + @inlinable + func finish(_ failure: (any Error)?) { + let action = self._stateMachine.withCriticalRegion { + $0.finish(failure) + } + + switch action { + case .callOnTermination(let onTermination): + onTermination?() + + case .resumeConsumerAndCallOnTermination( + let consumerContinuation, + let failure, + let onTermination + ): + switch failure { + case .some(let error): + consumerContinuation.resume(throwing: error) + case .none: + consumerContinuation.resume(returning: nil) + } + + onTermination?() + + case .resumeProducers(let producerContinuations): + for producerContinuation in producerContinuations { + producerContinuation(.failure(AlreadyFinishedError())) + } + + case .none: + break + } + } + + @inlinable + func next() async throws -> Element? { + let action = self._stateMachine.withCriticalRegion { + $0.next() + } + + switch action { + case .returnElement(let element): + return element + + case .returnElementAndResumeProducers(let element, let producerContinuations): + for producerContinuation in producerContinuations { + producerContinuation(Result.success(())) + } + + return element + + case .returnErrorAndCallOnTermination(let failure, let onTermination): + onTermination?() + switch failure { + case .some(let error): + throw error + + case .none: + return nil + } + + case .returnNil: + return nil + + case .suspendTask: + return try await self.suspendNext() + } + } + + @inlinable + func suspendNext() async throws -> Element? { + return try await withTaskCancellationHandler { + return try await withCheckedThrowingContinuation { continuation in + let action = self._stateMachine.withCriticalRegion { + $0.suspendNext(continuation: continuation) + } + + switch action { + case .resumeConsumerWithElement(let continuation, let element): + continuation.resume(returning: element) + + case .resumeConsumerWithElementAndProducers( + let continuation, + let element, + let producerContinuations + ): + continuation.resume(returning: element) + for producerContinuation in producerContinuations { + producerContinuation(Result.success(())) + } + + case .resumeConsumerWithErrorAndCallOnTermination( + let continuation, + let failure, + let onTermination + ): + switch failure { + case .some(let error): + continuation.resume(throwing: error) + + case .none: + continuation.resume(returning: nil) + } + onTermination?() + + case .resumeConsumerWithNil(let continuation): + continuation.resume(returning: nil) + + case .none: + break + } + } + } onCancel: { + let action = self._stateMachine.withCriticalRegion { + $0.cancelNext() + } + + switch action { + case .resumeConsumerWithCancellationErrorAndCallOnTermination( + let continuation, + let onTermination + ): + continuation.resume(throwing: CancellationError()) + onTermination?() + + case .failProducersAndCallOnTermination( + let producerContinuations, + let onTermination + ): + for producerContinuation in producerContinuations { + producerContinuation(.failure(AlreadyFinishedError())) + } + onTermination?() + + case .none: + break + } + } + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension BufferedStream { + /// The state machine of the backpressured async stream. + @usableFromInline + struct _StateMachine { + @usableFromInline + enum _State { + @usableFromInline + struct Initial { + /// The backpressure strategy. + @usableFromInline + var backPressureStrategy: _InternalBackPressureStrategy + /// Indicates if the iterator was initialized. + @usableFromInline + var iteratorInitialized: Bool + /// The onTermination callback. + @usableFromInline + var onTermination: (@Sendable () -> Void)? + + @usableFromInline + init( + backPressureStrategy: _InternalBackPressureStrategy, + iteratorInitialized: Bool, + onTermination: (@Sendable () -> Void)? = nil + ) { + self.backPressureStrategy = backPressureStrategy + self.iteratorInitialized = iteratorInitialized + self.onTermination = onTermination + } + } + + @usableFromInline + struct Streaming { + /// The backpressure strategy. + @usableFromInline + var backPressureStrategy: _InternalBackPressureStrategy + /// Indicates if the iterator was initialized. + @usableFromInline + var iteratorInitialized: Bool + /// The onTermination callback. + @usableFromInline + var onTermination: (@Sendable () -> Void)? + /// The buffer of elements. + @usableFromInline + var buffer: Deque + /// The optional consumer continuation. + @usableFromInline + var consumerContinuation: CheckedContinuation? + /// The producer continuations. + @usableFromInline + var producerContinuations: Deque<(UInt, (Result) -> Void)> + /// The producers that have been cancelled. + @usableFromInline + var cancelledAsyncProducers: Deque + /// Indicates if we currently have outstanding demand. + @usableFromInline + var hasOutstandingDemand: Bool + + @usableFromInline + init( + backPressureStrategy: _InternalBackPressureStrategy, + iteratorInitialized: Bool, + onTermination: (@Sendable () -> Void)? = nil, + buffer: Deque, + consumerContinuation: CheckedContinuation? = nil, + producerContinuations: Deque<(UInt, (Result) -> Void)>, + cancelledAsyncProducers: Deque, + hasOutstandingDemand: Bool + ) { + self.backPressureStrategy = backPressureStrategy + self.iteratorInitialized = iteratorInitialized + self.onTermination = onTermination + self.buffer = buffer + self.consumerContinuation = consumerContinuation + self.producerContinuations = producerContinuations + self.cancelledAsyncProducers = cancelledAsyncProducers + self.hasOutstandingDemand = hasOutstandingDemand + } + } + + @usableFromInline + struct SourceFinished { + /// Indicates if the iterator was initialized. + @usableFromInline + var iteratorInitialized: Bool + /// The buffer of elements. + @usableFromInline + var buffer: Deque + /// The failure that should be thrown after the last element has been consumed. + @usableFromInline + var failure: (any Error)? + /// The onTermination callback. + @usableFromInline + var onTermination: (@Sendable () -> Void)? + + @usableFromInline + init( + iteratorInitialized: Bool, + buffer: Deque, + failure: (any Error)? = nil, + onTermination: (@Sendable () -> Void)? + ) { + self.iteratorInitialized = iteratorInitialized + self.buffer = buffer + self.failure = failure + self.onTermination = onTermination + } + } + + case initial(Initial) + /// The state once either any element was yielded or `next()` was called. + case streaming(Streaming) + /// The state once the underlying source signalled that it is finished. + case sourceFinished(SourceFinished) + + /// The state once there can be no outstanding demand. This can happen if: + /// 1. The iterator was deinited + /// 2. The underlying source finished and all buffered elements have been consumed + case finished(iteratorInitialized: Bool) + + /// An intermediate state to avoid CoWs. + case modify + } + + /// The state machine's current state. + @usableFromInline + var _state: _State + + // The ID used for the next CallbackToken. + @usableFromInline + var nextCallbackTokenID: UInt = 0 + + @inlinable + var _onTermination: (@Sendable () -> Void)? { + set { + switch self._state { + case .initial(var initial): + initial.onTermination = newValue + self._state = .initial(initial) + + case .streaming(var streaming): + streaming.onTermination = newValue + self._state = .streaming(streaming) + + case .sourceFinished(var sourceFinished): + sourceFinished.onTermination = newValue + self._state = .sourceFinished(sourceFinished) + + case .finished: + break + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + get { + switch self._state { + case .initial(let initial): + return initial.onTermination + + case .streaming(let streaming): + return streaming.onTermination + + case .sourceFinished(let sourceFinished): + return sourceFinished.onTermination + + case .finished: + return nil + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + } + + /// Initializes a new `StateMachine`. + /// + /// We are passing and holding the back-pressure strategy here because + /// it is a customizable extension of the state machine. + /// + /// - Parameter backPressureStrategy: The back-pressure strategy. + @usableFromInline + init( + backPressureStrategy: _InternalBackPressureStrategy + ) { + self._state = .initial( + .init( + backPressureStrategy: backPressureStrategy, + iteratorInitialized: false + ) + ) + } + + /// Generates the next callback token. + @inlinable + mutating func nextCallbackToken() -> Source.WriteResult.CallbackToken { + let id = self.nextCallbackTokenID + self.nextCallbackTokenID += 1 + return .init(id: id) + } + + /// Actions returned by `sequenceDeinitialized()`. + @usableFromInline + enum SequenceDeinitializedAction { + /// Indicates that `onTermination` should be called. + case callOnTermination((@Sendable () -> Void)?) + /// Indicates that all producers should be failed and `onTermination` should be called. + case failProducersAndCallOnTermination( + [(Result) -> Void], + (@Sendable () -> Void)? + ) + } + + @inlinable + mutating func sequenceDeinitialized() -> SequenceDeinitializedAction? { + switch self._state { + case .initial(let initial): + if initial.iteratorInitialized { + // An iterator was created and we deinited the sequence. + // This is an expected pattern and we just continue on normal. + return .none + } else { + // No iterator was created so we can transition to finished right away. + self._state = .finished(iteratorInitialized: false) + + return .callOnTermination(initial.onTermination) + } + + case .streaming(let streaming): + if streaming.iteratorInitialized { + // An iterator was created and we deinited the sequence. + // This is an expected pattern and we just continue on normal. + return .none + } else { + // No iterator was created so we can transition to finished right away. + self._state = .finished(iteratorInitialized: false) + + return .failProducersAndCallOnTermination( + Array(streaming.producerContinuations.map { $0.1 }), + streaming.onTermination + ) + } + + case .sourceFinished(let sourceFinished): + if sourceFinished.iteratorInitialized { + // An iterator was created and we deinited the sequence. + // This is an expected pattern and we just continue on normal. + return .none + } else { + // No iterator was created so we can transition to finished right away. + self._state = .finished(iteratorInitialized: false) + + return .callOnTermination(sourceFinished.onTermination) + } + + case .finished: + // We are already finished so there is nothing left to clean up. + // This is just the references dropping afterwards. + return .none + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + @inlinable + mutating func iteratorInitialized() { + switch self._state { + case .initial(var initial): + if initial.iteratorInitialized { + // Our sequence is a unicast sequence and does not support multiple AsyncIterator's + fatalError("Only a single AsyncIterator can be created") + } else { + // The first and only iterator was initialized. + initial.iteratorInitialized = true + self._state = .initial(initial) + } + + case .streaming(var streaming): + if streaming.iteratorInitialized { + // Our sequence is a unicast sequence and does not support multiple AsyncIterator's + fatalError("Only a single AsyncIterator can be created") + } else { + // The first and only iterator was initialized. + streaming.iteratorInitialized = true + self._state = .streaming(streaming) + } + + case .sourceFinished(var sourceFinished): + if sourceFinished.iteratorInitialized { + // Our sequence is a unicast sequence and does not support multiple AsyncIterator's + fatalError("Only a single AsyncIterator can be created") + } else { + // The first and only iterator was initialized. + sourceFinished.iteratorInitialized = true + self._state = .sourceFinished(sourceFinished) + } + + case .finished(iteratorInitialized: true): + // Our sequence is a unicast sequence and does not support multiple AsyncIterator's + fatalError("Only a single AsyncIterator can be created") + + case .finished(iteratorInitialized: false): + // It is strange that an iterator is created after we are finished + // but it can definitely happen, e.g. + // Sequence.init -> source.finish -> sequence.makeAsyncIterator + self._state = .finished(iteratorInitialized: true) + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `iteratorDeinitialized()`. + @usableFromInline + enum IteratorDeinitializedAction { + /// Indicates that `onTermination` should be called. + case callOnTermination((@Sendable () -> Void)?) + /// Indicates that all producers should be failed and `onTermination` should be called. + case failProducersAndCallOnTermination( + [(Result) -> Void], + (@Sendable () -> Void)? + ) + } + + @inlinable + mutating func iteratorDeinitialized() -> IteratorDeinitializedAction? { + switch self._state { + case .initial(let initial): + if initial.iteratorInitialized { + // An iterator was created and deinited. Since we only support + // a single iterator we can now transition to finish. + self._state = .finished(iteratorInitialized: true) + return .callOnTermination(initial.onTermination) + } else { + // An iterator needs to be initialized before it can be deinitialized. + fatalError("AsyncStream internal inconsistency") + } + + case .streaming(let streaming): + if streaming.iteratorInitialized { + // An iterator was created and deinited. Since we only support + // a single iterator we can now transition to finish. + self._state = .finished(iteratorInitialized: true) + + return .failProducersAndCallOnTermination( + Array(streaming.producerContinuations.map { $0.1 }), + streaming.onTermination + ) + } else { + // An iterator needs to be initialized before it can be deinitialized. + fatalError("AsyncStream internal inconsistency") + } + + case .sourceFinished(let sourceFinished): + if sourceFinished.iteratorInitialized { + // An iterator was created and deinited. Since we only support + // a single iterator we can now transition to finish. + self._state = .finished(iteratorInitialized: true) + return .callOnTermination(sourceFinished.onTermination) + } else { + // An iterator needs to be initialized before it can be deinitialized. + fatalError("AsyncStream internal inconsistency") + } + + case .finished: + // We are already finished so there is nothing left to clean up. + // This is just the references dropping afterwards. + return .none + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `sourceDeinitialized()`. + @usableFromInline + enum SourceDeinitializedAction { + /// Indicates that `onTermination` should be called. + case callOnTermination((() -> Void)?) + /// Indicates that all producers should be failed and `onTermination` should be called. + case failProducersAndCallOnTermination( + CheckedContinuation?, + [(Result) -> Void], + (@Sendable () -> Void)? + ) + /// Indicates that all producers should be failed. + case failProducers([(Result) -> Void]) + } + + @inlinable + mutating func sourceDeinitialized() -> SourceDeinitializedAction? { + switch self._state { + case .initial(let initial): + // The source got deinited before anything was written + self._state = .finished(iteratorInitialized: initial.iteratorInitialized) + return .callOnTermination(initial.onTermination) + + case .streaming(let streaming): + if streaming.buffer.isEmpty { + // We can transition to finished right away since the buffer is empty now + self._state = .finished(iteratorInitialized: streaming.iteratorInitialized) + + return .failProducersAndCallOnTermination( + streaming.consumerContinuation, + Array(streaming.producerContinuations.map { $0.1 }), + streaming.onTermination + ) + } else { + // The continuation must be `nil` if the buffer has elements + precondition(streaming.consumerContinuation == nil) + + self._state = .sourceFinished( + .init( + iteratorInitialized: streaming.iteratorInitialized, + buffer: streaming.buffer, + failure: nil, + onTermination: streaming.onTermination + ) + ) + + return .failProducers( + Array(streaming.producerContinuations.map { $0.1 }) + ) + } + + case .sourceFinished, .finished: + // This is normal and we just have to tolerate it + return .none + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `write()`. + @usableFromInline + enum WriteAction { + /// Indicates that the producer should be notified to produce more. + case returnProduceMore + /// Indicates that the producer should be suspended to stop producing. + case returnEnqueue( + callbackToken: Source.WriteResult.CallbackToken + ) + /// Indicates that the consumer should be resumed and the producer should be notified to produce more. + case resumeConsumerAndReturnProduceMore( + continuation: CheckedContinuation, + element: Element + ) + /// Indicates that the consumer should be resumed and the producer should be suspended. + case resumeConsumerAndReturnEnqueue( + continuation: CheckedContinuation, + element: Element, + callbackToken: Source.WriteResult.CallbackToken + ) + /// Indicates that the producer has been finished. + case throwFinishedError + + @inlinable + init( + callbackToken: Source.WriteResult.CallbackToken?, + continuationAndElement: (CheckedContinuation, Element)? = nil + ) { + switch (callbackToken, continuationAndElement) { + case (.none, .none): + self = .returnProduceMore + + case (.some(let callbackToken), .none): + self = .returnEnqueue(callbackToken: callbackToken) + + case (.none, .some((let continuation, let element))): + self = .resumeConsumerAndReturnProduceMore( + continuation: continuation, + element: element + ) + + case (.some(let callbackToken), .some((let continuation, let element))): + self = .resumeConsumerAndReturnEnqueue( + continuation: continuation, + element: element, + callbackToken: callbackToken + ) + } + } + } + + @inlinable + mutating func write(_ sequence: some Sequence) -> WriteAction { + switch self._state { + case .initial(var initial): + var buffer = Deque() + buffer.append(contentsOf: sequence) + + let shouldProduceMore = initial.backPressureStrategy.didYield(elements: buffer[...]) + let callbackToken = shouldProduceMore ? nil : self.nextCallbackToken() + + self._state = .streaming( + .init( + backPressureStrategy: initial.backPressureStrategy, + iteratorInitialized: initial.iteratorInitialized, + onTermination: initial.onTermination, + buffer: buffer, + consumerContinuation: nil, + producerContinuations: .init(), + cancelledAsyncProducers: .init(), + hasOutstandingDemand: shouldProduceMore + ) + ) + + return .init(callbackToken: callbackToken) + + case .streaming(var streaming): + self._state = .modify + + let bufferEndIndexBeforeAppend = streaming.buffer.endIndex + streaming.buffer.append(contentsOf: sequence) + + // We have an element and can resume the continuation + streaming.hasOutstandingDemand = streaming.backPressureStrategy.didYield( + elements: streaming.buffer[bufferEndIndexBeforeAppend...] + ) + + if let consumerContinuation = streaming.consumerContinuation { + guard let element = streaming.buffer.popFirst() else { + // We got a yield of an empty sequence. We just tolerate this. + self._state = .streaming(streaming) + + return .init(callbackToken: streaming.hasOutstandingDemand ? nil : self.nextCallbackToken()) + } + streaming.hasOutstandingDemand = streaming.backPressureStrategy.didConsume(element: element) + + // We got a consumer continuation and an element. We can resume the consumer now + streaming.consumerContinuation = nil + self._state = .streaming(streaming) + return .init( + callbackToken: streaming.hasOutstandingDemand ? nil : self.nextCallbackToken(), + continuationAndElement: (consumerContinuation, element) + ) + } else { + // We don't have a suspended consumer so we just buffer the elements + self._state = .streaming(streaming) + return .init( + callbackToken: streaming.hasOutstandingDemand ? nil : self.nextCallbackToken() + ) + } + + case .sourceFinished, .finished: + // If the source has finished we are dropping the elements. + return .throwFinishedError + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `enqueueProducer()`. + @usableFromInline + enum EnqueueProducerAction { + /// Indicates that the producer should be notified to produce more. + case resumeProducer((Result) -> Void) + /// Indicates that the producer should be notified about an error. + case resumeProducerWithError((Result) -> Void, any Error) + } + + @inlinable + mutating func enqueueProducer( + callbackToken: Source.WriteResult.CallbackToken, + onProduceMore: @Sendable @escaping (Result) -> Void + ) -> EnqueueProducerAction? { + switch self._state { + case .initial: + // We need to transition to streaming before we can suspend + // This is enforced because the CallbackToken has no internal init so + // one must create it by calling `write` first. + fatalError("AsyncStream internal inconsistency") + + case .streaming(var streaming): + if let index = streaming.cancelledAsyncProducers.firstIndex(of: callbackToken.id) { + // Our producer got marked as cancelled. + self._state = .modify + streaming.cancelledAsyncProducers.remove(at: index) + self._state = .streaming(streaming) + + return .resumeProducerWithError(onProduceMore, CancellationError()) + } else if streaming.hasOutstandingDemand { + // We hit an edge case here where we wrote but the consuming thread got interleaved + return .resumeProducer(onProduceMore) + } else { + self._state = .modify + streaming.producerContinuations.append((callbackToken.id, onProduceMore)) + + self._state = .streaming(streaming) + return .none + } + + case .sourceFinished, .finished: + // Since we are unlocking between yielding and suspending the yield + // It can happen that the source got finished or the consumption fully finishes. + return .resumeProducerWithError(onProduceMore, AlreadyFinishedError()) + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `cancelProducer()`. + @usableFromInline + enum CancelProducerAction { + /// Indicates that the producer should be notified about cancellation. + case resumeProducerWithCancellationError((Result) -> Void) + } + + @inlinable + mutating func cancelProducer( + callbackToken: Source.WriteResult.CallbackToken + ) -> CancelProducerAction? { + switch self._state { + case .initial: + // We need to transition to streaming before we can suspend + fatalError("AsyncStream internal inconsistency") + + case .streaming(var streaming): + if let index = streaming.producerContinuations.firstIndex(where: { + $0.0 == callbackToken.id + }) { + // We have an enqueued producer that we need to resume now + self._state = .modify + let continuation = streaming.producerContinuations.remove(at: index).1 + self._state = .streaming(streaming) + + return .resumeProducerWithCancellationError(continuation) + } else { + // The task that yields was cancelled before yielding so the cancellation handler + // got invoked right away + self._state = .modify + streaming.cancelledAsyncProducers.append(callbackToken.id) + self._state = .streaming(streaming) + + return .none + } + + case .sourceFinished, .finished: + // Since we are unlocking between yielding and suspending the yield + // It can happen that the source got finished or the consumption fully finishes. + return .none + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `finish()`. + @usableFromInline + enum FinishAction { + /// Indicates that `onTermination` should be called. + case callOnTermination((() -> Void)?) + /// Indicates that the consumer should be resumed with the failure, the producers + /// should be resumed with an error and `onTermination` should be called. + case resumeConsumerAndCallOnTermination( + consumerContinuation: CheckedContinuation, + failure: (any Error)?, + onTermination: (() -> Void)? + ) + /// Indicates that the producers should be resumed with an error. + case resumeProducers( + producerContinuations: [(Result) -> Void] + ) + } + + @inlinable + mutating func finish(_ failure: (any Error)?) -> FinishAction? { + switch self._state { + case .initial(let initial): + // Nothing was yielded nor did anybody call next + // This means we can transition to sourceFinished and store the failure + self._state = .sourceFinished( + .init( + iteratorInitialized: initial.iteratorInitialized, + buffer: .init(), + failure: failure, + onTermination: initial.onTermination + ) + ) + + return .callOnTermination(initial.onTermination) + + case .streaming(let streaming): + if let consumerContinuation = streaming.consumerContinuation { + // We have a continuation, this means our buffer must be empty + // Furthermore, we can now transition to finished + // and resume the continuation with the failure + precondition(streaming.buffer.isEmpty, "Expected an empty buffer") + precondition( + streaming.producerContinuations.isEmpty, + "Expected no suspended producers" + ) + + self._state = .finished(iteratorInitialized: streaming.iteratorInitialized) + + return .resumeConsumerAndCallOnTermination( + consumerContinuation: consumerContinuation, + failure: failure, + onTermination: streaming.onTermination + ) + } else { + self._state = .sourceFinished( + .init( + iteratorInitialized: streaming.iteratorInitialized, + buffer: streaming.buffer, + failure: failure, + onTermination: streaming.onTermination + ) + ) + + return .resumeProducers( + producerContinuations: Array(streaming.producerContinuations.map { $0.1 }) + ) + } + + case .sourceFinished, .finished: + // If the source has finished, finishing again has no effect. + return .none + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `next()`. + @usableFromInline + enum NextAction { + /// Indicates that the element should be returned to the caller. + case returnElement(Element) + /// Indicates that the element should be returned to the caller and that all producers should be called. + case returnElementAndResumeProducers(Element, [(Result) -> Void]) + /// Indicates that the `Error` should be returned to the caller and that `onTermination` should be called. + case returnErrorAndCallOnTermination((any Error)?, (() -> Void)?) + /// Indicates that the `nil` should be returned to the caller. + case returnNil + /// Indicates that the `Task` of the caller should be suspended. + case suspendTask + } + + @inlinable + mutating func next() -> NextAction { + switch self._state { + case .initial(let initial): + // We are not interacting with the back-pressure strategy here because + // we are doing this inside `next(:)` + self._state = .streaming( + .init( + backPressureStrategy: initial.backPressureStrategy, + iteratorInitialized: initial.iteratorInitialized, + onTermination: initial.onTermination, + buffer: Deque(), + consumerContinuation: nil, + producerContinuations: .init(), + cancelledAsyncProducers: .init(), + hasOutstandingDemand: false + ) + ) + + return .suspendTask + case .streaming(var streaming): + guard streaming.consumerContinuation == nil else { + // We have multiple AsyncIterators iterating the sequence + fatalError("AsyncStream internal inconsistency") + } + + self._state = .modify + + if let element = streaming.buffer.popFirst() { + // We have an element to fulfil the demand right away. + streaming.hasOutstandingDemand = streaming.backPressureStrategy.didConsume(element: element) + + if streaming.hasOutstandingDemand { + // There is demand and we have to resume our producers + let producers = Array(streaming.producerContinuations.map { $0.1 }) + streaming.producerContinuations.removeAll() + self._state = .streaming(streaming) + return .returnElementAndResumeProducers(element, producers) + } else { + // We don't have any new demand, so we can just return the element. + self._state = .streaming(streaming) + return .returnElement(element) + } + } else { + // There is nothing in the buffer to fulfil the demand so we need to suspend. + // We are not interacting with the back-pressure strategy here because + // we are doing this inside `suspendNext` + self._state = .streaming(streaming) + + return .suspendTask + } + + case .sourceFinished(var sourceFinished): + // Check if we have an element left in the buffer and return it + self._state = .modify + + if let element = sourceFinished.buffer.popFirst() { + self._state = .sourceFinished(sourceFinished) + + return .returnElement(element) + } else { + // We are returning the queued failure now and can transition to finished + self._state = .finished(iteratorInitialized: sourceFinished.iteratorInitialized) + + return .returnErrorAndCallOnTermination( + sourceFinished.failure, + sourceFinished.onTermination + ) + } + + case .finished: + return .returnNil + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `suspendNext()`. + @usableFromInline + enum SuspendNextAction { + /// Indicates that the consumer should be resumed. + case resumeConsumerWithElement(CheckedContinuation, Element) + /// Indicates that the consumer and all producers should be resumed. + case resumeConsumerWithElementAndProducers( + CheckedContinuation, + Element, + [(Result) -> Void] + ) + /// Indicates that the consumer should be resumed with the failure and that `onTermination` should be called. + case resumeConsumerWithErrorAndCallOnTermination( + CheckedContinuation, + (any Error)?, + (() -> Void)? + ) + /// Indicates that the consumer should be resumed with `nil`. + case resumeConsumerWithNil(CheckedContinuation) + } + + @inlinable + mutating func suspendNext( + continuation: CheckedContinuation + ) -> SuspendNextAction? { + switch self._state { + case .initial: + // We need to transition to streaming before we can suspend + preconditionFailure("AsyncStream internal inconsistency") + + case .streaming(var streaming): + guard streaming.consumerContinuation == nil else { + // We have multiple AsyncIterators iterating the sequence + fatalError( + "This should never happen since we only allow a single Iterator to be created" + ) + } + + self._state = .modify + + // We have to check here again since we might have a producer interleave next and suspendNext + if let element = streaming.buffer.popFirst() { + // We have an element to fulfil the demand right away. + + streaming.hasOutstandingDemand = streaming.backPressureStrategy.didConsume(element: element) + + if streaming.hasOutstandingDemand { + // There is demand and we have to resume our producers + let producers = Array(streaming.producerContinuations.map { $0.1 }) + streaming.producerContinuations.removeAll() + self._state = .streaming(streaming) + return .resumeConsumerWithElementAndProducers( + continuation, + element, + producers + ) + } else { + // We don't have any new demand, so we can just return the element. + self._state = .streaming(streaming) + return .resumeConsumerWithElement(continuation, element) + } + } else { + // There is nothing in the buffer to fulfil the demand so we to store the continuation. + streaming.consumerContinuation = continuation + self._state = .streaming(streaming) + + return .none + } + + case .sourceFinished(var sourceFinished): + // Check if we have an element left in the buffer and return it + self._state = .modify + + if let element = sourceFinished.buffer.popFirst() { + self._state = .sourceFinished(sourceFinished) + + return .resumeConsumerWithElement(continuation, element) + } else { + // We are returning the queued failure now and can transition to finished + self._state = .finished(iteratorInitialized: sourceFinished.iteratorInitialized) + + return .resumeConsumerWithErrorAndCallOnTermination( + continuation, + sourceFinished.failure, + sourceFinished.onTermination + ) + } + + case .finished: + return .resumeConsumerWithNil(continuation) + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + + /// Actions returned by `cancelNext()`. + @usableFromInline + enum CancelNextAction { + /// Indicates that the continuation should be resumed with a cancellation error, the producers should be finished and call onTermination. + case resumeConsumerWithCancellationErrorAndCallOnTermination( + CheckedContinuation, + (() -> Void)? + ) + /// Indicates that the producers should be finished and call onTermination. + case failProducersAndCallOnTermination([(Result) -> Void], (() -> Void)?) + } + + @inlinable + mutating func cancelNext() -> CancelNextAction? { + switch self._state { + case .initial: + // We need to transition to streaming before we can suspend + fatalError("AsyncStream internal inconsistency") + + case .streaming(let streaming): + self._state = .finished(iteratorInitialized: streaming.iteratorInitialized) + + if let consumerContinuation = streaming.consumerContinuation { + precondition( + streaming.producerContinuations.isEmpty, + "Internal inconsistency. Unexpected producer continuations." + ) + return .resumeConsumerWithCancellationErrorAndCallOnTermination( + consumerContinuation, + streaming.onTermination + ) + } else { + return .failProducersAndCallOnTermination( + Array(streaming.producerContinuations.map { $0.1 }), + streaming.onTermination + ) + } + + case .sourceFinished, .finished: + return .none + + case .modify: + fatalError("AsyncStream internal inconsistency") + } + } + } +} diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift new file mode 100644 index 00000000..444d7c31 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift @@ -0,0 +1,278 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// swift-format-ignore-file +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif os(Windows) +import WinSDK +#endif + +#if os(Windows) +@usableFromInline +typealias LockPrimitive = SRWLOCK +#else +@usableFromInline +typealias LockPrimitive = pthread_mutex_t +#endif + +@usableFromInline +enum LockOperations {} + +extension LockOperations { + @inlinable + static func create(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + InitializeSRWLock(mutex) + #else + var attr = pthread_mutexattr_t() + pthread_mutexattr_init(&attr) + + let err = pthread_mutex_init(mutex, &attr) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } + + @inlinable + static func destroy(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + // SRWLOCK does not need to be freed + #else + let err = pthread_mutex_destroy(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } + + @inlinable + static func lock(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + AcquireSRWLockExclusive(mutex) + #else + let err = pthread_mutex_lock(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } + + @inlinable + static func unlock(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + ReleaseSRWLockExclusive(mutex) + #else + let err = pthread_mutex_unlock(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } +} + +// Tail allocate both the mutex and a generic value using ManagedBuffer. +// Both the header pointer and the elements pointer are stable for +// the class's entire lifetime. +// +// However, for safety reasons, we elect to place the lock in the "elements" +// section of the buffer instead of the head. The reasoning here is subtle, +// so buckle in. +// +// _As a practical matter_, the implementation of ManagedBuffer ensures that +// the pointer to the header is stable across the lifetime of the class, and so +// each time you call `withUnsafeMutablePointers` or `withUnsafeMutablePointerToHeader` +// the value of the header pointer will be the same. This is because ManagedBuffer uses +// `Builtin.addressOf` to load the value of the header, and that does ~magic~ to ensure +// that it does not invoke any weird Swift accessors that might copy the value. +// +// _However_, the header is also available via the `.header` field on the ManagedBuffer. +// This presents a problem! The reason there's an issue is that `Builtin.addressOf` and friends +// do not interact with Swift's exclusivity model. That is, the various `with` functions do not +// conceptually trigger a mutating access to `.header`. For elements this isn't a concern because +// there's literally no other way to perform the access, but for `.header` it's entirely possible +// to accidentally recursively read it. +// +// Our implementation is free from these issues, so we don't _really_ need to worry about it. +// However, out of an abundance of caution, we store the Value in the header, and the LockPrimitive +// in the trailing elements. We still don't use `.header`, but it's better to be safe than sorry, +// and future maintainers will be happier that we were cautious. +// +// See also: https://github.com/apple/swift/pull/40000 +@usableFromInline +final class LockStorage: ManagedBuffer { + + @inlinable + static func create(value: Value) -> Self { + let buffer = Self.create(minimumCapacity: 1) { _ in + return value + } + // Avoid 'unsafeDowncast' as there is a miscompilation on 5.10. + let storage = buffer as! Self + + storage.withUnsafeMutablePointers { _, lockPtr in + LockOperations.create(lockPtr) + } + + return storage + } + + @inlinable + func lock() { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.lock(lockPtr) + } + } + + @inlinable + func unlock() { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.unlock(lockPtr) + } + } + + @usableFromInline + deinit { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.destroy(lockPtr) + } + } + + @inlinable + func withLockPrimitive( + _ body: (UnsafeMutablePointer) throws -> T + ) rethrows -> T { + try self.withUnsafeMutablePointerToElements { lockPtr in + return try body(lockPtr) + } + } + + @inlinable + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + try self.withUnsafeMutablePointers { valuePtr, lockPtr in + LockOperations.lock(lockPtr) + defer { LockOperations.unlock(lockPtr) } + return try mutate(&valuePtr.pointee) + } + } +} + +extension LockStorage: @unchecked Sendable {} + +/// A threading lock based on `libpthread` instead of `libdispatch`. +/// +/// - note: ``Lock`` has reference semantics. +/// +/// This object provides a lock on top of a single `pthread_mutex_t`. This kind +/// of lock is safe to use with `libpthread`-based threading models, such as the +/// one used by NIO. On Windows, the lock is based on the substantially similar +/// `SRWLOCK` type. +@usableFromInline +struct Lock { + @usableFromInline + internal let _storage: LockStorage + + /// Create a new lock. + @usableFromInline + init() { + self._storage = .create(value: ()) + } + + /// Acquire the lock. + /// + /// Whenever possible, consider using `withLock` instead of this method and + /// `unlock`, to simplify lock handling. + @inlinable + func lock() { + self._storage.lock() + } + + /// Release the lock. + /// + /// Whenever possible, consider using `withLock` instead of this method and + /// `lock`, to simplify lock handling. + @inlinable + func unlock() { + self._storage.unlock() + } + + @inlinable + internal func withLockPrimitive( + _ body: (UnsafeMutablePointer) throws -> T + ) rethrows -> T { + return try self._storage.withLockPrimitive(body) + } +} + +extension Lock { + /// Acquire the lock for the duration of the given block. + /// + /// This convenience method should be preferred to `lock` and `unlock` in + /// most situations, as it ensures that the lock will be released regardless + /// of how `body` exits. + /// + /// - Parameter body: The block to execute while holding the lock. + /// - Returns: The value returned by the block. + @inlinable + func withLock(_ body: () throws -> T) rethrows -> T { + self.lock() + defer { + self.unlock() + } + return try body() + } +} + +extension Lock: Sendable {} + +extension UnsafeMutablePointer { + @inlinable + func assertValidAlignment() { + assert(UInt(bitPattern: self) % UInt(MemoryLayout.alignment) == 0) + } +} + +@usableFromInline +struct LockedValueBox { + @usableFromInline + let storage: LockStorage + + @usableFromInline + init(_ value: Value) { + self.storage = .create(value: value) + } + + @inlinable + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + return try self.storage.withLockedValue(mutate) + } +} + +extension LockedValueBox: Sendable where Value: Sendable {} \ No newline at end of file diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift new file mode 100644 index 00000000..94c5d395 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift @@ -0,0 +1,8 @@ +// +// Reexports.swift +// HTTPClient +// +// Created by Guilherme Souza on 04/08/25. +// + +@_exported import HTTPTypesFoundation diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift new file mode 100644 index 00000000..0c6057cf --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +#if canImport(Darwin) + import Foundation + + /// Delegate that supports bidirectional streaming of request and response bodies. + /// + /// While URLSession provides a high-level API that returns an async sequence of + /// bytes, `bytes(for:delegate:)`, but does not provide an API that takes an async sequence + /// as a request body. For instance, `upload(for:delegate:)` and `upload(fromFile:delegate:)` + /// both buffer the entire response body and return `Data`. + /// + /// Additionally, bridging `URLSession.AsyncBytes`, which is an `AsyncSequence` to + /// `OpenAPIRuntime.HTTPBody`, an `AsyncSequence`, is problematic and will + /// incur an allocation for every byte. + /// + /// This delegate vends the response body as a `HTTBody` with one chunk for each + /// `urlSession(_:didReceive data:)` callback. It also provides backpressure, which will + /// suspend and resume the URLSession task based on a configurable high and low watermark. + /// + /// When performing requests without a body, this delegate should be used with a + /// `URLSessionDataTask` to stream the response body. + /// + /// When performing requests with a body, this delegate should be used with a + /// `URLSessionUploadTask` using `uploadTask(withStreamedRequest:delegate:)`, which will + /// ask the delegate for a `InputStream` for the request body via the + /// `urlSession(_:needNewBodyStreamForTask:)` callback. + /// + /// The `urlSession(_:needNewBodyStreamForTask:)` callback will create a pair of bound + /// streams, bridge the `HTTPBody` request body to the `OutputStream` and return the + /// `InputStream` to URLSession. Backpressure for the request body stream is provided + /// as an implementation detail of how URLSession reads from the `InputStream`. + /// + /// Note that `urlSession(_:needNewBodyStreamForTask:)` may be called more than once, e.g. + /// when performing a HTTP redirect, upon which the delegate is expected to create a new + /// `InputStream` for the request body. This is only possible if the underlying `HTTPBody` + /// request body can be iterated multiple times, i.e. `iterationBehavior == .multiple`. + /// If the request body cannot be iterated multiple times, then the URLSession task will be cancelled. + final class BidirectionalStreamingURLSessionDelegate: NSObject, URLSessionTaskDelegate, + URLSessionDataDelegate + { + + let requestBody: HTTPBody? + var hasAlreadyIteratedRequestBody: Bool + /// In addition to the callback lock, there is one point of rentrancy, where the response stream callback gets fired + /// immediately, for this we have a different lock, which protects `hasSuspendedURLSessionTask`. + var hasSuspendedURLSessionTask: LockedValueBox + let requestStreamBufferSize: Int + var requestStream: HTTPBodyOutputStreamBridge? + + typealias ResponseContinuation = CheckedContinuation + var responseContinuation: ResponseContinuation? + + typealias ResponseBodyStream = BufferedStream + var responseBodyStream: ResponseBodyStream + var responseBodyStreamSource: ResponseBodyStream.Source + + /// This lock is taken for the duration of all delegate callbacks to protect the mutable delegate state. + /// + /// Although all the delegate callbacks are performed on the session's `delegateQueue`, there is no guarantee that + /// this is a _serial_ queue. + /// + /// Regardless of the type of delegate queue, URLSession will attempt to order the callbacks for each task in a + /// sensible way, but it cannot be guaranteed, specifically when the URLSession task is cancelled. + /// + /// Therefore, even though the `suspend()`, `resume()`, and `cancel()` URLSession methods are thread-safe, we need + /// to protect any mutable state within the delegate itself. + let callbackLock = Lock() + + /// Use `bidirectionalStreamingRequest(for:baseURL:requestBody:requestStreamBufferSize:responseStreamWatermarks:)`. + init( + requestBody: HTTPBody?, + requestStreamBufferSize: Int, + responseStreamWatermarks: (low: Int, high: Int) + ) { + self.requestBody = requestBody + self.hasAlreadyIteratedRequestBody = false + self.hasSuspendedURLSessionTask = LockedValueBox(false) + self.requestStreamBufferSize = requestStreamBufferSize + (self.responseBodyStream, self.responseBodyStreamSource) = + ResponseBodyStream.makeStream( + backPressureStrategy: .customWatermark( + low: responseStreamWatermarks.low, + high: responseStreamWatermarks.high, + waterLevelForElement: { $0.count } + ) + ) + } + + func urlSession(_ session: URLSession, needNewBodyStreamForTask task: URLSessionTask) async + -> InputStream? + { + callbackLock.withLock { + debug("Task delegate: needNewBodyStreamForTask") + // If the HTTP body cannot be iterated multiple times then bad luck; the only thing + // we can do is cancel the task and return nil. + if hasAlreadyIteratedRequestBody { + guard requestBody!.iterationBehavior == .multiple else { + debug("Task delegate: Cannot rewind request body, cancelling task") + task.cancel() + return nil + } + } + hasAlreadyIteratedRequestBody = true + + // Create a fresh pair of streams. + let (inputStream, outputStream) = createStreamPair( + withBufferSize: requestStreamBufferSize + ) + + // Bridge the output stream to the request body (which opens the output stream). + requestStream = HTTPBodyOutputStreamBridge(outputStream, requestBody!) + + // Return the new input stream (unopened, it gets opened by URLSession). + return inputStream + } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + callbackLock.withLock { + debug("Task delegate: didReceive data (numBytes: \(data.count))") + do { + switch try responseBodyStreamSource.write( + contentsOf: CollectionOfOne(ArraySlice(data)) + ) + { + case .produceMore: break + case .enqueueCallback(let callbackToken): + let shouldActuallyEnqueueCallback = + hasSuspendedURLSessionTask.withLockedValue { + hasSuspendedURLSessionTask in + if hasSuspendedURLSessionTask { + debug( + "Task delegate: already suspended task, not enqueing another writer callback" + ) + return false + } + debug( + "Task delegate: response stream backpressure, suspending task and enqueing callback" + ) + dataTask.suspend() + hasSuspendedURLSessionTask = true + return true + } + if shouldActuallyEnqueueCallback { + responseBodyStreamSource.enqueueCallback(callbackToken: callbackToken) { + result in + self.hasSuspendedURLSessionTask.withLockedValue { + hasSuspendedURLSessionTask in + switch result { + case .success: + debug( + "Task delegate: response stream callback, resuming task" + ) + dataTask.resume() + hasSuspendedURLSessionTask = false + case .failure(let error): + debug( + "Task delegate: response stream callback, cancelling task, error: \(error)" + ) + dataTask.cancel() + } + } + } + } + } + } catch { + debug("Task delegate: response stream consumer terminated, cancelling task") + dataTask.cancel() + } + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse + ) async + -> URLSession.ResponseDisposition + { + callbackLock.withLock { + debug("Task delegate: didReceive response") + responseContinuation?.resume(returning: response) + responseContinuation = nil + return .allow + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + callbackLock.withLock { + debug("Task delegate: didCompleteWithError (error: \(String(describing: error)))") + responseBodyStreamSource.finish(throwing: error) + if let error { + responseContinuation?.resume(throwing: error) + responseContinuation = nil + } + } + } + } + + extension BidirectionalStreamingURLSessionDelegate: @unchecked Sendable {} // State synchronized using DispatchQueue. + + private func createStreamPair(withBufferSize bufferSize: Int) -> (InputStream, OutputStream) { + var inputStream: InputStream? + var outputStream: OutputStream? + Stream.getBoundStreams( + withBufferSize: bufferSize, + inputStream: &inputStream, + outputStream: &outputStream + ) + guard let inputStream, let outputStream else { + fatalError("getBoundStreams did not return non-nil streams") + } + return (inputStream, outputStream) + } + +#endif // canImport(Darwin) diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift new file mode 100644 index 00000000..846d124b --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift @@ -0,0 +1,312 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +#if canImport(Darwin) + import Foundation + + final class HTTPBodyOutputStreamBridge: NSObject, StreamDelegate { + static let streamQueue = DispatchQueue( + label: "HTTPBodyStreamDelegate", autoreleaseFrequency: .workItem) + + let httpBody: HTTPBody + let outputStream: OutputStream + private(set) var state: State { + didSet { debug("Output stream delegate state transition: \(oldValue) -> \(state)") } + } + + /// Creates a new `HTTPBodyOutputStreamBridge` and opens the output stream. + init(_ outputStream: OutputStream, _ httpBody: HTTPBody) { + self.httpBody = httpBody + self.outputStream = outputStream + self.state = .initial + super.init() + self.outputStream.delegate = self + CFWriteStreamSetDispatchQueue(self.outputStream as CFWriteStream, Self.streamQueue) + self.outputStream.open() + } + + deinit { + debug("Output stream delegate deinit") + outputStream.delegate = nil + } + + func performAction(_ action: State.Action) { + debug("Output stream delegate performing action from state machine: \(action)") + dispatchPrecondition(condition: .onQueue(Self.streamQueue)) + switch action { + case .none: return + case .resumeProducer(let producerContinuation): + producerContinuation.resume() + performAction(state.resumedProducer()) + case .writeBytes(let chunk): writePendingBytes(chunk) + case .cancelProducerAndCloseStream(let producerContinuation): + producerContinuation.resume(throwing: CancellationError()) + outputStream.close() + case .cancelProducer(let producerContinuation): + producerContinuation.resume(throwing: CancellationError()) + case .closeStream: outputStream.close() + } + } + + func startWriterTask() { + dispatchPrecondition(condition: .onQueue(Self.streamQueue)) + let task = Task { + dispatchPrecondition(condition: .notOnQueue(Self.streamQueue)) + for try await chunk in httpBody { + try await withCheckedThrowingContinuation { continuation in + Self.streamQueue.async { + debug("Output stream delegate produced chunk and suspended producer.") + self.performAction( + self.state.producedChunkAndSuspendedProducer(chunk, continuation)) + } + } + } + Self.streamQueue.async { + debug("Output stream delegate wrote final chunk.") + self.performAction(self.state.wroteFinalChunk()) + } + } + performAction(state.startedProducerTask(task)) + } + + private func writePendingBytes(_ bytesToWrite: Chunk) { + dispatchPrecondition(condition: .onQueue(Self.streamQueue)) + precondition(!bytesToWrite.isEmpty, "\(#function) must be called with non-empty bytes") + guard outputStream.streamStatus == .open else { + debug("Output stream closed unexpectedly.") + performAction( + state.wroteBytes(numBytesWritten: 0, streamStillHasSpaceAvailable: false)) + return + } + switch bytesToWrite.withUnsafeBytes({ + outputStream.write($0.baseAddress!, maxLength: bytesToWrite.count) + }) { + case 0: + debug("Output stream delegate reached end of stream when writing.") + performAction(state.endEncountered()) + case -1: + debug( + "Output stream delegate encountered error writing to stream: \(outputStream.streamError!)." + ) + performAction(state.errorOccurred(outputStream.streamError!)) + case let written where written > 0: + debug("Output stream delegate wrote \(written) bytes to stream.") + performAction( + state.wroteBytes( + numBytesWritten: written, + streamStillHasSpaceAvailable: outputStream.hasSpaceAvailable) + ) + default: + preconditionFailure("OutputStream.write(_:maxLength:) returned undocumented value") + } + } + + func stream(_ stream: Stream, handle event: Stream.Event) { + dispatchPrecondition(condition: .onQueue(Self.streamQueue)) + debug("Output stream delegate received event: \(event).") + switch event { + case .openCompleted: + guard case .initial = state else { + debug("Output stream delegate ignoring duplicate openCompleted event.") + return + } + startWriterTask() + case .hasSpaceAvailable: performAction(state.spaceBecameAvailable()) + case .errorOccurred: performAction(state.errorOccurred(stream.streamError!)) + case .endEncountered: performAction(state.endEncountered()) + default: + debug("Output stream ignoring event: \(event).") + break + } + } + } + + extension HTTPBodyOutputStreamBridge { + typealias Chunk = ArraySlice + typealias ProducerTask = Task + typealias ProducerContinuation = CheckedContinuation + + enum State { + case initial + case waitingForBytes(spaceAvailable: Bool) + case haveBytes(spaceAvailable: Bool, Chunk, ProducerContinuation) + case needBytes(spaceAvailable: Bool, ProducerContinuation) + case closed((any Error)?) + + mutating func startedProducerTask(_ producerTask: ProducerTask) -> Action { + switch self { + case .initial: + self = .waitingForBytes(spaceAvailable: false) + return .none + case .waitingForBytes, .haveBytes, .needBytes, .closed: + preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func producedChunkAndSuspendedProducer( + _ chunk: Chunk, _ producerContinuation: ProducerContinuation + ) + -> Action + { + switch self { + case .waitingForBytes(let spaceAvailable): + self = .haveBytes(spaceAvailable: spaceAvailable, chunk, producerContinuation) + guard spaceAvailable else { return .none } + return .writeBytes(chunk) + case .closed: return .cancelProducer(producerContinuation) + case .initial, .haveBytes, .needBytes: + preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func wroteBytes(numBytesWritten: Int, streamStillHasSpaceAvailable: Bool) + -> Action + { + switch self { + case .haveBytes(let spaceAvailable, let chunk, let producerContinuation): + guard spaceAvailable, numBytesWritten <= chunk.count else { + preconditionFailure() + } + let remaining = chunk.dropFirst(numBytesWritten) + guard remaining.isEmpty else { + self = .haveBytes( + spaceAvailable: streamStillHasSpaceAvailable, remaining, + producerContinuation) + guard streamStillHasSpaceAvailable else { return .none } + return .writeBytes(remaining) + } + self = .needBytes( + spaceAvailable: streamStillHasSpaceAvailable, producerContinuation) + return .resumeProducer(producerContinuation) + case .initial, .needBytes, .waitingForBytes, .closed: + preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func resumedProducer() -> Action { + switch self { + case .needBytes(let spaceAvailable, _): + self = .waitingForBytes(spaceAvailable: spaceAvailable) + return .none + case .initial, .haveBytes, .waitingForBytes, .closed: + preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func errorOccurred(_ error: any Error) -> Action { + switch self { + case .initial: + self = .closed(error) + return .none + case .waitingForBytes(_): + self = .closed(error) + return .closeStream + case .haveBytes(_, _, let producerContinuation): + self = .closed(error) + return .cancelProducerAndCloseStream(producerContinuation) + case .needBytes(_, let producerContinuation): + self = .closed(error) + return .cancelProducerAndCloseStream(producerContinuation) + case .closed: preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func wroteFinalChunk() -> Action { + switch self { + case .waitingForBytes(_): + self = .closed(nil) + return .closeStream + case .initial, .haveBytes, .needBytes, .closed: + preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func endEncountered() -> Action { + switch self { + case .waitingForBytes(_): + self = .closed(nil) + return .closeStream + case .haveBytes(_, _, let producerContinuation): + self = .closed(nil) + return .cancelProducerAndCloseStream(producerContinuation) + case .needBytes(_, let producerContinuation): + self = .closed(nil) + return .cancelProducerAndCloseStream(producerContinuation) + case .initial, .closed: + preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + mutating func spaceBecameAvailable() -> Action { + switch self { + case .waitingForBytes(_): + self = .waitingForBytes(spaceAvailable: true) + return .none + case .haveBytes(_, let chunk, let producerContinuation): + self = .haveBytes(spaceAvailable: true, chunk, producerContinuation) + return .writeBytes(chunk) + case .needBytes(_, let producerContinuation): + self = .needBytes(spaceAvailable: true, producerContinuation) + return .none + case .closed: + debug("Ignoring space available event in closed state") + return .none + case .initial: preconditionFailure("\(#function) called in invalid state: \(self)") + } + } + + enum Action { + case none + case resumeProducer(ProducerContinuation) + case writeBytes(Chunk) + case cancelProducerAndCloseStream(ProducerContinuation) + case cancelProducer(ProducerContinuation) + case closeStream + } + } + } + + extension HTTPBodyOutputStreamBridge: @unchecked Sendable {} // State synchronized using DispatchQueue. + + extension HTTPBodyOutputStreamBridge.State: CustomStringConvertible { + var description: String { + switch self { + case .initial: return "initial" + case .waitingForBytes(let spaceAvailable): + return "waitingForBytes(spaceAvailable: \(spaceAvailable))" + case .haveBytes(let spaceAvailable, let chunk, _): + return "haveBytes(spaceAvailable: \(spaceAvailable), [\(chunk.count) bytes])" + case .needBytes(let spaceAvailable, _): + return "needBytes (spaceAvailable: \(spaceAvailable), _)" + case .closed(let error): return "closed (error: \(String(describing: error)))" + } + } + } + + extension HTTPBodyOutputStreamBridge.State.Action: CustomStringConvertible { + var description: String { + switch self { + case .none: return "none" + case .resumeProducer: return "resumeProducer" + case .writeBytes: return "writeBytes" + case .cancelProducerAndCloseStream: return "cancelProducerAndCloseStream" + case .cancelProducer: return "cancelProducer" + case .closeStream: return "closeStream" + } + } + } + +#endif // canImport(Darwin) diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift new file mode 100644 index 00000000..7453b126 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +#if canImport(Darwin) + import Foundation + + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension URLSession { + func bidirectionalStreamingRequest( + for request: HTTPTypes.HTTPRequest, + baseURL: URL, + requestBody: HTTPBody?, + requestStreamBufferSize: Int, + responseStreamWatermarks: (low: Int, high: Int) + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + let urlRequest = try URLRequest(request, baseURL: baseURL) + let task: URLSessionTask + if requestBody != nil { + task = uploadTask(withStreamedRequest: urlRequest) + } else { + task = dataTask(with: urlRequest) + } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + let delegate = BidirectionalStreamingURLSessionDelegate( + requestBody: requestBody, + requestStreamBufferSize: requestStreamBufferSize, + responseStreamWatermarks: responseStreamWatermarks + ) + let response = try await withCheckedThrowingContinuation { continuation in + delegate.responseContinuation = continuation + task.delegate = delegate + task.resume() + } + let responseBody = HTTPBody( + delegate.responseBodyStream, + length: .init(from: response), + iterationBehavior: .single + ) + try Task.checkCancellation() + return (try HTTPTypes.HTTPResponse(response), responseBody) + } onCancel: { + debug("Concurrency task cancelled, cancelling URLSession task.") + task.cancel() + } + } + } + +#endif // canImport(Darwin) diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift new file mode 100644 index 00000000..449f72f2 --- /dev/null +++ b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift @@ -0,0 +1,427 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes + +#if canImport(Darwin) + import Foundation +#else + @preconcurrency import struct Foundation.URL + import struct Foundation.URLComponents + import struct Foundation.Data + import protocol Foundation.LocalizedError + import class Foundation.FileHandle + #if canImport(FoundationNetworking) + @preconcurrency import struct FoundationNetworking.URLRequest + import class FoundationNetworking.URLSession + import class FoundationNetworking.URLSessionTask + import class FoundationNetworking.URLResponse + import class FoundationNetworking.HTTPURLResponse + #endif +#endif + +/// A client transport that performs HTTP operations using the URLSession type +/// provided by the Foundation framework. +/// +/// ### Use the URLSession transport +/// +/// Instantiate the transport: +/// +/// let transport = URLSessionTransport() +/// +/// Instantiate the `Client` type generated by the Swift OpenAPI Generator for +/// your provided OpenAPI document. For example: +/// +/// let client = Client( +/// serverURL: URL(string: "https://example.com")!, +/// transport: transport +/// ) +/// +/// Use the client to make HTTP calls defined in your OpenAPI document. For +/// example, if the OpenAPI document contains an HTTP operation with +/// the identifier `checkHealth`, call it from Swift with: +/// +/// let response = try await client.checkHealth() +/// +/// ### Provide a custom URLSession +/// +/// The ``URLSessionTransport/Configuration-swift.struct`` type allows you to +/// provide a custom URLSession and tweak behaviors such as the default +/// timeouts, authentication challenges, and more. +public struct URLSessionTransport: ClientTransport { + + /// A set of configuration values for the URLSession transport. + public struct Configuration: Sendable { + + /// The URLSession used for performing HTTP operations. + public var session: URLSession + + /// Creates a new configuration with the provided session. + /// - Parameters: + /// - session: The URLSession used for performing HTTP operations. + /// If none is provided, the system uses the shared URLSession. + /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. + public init( + session: URLSession = .shared, + httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault + ) { + let implementation = httpBodyProcessingMode.implementation + self.init(session: session, implementation: implementation) + } + /// Creates a new configuration with the provided session. + /// - Parameter session: The URLSession used for performing HTTP operations. + /// If none is provided, the system uses the shared URLSession. + public init(session: URLSession = .shared) { + self.init(session: session, implementation: .platformDefault) + } + /// Specifies the mode in which HTTP request and response bodies are processed. + public struct HTTPBodyProcessingMode: Sendable { + /// Exposing the internal implementation directly. + fileprivate let implementation: Configuration.Implementation + + private init(_ implementation: Configuration.Implementation) { + self.implementation = implementation + } + + /// Use this mode to force URLSessionTransport to transfer data in a buffered mode, even if + /// streaming would be available on the platform. + public static let buffered = HTTPBodyProcessingMode(.buffering) + /// Data is transfered via streaming if available on the platform, else it falls back to buffering. + public static let platformDefault = HTTPBodyProcessingMode(.platformDefault) + } + + enum Implementation { + case buffering + case streaming( + requestBodyStreamBufferSize: Int, + responseBodyStreamWatermarks: (low: Int, high: Int) + ) + } + + var implementation: Implementation + + init(session: URLSession = .shared, implementation: Implementation = .platformDefault) { + self.session = session + if case .streaming = implementation { + precondition( + Implementation.platformSupportsStreaming, + "Streaming not supported on platform" + ) + } + self.implementation = implementation + } + } + + /// A set of configuration values used by the transport. + public var configuration: Configuration + + /// Creates a new URLSession-based transport. + /// - Parameter configuration: A set of configuration values used by the transport. + public init(configuration: Configuration = .init()) { self.configuration = configuration } + + /// Sends the underlying HTTP request and returns the received HTTP response. + /// - Parameters: + /// - request: An HTTP request. + /// - requestBody: An HTTP request body. + /// - baseURL: A server base URL. + /// - Returns: An HTTP response and its body. + /// - Throws: If there was an error performing the HTTP request. + public func send( + _ request: HTTPTypes.HTTPRequest, + body requestBody: HTTPBody?, + baseURL: URL + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + switch configuration.implementation { + case .streaming(let requestBodyStreamBufferSize, let responseBodyStreamWatermarks): + #if canImport(Darwin) + guard #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) else { + throw URLSessionTransportError.streamingNotSupported + } + return try await configuration.session.bidirectionalStreamingRequest( + for: request, + baseURL: baseURL, + requestBody: requestBody, + requestStreamBufferSize: requestBodyStreamBufferSize, + responseStreamWatermarks: responseBodyStreamWatermarks + ) + #else + throw URLSessionTransportError.streamingNotSupported + #endif + case .buffering: + return try await configuration.session.bufferedRequest( + for: request, + baseURL: baseURL, + requestBody: requestBody + ) + } + } +} + +extension HTTPBody.Length { + init(from urlResponse: URLResponse) { + if urlResponse.expectedContentLength == -1 { + self = .unknown + } else { + self = .known(urlResponse.expectedContentLength) + } + } +} + +/// Specialized error thrown by the transport. +internal enum URLSessionTransportError: Error { + + /// Invalid URL composed from base URL and received request. + case invalidRequestURL(path: String, method: HTTPTypes.HTTPRequest.Method, baseURL: URL) + + /// Returned `URLResponse` could not be converted to `HTTPURLResponse`. + case notHTTPResponse(URLResponse) + + /// Returned `HTTPURLResponse` has an invalid status code + case invalidResponseStatusCode(HTTPURLResponse) + + /// Returned `URLResponse` was nil + case noResponse(url: URL?) + + /// Platform does not support streaming. + case streamingNotSupported +} + +extension HTTPTypes.HTTPResponse { + init(_ urlResponse: URLResponse) throws { + guard let httpResponse = urlResponse as? HTTPURLResponse else { + throw URLSessionTransportError.notHTTPResponse(urlResponse) + } + guard (0...999).contains(httpResponse.statusCode) else { + throw URLSessionTransportError.invalidResponseStatusCode(httpResponse) + } + self.init(status: .init(code: httpResponse.statusCode)) + if let fields = httpResponse.allHeaderFields as? [String: String] { + self.headerFields.reserveCapacity(fields.count) + for (name, value) in fields { + if let name = HTTPField.Name(name) { + self.headerFields.append(HTTPField(name: name, isoLatin1Value: value)) + } + } + } + } +} + +extension URLRequest { + init(_ request: HTTPTypes.HTTPRequest, baseURL: URL) throws { + guard var baseUrlComponents = URLComponents(string: baseURL.absoluteString), + let requestUrlComponents = URLComponents(string: request.path ?? "") + else { + throw URLSessionTransportError.invalidRequestURL( + path: request.path ?? "", + method: request.method, + baseURL: baseURL + ) + } + + let path = requestUrlComponents.percentEncodedPath + baseUrlComponents.percentEncodedPath += path + baseUrlComponents.percentEncodedQuery = requestUrlComponents.percentEncodedQuery + guard let url = baseUrlComponents.url else { + throw URLSessionTransportError.invalidRequestURL( + path: path, + method: request.method, + baseURL: baseURL + ) + } + self.init(url: url) + self.httpMethod = request.method.rawValue + var combinedFields = [HTTPField.Name: String](minimumCapacity: request.headerFields.count) + for field in request.headerFields { + if let existingValue = combinedFields[field.name] { + let separator = field.name == .cookie ? "; " : ", " + combinedFields[field.name] = "\(existingValue)\(separator)\(field.isoLatin1Value)" + } else { + combinedFields[field.name] = field.isoLatin1Value + } + } + var headerFields = [String: String](minimumCapacity: combinedFields.count) + for (name, value) in combinedFields { headerFields[name.rawName] = value } + self.allHTTPHeaderFields = headerFields + } +} + +extension String { fileprivate var isASCII: Bool { self.utf8.allSatisfy { $0 & 0x80 == 0 } } } + +extension HTTPField { + fileprivate init(name: Name, isoLatin1Value: String) { + if isoLatin1Value.isASCII { + self.init(name: name, value: isoLatin1Value) + } else { + self = withUnsafeTemporaryAllocation( + of: UInt8.self, + capacity: isoLatin1Value.unicodeScalars.count + ) { + buffer in + for (index, scalar) in isoLatin1Value.unicodeScalars.enumerated() { + if scalar.value > UInt8.max { + buffer[index] = 0x20 + } else { + buffer[index] = UInt8(truncatingIfNeeded: scalar.value) + } + } + return HTTPField(name: name, value: buffer) + } + } + } + + fileprivate var isoLatin1Value: String { + if self.value.isASCII { return self.value } + return self.withUnsafeBytesOfValue { buffer in + let scalars = buffer.lazy.map { UnicodeScalar(UInt32($0))! } + var string = "" + string.unicodeScalars.append(contentsOf: scalars) + return string + } + } +} + +extension URLSessionTransportError: LocalizedError { + /// A localized message describing what error occurred. + var errorDescription: String? { description } +} + +extension URLSessionTransportError: CustomStringConvertible { + /// A textual representation of this instance. + var description: String { + switch self { + case let .invalidRequestURL(path: path, method: method, baseURL: baseURL): + return + "Invalid request URL from request path: \(path), method: \(method), relative to base URL: \(baseURL.absoluteString)" + case .notHTTPResponse(let response): + return + "Received a non-HTTP response, of type: \(String(describing: type(of: response)))" + case .invalidResponseStatusCode(let response): + return "Received an HTTP response with invalid status code: \(response.statusCode))" + case .noResponse(let url): + return "Received a nil response for \(url?.absoluteString ?? "")" + case .streamingNotSupported: return "Streaming is not supported on this platform" + } + } +} + +private let _debugLoggingEnabled = LockStorage.create(value: false) +var debugLoggingEnabled: Bool { + get { _debugLoggingEnabled.withLockedValue { $0 } } + set { _debugLoggingEnabled.withLockedValue { $0 = newValue } } +} +private let _standardErrorLock = LockStorage.create(value: FileHandle.standardError) +func debug( + _ message: @autoclosure () -> String, + function: String = #function, + file: String = #file, + line: UInt = #line +) { + assert( + { + if debugLoggingEnabled { + _standardErrorLock.withLockedValue { + let logLine = + "[\(function) \(file.split(separator: "/").last!):\(line)] \(message())\n" + $0.write(Data((logLine).utf8)) + } + } + return true + }() + ) +} + +extension URLSession { + func bufferedRequest( + for request: HTTPTypes.HTTPRequest, + baseURL: URL, + requestBody: HTTPBody? + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + try Task.checkCancellation() + var urlRequest = try URLRequest(request, baseURL: baseURL) + if let requestBody { + urlRequest.httpBody = try await Data(collecting: requestBody, upTo: .max) + } + try Task.checkCancellation() + + /// Use `dataTask(with:completionHandler:)` here because `data(for:[delegate:]) async` is only available on + /// Darwin platforms newer than our minimum deployment target, and not at all on Linux. + let taskBox: LockedValueBox = .init(nil) + return try await withTaskCancellationHandler { + let (response, maybeResponseBodyData): (URLResponse, Data?) = + try await withCheckedThrowingContinuation { + continuation in + let task = self.dataTask(with: urlRequest) { + [urlRequest] data, response, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let response else { + continuation.resume( + throwing: URLSessionTransportError.noResponse(url: urlRequest.url) + ) + return + } + continuation.resume(with: .success((response, data))) + } + // Swift concurrency task cancelled here. + taskBox.withLockedValue { boxedTask in + guard task.state == .suspended else { + debug( + "URLSession task cannot be resumed, probably because it was cancelled by onCancel." + ) + return + } + task.resume() + boxedTask = task + } + } + + let maybeResponseBody = maybeResponseBodyData.map { data in + HTTPBody( + data, + length: HTTPBody.Length(from: response), + iterationBehavior: .multiple + ) + } + return (try HTTPTypes.HTTPResponse(response), maybeResponseBody) + } onCancel: { + taskBox.withLockedValue { boxedTask in + debug("Concurrency task cancelled, cancelling URLSession task.") + boxedTask?.cancel() + boxedTask = nil + } + } + } +} + +extension URLSessionTransport.Configuration.Implementation { + static var platformSupportsStreaming: Bool { + #if canImport(Darwin) + guard #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) else { return false } + _ = URLSession.bidirectionalStreamingRequest + return true + #else + return false + #endif + } + + static var platformDefault: Self { + guard platformSupportsStreaming else { return .buffering } + return .streaming( + requestBodyStreamBufferSize: 16 * 1024, + responseBodyStreamWatermarks: (low: 16 * 1024, high: 32 * 1024) + ) + } +} diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index d0804396..74fe6fae 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "006a8b9f8235c1a40761e0042fa559d6cbd9369399d90879c3f4aa5a087d194f", "pins" : [ { "identity" : "appauth-ios", @@ -171,6 +172,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -208,5 +218,5 @@ } } ], - "version" : 2 + "version" : 3 } From 106534b56a5e6cf95147e12b99e40eac6a2a434f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 13:38:12 -0300 Subject: [PATCH 02/16] chore: import libraries directly and reexport types --- Package.swift | 4 + Sources/Helpers/HTTP/Client.swift | 72 + Sources/Helpers/HTTP/Exports.swift | 3 + .../Base/PrettyStringConvertible.swift | 20 - .../HTTP/HTTPClient/Errors/ClientError.swift | 128 -- .../HTTP/HTTPClient/Errors/RuntimeError.swift | 88 - Sources/Helpers/HTTP/HTTPClient/Exports.swift | 1 - .../Interface/AsyncSequenceCommon.swift | 123 - .../HTTP/HTTPClient/Interface/Client.swift | 147 -- .../Interface/ClientTransport.swift | 214 -- .../HTTPClient/Interface/CurrencyTypes.swift | 39 - .../HTTP/HTTPClient/Interface/HTTPBody.swift | 596 ----- .../BufferedStream/BufferedStream.swift | 1973 ----------------- .../BufferedStream/Lock.swift | 278 --- .../HTTP/HTTPClientFoundation/Reexports.swift | 8 - ...rectionalStreamingURLSessionDelegate.swift | 235 -- .../HTTPBodyOutputStreamBridge.swift | 312 --- .../URLSession+Extensions.swift | 61 - .../URLSessionTransport.swift | 427 ---- Sources/Helpers/HTTP/LoggerInterceptor.swift | 1 + .../Middlewares => }/LoggingMiddleware.swift | 29 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- 22 files changed, 126 insertions(+), 4653 deletions(-) create mode 100644 Sources/Helpers/HTTP/Client.swift create mode 100644 Sources/Helpers/HTTP/Exports.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Exports.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift delete mode 100644 Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift rename Sources/Helpers/HTTP/{HTTPClient/Middlewares => }/LoggingMiddleware.swift (52%) diff --git a/Package.swift b/Package.swift index 66815226..9914cc05 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-log", from: "1.0.0"), .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), @@ -46,6 +48,8 @@ let package = Package( .product(name: "DequeModule", package: "swift-collections"), .product(name: "Clocks", package: "swift-clocks"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), ] ), .testTarget( diff --git a/Sources/Helpers/HTTP/Client.swift b/Sources/Helpers/HTTP/Client.swift new file mode 100644 index 00000000..28cb4c73 --- /dev/null +++ b/Sources/Helpers/HTTP/Client.swift @@ -0,0 +1,72 @@ +import HTTPTypes +import OpenAPIRuntime + +#if canImport(Darwin) + import struct Foundation.URL +#else + @preconcurrency import struct Foundation.URL +#endif + +/// A client that can send HTTP requests and receive HTTP responses. +struct Client: Sendable { + + /// The URL of the server, used as the base URL for requests made by the + /// client. + let serverURL: URL + + /// A type capable of sending HTTP requests and receiving HTTP responses. + var transport: any ClientTransport + + /// The middlewares to be invoked before the transport. + var middlewares: [any ClientMiddleware] + + /// Creates a new client. + init( + serverURL: URL, + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.serverURL = serverURL + self.transport = transport + self.middlewares = middlewares + } + + /// Sends the HTTP request and returns the HTTP response. + /// + /// - Parameters: + /// - request: The HTTP request to send. + /// - body: The HTTP request body to send. + /// - Returns: The HTTP response and its body. + /// - Throws: An error if any part of the HTTP operation process fails. + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody? = nil + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + let baseURL = serverURL + var next: + @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> ( + HTTPTypes.HTTPResponse, HTTPBody? + ) = { + (_request, _body, _url) in + try await transport.send( + _request, + body: _body, + baseURL: _url, + operationID: "" + ) + } + for middleware in middlewares.reversed() { + let tmp = next + next = { (_request, _body, _url) in + try await middleware.intercept( + _request, + body: _body, + baseURL: _url, + operationID: "", + next: tmp + ) + } + } + return try await next(request, body, baseURL) + } +} diff --git a/Sources/Helpers/HTTP/Exports.swift b/Sources/Helpers/HTTP/Exports.swift new file mode 100644 index 00000000..37ba2f2a --- /dev/null +++ b/Sources/Helpers/HTTP/Exports.swift @@ -0,0 +1,3 @@ +@_exported import HTTPTypes +@_exported import protocol OpenAPIRuntime.ClientTransport +@_exported import class OpenAPIRuntime.HTTPBody diff --git a/Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift b/Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift deleted file mode 100644 index cfcb75ca..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Base/PrettyStringConvertible.swift +++ /dev/null @@ -1,20 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// A helper protocol for customizing descriptions. -internal protocol PrettyStringConvertible { - - /// A pretty string description. - var prettyDescription: String { get } -} \ No newline at end of file diff --git a/Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift b/Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift deleted file mode 100644 index 9f0c4996..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Errors/ClientError.swift +++ /dev/null @@ -1,128 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes - -import protocol Foundation.LocalizedError - -#if canImport(Darwin) - import struct Foundation.URL -#else - @preconcurrency import struct Foundation.URL -#endif - -/// An error thrown by a client performing an OpenAPI operation. -/// -/// Use a `ClientError` to inspect details about the request and response -/// that resulted in an error. -/// -/// You don't create or throw instances of `ClientError` yourself; they are -/// created and thrown on your behalf by the runtime library when a client -/// operation fails. -struct ClientError: Error { - /// The HTTP request created during the operation. - /// - /// Will be nil if the error resulted before the request was generated, - /// for example if generating the request from the Input failed. - var request: HTTPTypes.HTTPRequest? - - /// The HTTP request body created during the operation. - /// - /// Will be nil if the error resulted before the request was generated, - /// for example if generating the request from the Input failed. - var requestBody: HTTPBody? - - /// The base URL for HTTP requests. - /// - /// Will be nil if the error resulted before the request was generated, - /// for example if generating the request from the Input failed. - var baseURL: URL? - - /// The HTTP response received during the operation. - /// - /// Will be nil if the error resulted before the response was received. - var response: HTTPTypes.HTTPResponse? - - /// The HTTP response body received during the operation. - /// - /// Will be nil if the error resulted before the response was received. - var responseBody: HTTPBody? - - /// A user-facing description of what caused the underlying error - /// to be thrown. - var causeDescription: String - - /// The underlying error that caused the operation to fail. - var underlyingError: any Error - - /// Creates a new error. - /// - Parameters: - /// - request: The HTTP request created during the operation. - /// - requestBody: The HTTP request body created during the operation. - /// - baseURL: The base URL for HTTP requests. - /// - response: The HTTP response received during the operation. - /// - responseBody: The HTTP response body received during the operation. - /// - causeDescription: A user-facing description of what caused - /// the underlying error to be thrown. - /// - underlyingError: The underlying error that caused the operation - /// to fail. - init( - request: HTTPTypes.HTTPRequest? = nil, - requestBody: HTTPBody? = nil, - baseURL: URL? = nil, - response: HTTPTypes.HTTPResponse? = nil, - responseBody: HTTPBody? = nil, - causeDescription: String, - underlyingError: any Error - ) { - self.request = request - self.requestBody = requestBody - self.baseURL = baseURL - self.response = response - self.responseBody = responseBody - self.causeDescription = causeDescription - self.underlyingError = underlyingError - } - - // MARK: Private - - fileprivate var underlyingErrorDescription: String { - guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { - return "\(underlyingError)" - } - return prettyError.prettyDescription - } -} - -extension ClientError: CustomStringConvertible { - /// A human-readable description of the client error. - /// - /// This computed property returns a string that includes information about the client error. - /// - /// - Returns: A string describing the client error and its associated details. - var description: String { - "Client error - cause description: '\(causeDescription)', underlying error: \(underlyingErrorDescription), request: \(request?.prettyDescription ?? ""), requestBody: \(requestBody?.prettyDescription ?? ""), baseURL: \(baseURL?.absoluteString ?? ""), response: \(response?.prettyDescription ?? ""), responseBody: \(responseBody?.prettyDescription ?? "")" - } -} - -extension ClientError: LocalizedError { - /// A localized description of the client error. - /// - /// This computed property provides a localized human-readable description of the client error, which is suitable for displaying to users. - /// - /// - Returns: A localized string describing the client error. - var errorDescription: String? { - "Client encountered an error, caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)." - } -} diff --git a/Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift b/Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift deleted file mode 100644 index 8ebe172f..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Errors/RuntimeError.swift +++ /dev/null @@ -1,88 +0,0 @@ -import HTTPTypes - -import struct Foundation.Data -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -import protocol Foundation.LocalizedError - -/// Error thrown by generated code. -internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, PrettyStringConvertible -{ - - // Transport/Handler - case transportFailed(any Error) - case middlewareFailed(middlewareType: Any.Type, any Error) - - /// A wrapped root cause error, if one was thrown by other code. - var underlyingError: (any Error)? { - switch self { - case .transportFailed(let error), .middlewareFailed(_, let error): - return error - } - } - - // MARK: CustomStringConvertible - - var description: String { prettyDescription } - - var prettyDescription: String { - switch self { - case .transportFailed: return "Transport threw an error." - case .middlewareFailed(middlewareType: let type, _): - return "Middleware of type '\(type)' threw an error." - } - } - - // MARK: - LocalizedError - - var errorDescription: String? { description } -} - -/// HTTP Response status definition for ``RuntimeError``. -extension RuntimeError: HTTPResponseConvertible { - /// HTTP Status code corresponding to each error case - var httpStatus: HTTPTypes.HTTPResponse.Status { - switch self { - case .middlewareFailed, .transportFailed: - .internalServerError - } - } -} - -/// A value that can be converted to an HTTP response and body. -/// -/// Conform your error type to this protocol to convert it to an `HTTPResponse` and ``HTTPBody``. -/// -/// Used by ``ErrorHandlingMiddleware``. -protocol HTTPResponseConvertible { - - /// An HTTP status to return in the response. - var httpStatus: HTTPTypes.HTTPResponse.Status { get } - - /// The HTTP header fields of the response. - /// This is optional as default values are provided in the extension. - var httpHeaderFields: HTTPTypes.HTTPFields { get } - - /// The body of the HTTP response. - var httpBody: HTTPBody? { get } -} - -extension HTTPResponseConvertible { - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - var httpHeaderFields: HTTPTypes.HTTPFields { [:] } - - // swift-format-ignore: AllPublicDeclarationsHaveDocumentation - var httpBody: HTTPBody? { nil } -} diff --git a/Sources/Helpers/HTTP/HTTPClient/Exports.swift b/Sources/Helpers/HTTP/HTTPClient/Exports.swift deleted file mode 100644 index 3d4db08e..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Exports.swift +++ /dev/null @@ -1 +0,0 @@ -@_exported import HTTPTypes diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift deleted file mode 100644 index 16b56977..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Interface/AsyncSequenceCommon.swift +++ /dev/null @@ -1,123 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// Describes how many times the provided sequence can be iterated. -public enum IterationBehavior: Sendable { - - /// The input sequence can only be iterated once. - /// - /// If a retry or a redirect is encountered, fail the call with - /// a descriptive error. - case single - - /// The input sequence can be iterated multiple times. - /// - /// Supports retries and redirects, as a new iterator is created each - /// time. - case multiple -} - -// MARK: - Internal - -/// A type-erasing closure-based iterator. -@usableFromInline struct AnyIterator: AsyncIteratorProtocol { - - /// The closure that produces the next element. - private let produceNext: () async throws -> Element? - - /// Creates a new type-erased iterator from the provided iterator. - /// - Parameter iterator: The iterator to type-erase. - @usableFromInline init(_ iterator: Iterator) - where Iterator.Element == Element { - var iterator = iterator - self.produceNext = { try await iterator.next() } - } - - /// Advances the iterator to the next element and returns it asynchronously. - /// - /// - Returns: The next element in the sequence, or `nil` if there are no more elements. - /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. - public mutating func next() async throws -> Element? { try await produceNext() } -} - -/// A type-erased async sequence that wraps input sequences. -@usableFromInline struct AnySequence: AsyncSequence, Sendable { - - /// The type of the type-erased iterator. - @usableFromInline typealias AsyncIterator = AnyIterator - - /// A closure that produces a new iterator. - @usableFromInline let produceIterator: @Sendable () -> AsyncIterator - - /// Creates a new sequence. - /// - Parameter sequence: The input sequence to type-erase. - @usableFromInline init(_ sequence: Upstream) - where Upstream.Element == Element, Upstream: Sendable { - self.produceIterator = { .init(sequence.makeAsyncIterator()) } - } - - @usableFromInline func makeAsyncIterator() -> AsyncIterator { produceIterator() } -} - -/// An async sequence wrapper for a sync sequence. -@usableFromInline struct WrappedSyncSequence: AsyncSequence, Sendable -where Upstream.Element: Sendable { - - /// The type of the iterator. - @usableFromInline typealias AsyncIterator = Iterator - - /// The element type. - @usableFromInline typealias Element = Upstream.Element - - /// An iterator type that wraps a sync sequence iterator. - @usableFromInline struct Iterator: AsyncIteratorProtocol { - - /// The element type. - @usableFromInline typealias Element = IteratorElement - - /// The underlying sync sequence iterator. - var iterator: any IteratorProtocol - - @usableFromInline mutating func next() async throws -> IteratorElement? { iterator.next() } - } - - /// The underlying sync sequence. - @usableFromInline let sequence: Upstream - - /// Creates a new async sequence with the provided sync sequence. - /// - Parameter sequence: The sync sequence to wrap. - @usableFromInline init(sequence: Upstream) { self.sequence = sequence } - - @usableFromInline func makeAsyncIterator() -> AsyncIterator { - Iterator(iterator: sequence.makeIterator()) - } -} - -/// An empty async sequence. -@usableFromInline struct EmptySequence: AsyncSequence, Sendable { - - /// The type of the empty iterator. - @usableFromInline typealias AsyncIterator = EmptyIterator - - /// An async iterator of an empty sequence. - @usableFromInline struct EmptyIterator: AsyncIteratorProtocol { - - @usableFromInline mutating func next() async throws -> IteratorElement? { nil } - } - - /// Creates a new empty async sequence. - @usableFromInline init() {} - - @usableFromInline func makeAsyncIterator() -> AsyncIterator { EmptyIterator() } -} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift deleted file mode 100644 index 51b9d562..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Interface/Client.swift +++ /dev/null @@ -1,147 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -import HTTPTypes - -#if canImport(Darwin) - import struct Foundation.URL -#else - @preconcurrency import struct Foundation.URL -#endif - -/// A client that can send HTTP requests and receive HTTP responses. -struct Client: Sendable { - - /// The URL of the server, used as the base URL for requests made by the - /// client. - let serverURL: URL - - /// A type capable of sending HTTP requests and receiving HTTP responses. - var transport: any ClientTransport - - /// The middlewares to be invoked before the transport. - var middlewares: [any ClientMiddleware] - - /// Creates a new client. - init( - serverURL: URL, - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = [] - ) { - self.serverURL = serverURL - self.transport = transport - self.middlewares = middlewares - } - - /// Sends the HTTP request and returns the HTTP response. - /// - /// - Parameters: - /// - request: The HTTP request to send. - /// - body: The HTTP request body to send. - /// - Returns: The HTTP response and its body. - /// - Throws: An error if any part of the HTTP operation process fails. - func send( - _ request: HTTPTypes.HTTPRequest, - body: HTTPBody? = nil - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { - @Sendable func wrappingErrors( - work: () async throws -> R, - mapError: (any Error) -> ClientError - ) async throws -> R { - do { - return try await work() - } catch let error as ClientError { - throw error - } catch { - throw mapError(error) - } - } - let baseURL = serverURL - @Sendable func makeError( - request: HTTPTypes.HTTPRequest? = nil, - requestBody: HTTPBody? = nil, - baseURL: URL? = nil, - response: HTTPTypes.HTTPResponse? = nil, - responseBody: HTTPBody? = nil, - error: any Error - ) -> ClientError { - if var error = error as? ClientError { - error.request = error.request ?? request - error.requestBody = error.requestBody ?? requestBody - error.baseURL = error.baseURL ?? baseURL - error.response = error.response ?? response - error.responseBody = error.responseBody ?? responseBody - return error - } - let causeDescription: String - let underlyingError: any Error - if let runtimeError = error as? RuntimeError { - causeDescription = runtimeError.prettyDescription - underlyingError = runtimeError.underlyingError ?? error - } else { - causeDescription = "Unknown" - underlyingError = error - } - return ClientError( - request: request, - requestBody: requestBody, - baseURL: baseURL, - response: response, - responseBody: responseBody, - causeDescription: causeDescription, - underlyingError: underlyingError - ) - } - var next: @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) = { - (_request, _body, _url) in - try await wrappingErrors { - try await transport.send( - _request, - body: _body, - baseURL: _url - ) - } mapError: { error in - makeError( - request: request, - requestBody: body, - baseURL: baseURL, - error: RuntimeError.transportFailed(error) - ) - } - } - for middleware in middlewares.reversed() { - let tmp = next - next = { (_request, _body, _url) in - try await wrappingErrors { - try await middleware.intercept( - _request, - body: _body, - baseURL: _url, - next: tmp - ) - } mapError: { error in - makeError( - request: request, - requestBody: body, - baseURL: baseURL, - error: RuntimeError.middlewareFailed( - middlewareType: type(of: middleware), - error - ) - ) - } - } - } - return try await next(request, body, baseURL) - } -} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift deleted file mode 100644 index 7b282d7e..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Interface/ClientTransport.swift +++ /dev/null @@ -1,214 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes - -import struct Foundation.URL - -/// A type that performs HTTP operations. -/// -/// Decouples an underlying HTTP library from generated client code. -/// -/// ### Choose between a transport and a middleware -/// -/// The ``ClientTransport`` and ``ClientMiddleware`` protocols look similar, -/// however each serves a different purpose. -/// -/// A _transport_ abstracts over the underlying HTTP library that actually -/// performs the HTTP operation by using the network. A ``Client`` -/// requires an exactly one client transport. -/// -/// A _middleware_ intercepts the HTTP request and response, without being -/// responsible for performing the HTTP operation itself. That's why -/// middlewares take the extra `next` parameter, to delegate making the HTTP -/// call to the transport at the top of the middleware stack. -/// -/// ### Use an existing client transport -/// -/// Instantiate the transport using the parameters required by the specific -/// implementation. For example, using the client transport for the -/// `URLSession` HTTP client provided by the Foundation framework: -/// -/// let transport = URLSessionTransport() -/// -/// Instantiate the `Client` type. For example: -/// -/// let client = Client( -/// serverURL: URL(string: "https://example.com")!, -/// transport: transport -/// ) -/// -/// ### Implement a custom client transport -/// -/// If a client transport implementation for your preferred HTTP library doesn't -/// yet exist, or you need to simulate rare network conditions in your tests, -/// consider implementing a custom client transport. -/// -/// For example, to implement a test client transport that allows you -/// to test both a healthy and unhealthy response from a `checkHealth` -/// operation, define a new struct that conforms to the `ClientTransport` -/// protocol: -/// -/// struct TestTransport: ClientTransport { -/// var isHealthy: Bool = true -/// func send( -/// _ request: HTTPRequest, -/// body: HTTPBody?, -/// baseURL: URL, -/// operationID: String -/// ) async throws -> (HTTPResponse, HTTPBody?) { -/// ( -/// HTTPResponse(status: isHealthy ? .ok : .internalServerError), -/// nil -/// ) -/// } -/// } -/// -/// Then in your test code, instantiate and provide the test transport to your -/// generated client instead: -/// -/// var transport = TestTransport() -/// transport.isHealthy = true // for HTTP status code 200 (success) -/// let client = Client( -/// serverURL: URL(string: "https://example.com")!, -/// transport: transport -/// ) -/// let response = try await client.checkHealth() -/// -/// Implementing a test client transport is just one way to help test your -/// code that integrates with a generated client. Another is to implement -/// a type conforming to the generated protocol `APIProtocol`, and to implement -/// a custom ``ClientMiddleware``. -public protocol ClientTransport: Sendable { - - /// Sends the underlying HTTP request and returns the received - /// HTTP response. - /// - Parameters: - /// - request: An HTTP request. - /// - body: An HTTP request body. - /// - baseURL: A server base URL. - /// - operationID: The identifier of the OpenAPI operation. - /// - Returns: An HTTP response and its body. - /// - Throws: An error if sending the request and receiving the response fails. - func send( - _ request: HTTPTypes.HTTPRequest, - body: HTTPBody?, - baseURL: URL - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) -} - -/// A type that intercepts HTTP requests and responses. -/// -/// It allows you to read and modify the request before it is received by -/// the transport and the response after it is returned by the transport. -/// -/// Appropriate for handling authentication, logging, metrics, tracing, -/// injecting custom headers such as "user-agent", and more. -/// -/// ### Choose between a transport and a middleware -/// -/// The ``ClientTransport`` and ``ClientMiddleware`` protocols look similar, -/// however each serves a different purpose. -/// -/// A _transport_ abstracts over the underlying HTTP library that actually -/// performs the HTTP operation by using the network. A ``Client`` -/// requires an exactly one client transport. -/// -/// A _middleware_ intercepts the HTTP request and response, without being -/// responsible for performing the HTTP operation itself. That's why -/// middlewares take the extra `next` parameter, to delegate making the HTTP -/// call to the transport at the top of the middleware stack. -/// -/// ### Use an existing client middleware -/// -/// Instantiate the middleware using the parameters required by the specific -/// implementation. For example, using a hypothetical existing middleware -/// that logs every request and response: -/// -/// let loggingMiddleware = LoggingMiddleware() -/// -/// Similarly to the process of using an existing ``ClientTransport``, provide -/// the middleware to the initializer of the ``Client`` type: -/// -/// let client = Client( -/// serverURL: URL(string: "https://example.com")!, -/// transport: transport, -/// middlewares: [ -/// loggingMiddleware, -/// ] -/// ) -/// -/// Then make a call to one of the client methods: -/// -/// let response = try await client.checkHealth() -/// -/// As part of the invocation of `checkHealth`, the client first invokes -/// the middlewares in the order you provided them, and then passes the request -/// to the transport. When a response is received, the last middleware handles -/// it first, in the reverse order of the `middlewares` array. -/// -/// ### Implement a custom client middleware -/// -/// If a client middleware implementation with your desired behavior doesn't -/// yet exist, or you need to simulate rare network conditions your tests, -/// consider implementing a custom client middleware. -/// -/// For example, to implement a middleware that injects the "Authorization" -/// header to every outgoing request, define a new struct that conforms to -/// the `ClientMiddleware` protocol: -/// -/// /// Injects an authorization header to every request. -/// struct AuthenticationMiddleware: ClientMiddleware { -/// -/// /// The token value. -/// var bearerToken: String -/// -/// func intercept( -/// _ request: HTTPRequest, -/// body: HTTPBody?, -/// baseURL: URL, -/// operationID: String, -/// next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) -/// ) async throws -> (HTTPResponse, HTTPBody?) { -/// var request = request -/// request.headerFields[.authorization] = "Bearer \(bearerToken)" -/// return try await next(request, body, baseURL) -/// } -/// } -/// -/// An alternative use case for a middleware is to inject random failures -/// when calling a real server, to test your retry and error-handling logic. -/// -/// Implementing a test client middleware is just one way to help test your -/// code that integrates with a generated client. Another is to implement -/// a type conforming to the generated protocol `APIProtocol`, and to implement -/// a custom ``ClientTransport``. -protocol ClientMiddleware: Sendable { - - /// Intercepts an outgoing HTTP request and an incoming HTTP response. - /// - Parameters: - /// - request: An HTTP request. - /// - body: An HTTP request body. - /// - baseURL: A server base URL. - /// - operationID: The identifier of the OpenAPI operation. - /// - next: A closure that calls the next middleware, or the transport. - /// - Returns: An HTTP response and its body. - /// - Throws: An error if interception of the request and response fails. - func intercept( - _ request: HTTPTypes.HTTPRequest, - body: HTTPBody?, - baseURL: URL, - next: @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) -} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift deleted file mode 100644 index 831ab840..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Interface/CurrencyTypes.swift +++ /dev/null @@ -1,39 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -import HTTPTypesFoundation - -extension HTTPFields: PrettyStringConvertible { - var prettyDescription: String { - sorted(by: { - $0.name.canonicalName.localizedCompare($1.name.canonicalName) == .orderedAscending - }) - .map { "\($0.name.canonicalName): \($0.value)" }.joined(separator: "; ") - } -} - -extension HTTPTypes.HTTPRequest: PrettyStringConvertible { - var prettyDescription: String { - "\(method.rawValue) \(url?.absoluteString.removingPercentEncoding ?? "") [\(headerFields.prettyDescription)]" - } -} - -extension HTTPTypes.HTTPResponse: PrettyStringConvertible { - var prettyDescription: String { "\(status.code) \(status.reasonPhrase) [\(headerFields.prettyDescription)]" } -} - -extension HTTPBody: PrettyStringConvertible { - var prettyDescription: String { String(describing: self) } -} diff --git a/Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift b/Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift deleted file mode 100644 index 640237a2..00000000 --- a/Sources/Helpers/HTTP/HTTPClient/Interface/HTTPBody.swift +++ /dev/null @@ -1,596 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import struct Foundation.Data // only for convenience initializers -import protocol Foundation.LocalizedError -import class Foundation.NSLock - -/// A body of an HTTP request or HTTP response. -/// -/// Under the hood, it represents an async sequence of byte chunks. -/// -/// ## Creating a body from a buffer -/// There are convenience initializers to create a body from common types, such -/// as `Data`, `[UInt8]`, `ArraySlice`, and `String`. -/// -/// Create an empty body: -/// ```swift -/// let body = HTTPBody() -/// ``` -/// -/// Create a body from a byte chunk: -/// ```swift -/// let bytes: ArraySlice = ... -/// let body = HTTPBody(bytes) -/// ``` -/// -/// Create a body from `Foundation.Data`: -/// ```swift -/// let data: Foundation.Data = ... -/// let body = HTTPBody(data) -/// ``` -/// -/// Create a body from a string: -/// ```swift -/// let body = HTTPBody("Hello, world!") -/// ``` -/// -/// ## Creating a body from an async sequence -/// The body type also supports initialization from an async sequence. -/// -/// ```swift -/// let producingSequence = ... // an AsyncSequence -/// let length: HTTPBody.Length = .known(1024) // or .unknown -/// let body = HTTPBody( -/// producingSequence, -/// length: length, -/// iterationBehavior: .single // or .multiple -/// ) -/// ``` -/// -/// In addition to the async sequence, also provide the total body length, -/// if known (this can be sent in the `content-length` header), and whether -/// the sequence is safe to be iterated multiple times, or can only be iterated -/// once. -/// -/// Sequences that can be iterated multiple times work better when an HTTP -/// request needs to be retried, or if a redirect is encountered. -/// -/// In addition to providing the async sequence, you can also produce the body -/// using an `AsyncStream` or `AsyncThrowingStream`: -/// -/// ```swift -/// let body = HTTPBody( -/// AsyncStream(ArraySlice.self, { continuation in -/// continuation.yield([72, 69]) -/// continuation.yield([76, 76, 79]) -/// continuation.finish() -/// }), -/// length: .known(5) -/// ) -/// ``` -/// -/// ## Consuming a body as an async sequence -/// The `HTTPBody` type conforms to `AsyncSequence` and uses `ArraySlice` -/// as its element type, so it can be consumed in a streaming fashion, without -/// ever buffering the whole body in your process. -/// -/// For example, to get another sequence that contains only the size of each -/// chunk, and print each size, use: -/// -/// ```swift -/// let chunkSizes = body.map { chunk in chunk.count } -/// for try await chunkSize in chunkSizes { -/// print("Chunk size: \(chunkSize)") -/// } -/// ``` -/// -/// ## Consuming a body as a buffer -/// If you need to collect the whole body before processing it, use one of -/// the convenience initializers on the target types that take an `HTTPBody`. -/// -/// To get all the bytes, use the initializer on `ArraySlice` or `[UInt8]`: -/// -/// ```swift -/// let buffer = try await ArraySlice(collecting: body, upTo: 2 * 1024 * 1024) -/// ``` -/// -/// The body type provides more variants of the collecting initializer on commonly -/// used buffers, such as: -/// - `Foundation.Data` -/// - `Swift.String` -/// -/// > Important: You must provide the maximum number of bytes you can buffer in -/// memory, in the example above we provide 2 MB. If more bytes are available, -/// the method throws the `TooManyBytesError` to stop the process running out -/// of memory. While discouraged, you can provide `upTo: .max` to -/// read all the available bytes, without a limit. -public final class HTTPBody: @unchecked Sendable { - - /// The underlying byte chunk type. - public typealias ByteChunk = ArraySlice - - /// The iteration behavior, which controls how many times - /// the input sequence can be iterated. - public let iterationBehavior: IterationBehavior - - /// Describes the total length of the body, in bytes, if known. - public enum Length: Sendable, Equatable { - - /// Total length not known yet. - case unknown - - /// Total length is known. - case known(Int64) - } - - /// The total length of the body, in bytes, if known. - public let length: Length - - /// The underlying type-erased async sequence. - private let sequence: AnySequence - - /// A lock for shared mutable state. - private let lock: NSLock = { - let lock = NSLock() - lock.name = "com.apple.swift-openapi-generator.runtime.body" - return lock - }() - - /// A flag indicating whether an iterator has already been created. - private var locked_iteratorCreated: Bool = false - - /// A flag indicating whether an iterator has already been created, only - /// used for testing. - internal var testing_iteratorCreated: Bool { - lock.lock() - defer { lock.unlock() } - return locked_iteratorCreated - } - - /// Tries to mark an iterator as created, verifying that it is allowed - /// based on the values of `iterationBehavior` and `locked_iteratorCreated`. - /// - Throws: If another iterator is not allowed to be created. - private func tryToMarkIteratorCreated() throws { - lock.lock() - defer { - locked_iteratorCreated = true - lock.unlock() - } - guard iterationBehavior == .single else { return } - if locked_iteratorCreated { throw TooManyIterationsError() } - } - - /// Creates a new body. - /// - Parameters: - /// - sequence: The input sequence providing the byte chunks. - /// - length: The total length of the body, in other words the accumulated - /// length of all the byte chunks. - /// - iterationBehavior: The sequence's iteration behavior, which - /// indicates whether the sequence can be iterated multiple times. - @usableFromInline init( - _ sequence: AnySequence, - length: Length, - iterationBehavior: IterationBehavior - ) { - self.sequence = sequence - self.length = length - self.iterationBehavior = iterationBehavior - } - - /// Creates a new body with the provided sequence of byte chunks. - /// - Parameters: - /// - byteChunks: A sequence of byte chunks. - /// - length: The total length of the body. - /// - iterationBehavior: The iteration behavior of the sequence, which - /// indicates whether it can be iterated multiple times. - @usableFromInline convenience init( - _ byteChunks: some Sequence & Sendable, - length: Length, - iterationBehavior: IterationBehavior - ) { - self.init( - .init(WrappedSyncSequence(sequence: byteChunks)), - length: length, - iterationBehavior: iterationBehavior - ) - } -} - -extension HTTPBody: Equatable { - /// Compares two HTTPBody instances for equality by comparing their object identifiers. - /// - /// - Parameters: - /// - lhs: The left-hand side HTTPBody. - /// - rhs: The right-hand side HTTPBody. - /// - /// - Returns: `true` if the object identifiers of the two HTTPBody instances are equal, - /// indicating that they are the same object in memory; otherwise, returns `false`. - public static func == (lhs: HTTPBody, rhs: HTTPBody) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } -} - -extension HTTPBody: Hashable { - /// Hashes the HTTPBody instance by combining its object identifier into the provided hasher. - /// - /// - Parameter hasher: The hasher used to combine the hash value. - public func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } -} - -// MARK: - Creating the HTTPBody - -extension HTTPBody { - - /// Creates a new empty body. - @inlinable public convenience init() { - self.init(.init(EmptySequence()), length: .known(0), iterationBehavior: .multiple) - } - - /// Creates a new body with the provided byte chunk. - /// - Parameters: - /// - bytes: A byte chunk. - /// - length: The total length of the body. - @inlinable public convenience init(_ bytes: ByteChunk, length: Length) { - self.init([bytes], length: length, iterationBehavior: .multiple) - } - - /// Creates a new body with the provided byte chunk. - /// - Parameter bytes: A byte chunk. - @inlinable public convenience init(_ bytes: ByteChunk) { - self.init([bytes], length: .known(Int64(bytes.count)), iterationBehavior: .multiple) - } - - /// Creates a new body with the provided byte sequence. - /// - Parameters: - /// - bytes: A byte chunk. - /// - length: The total length of the body. - /// - iterationBehavior: The iteration behavior of the sequence, which - /// indicates whether it can be iterated multiple times. - @inlinable public convenience init( - _ bytes: some Sequence & Sendable, - length: Length, - iterationBehavior: IterationBehavior - ) { self.init([ArraySlice(bytes)], length: length, iterationBehavior: iterationBehavior) } - - /// Creates a new body with the provided byte collection. - /// - Parameters: - /// - bytes: A byte chunk. - /// - length: The total length of the body. - @inlinable public convenience init(_ bytes: some Collection & Sendable, length: Length) { - self.init(ArraySlice(bytes), length: length, iterationBehavior: .multiple) - } - - /// Creates a new body with the provided byte collection. - /// - Parameter bytes: A byte chunk. - @inlinable public convenience init(_ bytes: some Collection & Sendable) { - self.init(bytes, length: .known(Int64(bytes.count))) - } - - /// Creates a new body with the provided async throwing stream. - /// - Parameters: - /// - stream: An async throwing stream that provides the byte chunks. - /// - length: The total length of the body. - @inlinable public convenience init( - _ stream: AsyncThrowingStream, - length: HTTPBody.Length - ) { - self.init(.init(stream), length: length, iterationBehavior: .single) - } - - /// Creates a new body with the provided async stream. - /// - Parameters: - /// - stream: An async stream that provides the byte chunks. - /// - length: The total length of the body. - @inlinable public convenience init(_ stream: AsyncStream, length: HTTPBody.Length) { - self.init(.init(stream), length: length, iterationBehavior: .single) - } - - /// Creates a new body with the provided async sequence. - /// - Parameters: - /// - sequence: An async sequence that provides the byte chunks. - /// - length: The total length of the body. - /// - iterationBehavior: The iteration behavior of the sequence, which - /// indicates whether it can be iterated multiple times. - @inlinable public convenience init( - _ sequence: Bytes, - length: HTTPBody.Length, - iterationBehavior: IterationBehavior - ) where Bytes.Element == ByteChunk, Bytes: Sendable { - self.init(.init(sequence), length: length, iterationBehavior: iterationBehavior) - } - - /// Creates a new body with the provided async sequence of byte sequences. - /// - Parameters: - /// - sequence: An async sequence that provides the byte chunks. - /// - length: The total length of the body. - /// - iterationBehavior: The iteration behavior of the sequence, which - /// indicates whether it can be iterated multiple times. - @inlinable public convenience init( - _ sequence: Bytes, - length: HTTPBody.Length, - iterationBehavior: IterationBehavior - ) where Bytes: Sendable, Bytes.Element: Sequence & Sendable, Bytes.Element.Element == UInt8 { - self.init( - sequence.map { ArraySlice($0) }, - length: length, - iterationBehavior: iterationBehavior - ) - } -} - -// MARK: - Consuming the body - -extension HTTPBody: AsyncSequence { - /// Represents a single element within an asynchronous sequence - public typealias Element = ByteChunk - /// Represents an asynchronous iterator over a sequence of elements. - public typealias AsyncIterator = Iterator - /// Creates and returns an asynchronous iterator - /// - /// - Returns: An asynchronous iterator for byte chunks. - /// - Note: The returned sequence throws an error if no further iterations are allowed. See ``IterationBehavior``. - public func makeAsyncIterator() -> AsyncIterator { - do { - try tryToMarkIteratorCreated() - return .init(sequence.makeAsyncIterator()) - } catch { return .init(throwing: error) } - } -} - -extension HTTPBody { - - /// An error thrown by the collecting initializer when the body contains more - /// than the maximum allowed number of bytes. - private struct TooManyBytesError: Error, CustomStringConvertible, LocalizedError { - - /// The maximum number of bytes acceptable by the user. - let maxBytes: Int - - var description: String { - "OpenAPIRuntime.HTTPBody contains more than the maximum allowed \(maxBytes) bytes." - } - - var errorDescription: String? { description } - } - - /// An error thrown by the collecting initializer when another iteration of - /// the body is not allowed. - private struct TooManyIterationsError: Error, CustomStringConvertible, LocalizedError { - - var description: String { - "OpenAPIRuntime.HTTPBody attempted to create a second iterator, but the underlying sequence is only safe to be iterated once." - } - - var errorDescription: String? { description } - } - - /// Accumulates the full body in-memory into a single buffer - /// up to the provided maximum number of bytes and returns it. - /// - Parameter maxBytes: The maximum number of bytes this method is allowed - /// to accumulate in memory before it throws an error. - /// - Throws: `TooManyBytesError` if the body contains more - /// than `maxBytes`. - /// - Returns: A byte chunk containing all the accumulated bytes. - fileprivate func collect(upTo maxBytes: Int) async throws -> ByteChunk { - // If the length is known, verify it's within the limit. - if case .known(let knownBytes) = length { - guard knownBytes <= maxBytes else { throw TooManyBytesError(maxBytes: maxBytes) } - } - - // Accumulate the byte chunks. - var buffer = ByteChunk() - for try await chunk in self { - guard buffer.count + chunk.count <= maxBytes else { - throw TooManyBytesError(maxBytes: maxBytes) - } - buffer.append(contentsOf: chunk) - } - return buffer - } -} - -extension HTTPBody.ByteChunk { - /// Creates a byte chunk by accumulating the full body in-memory into a single buffer - /// up to the provided maximum number of bytes and returning it. - /// - Parameters: - /// - body: The HTTP body to collect. - /// - maxBytes: The maximum number of bytes this method is allowed - /// to accumulate in memory before it throws an error. - /// - Throws: `TooManyBytesError` if the body contains more - /// than `maxBytes`. - public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { - self = try await body.collect(upTo: maxBytes) - } -} - -extension Array where Element == UInt8 { - /// Creates a byte array by accumulating the full body in-memory into a single buffer - /// up to the provided maximum number of bytes and returning it. - /// - Parameters: - /// - body: The HTTP body to collect. - /// - maxBytes: The maximum number of bytes this method is allowed - /// to accumulate in memory before it throws an error. - /// - Throws: `TooManyBytesError` if the body contains more - /// than `maxBytes`. - public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { - self = try await Array(body.collect(upTo: maxBytes)) - } -} - -// MARK: - String-based bodies - -extension HTTPBody { - - /// Creates a new body with the provided string encoded as UTF-8 bytes. - /// - Parameters: - /// - string: A string to encode as bytes. - /// - length: The total length of the body. - @inlinable public convenience init(_ string: some StringProtocol & Sendable, length: Length) { - self.init(ByteChunk(string), length: length) - } - - /// Creates a new body with the provided string encoded as UTF-8 bytes. - /// - Parameter string: A string to encode as bytes. - @inlinable public convenience init(_ string: some StringProtocol & Sendable) { - self.init(ByteChunk(string)) - } - - /// Creates a new body with the provided async throwing stream of strings. - /// - Parameters: - /// - stream: An async throwing stream that provides the string chunks. - /// - length: The total length of the body. - @inlinable public convenience init( - _ stream: AsyncThrowingStream, - length: HTTPBody.Length - ) { - self.init( - .init(stream.map { ByteChunk.init($0) }), - length: length, - iterationBehavior: .single - ) - } - - /// Creates a new body with the provided async stream of strings. - /// - Parameters: - /// - stream: An async stream that provides the string chunks. - /// - length: The total length of the body. - @inlinable public convenience init( - _ stream: AsyncStream, - length: HTTPBody.Length - ) { - self.init( - .init(stream.map { ByteChunk.init($0) }), - length: length, - iterationBehavior: .single - ) - } - - /// Creates a new body with the provided async sequence of string chunks. - /// - Parameters: - /// - sequence: An async sequence that provides the string chunks. - /// - length: The total length of the body. - /// - iterationBehavior: The iteration behavior of the sequence, which - /// indicates whether it can be iterated multiple times. - @inlinable public convenience init( - _ sequence: Strings, - length: HTTPBody.Length, - iterationBehavior: IterationBehavior - ) where Strings.Element: StringProtocol & Sendable, Strings: Sendable { - self.init( - .init(sequence.map { ByteChunk.init($0) }), - length: length, - iterationBehavior: iterationBehavior - ) - } -} - -extension HTTPBody.ByteChunk { - - /// Creates a byte chunk compatible with the `HTTPBody` type from the provided string. - /// - Parameter string: The string to encode. - @inlinable init(_ string: some StringProtocol & Sendable) { self = Array(string.utf8)[...] } -} - -extension String { - /// Creates a string by accumulating the full body in-memory into a single buffer up to - /// the provided maximum number of bytes, converting it to string using UTF-8 encoding. - /// - Parameters: - /// - body: The HTTP body to collect. - /// - maxBytes: The maximum number of bytes this method is allowed - /// to accumulate in memory before it throws an error. - /// - Throws: `TooManyBytesError` if the body contains more - /// than `maxBytes`. - public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { - self = try await String(decoding: body.collect(upTo: maxBytes), as: UTF8.self) - } -} - -// MARK: - HTTPBody conversions - -extension HTTPBody: ExpressibleByStringLiteral { - /// Initializes an `HTTPBody` instance with the provided string value. - /// - /// - Parameter value: The string literal to use for initializing the `HTTPBody`. - public convenience init(stringLiteral value: String) { self.init(value) } -} - -extension HTTPBody { - - /// Creates a new body from the provided array of bytes. - /// - Parameter bytes: An array of bytes. - @inlinable public convenience init(_ bytes: [UInt8]) { self.init(bytes[...]) } -} - -extension HTTPBody: ExpressibleByArrayLiteral { - /// Element type for array literals. - public typealias ArrayLiteralElement = UInt8 - /// Initializes an `HTTPBody` instance with a sequence of `UInt8` elements. - /// - /// - Parameter elements: A variadic list of `UInt8` elements used to initialize the `HTTPBody`. - public convenience init(arrayLiteral elements: UInt8...) { self.init(elements) } -} - -extension HTTPBody { - - /// Creates a new body from the provided data chunk. - /// - Parameter data: A single data chunk. - public convenience init(_ data: Data) { self.init(ArraySlice(data)) } -} - -extension Data { - /// Creates a Data by accumulating the full body in-memory into a single buffer up to - /// the provided maximum number of bytes and converting it to `Data`. - /// - Parameters: - /// - body: The HTTP body to collect. - /// - maxBytes: The maximum number of bytes this method is allowed - /// to accumulate in memory before it throws an error. - /// - Throws: `TooManyBytesError` if the body contains more - /// than `maxBytes`. - public init(collecting body: HTTPBody, upTo maxBytes: Int) async throws { - self = try await Data(body.collect(upTo: maxBytes)) - } -} - -// MARK: - Underlying async sequences - -extension HTTPBody { - - /// An async iterator of both input async sequences and of the body itself. - public struct Iterator: AsyncIteratorProtocol { - - /// The element byte chunk type. - public typealias Element = HTTPBody.ByteChunk - - /// The closure that produces the next element. - private let produceNext: () async throws -> Element? - - /// Creates a new type-erased iterator from the provided iterator. - /// - Parameter iterator: The iterator to type-erase. - @usableFromInline init(_ iterator: Iterator) - where Iterator.Element == Element { - var iterator = iterator - self.produceNext = { try await iterator.next() } - } - /// Creates an iterator throwing the given error when iterated. - /// - Parameter error: The error to throw on iteration. - fileprivate init(throwing error: any Error) { self.produceNext = { throw error } } - - /// Advances the iterator to the next element and returns it asynchronously. - /// - /// - Returns: The next element in the sequence, or `nil` if there are no more elements. - /// - Throws: An error if there is an issue advancing the iterator or retrieving the next element. - public mutating func next() async throws -> Element? { try await produceNext() } - } -} diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift deleted file mode 100644 index 6b30d219..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/BufferedStream.swift +++ /dev/null @@ -1,1973 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -// swift-format-ignore-file -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2020-2021 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors -// -//===----------------------------------------------------------------------===// - -import DequeModule - -/// An asynchronous sequence generated from an error-throwing closure that -/// calls a continuation to produce new elements. -/// -/// `BufferedStream` conforms to `AsyncSequence`, providing a convenient -/// way to create an asynchronous sequence without manually implementing an -/// asynchronous iterator. In particular, an asynchronous stream is well-suited -/// to adapt callback- or delegation-based APIs to participate with -/// `async`-`await`. -/// -/// In contrast to `AsyncStream`, this type can throw an error from the awaited -/// `next()`, which terminates the stream with the thrown error. -/// -/// You initialize an `BufferedStream` with a closure that receives an -/// `BufferedStream.Continuation`. Produce elements in this closure, then -/// provide them to the stream by calling the continuation's `yield(_:)` method. -/// When there are no further elements to produce, call the continuation's -/// `finish()` method. This causes the sequence iterator to produce a `nil`, -/// which terminates the sequence. If an error occurs, call the continuation's -/// `finish(throwing:)` method, which causes the iterator's `next()` method to -/// throw the error to the awaiting call point. The continuation is `Sendable`, -/// which permits calling it from concurrent contexts external to the iteration -/// of the `BufferedStream`. -/// -/// An arbitrary source of elements can produce elements faster than they are -/// consumed by a caller iterating over them. Because of this, `BufferedStream` -/// defines a buffering behavior, allowing the stream to buffer a specific -/// number of oldest or newest elements. By default, the buffer limit is -/// `Int.max`, which means it's unbounded. -/// -/// ### Adapting Existing Code to Use Streams -/// -/// To adapt existing callback code to use `async`-`await`, use the callbacks -/// to provide values to the stream, by using the continuation's `yield(_:)` -/// method. -/// -/// Consider a hypothetical `QuakeMonitor` type that provides callers with -/// `Quake` instances every time it detects an earthquake. To receive callbacks, -/// callers set a custom closure as the value of the monitor's -/// `quakeHandler` property, which the monitor calls back as necessary. Callers -/// can also set an `errorHandler` to receive asynchronous error notifications, -/// such as the monitor service suddenly becoming unavailable. -/// -/// class QuakeMonitor { -/// var quakeHandler: ((Quake) -> Void)? -/// var errorHandler: ((Error) -> Void)? -/// -/// func startMonitoring() {…} -/// func stopMonitoring() {…} -/// } -/// -/// To adapt this to use `async`-`await`, extend the `QuakeMonitor` to add a -/// `quakes` property, of type `BufferedStream`. In the getter for -/// this property, return an `BufferedStream`, whose `build` closure -- -/// called at runtime to create the stream -- uses the continuation to -/// perform the following steps: -/// -/// 1. Creates a `QuakeMonitor` instance. -/// 2. Sets the monitor's `quakeHandler` property to a closure that receives -/// each `Quake` instance and forwards it to the stream by calling the -/// continuation's `yield(_:)` method. -/// 3. Sets the monitor's `errorHandler` property to a closure that receives -/// any error from the monitor and forwards it to the stream by calling the -/// continuation's `finish(throwing:)` method. This causes the stream's -/// iterator to throw the error and terminate the stream. -/// 4. Sets the continuation's `onTermination` property to a closure that -/// calls `stopMonitoring()` on the monitor. -/// 5. Calls `startMonitoring` on the `QuakeMonitor`. -/// -/// ``` -/// extension QuakeMonitor { -/// -/// static var throwingQuakes: BufferedStream { -/// BufferedStream { continuation in -/// let monitor = QuakeMonitor() -/// monitor.quakeHandler = { quake in -/// continuation.yield(quake) -/// } -/// monitor.errorHandler = { error in -/// continuation.finish(throwing: error) -/// } -/// continuation.onTermination = { @Sendable _ in -/// monitor.stopMonitoring() -/// } -/// monitor.startMonitoring() -/// } -/// } -/// } -/// ``` -/// -/// -/// Because the stream is an `AsyncSequence`, the call point uses the -/// `for`-`await`-`in` syntax to process each `Quake` instance as produced by the stream: -/// -/// do { -/// for try await quake in quakeStream { -/// print("Quake: \(quake.date)") -/// } -/// print("Stream done.") -/// } catch { -/// print("Error: \(error)") -/// } -/// -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -@usableFromInline -internal struct BufferedStream { - @usableFromInline - final class _Backing: Sendable { - @usableFromInline - let storage: _BackPressuredStorage - - @usableFromInline - init(storage: _BackPressuredStorage) { - self.storage = storage - } - - deinit { - self.storage.sequenceDeinitialized() - } - } - - @usableFromInline - enum _Implementation: Sendable { - /// This is the implementation with backpressure based on the Source - case backpressured(_Backing) - } - - @usableFromInline - let implementation: _Implementation -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension BufferedStream: AsyncSequence { - /// The asynchronous iterator for iterating an asynchronous stream. - /// - /// This type is not `Sendable`. Don't use it from multiple - /// concurrent contexts. It is a programmer error to invoke `next()` from a - /// concurrent context that contends with another such call, which - /// results in a call to `fatalError()`. - @usableFromInline - internal struct Iterator: AsyncIteratorProtocol { - @usableFromInline - final class _Backing { - @usableFromInline - let storage: _BackPressuredStorage - - @usableFromInline - init(storage: _BackPressuredStorage) { - self.storage = storage - self.storage.iteratorInitialized() - } - - deinit { - self.storage.iteratorDeinitialized() - } - } - - @usableFromInline - enum _Implementation { - /// This is the implementation with backpressure based on the Source - case backpressured(_Backing) - } - - @usableFromInline - var implementation: _Implementation - - @usableFromInline - init(implementation: _Implementation) { - self.implementation = implementation - } - - /// The next value from the asynchronous stream. - /// - /// When `next()` returns `nil`, this signifies the end of the - /// `BufferedStream`. - /// - /// It is a programmer error to invoke `next()` from a concurrent context - /// that contends with another such call, which results in a call to - /// `fatalError()`. - /// - /// If you cancel the task this iterator is running in while `next()` is - /// awaiting a value, the `BufferedStream` terminates. In this case, - /// `next()` may return `nil` immediately, or else return `nil` on - /// subsequent calls. - @inlinable - internal mutating func next() async throws -> Element? { - switch self.implementation { - case .backpressured(let backing): - return try await backing.storage.next() - } - } - } - - /// Creates the asynchronous iterator that produces elements of this - /// asynchronous sequence. - @inlinable - internal func makeAsyncIterator() -> Iterator { - switch self.implementation { - case .backpressured(let backing): - return Iterator(implementation: .backpressured(.init(storage: backing.storage))) - } - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension BufferedStream: Sendable where Element: Sendable {} - -@usableFromInline -internal struct _ManagedCriticalState: @unchecked Sendable { - @usableFromInline - let lock: LockedValueBox - - @usableFromInline - internal init(_ initial: State) { - self.lock = .init(initial) - } - - @inlinable - internal func withCriticalRegion( - _ critical: (inout State) throws -> R - ) rethrows -> R { - try self.lock.withLockedValue(critical) - } -} - -@usableFromInline -internal struct AlreadyFinishedError: Error { - @usableFromInline - init() {} -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension BufferedStream { - /// A mechanism to interface between producer code and an asynchronous stream. - /// - /// Use this source to provide elements to the stream by calling one of the `write` methods, then terminate the stream normally - /// by calling the `finish()` method. You can also use the source's `finish(throwing:)` method to terminate the stream by - /// throwing an error. - @usableFromInline - internal struct Source: Sendable { - /// A strategy that handles the backpressure of the asynchronous stream. - @usableFromInline - internal struct BackPressureStrategy: Sendable { - /// When the high watermark is reached producers will be suspended. All producers will be resumed again once - /// the low watermark is reached. The current watermark is the number of elements in the buffer. - @inlinable - internal static func watermark(low: Int, high: Int) -> BackPressureStrategy { - BackPressureStrategy( - internalBackPressureStrategy: .watermark(.init(low: low, high: high)) - ) - } - - /// When the high watermark is reached producers will be suspended. All producers will be resumed again once - /// the low watermark is reached. The current watermark is computed using the given closure. - static func customWatermark( - low: Int, - high: Int, - waterLevelForElement: @escaping @Sendable (Element) -> Int - ) -> BackPressureStrategy where Element: RandomAccessCollection { - BackPressureStrategy( - internalBackPressureStrategy: .watermark(.init(low: low, high: high, waterLevelForElement: waterLevelForElement)) - ) - } - - @usableFromInline - init(internalBackPressureStrategy: _InternalBackPressureStrategy) { - self._internalBackPressureStrategy = internalBackPressureStrategy - } - - @usableFromInline - let _internalBackPressureStrategy: _InternalBackPressureStrategy - } - - /// A type that indicates the result of writing elements to the source. - @frozen - @usableFromInline - internal enum WriteResult: Sendable { - /// A token that is returned when the asynchronous stream's backpressure strategy indicated that production should - /// be suspended. Use this token to enqueue a callback by calling the ``enqueueCallback(_:)`` method. - @usableFromInline - internal struct CallbackToken: Sendable { - @usableFromInline - let id: UInt - @usableFromInline - init(id: UInt) { - self.id = id - } - } - - /// Indicates that more elements should be produced and written to the source. - case produceMore - - /// Indicates that a callback should be enqueued. - /// - /// The associated token should be passed to the ``enqueueCallback(_:)`` method. - case enqueueCallback(CallbackToken) - } - - /// Backing class for the source used to hook a deinit. - @usableFromInline - final class _Backing: Sendable { - @usableFromInline - let storage: _BackPressuredStorage - - @usableFromInline - init(storage: _BackPressuredStorage) { - self.storage = storage - } - - deinit { - self.storage.sourceDeinitialized() - } - } - - /// A callback to invoke when the stream finished. - /// - /// The stream finishes and calls this closure in the following cases: - /// - No iterator was created and the sequence was deinited - /// - An iterator was created and deinited - /// - After ``finish(throwing:)`` was called and all elements have been consumed - /// - The consuming task got cancelled - @inlinable - internal var onTermination: (@Sendable () -> Void)? { - set { - self._backing.storage.onTermination = newValue - } - get { - self._backing.storage.onTermination - } - } - - @usableFromInline - var _backing: _Backing - - @usableFromInline - internal init(storage: _BackPressuredStorage) { - self._backing = .init(storage: storage) - } - - /// Writes new elements to the asynchronous stream. - /// - /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the - /// first element of the provided sequence. If the asynchronous stream already terminated then this method will throw an error - /// indicating the failure. - /// - /// - Parameter sequence: The elements to write to the asynchronous stream. - /// - Returns: The result that indicates if more elements should be produced at this time. - @inlinable - internal func write(contentsOf sequence: S) throws -> WriteResult - where Element == S.Element, S: Sequence { - try self._backing.storage.write(contentsOf: sequence) - } - - /// Write the element to the asynchronous stream. - /// - /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the - /// provided element. If the asynchronous stream already terminated then this method will throw an error - /// indicating the failure. - /// - /// - Parameter element: The element to write to the asynchronous stream. - /// - Returns: The result that indicates if more elements should be produced at this time. - @inlinable - internal func write(_ element: Element) throws -> WriteResult { - try self._backing.storage.write(contentsOf: CollectionOfOne(element)) - } - - /// Enqueues a callback that will be invoked once more elements should be produced. - /// - /// Call this method after ``write(contentsOf:)`` or ``write(:)`` returned ``WriteResult/enqueueCallback(_:)``. - /// - /// - Important: Enqueueing the same token multiple times is not allowed. - /// - /// - Parameters: - /// - callbackToken: The callback token. - /// - onProduceMore: The callback which gets invoked once more elements should be produced. - @inlinable - internal func enqueueCallback( - callbackToken: WriteResult.CallbackToken, - onProduceMore: @escaping @Sendable (Result) -> Void - ) { - self._backing.storage.enqueueProducer( - callbackToken: callbackToken, - onProduceMore: onProduceMore - ) - } - - /// Cancel an enqueued callback. - /// - /// Call this method to cancel a callback enqueued by the ``enqueueCallback(callbackToken:onProduceMore:)`` method. - /// - /// - Note: This methods supports being called before ``enqueueCallback(callbackToken:onProduceMore:)`` is called and - /// will mark the passed `callbackToken` as cancelled. - /// - /// - Parameter callbackToken: The callback token. - @inlinable - internal func cancelCallback(callbackToken: WriteResult.CallbackToken) { - self._backing.storage.cancelProducer(callbackToken: callbackToken) - } - - /// Write new elements to the asynchronous stream and provide a callback which will be invoked once more elements should be produced. - /// - /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the - /// first element of the provided sequence. If the asynchronous stream already terminated then `onProduceMore` will be invoked with - /// a `Result.failure`. - /// - /// - Parameters: - /// - sequence: The elements to write to the asynchronous stream. - /// - onProduceMore: The callback which gets invoked once more elements should be produced. This callback might be - /// invoked during the call to ``write(contentsOf:onProduceMore:)``. - @inlinable - internal func write( - contentsOf sequence: S, - onProduceMore: @escaping @Sendable (Result) -> Void - ) where Element == S.Element, S: Sequence { - do { - let writeResult = try self.write(contentsOf: sequence) - - switch writeResult { - case .produceMore: - onProduceMore(Result.success(())) - - case .enqueueCallback(let callbackToken): - self.enqueueCallback(callbackToken: callbackToken, onProduceMore: onProduceMore) - } - } catch { - onProduceMore(.failure(error)) - } - } - - /// Writes the element to the asynchronous stream. - /// - /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the - /// provided element. If the asynchronous stream already terminated then `onProduceMore` will be invoked with - /// a `Result.failure`. - /// - /// - Parameters: - /// - sequence: The element to write to the asynchronous stream. - /// - onProduceMore: The callback which gets invoked once more elements should be produced. This callback might be - /// invoked during the call to ``write(_:onProduceMore:)``. - @inlinable - internal func write( - _ element: Element, - onProduceMore: @escaping @Sendable (Result) -> Void - ) { - self.write(contentsOf: CollectionOfOne(element), onProduceMore: onProduceMore) - } - - /// Write new elements to the asynchronous stream. - /// - /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the - /// first element of the provided sequence. If the asynchronous stream already terminated then this method will throw an error - /// indicating the failure. - /// - /// This method returns once more elements should be produced. - /// - /// - Parameters: - /// - sequence: The elements to write to the asynchronous stream. - @inlinable - internal func write(contentsOf sequence: S) async throws - where Element == S.Element, S: Sequence { - let writeResult = try { try self.write(contentsOf: sequence) }() - - switch writeResult { - case .produceMore: - return - - case .enqueueCallback(let callbackToken): - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.enqueueCallback( - callbackToken: callbackToken, - onProduceMore: { result in - switch result { - case .success(): - continuation.resume(returning: ()) - case .failure(let error): - continuation.resume(throwing: error) - } - } - ) - } - } onCancel: { - self.cancelCallback(callbackToken: callbackToken) - } - } - } - - /// Write new element to the asynchronous stream. - /// - /// If there is a task consuming the stream and awaiting the next element then the task will get resumed with the - /// provided element. If the asynchronous stream already terminated then this method will throw an error - /// indicating the failure. - /// - /// This method returns once more elements should be produced. - /// - /// - Parameters: - /// - sequence: The element to write to the asynchronous stream. - @inlinable - internal func write(_ element: Element) async throws { - try await self.write(contentsOf: CollectionOfOne(element)) - } - - /// Write the elements of the asynchronous sequence to the asynchronous stream. - /// - /// This method returns once the provided asynchronous sequence or the the asynchronous stream finished. - /// - /// - Important: This method does not finish the source if consuming the upstream sequence terminated. - /// - /// - Parameters: - /// - sequence: The elements to write to the asynchronous stream. - @inlinable - internal func write(contentsOf sequence: S) async throws - where Element == S.Element, S: AsyncSequence { - for try await element in sequence { - try await self.write(contentsOf: CollectionOfOne(element)) - } - } - - /// Indicates that the production terminated. - /// - /// After all buffered elements are consumed the next iteration point will return `nil` or throw an error. - /// - /// Calling this function more than once has no effect. After calling finish, the stream enters a terminal state and doesn't accept - /// new elements. - /// - /// - Parameters: - /// - error: The error to throw, or `nil`, to finish normally. - @inlinable - internal func finish(throwing error: (any Error)?) { - self._backing.storage.finish(error) - } - } - - /// Initializes a new ``BufferedStream`` and an ``BufferedStream/Source``. - /// - /// - Parameters: - /// - elementType: The element type of the stream. - /// - failureType: The failure type of the stream. - /// - backPressureStrategy: The backpressure strategy that the stream should use. - /// - Returns: A tuple containing the stream and its source. The source should be passed to the - /// producer while the stream should be passed to the consumer. - @inlinable - internal static func makeStream( - of elementType: Element.Type = Element.self, - throwing failureType: any Error.Type = (any Error).self, - backPressureStrategy: Source.BackPressureStrategy - ) -> (`Self`, Source) where any Error == any Error { - let storage = _BackPressuredStorage( - backPressureStrategy: backPressureStrategy._internalBackPressureStrategy - ) - let source = Source(storage: storage) - - return (.init(storage: storage), source) - } - - @usableFromInline - init(storage: _BackPressuredStorage) { - self.implementation = .backpressured(.init(storage: storage)) - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension BufferedStream { - @usableFromInline - struct _WatermarkBackPressureStrategy: Sendable { - /// The low watermark where demand should start. - @usableFromInline - let _low: Int - /// The high watermark where demand should be stopped. - @usableFromInline - let _high: Int - /// The current watermark. - @usableFromInline - private(set) var _current: Int - /// Function to compute the contribution to the water level for a given element. - @usableFromInline - let _waterLevelForElement: (@Sendable (Element) -> Int)? - - /// Initializes a new ``_WatermarkBackPressureStrategy``. - /// - /// - Parameters: - /// - low: The low watermark where demand should start. - /// - high: The high watermark where demand should be stopped. - /// - waterLevelForElement: Function to compute the contribution to the water level for a given element. - @usableFromInline - init(low: Int, high: Int, waterLevelForElement: (@Sendable (Element) -> Int)? = nil) { - precondition(low <= high) - self._low = low - self._high = high - self._current = 0 - self._waterLevelForElement = waterLevelForElement - } - - @usableFromInline - mutating func didYield(elements: Deque.SubSequence) -> Bool { - if let waterLevelForElement = self._waterLevelForElement { - self._current += elements.reduce(0) { $0 + waterLevelForElement($1) } - } else { - self._current += elements.count - } - precondition(self._current >= 0, "Watermark below zero") - // We are demanding more until we reach the high watermark - return self._current < self._high - } - - @usableFromInline - mutating func didConsume(elements: Deque.SubSequence) -> Bool { - if let waterLevelForElement = self._waterLevelForElement { - self._current -= elements.reduce(0) { $0 + waterLevelForElement($1) } - } else { - self._current -= elements.count - } - precondition(self._current >= 0, "Watermark below zero") - // We start demanding again once we are below the low watermark - return self._current < self._low - } - - @usableFromInline - mutating func didConsume(element: Element) -> Bool { - if let waterLevelForElement = self._waterLevelForElement { - self._current -= waterLevelForElement(element) - } else { - self._current -= 1 - } - precondition(self._current >= 0, "Watermark below zero") - // We start demanding again once we are below the low watermark - return self._current < self._low - } - } - - @usableFromInline - enum _InternalBackPressureStrategy: Sendable { - case watermark(_WatermarkBackPressureStrategy) - - @inlinable - mutating func didYield(elements: Deque.SubSequence) -> Bool { - switch self { - case .watermark(var strategy): - let result = strategy.didYield(elements: elements) - self = .watermark(strategy) - return result - } - } - - @usableFromInline - mutating func didConsume(elements: Deque.SubSequence) -> Bool { - switch self { - case .watermark(var strategy): - let result = strategy.didConsume(elements: elements) - self = .watermark(strategy) - return result - } - } - - @usableFromInline - mutating func didConsume(element: Element) -> Bool { - switch self { - case .watermark(var strategy): - let result = strategy.didConsume(element: element) - self = .watermark(strategy) - return result - } - } - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension BufferedStream { - // We are unchecked Sendable since we are protecting our state with a lock. - @usableFromInline - final class _BackPressuredStorage: Sendable { - /// The state machine - @usableFromInline - let _stateMachine: _ManagedCriticalState<_StateMachine> - - @usableFromInline - var onTermination: (@Sendable () -> Void)? { - set { - self._stateMachine.withCriticalRegion { - $0._onTermination = newValue - } - } - get { - self._stateMachine.withCriticalRegion { - $0._onTermination - } - } - } - - @usableFromInline - init( - backPressureStrategy: _InternalBackPressureStrategy - ) { - self._stateMachine = .init(.init(backPressureStrategy: backPressureStrategy)) - } - - @inlinable - func sequenceDeinitialized() { - let action = self._stateMachine.withCriticalRegion { - $0.sequenceDeinitialized() - } - - switch action { - case .callOnTermination(let onTermination): - onTermination?() - - case .failProducersAndCallOnTermination(let producerContinuations, let onTermination): - for producerContinuation in producerContinuations { - producerContinuation(.failure(AlreadyFinishedError())) - } - onTermination?() - - case .none: - break - } - } - - @inlinable - func iteratorInitialized() { - self._stateMachine.withCriticalRegion { - $0.iteratorInitialized() - } - } - - @inlinable - func iteratorDeinitialized() { - let action = self._stateMachine.withCriticalRegion { - $0.iteratorDeinitialized() - } - - switch action { - case .callOnTermination(let onTermination): - onTermination?() - - case .failProducersAndCallOnTermination(let producerContinuations, let onTermination): - for producerContinuation in producerContinuations { - producerContinuation(.failure(AlreadyFinishedError())) - } - onTermination?() - - case .none: - break - } - } - - @inlinable - func sourceDeinitialized() { - let action = self._stateMachine.withCriticalRegion { - $0.sourceDeinitialized() - } - - switch action { - case .callOnTermination(let onTermination): - onTermination?() - - case .failProducersAndCallOnTermination( - let consumer, - let producerContinuations, - let onTermination - ): - consumer?.resume(returning: nil) - for producerContinuation in producerContinuations { - producerContinuation(.failure(AlreadyFinishedError())) - } - onTermination?() - - case .failProducers(let producerContinuations): - for producerContinuation in producerContinuations { - producerContinuation(.failure(AlreadyFinishedError())) - } - - case .none: - break - } - } - - @inlinable - func write( - contentsOf sequence: some Sequence - ) throws -> Source.WriteResult { - let action = self._stateMachine.withCriticalRegion { - return $0.write(sequence) - } - - switch action { - case .returnProduceMore: - return .produceMore - - case .returnEnqueue(let callbackToken): - return .enqueueCallback(callbackToken) - - case .resumeConsumerAndReturnProduceMore(let continuation, let element): - continuation.resume(returning: element) - return .produceMore - - case .resumeConsumerAndReturnEnqueue(let continuation, let element, let callbackToken): - continuation.resume(returning: element) - return .enqueueCallback(callbackToken) - - case .throwFinishedError: - throw AlreadyFinishedError() - } - } - - @inlinable - func enqueueProducer( - callbackToken: Source.WriteResult.CallbackToken, - onProduceMore: @escaping @Sendable (Result) -> Void - ) { - let action = self._stateMachine.withCriticalRegion { - $0.enqueueProducer(callbackToken: callbackToken, onProduceMore: onProduceMore) - } - - switch action { - case .resumeProducer(let onProduceMore): - onProduceMore(Result.success(())) - - case .resumeProducerWithError(let onProduceMore, let error): - onProduceMore(Result.failure(error)) - - case .none: - break - } - } - - @inlinable - func cancelProducer(callbackToken: Source.WriteResult.CallbackToken) { - let action = self._stateMachine.withCriticalRegion { - $0.cancelProducer(callbackToken: callbackToken) - } - - switch action { - case .resumeProducerWithCancellationError(let onProduceMore): - onProduceMore(Result.failure(CancellationError())) - - case .none: - break - } - } - - @inlinable - func finish(_ failure: (any Error)?) { - let action = self._stateMachine.withCriticalRegion { - $0.finish(failure) - } - - switch action { - case .callOnTermination(let onTermination): - onTermination?() - - case .resumeConsumerAndCallOnTermination( - let consumerContinuation, - let failure, - let onTermination - ): - switch failure { - case .some(let error): - consumerContinuation.resume(throwing: error) - case .none: - consumerContinuation.resume(returning: nil) - } - - onTermination?() - - case .resumeProducers(let producerContinuations): - for producerContinuation in producerContinuations { - producerContinuation(.failure(AlreadyFinishedError())) - } - - case .none: - break - } - } - - @inlinable - func next() async throws -> Element? { - let action = self._stateMachine.withCriticalRegion { - $0.next() - } - - switch action { - case .returnElement(let element): - return element - - case .returnElementAndResumeProducers(let element, let producerContinuations): - for producerContinuation in producerContinuations { - producerContinuation(Result.success(())) - } - - return element - - case .returnErrorAndCallOnTermination(let failure, let onTermination): - onTermination?() - switch failure { - case .some(let error): - throw error - - case .none: - return nil - } - - case .returnNil: - return nil - - case .suspendTask: - return try await self.suspendNext() - } - } - - @inlinable - func suspendNext() async throws -> Element? { - return try await withTaskCancellationHandler { - return try await withCheckedThrowingContinuation { continuation in - let action = self._stateMachine.withCriticalRegion { - $0.suspendNext(continuation: continuation) - } - - switch action { - case .resumeConsumerWithElement(let continuation, let element): - continuation.resume(returning: element) - - case .resumeConsumerWithElementAndProducers( - let continuation, - let element, - let producerContinuations - ): - continuation.resume(returning: element) - for producerContinuation in producerContinuations { - producerContinuation(Result.success(())) - } - - case .resumeConsumerWithErrorAndCallOnTermination( - let continuation, - let failure, - let onTermination - ): - switch failure { - case .some(let error): - continuation.resume(throwing: error) - - case .none: - continuation.resume(returning: nil) - } - onTermination?() - - case .resumeConsumerWithNil(let continuation): - continuation.resume(returning: nil) - - case .none: - break - } - } - } onCancel: { - let action = self._stateMachine.withCriticalRegion { - $0.cancelNext() - } - - switch action { - case .resumeConsumerWithCancellationErrorAndCallOnTermination( - let continuation, - let onTermination - ): - continuation.resume(throwing: CancellationError()) - onTermination?() - - case .failProducersAndCallOnTermination( - let producerContinuations, - let onTermination - ): - for producerContinuation in producerContinuations { - producerContinuation(.failure(AlreadyFinishedError())) - } - onTermination?() - - case .none: - break - } - } - } - } -} - -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension BufferedStream { - /// The state machine of the backpressured async stream. - @usableFromInline - struct _StateMachine { - @usableFromInline - enum _State { - @usableFromInline - struct Initial { - /// The backpressure strategy. - @usableFromInline - var backPressureStrategy: _InternalBackPressureStrategy - /// Indicates if the iterator was initialized. - @usableFromInline - var iteratorInitialized: Bool - /// The onTermination callback. - @usableFromInline - var onTermination: (@Sendable () -> Void)? - - @usableFromInline - init( - backPressureStrategy: _InternalBackPressureStrategy, - iteratorInitialized: Bool, - onTermination: (@Sendable () -> Void)? = nil - ) { - self.backPressureStrategy = backPressureStrategy - self.iteratorInitialized = iteratorInitialized - self.onTermination = onTermination - } - } - - @usableFromInline - struct Streaming { - /// The backpressure strategy. - @usableFromInline - var backPressureStrategy: _InternalBackPressureStrategy - /// Indicates if the iterator was initialized. - @usableFromInline - var iteratorInitialized: Bool - /// The onTermination callback. - @usableFromInline - var onTermination: (@Sendable () -> Void)? - /// The buffer of elements. - @usableFromInline - var buffer: Deque - /// The optional consumer continuation. - @usableFromInline - var consumerContinuation: CheckedContinuation? - /// The producer continuations. - @usableFromInline - var producerContinuations: Deque<(UInt, (Result) -> Void)> - /// The producers that have been cancelled. - @usableFromInline - var cancelledAsyncProducers: Deque - /// Indicates if we currently have outstanding demand. - @usableFromInline - var hasOutstandingDemand: Bool - - @usableFromInline - init( - backPressureStrategy: _InternalBackPressureStrategy, - iteratorInitialized: Bool, - onTermination: (@Sendable () -> Void)? = nil, - buffer: Deque, - consumerContinuation: CheckedContinuation? = nil, - producerContinuations: Deque<(UInt, (Result) -> Void)>, - cancelledAsyncProducers: Deque, - hasOutstandingDemand: Bool - ) { - self.backPressureStrategy = backPressureStrategy - self.iteratorInitialized = iteratorInitialized - self.onTermination = onTermination - self.buffer = buffer - self.consumerContinuation = consumerContinuation - self.producerContinuations = producerContinuations - self.cancelledAsyncProducers = cancelledAsyncProducers - self.hasOutstandingDemand = hasOutstandingDemand - } - } - - @usableFromInline - struct SourceFinished { - /// Indicates if the iterator was initialized. - @usableFromInline - var iteratorInitialized: Bool - /// The buffer of elements. - @usableFromInline - var buffer: Deque - /// The failure that should be thrown after the last element has been consumed. - @usableFromInline - var failure: (any Error)? - /// The onTermination callback. - @usableFromInline - var onTermination: (@Sendable () -> Void)? - - @usableFromInline - init( - iteratorInitialized: Bool, - buffer: Deque, - failure: (any Error)? = nil, - onTermination: (@Sendable () -> Void)? - ) { - self.iteratorInitialized = iteratorInitialized - self.buffer = buffer - self.failure = failure - self.onTermination = onTermination - } - } - - case initial(Initial) - /// The state once either any element was yielded or `next()` was called. - case streaming(Streaming) - /// The state once the underlying source signalled that it is finished. - case sourceFinished(SourceFinished) - - /// The state once there can be no outstanding demand. This can happen if: - /// 1. The iterator was deinited - /// 2. The underlying source finished and all buffered elements have been consumed - case finished(iteratorInitialized: Bool) - - /// An intermediate state to avoid CoWs. - case modify - } - - /// The state machine's current state. - @usableFromInline - var _state: _State - - // The ID used for the next CallbackToken. - @usableFromInline - var nextCallbackTokenID: UInt = 0 - - @inlinable - var _onTermination: (@Sendable () -> Void)? { - set { - switch self._state { - case .initial(var initial): - initial.onTermination = newValue - self._state = .initial(initial) - - case .streaming(var streaming): - streaming.onTermination = newValue - self._state = .streaming(streaming) - - case .sourceFinished(var sourceFinished): - sourceFinished.onTermination = newValue - self._state = .sourceFinished(sourceFinished) - - case .finished: - break - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - get { - switch self._state { - case .initial(let initial): - return initial.onTermination - - case .streaming(let streaming): - return streaming.onTermination - - case .sourceFinished(let sourceFinished): - return sourceFinished.onTermination - - case .finished: - return nil - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - } - - /// Initializes a new `StateMachine`. - /// - /// We are passing and holding the back-pressure strategy here because - /// it is a customizable extension of the state machine. - /// - /// - Parameter backPressureStrategy: The back-pressure strategy. - @usableFromInline - init( - backPressureStrategy: _InternalBackPressureStrategy - ) { - self._state = .initial( - .init( - backPressureStrategy: backPressureStrategy, - iteratorInitialized: false - ) - ) - } - - /// Generates the next callback token. - @inlinable - mutating func nextCallbackToken() -> Source.WriteResult.CallbackToken { - let id = self.nextCallbackTokenID - self.nextCallbackTokenID += 1 - return .init(id: id) - } - - /// Actions returned by `sequenceDeinitialized()`. - @usableFromInline - enum SequenceDeinitializedAction { - /// Indicates that `onTermination` should be called. - case callOnTermination((@Sendable () -> Void)?) - /// Indicates that all producers should be failed and `onTermination` should be called. - case failProducersAndCallOnTermination( - [(Result) -> Void], - (@Sendable () -> Void)? - ) - } - - @inlinable - mutating func sequenceDeinitialized() -> SequenceDeinitializedAction? { - switch self._state { - case .initial(let initial): - if initial.iteratorInitialized { - // An iterator was created and we deinited the sequence. - // This is an expected pattern and we just continue on normal. - return .none - } else { - // No iterator was created so we can transition to finished right away. - self._state = .finished(iteratorInitialized: false) - - return .callOnTermination(initial.onTermination) - } - - case .streaming(let streaming): - if streaming.iteratorInitialized { - // An iterator was created and we deinited the sequence. - // This is an expected pattern and we just continue on normal. - return .none - } else { - // No iterator was created so we can transition to finished right away. - self._state = .finished(iteratorInitialized: false) - - return .failProducersAndCallOnTermination( - Array(streaming.producerContinuations.map { $0.1 }), - streaming.onTermination - ) - } - - case .sourceFinished(let sourceFinished): - if sourceFinished.iteratorInitialized { - // An iterator was created and we deinited the sequence. - // This is an expected pattern and we just continue on normal. - return .none - } else { - // No iterator was created so we can transition to finished right away. - self._state = .finished(iteratorInitialized: false) - - return .callOnTermination(sourceFinished.onTermination) - } - - case .finished: - // We are already finished so there is nothing left to clean up. - // This is just the references dropping afterwards. - return .none - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - @inlinable - mutating func iteratorInitialized() { - switch self._state { - case .initial(var initial): - if initial.iteratorInitialized { - // Our sequence is a unicast sequence and does not support multiple AsyncIterator's - fatalError("Only a single AsyncIterator can be created") - } else { - // The first and only iterator was initialized. - initial.iteratorInitialized = true - self._state = .initial(initial) - } - - case .streaming(var streaming): - if streaming.iteratorInitialized { - // Our sequence is a unicast sequence and does not support multiple AsyncIterator's - fatalError("Only a single AsyncIterator can be created") - } else { - // The first and only iterator was initialized. - streaming.iteratorInitialized = true - self._state = .streaming(streaming) - } - - case .sourceFinished(var sourceFinished): - if sourceFinished.iteratorInitialized { - // Our sequence is a unicast sequence and does not support multiple AsyncIterator's - fatalError("Only a single AsyncIterator can be created") - } else { - // The first and only iterator was initialized. - sourceFinished.iteratorInitialized = true - self._state = .sourceFinished(sourceFinished) - } - - case .finished(iteratorInitialized: true): - // Our sequence is a unicast sequence and does not support multiple AsyncIterator's - fatalError("Only a single AsyncIterator can be created") - - case .finished(iteratorInitialized: false): - // It is strange that an iterator is created after we are finished - // but it can definitely happen, e.g. - // Sequence.init -> source.finish -> sequence.makeAsyncIterator - self._state = .finished(iteratorInitialized: true) - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `iteratorDeinitialized()`. - @usableFromInline - enum IteratorDeinitializedAction { - /// Indicates that `onTermination` should be called. - case callOnTermination((@Sendable () -> Void)?) - /// Indicates that all producers should be failed and `onTermination` should be called. - case failProducersAndCallOnTermination( - [(Result) -> Void], - (@Sendable () -> Void)? - ) - } - - @inlinable - mutating func iteratorDeinitialized() -> IteratorDeinitializedAction? { - switch self._state { - case .initial(let initial): - if initial.iteratorInitialized { - // An iterator was created and deinited. Since we only support - // a single iterator we can now transition to finish. - self._state = .finished(iteratorInitialized: true) - return .callOnTermination(initial.onTermination) - } else { - // An iterator needs to be initialized before it can be deinitialized. - fatalError("AsyncStream internal inconsistency") - } - - case .streaming(let streaming): - if streaming.iteratorInitialized { - // An iterator was created and deinited. Since we only support - // a single iterator we can now transition to finish. - self._state = .finished(iteratorInitialized: true) - - return .failProducersAndCallOnTermination( - Array(streaming.producerContinuations.map { $0.1 }), - streaming.onTermination - ) - } else { - // An iterator needs to be initialized before it can be deinitialized. - fatalError("AsyncStream internal inconsistency") - } - - case .sourceFinished(let sourceFinished): - if sourceFinished.iteratorInitialized { - // An iterator was created and deinited. Since we only support - // a single iterator we can now transition to finish. - self._state = .finished(iteratorInitialized: true) - return .callOnTermination(sourceFinished.onTermination) - } else { - // An iterator needs to be initialized before it can be deinitialized. - fatalError("AsyncStream internal inconsistency") - } - - case .finished: - // We are already finished so there is nothing left to clean up. - // This is just the references dropping afterwards. - return .none - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `sourceDeinitialized()`. - @usableFromInline - enum SourceDeinitializedAction { - /// Indicates that `onTermination` should be called. - case callOnTermination((() -> Void)?) - /// Indicates that all producers should be failed and `onTermination` should be called. - case failProducersAndCallOnTermination( - CheckedContinuation?, - [(Result) -> Void], - (@Sendable () -> Void)? - ) - /// Indicates that all producers should be failed. - case failProducers([(Result) -> Void]) - } - - @inlinable - mutating func sourceDeinitialized() -> SourceDeinitializedAction? { - switch self._state { - case .initial(let initial): - // The source got deinited before anything was written - self._state = .finished(iteratorInitialized: initial.iteratorInitialized) - return .callOnTermination(initial.onTermination) - - case .streaming(let streaming): - if streaming.buffer.isEmpty { - // We can transition to finished right away since the buffer is empty now - self._state = .finished(iteratorInitialized: streaming.iteratorInitialized) - - return .failProducersAndCallOnTermination( - streaming.consumerContinuation, - Array(streaming.producerContinuations.map { $0.1 }), - streaming.onTermination - ) - } else { - // The continuation must be `nil` if the buffer has elements - precondition(streaming.consumerContinuation == nil) - - self._state = .sourceFinished( - .init( - iteratorInitialized: streaming.iteratorInitialized, - buffer: streaming.buffer, - failure: nil, - onTermination: streaming.onTermination - ) - ) - - return .failProducers( - Array(streaming.producerContinuations.map { $0.1 }) - ) - } - - case .sourceFinished, .finished: - // This is normal and we just have to tolerate it - return .none - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `write()`. - @usableFromInline - enum WriteAction { - /// Indicates that the producer should be notified to produce more. - case returnProduceMore - /// Indicates that the producer should be suspended to stop producing. - case returnEnqueue( - callbackToken: Source.WriteResult.CallbackToken - ) - /// Indicates that the consumer should be resumed and the producer should be notified to produce more. - case resumeConsumerAndReturnProduceMore( - continuation: CheckedContinuation, - element: Element - ) - /// Indicates that the consumer should be resumed and the producer should be suspended. - case resumeConsumerAndReturnEnqueue( - continuation: CheckedContinuation, - element: Element, - callbackToken: Source.WriteResult.CallbackToken - ) - /// Indicates that the producer has been finished. - case throwFinishedError - - @inlinable - init( - callbackToken: Source.WriteResult.CallbackToken?, - continuationAndElement: (CheckedContinuation, Element)? = nil - ) { - switch (callbackToken, continuationAndElement) { - case (.none, .none): - self = .returnProduceMore - - case (.some(let callbackToken), .none): - self = .returnEnqueue(callbackToken: callbackToken) - - case (.none, .some((let continuation, let element))): - self = .resumeConsumerAndReturnProduceMore( - continuation: continuation, - element: element - ) - - case (.some(let callbackToken), .some((let continuation, let element))): - self = .resumeConsumerAndReturnEnqueue( - continuation: continuation, - element: element, - callbackToken: callbackToken - ) - } - } - } - - @inlinable - mutating func write(_ sequence: some Sequence) -> WriteAction { - switch self._state { - case .initial(var initial): - var buffer = Deque() - buffer.append(contentsOf: sequence) - - let shouldProduceMore = initial.backPressureStrategy.didYield(elements: buffer[...]) - let callbackToken = shouldProduceMore ? nil : self.nextCallbackToken() - - self._state = .streaming( - .init( - backPressureStrategy: initial.backPressureStrategy, - iteratorInitialized: initial.iteratorInitialized, - onTermination: initial.onTermination, - buffer: buffer, - consumerContinuation: nil, - producerContinuations: .init(), - cancelledAsyncProducers: .init(), - hasOutstandingDemand: shouldProduceMore - ) - ) - - return .init(callbackToken: callbackToken) - - case .streaming(var streaming): - self._state = .modify - - let bufferEndIndexBeforeAppend = streaming.buffer.endIndex - streaming.buffer.append(contentsOf: sequence) - - // We have an element and can resume the continuation - streaming.hasOutstandingDemand = streaming.backPressureStrategy.didYield( - elements: streaming.buffer[bufferEndIndexBeforeAppend...] - ) - - if let consumerContinuation = streaming.consumerContinuation { - guard let element = streaming.buffer.popFirst() else { - // We got a yield of an empty sequence. We just tolerate this. - self._state = .streaming(streaming) - - return .init(callbackToken: streaming.hasOutstandingDemand ? nil : self.nextCallbackToken()) - } - streaming.hasOutstandingDemand = streaming.backPressureStrategy.didConsume(element: element) - - // We got a consumer continuation and an element. We can resume the consumer now - streaming.consumerContinuation = nil - self._state = .streaming(streaming) - return .init( - callbackToken: streaming.hasOutstandingDemand ? nil : self.nextCallbackToken(), - continuationAndElement: (consumerContinuation, element) - ) - } else { - // We don't have a suspended consumer so we just buffer the elements - self._state = .streaming(streaming) - return .init( - callbackToken: streaming.hasOutstandingDemand ? nil : self.nextCallbackToken() - ) - } - - case .sourceFinished, .finished: - // If the source has finished we are dropping the elements. - return .throwFinishedError - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `enqueueProducer()`. - @usableFromInline - enum EnqueueProducerAction { - /// Indicates that the producer should be notified to produce more. - case resumeProducer((Result) -> Void) - /// Indicates that the producer should be notified about an error. - case resumeProducerWithError((Result) -> Void, any Error) - } - - @inlinable - mutating func enqueueProducer( - callbackToken: Source.WriteResult.CallbackToken, - onProduceMore: @Sendable @escaping (Result) -> Void - ) -> EnqueueProducerAction? { - switch self._state { - case .initial: - // We need to transition to streaming before we can suspend - // This is enforced because the CallbackToken has no internal init so - // one must create it by calling `write` first. - fatalError("AsyncStream internal inconsistency") - - case .streaming(var streaming): - if let index = streaming.cancelledAsyncProducers.firstIndex(of: callbackToken.id) { - // Our producer got marked as cancelled. - self._state = .modify - streaming.cancelledAsyncProducers.remove(at: index) - self._state = .streaming(streaming) - - return .resumeProducerWithError(onProduceMore, CancellationError()) - } else if streaming.hasOutstandingDemand { - // We hit an edge case here where we wrote but the consuming thread got interleaved - return .resumeProducer(onProduceMore) - } else { - self._state = .modify - streaming.producerContinuations.append((callbackToken.id, onProduceMore)) - - self._state = .streaming(streaming) - return .none - } - - case .sourceFinished, .finished: - // Since we are unlocking between yielding and suspending the yield - // It can happen that the source got finished or the consumption fully finishes. - return .resumeProducerWithError(onProduceMore, AlreadyFinishedError()) - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `cancelProducer()`. - @usableFromInline - enum CancelProducerAction { - /// Indicates that the producer should be notified about cancellation. - case resumeProducerWithCancellationError((Result) -> Void) - } - - @inlinable - mutating func cancelProducer( - callbackToken: Source.WriteResult.CallbackToken - ) -> CancelProducerAction? { - switch self._state { - case .initial: - // We need to transition to streaming before we can suspend - fatalError("AsyncStream internal inconsistency") - - case .streaming(var streaming): - if let index = streaming.producerContinuations.firstIndex(where: { - $0.0 == callbackToken.id - }) { - // We have an enqueued producer that we need to resume now - self._state = .modify - let continuation = streaming.producerContinuations.remove(at: index).1 - self._state = .streaming(streaming) - - return .resumeProducerWithCancellationError(continuation) - } else { - // The task that yields was cancelled before yielding so the cancellation handler - // got invoked right away - self._state = .modify - streaming.cancelledAsyncProducers.append(callbackToken.id) - self._state = .streaming(streaming) - - return .none - } - - case .sourceFinished, .finished: - // Since we are unlocking between yielding and suspending the yield - // It can happen that the source got finished or the consumption fully finishes. - return .none - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `finish()`. - @usableFromInline - enum FinishAction { - /// Indicates that `onTermination` should be called. - case callOnTermination((() -> Void)?) - /// Indicates that the consumer should be resumed with the failure, the producers - /// should be resumed with an error and `onTermination` should be called. - case resumeConsumerAndCallOnTermination( - consumerContinuation: CheckedContinuation, - failure: (any Error)?, - onTermination: (() -> Void)? - ) - /// Indicates that the producers should be resumed with an error. - case resumeProducers( - producerContinuations: [(Result) -> Void] - ) - } - - @inlinable - mutating func finish(_ failure: (any Error)?) -> FinishAction? { - switch self._state { - case .initial(let initial): - // Nothing was yielded nor did anybody call next - // This means we can transition to sourceFinished and store the failure - self._state = .sourceFinished( - .init( - iteratorInitialized: initial.iteratorInitialized, - buffer: .init(), - failure: failure, - onTermination: initial.onTermination - ) - ) - - return .callOnTermination(initial.onTermination) - - case .streaming(let streaming): - if let consumerContinuation = streaming.consumerContinuation { - // We have a continuation, this means our buffer must be empty - // Furthermore, we can now transition to finished - // and resume the continuation with the failure - precondition(streaming.buffer.isEmpty, "Expected an empty buffer") - precondition( - streaming.producerContinuations.isEmpty, - "Expected no suspended producers" - ) - - self._state = .finished(iteratorInitialized: streaming.iteratorInitialized) - - return .resumeConsumerAndCallOnTermination( - consumerContinuation: consumerContinuation, - failure: failure, - onTermination: streaming.onTermination - ) - } else { - self._state = .sourceFinished( - .init( - iteratorInitialized: streaming.iteratorInitialized, - buffer: streaming.buffer, - failure: failure, - onTermination: streaming.onTermination - ) - ) - - return .resumeProducers( - producerContinuations: Array(streaming.producerContinuations.map { $0.1 }) - ) - } - - case .sourceFinished, .finished: - // If the source has finished, finishing again has no effect. - return .none - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `next()`. - @usableFromInline - enum NextAction { - /// Indicates that the element should be returned to the caller. - case returnElement(Element) - /// Indicates that the element should be returned to the caller and that all producers should be called. - case returnElementAndResumeProducers(Element, [(Result) -> Void]) - /// Indicates that the `Error` should be returned to the caller and that `onTermination` should be called. - case returnErrorAndCallOnTermination((any Error)?, (() -> Void)?) - /// Indicates that the `nil` should be returned to the caller. - case returnNil - /// Indicates that the `Task` of the caller should be suspended. - case suspendTask - } - - @inlinable - mutating func next() -> NextAction { - switch self._state { - case .initial(let initial): - // We are not interacting with the back-pressure strategy here because - // we are doing this inside `next(:)` - self._state = .streaming( - .init( - backPressureStrategy: initial.backPressureStrategy, - iteratorInitialized: initial.iteratorInitialized, - onTermination: initial.onTermination, - buffer: Deque(), - consumerContinuation: nil, - producerContinuations: .init(), - cancelledAsyncProducers: .init(), - hasOutstandingDemand: false - ) - ) - - return .suspendTask - case .streaming(var streaming): - guard streaming.consumerContinuation == nil else { - // We have multiple AsyncIterators iterating the sequence - fatalError("AsyncStream internal inconsistency") - } - - self._state = .modify - - if let element = streaming.buffer.popFirst() { - // We have an element to fulfil the demand right away. - streaming.hasOutstandingDemand = streaming.backPressureStrategy.didConsume(element: element) - - if streaming.hasOutstandingDemand { - // There is demand and we have to resume our producers - let producers = Array(streaming.producerContinuations.map { $0.1 }) - streaming.producerContinuations.removeAll() - self._state = .streaming(streaming) - return .returnElementAndResumeProducers(element, producers) - } else { - // We don't have any new demand, so we can just return the element. - self._state = .streaming(streaming) - return .returnElement(element) - } - } else { - // There is nothing in the buffer to fulfil the demand so we need to suspend. - // We are not interacting with the back-pressure strategy here because - // we are doing this inside `suspendNext` - self._state = .streaming(streaming) - - return .suspendTask - } - - case .sourceFinished(var sourceFinished): - // Check if we have an element left in the buffer and return it - self._state = .modify - - if let element = sourceFinished.buffer.popFirst() { - self._state = .sourceFinished(sourceFinished) - - return .returnElement(element) - } else { - // We are returning the queued failure now and can transition to finished - self._state = .finished(iteratorInitialized: sourceFinished.iteratorInitialized) - - return .returnErrorAndCallOnTermination( - sourceFinished.failure, - sourceFinished.onTermination - ) - } - - case .finished: - return .returnNil - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `suspendNext()`. - @usableFromInline - enum SuspendNextAction { - /// Indicates that the consumer should be resumed. - case resumeConsumerWithElement(CheckedContinuation, Element) - /// Indicates that the consumer and all producers should be resumed. - case resumeConsumerWithElementAndProducers( - CheckedContinuation, - Element, - [(Result) -> Void] - ) - /// Indicates that the consumer should be resumed with the failure and that `onTermination` should be called. - case resumeConsumerWithErrorAndCallOnTermination( - CheckedContinuation, - (any Error)?, - (() -> Void)? - ) - /// Indicates that the consumer should be resumed with `nil`. - case resumeConsumerWithNil(CheckedContinuation) - } - - @inlinable - mutating func suspendNext( - continuation: CheckedContinuation - ) -> SuspendNextAction? { - switch self._state { - case .initial: - // We need to transition to streaming before we can suspend - preconditionFailure("AsyncStream internal inconsistency") - - case .streaming(var streaming): - guard streaming.consumerContinuation == nil else { - // We have multiple AsyncIterators iterating the sequence - fatalError( - "This should never happen since we only allow a single Iterator to be created" - ) - } - - self._state = .modify - - // We have to check here again since we might have a producer interleave next and suspendNext - if let element = streaming.buffer.popFirst() { - // We have an element to fulfil the demand right away. - - streaming.hasOutstandingDemand = streaming.backPressureStrategy.didConsume(element: element) - - if streaming.hasOutstandingDemand { - // There is demand and we have to resume our producers - let producers = Array(streaming.producerContinuations.map { $0.1 }) - streaming.producerContinuations.removeAll() - self._state = .streaming(streaming) - return .resumeConsumerWithElementAndProducers( - continuation, - element, - producers - ) - } else { - // We don't have any new demand, so we can just return the element. - self._state = .streaming(streaming) - return .resumeConsumerWithElement(continuation, element) - } - } else { - // There is nothing in the buffer to fulfil the demand so we to store the continuation. - streaming.consumerContinuation = continuation - self._state = .streaming(streaming) - - return .none - } - - case .sourceFinished(var sourceFinished): - // Check if we have an element left in the buffer and return it - self._state = .modify - - if let element = sourceFinished.buffer.popFirst() { - self._state = .sourceFinished(sourceFinished) - - return .resumeConsumerWithElement(continuation, element) - } else { - // We are returning the queued failure now and can transition to finished - self._state = .finished(iteratorInitialized: sourceFinished.iteratorInitialized) - - return .resumeConsumerWithErrorAndCallOnTermination( - continuation, - sourceFinished.failure, - sourceFinished.onTermination - ) - } - - case .finished: - return .resumeConsumerWithNil(continuation) - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - - /// Actions returned by `cancelNext()`. - @usableFromInline - enum CancelNextAction { - /// Indicates that the continuation should be resumed with a cancellation error, the producers should be finished and call onTermination. - case resumeConsumerWithCancellationErrorAndCallOnTermination( - CheckedContinuation, - (() -> Void)? - ) - /// Indicates that the producers should be finished and call onTermination. - case failProducersAndCallOnTermination([(Result) -> Void], (() -> Void)?) - } - - @inlinable - mutating func cancelNext() -> CancelNextAction? { - switch self._state { - case .initial: - // We need to transition to streaming before we can suspend - fatalError("AsyncStream internal inconsistency") - - case .streaming(let streaming): - self._state = .finished(iteratorInitialized: streaming.iteratorInitialized) - - if let consumerContinuation = streaming.consumerContinuation { - precondition( - streaming.producerContinuations.isEmpty, - "Internal inconsistency. Unexpected producer continuations." - ) - return .resumeConsumerWithCancellationErrorAndCallOnTermination( - consumerContinuation, - streaming.onTermination - ) - } else { - return .failProducersAndCallOnTermination( - Array(streaming.producerContinuations.map { $0.1 }), - streaming.onTermination - ) - } - - case .sourceFinished, .finished: - return .none - - case .modify: - fatalError("AsyncStream internal inconsistency") - } - } - } -} diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift deleted file mode 100644 index 444d7c31..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/BufferedStream/Lock.swift +++ /dev/null @@ -1,278 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -// swift-format-ignore-file -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftNIO open source project -// -// Copyright (c) 2017-2022 Apple Inc. and the SwiftNIO project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftNIO project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#elseif os(Windows) -import WinSDK -#endif - -#if os(Windows) -@usableFromInline -typealias LockPrimitive = SRWLOCK -#else -@usableFromInline -typealias LockPrimitive = pthread_mutex_t -#endif - -@usableFromInline -enum LockOperations {} - -extension LockOperations { - @inlinable - static func create(_ mutex: UnsafeMutablePointer) { - mutex.assertValidAlignment() - - #if os(Windows) - InitializeSRWLock(mutex) - #else - var attr = pthread_mutexattr_t() - pthread_mutexattr_init(&attr) - - let err = pthread_mutex_init(mutex, &attr) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } - - @inlinable - static func destroy(_ mutex: UnsafeMutablePointer) { - mutex.assertValidAlignment() - - #if os(Windows) - // SRWLOCK does not need to be freed - #else - let err = pthread_mutex_destroy(mutex) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } - - @inlinable - static func lock(_ mutex: UnsafeMutablePointer) { - mutex.assertValidAlignment() - - #if os(Windows) - AcquireSRWLockExclusive(mutex) - #else - let err = pthread_mutex_lock(mutex) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } - - @inlinable - static func unlock(_ mutex: UnsafeMutablePointer) { - mutex.assertValidAlignment() - - #if os(Windows) - ReleaseSRWLockExclusive(mutex) - #else - let err = pthread_mutex_unlock(mutex) - precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") - #endif - } -} - -// Tail allocate both the mutex and a generic value using ManagedBuffer. -// Both the header pointer and the elements pointer are stable for -// the class's entire lifetime. -// -// However, for safety reasons, we elect to place the lock in the "elements" -// section of the buffer instead of the head. The reasoning here is subtle, -// so buckle in. -// -// _As a practical matter_, the implementation of ManagedBuffer ensures that -// the pointer to the header is stable across the lifetime of the class, and so -// each time you call `withUnsafeMutablePointers` or `withUnsafeMutablePointerToHeader` -// the value of the header pointer will be the same. This is because ManagedBuffer uses -// `Builtin.addressOf` to load the value of the header, and that does ~magic~ to ensure -// that it does not invoke any weird Swift accessors that might copy the value. -// -// _However_, the header is also available via the `.header` field on the ManagedBuffer. -// This presents a problem! The reason there's an issue is that `Builtin.addressOf` and friends -// do not interact with Swift's exclusivity model. That is, the various `with` functions do not -// conceptually trigger a mutating access to `.header`. For elements this isn't a concern because -// there's literally no other way to perform the access, but for `.header` it's entirely possible -// to accidentally recursively read it. -// -// Our implementation is free from these issues, so we don't _really_ need to worry about it. -// However, out of an abundance of caution, we store the Value in the header, and the LockPrimitive -// in the trailing elements. We still don't use `.header`, but it's better to be safe than sorry, -// and future maintainers will be happier that we were cautious. -// -// See also: https://github.com/apple/swift/pull/40000 -@usableFromInline -final class LockStorage: ManagedBuffer { - - @inlinable - static func create(value: Value) -> Self { - let buffer = Self.create(minimumCapacity: 1) { _ in - return value - } - // Avoid 'unsafeDowncast' as there is a miscompilation on 5.10. - let storage = buffer as! Self - - storage.withUnsafeMutablePointers { _, lockPtr in - LockOperations.create(lockPtr) - } - - return storage - } - - @inlinable - func lock() { - self.withUnsafeMutablePointerToElements { lockPtr in - LockOperations.lock(lockPtr) - } - } - - @inlinable - func unlock() { - self.withUnsafeMutablePointerToElements { lockPtr in - LockOperations.unlock(lockPtr) - } - } - - @usableFromInline - deinit { - self.withUnsafeMutablePointerToElements { lockPtr in - LockOperations.destroy(lockPtr) - } - } - - @inlinable - func withLockPrimitive( - _ body: (UnsafeMutablePointer) throws -> T - ) rethrows -> T { - try self.withUnsafeMutablePointerToElements { lockPtr in - return try body(lockPtr) - } - } - - @inlinable - func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { - try self.withUnsafeMutablePointers { valuePtr, lockPtr in - LockOperations.lock(lockPtr) - defer { LockOperations.unlock(lockPtr) } - return try mutate(&valuePtr.pointee) - } - } -} - -extension LockStorage: @unchecked Sendable {} - -/// A threading lock based on `libpthread` instead of `libdispatch`. -/// -/// - note: ``Lock`` has reference semantics. -/// -/// This object provides a lock on top of a single `pthread_mutex_t`. This kind -/// of lock is safe to use with `libpthread`-based threading models, such as the -/// one used by NIO. On Windows, the lock is based on the substantially similar -/// `SRWLOCK` type. -@usableFromInline -struct Lock { - @usableFromInline - internal let _storage: LockStorage - - /// Create a new lock. - @usableFromInline - init() { - self._storage = .create(value: ()) - } - - /// Acquire the lock. - /// - /// Whenever possible, consider using `withLock` instead of this method and - /// `unlock`, to simplify lock handling. - @inlinable - func lock() { - self._storage.lock() - } - - /// Release the lock. - /// - /// Whenever possible, consider using `withLock` instead of this method and - /// `lock`, to simplify lock handling. - @inlinable - func unlock() { - self._storage.unlock() - } - - @inlinable - internal func withLockPrimitive( - _ body: (UnsafeMutablePointer) throws -> T - ) rethrows -> T { - return try self._storage.withLockPrimitive(body) - } -} - -extension Lock { - /// Acquire the lock for the duration of the given block. - /// - /// This convenience method should be preferred to `lock` and `unlock` in - /// most situations, as it ensures that the lock will be released regardless - /// of how `body` exits. - /// - /// - Parameter body: The block to execute while holding the lock. - /// - Returns: The value returned by the block. - @inlinable - func withLock(_ body: () throws -> T) rethrows -> T { - self.lock() - defer { - self.unlock() - } - return try body() - } -} - -extension Lock: Sendable {} - -extension UnsafeMutablePointer { - @inlinable - func assertValidAlignment() { - assert(UInt(bitPattern: self) % UInt(MemoryLayout.alignment) == 0) - } -} - -@usableFromInline -struct LockedValueBox { - @usableFromInline - let storage: LockStorage - - @usableFromInline - init(_ value: Value) { - self.storage = .create(value: value) - } - - @inlinable - func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { - return try self.storage.withLockedValue(mutate) - } -} - -extension LockedValueBox: Sendable where Value: Sendable {} \ No newline at end of file diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift deleted file mode 100644 index 94c5d395..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/Reexports.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Reexports.swift -// HTTPClient -// -// Created by Guilherme Souza on 04/08/25. -// - -@_exported import HTTPTypesFoundation diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift deleted file mode 100644 index 0c6057cf..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/BidirectionalStreamingURLSessionDelegate.swift +++ /dev/null @@ -1,235 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes - -#if canImport(Darwin) - import Foundation - - /// Delegate that supports bidirectional streaming of request and response bodies. - /// - /// While URLSession provides a high-level API that returns an async sequence of - /// bytes, `bytes(for:delegate:)`, but does not provide an API that takes an async sequence - /// as a request body. For instance, `upload(for:delegate:)` and `upload(fromFile:delegate:)` - /// both buffer the entire response body and return `Data`. - /// - /// Additionally, bridging `URLSession.AsyncBytes`, which is an `AsyncSequence` to - /// `OpenAPIRuntime.HTTPBody`, an `AsyncSequence`, is problematic and will - /// incur an allocation for every byte. - /// - /// This delegate vends the response body as a `HTTBody` with one chunk for each - /// `urlSession(_:didReceive data:)` callback. It also provides backpressure, which will - /// suspend and resume the URLSession task based on a configurable high and low watermark. - /// - /// When performing requests without a body, this delegate should be used with a - /// `URLSessionDataTask` to stream the response body. - /// - /// When performing requests with a body, this delegate should be used with a - /// `URLSessionUploadTask` using `uploadTask(withStreamedRequest:delegate:)`, which will - /// ask the delegate for a `InputStream` for the request body via the - /// `urlSession(_:needNewBodyStreamForTask:)` callback. - /// - /// The `urlSession(_:needNewBodyStreamForTask:)` callback will create a pair of bound - /// streams, bridge the `HTTPBody` request body to the `OutputStream` and return the - /// `InputStream` to URLSession. Backpressure for the request body stream is provided - /// as an implementation detail of how URLSession reads from the `InputStream`. - /// - /// Note that `urlSession(_:needNewBodyStreamForTask:)` may be called more than once, e.g. - /// when performing a HTTP redirect, upon which the delegate is expected to create a new - /// `InputStream` for the request body. This is only possible if the underlying `HTTPBody` - /// request body can be iterated multiple times, i.e. `iterationBehavior == .multiple`. - /// If the request body cannot be iterated multiple times, then the URLSession task will be cancelled. - final class BidirectionalStreamingURLSessionDelegate: NSObject, URLSessionTaskDelegate, - URLSessionDataDelegate - { - - let requestBody: HTTPBody? - var hasAlreadyIteratedRequestBody: Bool - /// In addition to the callback lock, there is one point of rentrancy, where the response stream callback gets fired - /// immediately, for this we have a different lock, which protects `hasSuspendedURLSessionTask`. - var hasSuspendedURLSessionTask: LockedValueBox - let requestStreamBufferSize: Int - var requestStream: HTTPBodyOutputStreamBridge? - - typealias ResponseContinuation = CheckedContinuation - var responseContinuation: ResponseContinuation? - - typealias ResponseBodyStream = BufferedStream - var responseBodyStream: ResponseBodyStream - var responseBodyStreamSource: ResponseBodyStream.Source - - /// This lock is taken for the duration of all delegate callbacks to protect the mutable delegate state. - /// - /// Although all the delegate callbacks are performed on the session's `delegateQueue`, there is no guarantee that - /// this is a _serial_ queue. - /// - /// Regardless of the type of delegate queue, URLSession will attempt to order the callbacks for each task in a - /// sensible way, but it cannot be guaranteed, specifically when the URLSession task is cancelled. - /// - /// Therefore, even though the `suspend()`, `resume()`, and `cancel()` URLSession methods are thread-safe, we need - /// to protect any mutable state within the delegate itself. - let callbackLock = Lock() - - /// Use `bidirectionalStreamingRequest(for:baseURL:requestBody:requestStreamBufferSize:responseStreamWatermarks:)`. - init( - requestBody: HTTPBody?, - requestStreamBufferSize: Int, - responseStreamWatermarks: (low: Int, high: Int) - ) { - self.requestBody = requestBody - self.hasAlreadyIteratedRequestBody = false - self.hasSuspendedURLSessionTask = LockedValueBox(false) - self.requestStreamBufferSize = requestStreamBufferSize - (self.responseBodyStream, self.responseBodyStreamSource) = - ResponseBodyStream.makeStream( - backPressureStrategy: .customWatermark( - low: responseStreamWatermarks.low, - high: responseStreamWatermarks.high, - waterLevelForElement: { $0.count } - ) - ) - } - - func urlSession(_ session: URLSession, needNewBodyStreamForTask task: URLSessionTask) async - -> InputStream? - { - callbackLock.withLock { - debug("Task delegate: needNewBodyStreamForTask") - // If the HTTP body cannot be iterated multiple times then bad luck; the only thing - // we can do is cancel the task and return nil. - if hasAlreadyIteratedRequestBody { - guard requestBody!.iterationBehavior == .multiple else { - debug("Task delegate: Cannot rewind request body, cancelling task") - task.cancel() - return nil - } - } - hasAlreadyIteratedRequestBody = true - - // Create a fresh pair of streams. - let (inputStream, outputStream) = createStreamPair( - withBufferSize: requestStreamBufferSize - ) - - // Bridge the output stream to the request body (which opens the output stream). - requestStream = HTTPBodyOutputStreamBridge(outputStream, requestBody!) - - // Return the new input stream (unopened, it gets opened by URLSession). - return inputStream - } - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - callbackLock.withLock { - debug("Task delegate: didReceive data (numBytes: \(data.count))") - do { - switch try responseBodyStreamSource.write( - contentsOf: CollectionOfOne(ArraySlice(data)) - ) - { - case .produceMore: break - case .enqueueCallback(let callbackToken): - let shouldActuallyEnqueueCallback = - hasSuspendedURLSessionTask.withLockedValue { - hasSuspendedURLSessionTask in - if hasSuspendedURLSessionTask { - debug( - "Task delegate: already suspended task, not enqueing another writer callback" - ) - return false - } - debug( - "Task delegate: response stream backpressure, suspending task and enqueing callback" - ) - dataTask.suspend() - hasSuspendedURLSessionTask = true - return true - } - if shouldActuallyEnqueueCallback { - responseBodyStreamSource.enqueueCallback(callbackToken: callbackToken) { - result in - self.hasSuspendedURLSessionTask.withLockedValue { - hasSuspendedURLSessionTask in - switch result { - case .success: - debug( - "Task delegate: response stream callback, resuming task" - ) - dataTask.resume() - hasSuspendedURLSessionTask = false - case .failure(let error): - debug( - "Task delegate: response stream callback, cancelling task, error: \(error)" - ) - dataTask.cancel() - } - } - } - } - } - } catch { - debug("Task delegate: response stream consumer terminated, cancelling task") - dataTask.cancel() - } - } - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse - ) async - -> URLSession.ResponseDisposition - { - callbackLock.withLock { - debug("Task delegate: didReceive response") - responseContinuation?.resume(returning: response) - responseContinuation = nil - return .allow - } - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: (any Error)? - ) { - callbackLock.withLock { - debug("Task delegate: didCompleteWithError (error: \(String(describing: error)))") - responseBodyStreamSource.finish(throwing: error) - if let error { - responseContinuation?.resume(throwing: error) - responseContinuation = nil - } - } - } - } - - extension BidirectionalStreamingURLSessionDelegate: @unchecked Sendable {} // State synchronized using DispatchQueue. - - private func createStreamPair(withBufferSize bufferSize: Int) -> (InputStream, OutputStream) { - var inputStream: InputStream? - var outputStream: OutputStream? - Stream.getBoundStreams( - withBufferSize: bufferSize, - inputStream: &inputStream, - outputStream: &outputStream - ) - guard let inputStream, let outputStream else { - fatalError("getBoundStreams did not return non-nil streams") - } - return (inputStream, outputStream) - } - -#endif // canImport(Darwin) diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift deleted file mode 100644 index 846d124b..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/HTTPBodyOutputStreamBridge.swift +++ /dev/null @@ -1,312 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes - -#if canImport(Darwin) - import Foundation - - final class HTTPBodyOutputStreamBridge: NSObject, StreamDelegate { - static let streamQueue = DispatchQueue( - label: "HTTPBodyStreamDelegate", autoreleaseFrequency: .workItem) - - let httpBody: HTTPBody - let outputStream: OutputStream - private(set) var state: State { - didSet { debug("Output stream delegate state transition: \(oldValue) -> \(state)") } - } - - /// Creates a new `HTTPBodyOutputStreamBridge` and opens the output stream. - init(_ outputStream: OutputStream, _ httpBody: HTTPBody) { - self.httpBody = httpBody - self.outputStream = outputStream - self.state = .initial - super.init() - self.outputStream.delegate = self - CFWriteStreamSetDispatchQueue(self.outputStream as CFWriteStream, Self.streamQueue) - self.outputStream.open() - } - - deinit { - debug("Output stream delegate deinit") - outputStream.delegate = nil - } - - func performAction(_ action: State.Action) { - debug("Output stream delegate performing action from state machine: \(action)") - dispatchPrecondition(condition: .onQueue(Self.streamQueue)) - switch action { - case .none: return - case .resumeProducer(let producerContinuation): - producerContinuation.resume() - performAction(state.resumedProducer()) - case .writeBytes(let chunk): writePendingBytes(chunk) - case .cancelProducerAndCloseStream(let producerContinuation): - producerContinuation.resume(throwing: CancellationError()) - outputStream.close() - case .cancelProducer(let producerContinuation): - producerContinuation.resume(throwing: CancellationError()) - case .closeStream: outputStream.close() - } - } - - func startWriterTask() { - dispatchPrecondition(condition: .onQueue(Self.streamQueue)) - let task = Task { - dispatchPrecondition(condition: .notOnQueue(Self.streamQueue)) - for try await chunk in httpBody { - try await withCheckedThrowingContinuation { continuation in - Self.streamQueue.async { - debug("Output stream delegate produced chunk and suspended producer.") - self.performAction( - self.state.producedChunkAndSuspendedProducer(chunk, continuation)) - } - } - } - Self.streamQueue.async { - debug("Output stream delegate wrote final chunk.") - self.performAction(self.state.wroteFinalChunk()) - } - } - performAction(state.startedProducerTask(task)) - } - - private func writePendingBytes(_ bytesToWrite: Chunk) { - dispatchPrecondition(condition: .onQueue(Self.streamQueue)) - precondition(!bytesToWrite.isEmpty, "\(#function) must be called with non-empty bytes") - guard outputStream.streamStatus == .open else { - debug("Output stream closed unexpectedly.") - performAction( - state.wroteBytes(numBytesWritten: 0, streamStillHasSpaceAvailable: false)) - return - } - switch bytesToWrite.withUnsafeBytes({ - outputStream.write($0.baseAddress!, maxLength: bytesToWrite.count) - }) { - case 0: - debug("Output stream delegate reached end of stream when writing.") - performAction(state.endEncountered()) - case -1: - debug( - "Output stream delegate encountered error writing to stream: \(outputStream.streamError!)." - ) - performAction(state.errorOccurred(outputStream.streamError!)) - case let written where written > 0: - debug("Output stream delegate wrote \(written) bytes to stream.") - performAction( - state.wroteBytes( - numBytesWritten: written, - streamStillHasSpaceAvailable: outputStream.hasSpaceAvailable) - ) - default: - preconditionFailure("OutputStream.write(_:maxLength:) returned undocumented value") - } - } - - func stream(_ stream: Stream, handle event: Stream.Event) { - dispatchPrecondition(condition: .onQueue(Self.streamQueue)) - debug("Output stream delegate received event: \(event).") - switch event { - case .openCompleted: - guard case .initial = state else { - debug("Output stream delegate ignoring duplicate openCompleted event.") - return - } - startWriterTask() - case .hasSpaceAvailable: performAction(state.spaceBecameAvailable()) - case .errorOccurred: performAction(state.errorOccurred(stream.streamError!)) - case .endEncountered: performAction(state.endEncountered()) - default: - debug("Output stream ignoring event: \(event).") - break - } - } - } - - extension HTTPBodyOutputStreamBridge { - typealias Chunk = ArraySlice - typealias ProducerTask = Task - typealias ProducerContinuation = CheckedContinuation - - enum State { - case initial - case waitingForBytes(spaceAvailable: Bool) - case haveBytes(spaceAvailable: Bool, Chunk, ProducerContinuation) - case needBytes(spaceAvailable: Bool, ProducerContinuation) - case closed((any Error)?) - - mutating func startedProducerTask(_ producerTask: ProducerTask) -> Action { - switch self { - case .initial: - self = .waitingForBytes(spaceAvailable: false) - return .none - case .waitingForBytes, .haveBytes, .needBytes, .closed: - preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func producedChunkAndSuspendedProducer( - _ chunk: Chunk, _ producerContinuation: ProducerContinuation - ) - -> Action - { - switch self { - case .waitingForBytes(let spaceAvailable): - self = .haveBytes(spaceAvailable: spaceAvailable, chunk, producerContinuation) - guard spaceAvailable else { return .none } - return .writeBytes(chunk) - case .closed: return .cancelProducer(producerContinuation) - case .initial, .haveBytes, .needBytes: - preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func wroteBytes(numBytesWritten: Int, streamStillHasSpaceAvailable: Bool) - -> Action - { - switch self { - case .haveBytes(let spaceAvailable, let chunk, let producerContinuation): - guard spaceAvailable, numBytesWritten <= chunk.count else { - preconditionFailure() - } - let remaining = chunk.dropFirst(numBytesWritten) - guard remaining.isEmpty else { - self = .haveBytes( - spaceAvailable: streamStillHasSpaceAvailable, remaining, - producerContinuation) - guard streamStillHasSpaceAvailable else { return .none } - return .writeBytes(remaining) - } - self = .needBytes( - spaceAvailable: streamStillHasSpaceAvailable, producerContinuation) - return .resumeProducer(producerContinuation) - case .initial, .needBytes, .waitingForBytes, .closed: - preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func resumedProducer() -> Action { - switch self { - case .needBytes(let spaceAvailable, _): - self = .waitingForBytes(spaceAvailable: spaceAvailable) - return .none - case .initial, .haveBytes, .waitingForBytes, .closed: - preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func errorOccurred(_ error: any Error) -> Action { - switch self { - case .initial: - self = .closed(error) - return .none - case .waitingForBytes(_): - self = .closed(error) - return .closeStream - case .haveBytes(_, _, let producerContinuation): - self = .closed(error) - return .cancelProducerAndCloseStream(producerContinuation) - case .needBytes(_, let producerContinuation): - self = .closed(error) - return .cancelProducerAndCloseStream(producerContinuation) - case .closed: preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func wroteFinalChunk() -> Action { - switch self { - case .waitingForBytes(_): - self = .closed(nil) - return .closeStream - case .initial, .haveBytes, .needBytes, .closed: - preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func endEncountered() -> Action { - switch self { - case .waitingForBytes(_): - self = .closed(nil) - return .closeStream - case .haveBytes(_, _, let producerContinuation): - self = .closed(nil) - return .cancelProducerAndCloseStream(producerContinuation) - case .needBytes(_, let producerContinuation): - self = .closed(nil) - return .cancelProducerAndCloseStream(producerContinuation) - case .initial, .closed: - preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - mutating func spaceBecameAvailable() -> Action { - switch self { - case .waitingForBytes(_): - self = .waitingForBytes(spaceAvailable: true) - return .none - case .haveBytes(_, let chunk, let producerContinuation): - self = .haveBytes(spaceAvailable: true, chunk, producerContinuation) - return .writeBytes(chunk) - case .needBytes(_, let producerContinuation): - self = .needBytes(spaceAvailable: true, producerContinuation) - return .none - case .closed: - debug("Ignoring space available event in closed state") - return .none - case .initial: preconditionFailure("\(#function) called in invalid state: \(self)") - } - } - - enum Action { - case none - case resumeProducer(ProducerContinuation) - case writeBytes(Chunk) - case cancelProducerAndCloseStream(ProducerContinuation) - case cancelProducer(ProducerContinuation) - case closeStream - } - } - } - - extension HTTPBodyOutputStreamBridge: @unchecked Sendable {} // State synchronized using DispatchQueue. - - extension HTTPBodyOutputStreamBridge.State: CustomStringConvertible { - var description: String { - switch self { - case .initial: return "initial" - case .waitingForBytes(let spaceAvailable): - return "waitingForBytes(spaceAvailable: \(spaceAvailable))" - case .haveBytes(let spaceAvailable, let chunk, _): - return "haveBytes(spaceAvailable: \(spaceAvailable), [\(chunk.count) bytes])" - case .needBytes(let spaceAvailable, _): - return "needBytes (spaceAvailable: \(spaceAvailable), _)" - case .closed(let error): return "closed (error: \(String(describing: error)))" - } - } - } - - extension HTTPBodyOutputStreamBridge.State.Action: CustomStringConvertible { - var description: String { - switch self { - case .none: return "none" - case .resumeProducer: return "resumeProducer" - case .writeBytes: return "writeBytes" - case .cancelProducerAndCloseStream: return "cancelProducerAndCloseStream" - case .cancelProducer: return "cancelProducer" - case .closeStream: return "closeStream" - } - } - } - -#endif // canImport(Darwin) diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift deleted file mode 100644 index 7453b126..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionBidirectionalStreaming/URLSession+Extensions.swift +++ /dev/null @@ -1,61 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes - -#if canImport(Darwin) - import Foundation - - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension URLSession { - func bidirectionalStreamingRequest( - for request: HTTPTypes.HTTPRequest, - baseURL: URL, - requestBody: HTTPBody?, - requestStreamBufferSize: Int, - responseStreamWatermarks: (low: Int, high: Int) - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { - let urlRequest = try URLRequest(request, baseURL: baseURL) - let task: URLSessionTask - if requestBody != nil { - task = uploadTask(withStreamedRequest: urlRequest) - } else { - task = dataTask(with: urlRequest) - } - return try await withTaskCancellationHandler { - try Task.checkCancellation() - let delegate = BidirectionalStreamingURLSessionDelegate( - requestBody: requestBody, - requestStreamBufferSize: requestStreamBufferSize, - responseStreamWatermarks: responseStreamWatermarks - ) - let response = try await withCheckedThrowingContinuation { continuation in - delegate.responseContinuation = continuation - task.delegate = delegate - task.resume() - } - let responseBody = HTTPBody( - delegate.responseBodyStream, - length: .init(from: response), - iterationBehavior: .single - ) - try Task.checkCancellation() - return (try HTTPTypes.HTTPResponse(response), responseBody) - } onCancel: { - debug("Concurrency task cancelled, cancelling URLSession task.") - task.cancel() - } - } - } - -#endif // canImport(Darwin) diff --git a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift b/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift deleted file mode 100644 index 449f72f2..00000000 --- a/Sources/Helpers/HTTP/HTTPClientFoundation/URLSessionTransport.swift +++ /dev/null @@ -1,427 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import HTTPTypes - -#if canImport(Darwin) - import Foundation -#else - @preconcurrency import struct Foundation.URL - import struct Foundation.URLComponents - import struct Foundation.Data - import protocol Foundation.LocalizedError - import class Foundation.FileHandle - #if canImport(FoundationNetworking) - @preconcurrency import struct FoundationNetworking.URLRequest - import class FoundationNetworking.URLSession - import class FoundationNetworking.URLSessionTask - import class FoundationNetworking.URLResponse - import class FoundationNetworking.HTTPURLResponse - #endif -#endif - -/// A client transport that performs HTTP operations using the URLSession type -/// provided by the Foundation framework. -/// -/// ### Use the URLSession transport -/// -/// Instantiate the transport: -/// -/// let transport = URLSessionTransport() -/// -/// Instantiate the `Client` type generated by the Swift OpenAPI Generator for -/// your provided OpenAPI document. For example: -/// -/// let client = Client( -/// serverURL: URL(string: "https://example.com")!, -/// transport: transport -/// ) -/// -/// Use the client to make HTTP calls defined in your OpenAPI document. For -/// example, if the OpenAPI document contains an HTTP operation with -/// the identifier `checkHealth`, call it from Swift with: -/// -/// let response = try await client.checkHealth() -/// -/// ### Provide a custom URLSession -/// -/// The ``URLSessionTransport/Configuration-swift.struct`` type allows you to -/// provide a custom URLSession and tweak behaviors such as the default -/// timeouts, authentication challenges, and more. -public struct URLSessionTransport: ClientTransport { - - /// A set of configuration values for the URLSession transport. - public struct Configuration: Sendable { - - /// The URLSession used for performing HTTP operations. - public var session: URLSession - - /// Creates a new configuration with the provided session. - /// - Parameters: - /// - session: The URLSession used for performing HTTP operations. - /// If none is provided, the system uses the shared URLSession. - /// - httpBodyProcessingMode: The mode used to process HTTP request and response bodies. - public init( - session: URLSession = .shared, - httpBodyProcessingMode: HTTPBodyProcessingMode = .platformDefault - ) { - let implementation = httpBodyProcessingMode.implementation - self.init(session: session, implementation: implementation) - } - /// Creates a new configuration with the provided session. - /// - Parameter session: The URLSession used for performing HTTP operations. - /// If none is provided, the system uses the shared URLSession. - public init(session: URLSession = .shared) { - self.init(session: session, implementation: .platformDefault) - } - /// Specifies the mode in which HTTP request and response bodies are processed. - public struct HTTPBodyProcessingMode: Sendable { - /// Exposing the internal implementation directly. - fileprivate let implementation: Configuration.Implementation - - private init(_ implementation: Configuration.Implementation) { - self.implementation = implementation - } - - /// Use this mode to force URLSessionTransport to transfer data in a buffered mode, even if - /// streaming would be available on the platform. - public static let buffered = HTTPBodyProcessingMode(.buffering) - /// Data is transfered via streaming if available on the platform, else it falls back to buffering. - public static let platformDefault = HTTPBodyProcessingMode(.platformDefault) - } - - enum Implementation { - case buffering - case streaming( - requestBodyStreamBufferSize: Int, - responseBodyStreamWatermarks: (low: Int, high: Int) - ) - } - - var implementation: Implementation - - init(session: URLSession = .shared, implementation: Implementation = .platformDefault) { - self.session = session - if case .streaming = implementation { - precondition( - Implementation.platformSupportsStreaming, - "Streaming not supported on platform" - ) - } - self.implementation = implementation - } - } - - /// A set of configuration values used by the transport. - public var configuration: Configuration - - /// Creates a new URLSession-based transport. - /// - Parameter configuration: A set of configuration values used by the transport. - public init(configuration: Configuration = .init()) { self.configuration = configuration } - - /// Sends the underlying HTTP request and returns the received HTTP response. - /// - Parameters: - /// - request: An HTTP request. - /// - requestBody: An HTTP request body. - /// - baseURL: A server base URL. - /// - Returns: An HTTP response and its body. - /// - Throws: If there was an error performing the HTTP request. - public func send( - _ request: HTTPTypes.HTTPRequest, - body requestBody: HTTPBody?, - baseURL: URL - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { - switch configuration.implementation { - case .streaming(let requestBodyStreamBufferSize, let responseBodyStreamWatermarks): - #if canImport(Darwin) - guard #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) else { - throw URLSessionTransportError.streamingNotSupported - } - return try await configuration.session.bidirectionalStreamingRequest( - for: request, - baseURL: baseURL, - requestBody: requestBody, - requestStreamBufferSize: requestBodyStreamBufferSize, - responseStreamWatermarks: responseBodyStreamWatermarks - ) - #else - throw URLSessionTransportError.streamingNotSupported - #endif - case .buffering: - return try await configuration.session.bufferedRequest( - for: request, - baseURL: baseURL, - requestBody: requestBody - ) - } - } -} - -extension HTTPBody.Length { - init(from urlResponse: URLResponse) { - if urlResponse.expectedContentLength == -1 { - self = .unknown - } else { - self = .known(urlResponse.expectedContentLength) - } - } -} - -/// Specialized error thrown by the transport. -internal enum URLSessionTransportError: Error { - - /// Invalid URL composed from base URL and received request. - case invalidRequestURL(path: String, method: HTTPTypes.HTTPRequest.Method, baseURL: URL) - - /// Returned `URLResponse` could not be converted to `HTTPURLResponse`. - case notHTTPResponse(URLResponse) - - /// Returned `HTTPURLResponse` has an invalid status code - case invalidResponseStatusCode(HTTPURLResponse) - - /// Returned `URLResponse` was nil - case noResponse(url: URL?) - - /// Platform does not support streaming. - case streamingNotSupported -} - -extension HTTPTypes.HTTPResponse { - init(_ urlResponse: URLResponse) throws { - guard let httpResponse = urlResponse as? HTTPURLResponse else { - throw URLSessionTransportError.notHTTPResponse(urlResponse) - } - guard (0...999).contains(httpResponse.statusCode) else { - throw URLSessionTransportError.invalidResponseStatusCode(httpResponse) - } - self.init(status: .init(code: httpResponse.statusCode)) - if let fields = httpResponse.allHeaderFields as? [String: String] { - self.headerFields.reserveCapacity(fields.count) - for (name, value) in fields { - if let name = HTTPField.Name(name) { - self.headerFields.append(HTTPField(name: name, isoLatin1Value: value)) - } - } - } - } -} - -extension URLRequest { - init(_ request: HTTPTypes.HTTPRequest, baseURL: URL) throws { - guard var baseUrlComponents = URLComponents(string: baseURL.absoluteString), - let requestUrlComponents = URLComponents(string: request.path ?? "") - else { - throw URLSessionTransportError.invalidRequestURL( - path: request.path ?? "", - method: request.method, - baseURL: baseURL - ) - } - - let path = requestUrlComponents.percentEncodedPath - baseUrlComponents.percentEncodedPath += path - baseUrlComponents.percentEncodedQuery = requestUrlComponents.percentEncodedQuery - guard let url = baseUrlComponents.url else { - throw URLSessionTransportError.invalidRequestURL( - path: path, - method: request.method, - baseURL: baseURL - ) - } - self.init(url: url) - self.httpMethod = request.method.rawValue - var combinedFields = [HTTPField.Name: String](minimumCapacity: request.headerFields.count) - for field in request.headerFields { - if let existingValue = combinedFields[field.name] { - let separator = field.name == .cookie ? "; " : ", " - combinedFields[field.name] = "\(existingValue)\(separator)\(field.isoLatin1Value)" - } else { - combinedFields[field.name] = field.isoLatin1Value - } - } - var headerFields = [String: String](minimumCapacity: combinedFields.count) - for (name, value) in combinedFields { headerFields[name.rawName] = value } - self.allHTTPHeaderFields = headerFields - } -} - -extension String { fileprivate var isASCII: Bool { self.utf8.allSatisfy { $0 & 0x80 == 0 } } } - -extension HTTPField { - fileprivate init(name: Name, isoLatin1Value: String) { - if isoLatin1Value.isASCII { - self.init(name: name, value: isoLatin1Value) - } else { - self = withUnsafeTemporaryAllocation( - of: UInt8.self, - capacity: isoLatin1Value.unicodeScalars.count - ) { - buffer in - for (index, scalar) in isoLatin1Value.unicodeScalars.enumerated() { - if scalar.value > UInt8.max { - buffer[index] = 0x20 - } else { - buffer[index] = UInt8(truncatingIfNeeded: scalar.value) - } - } - return HTTPField(name: name, value: buffer) - } - } - } - - fileprivate var isoLatin1Value: String { - if self.value.isASCII { return self.value } - return self.withUnsafeBytesOfValue { buffer in - let scalars = buffer.lazy.map { UnicodeScalar(UInt32($0))! } - var string = "" - string.unicodeScalars.append(contentsOf: scalars) - return string - } - } -} - -extension URLSessionTransportError: LocalizedError { - /// A localized message describing what error occurred. - var errorDescription: String? { description } -} - -extension URLSessionTransportError: CustomStringConvertible { - /// A textual representation of this instance. - var description: String { - switch self { - case let .invalidRequestURL(path: path, method: method, baseURL: baseURL): - return - "Invalid request URL from request path: \(path), method: \(method), relative to base URL: \(baseURL.absoluteString)" - case .notHTTPResponse(let response): - return - "Received a non-HTTP response, of type: \(String(describing: type(of: response)))" - case .invalidResponseStatusCode(let response): - return "Received an HTTP response with invalid status code: \(response.statusCode))" - case .noResponse(let url): - return "Received a nil response for \(url?.absoluteString ?? "")" - case .streamingNotSupported: return "Streaming is not supported on this platform" - } - } -} - -private let _debugLoggingEnabled = LockStorage.create(value: false) -var debugLoggingEnabled: Bool { - get { _debugLoggingEnabled.withLockedValue { $0 } } - set { _debugLoggingEnabled.withLockedValue { $0 = newValue } } -} -private let _standardErrorLock = LockStorage.create(value: FileHandle.standardError) -func debug( - _ message: @autoclosure () -> String, - function: String = #function, - file: String = #file, - line: UInt = #line -) { - assert( - { - if debugLoggingEnabled { - _standardErrorLock.withLockedValue { - let logLine = - "[\(function) \(file.split(separator: "/").last!):\(line)] \(message())\n" - $0.write(Data((logLine).utf8)) - } - } - return true - }() - ) -} - -extension URLSession { - func bufferedRequest( - for request: HTTPTypes.HTTPRequest, - baseURL: URL, - requestBody: HTTPBody? - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { - try Task.checkCancellation() - var urlRequest = try URLRequest(request, baseURL: baseURL) - if let requestBody { - urlRequest.httpBody = try await Data(collecting: requestBody, upTo: .max) - } - try Task.checkCancellation() - - /// Use `dataTask(with:completionHandler:)` here because `data(for:[delegate:]) async` is only available on - /// Darwin platforms newer than our minimum deployment target, and not at all on Linux. - let taskBox: LockedValueBox = .init(nil) - return try await withTaskCancellationHandler { - let (response, maybeResponseBodyData): (URLResponse, Data?) = - try await withCheckedThrowingContinuation { - continuation in - let task = self.dataTask(with: urlRequest) { - [urlRequest] data, response, error in - if let error { - continuation.resume(throwing: error) - return - } - guard let response else { - continuation.resume( - throwing: URLSessionTransportError.noResponse(url: urlRequest.url) - ) - return - } - continuation.resume(with: .success((response, data))) - } - // Swift concurrency task cancelled here. - taskBox.withLockedValue { boxedTask in - guard task.state == .suspended else { - debug( - "URLSession task cannot be resumed, probably because it was cancelled by onCancel." - ) - return - } - task.resume() - boxedTask = task - } - } - - let maybeResponseBody = maybeResponseBodyData.map { data in - HTTPBody( - data, - length: HTTPBody.Length(from: response), - iterationBehavior: .multiple - ) - } - return (try HTTPTypes.HTTPResponse(response), maybeResponseBody) - } onCancel: { - taskBox.withLockedValue { boxedTask in - debug("Concurrency task cancelled, cancelling URLSession task.") - boxedTask?.cancel() - boxedTask = nil - } - } - } -} - -extension URLSessionTransport.Configuration.Implementation { - static var platformSupportsStreaming: Bool { - #if canImport(Darwin) - guard #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) else { return false } - _ = URLSession.bidirectionalStreamingRequest - return true - #else - return false - #endif - } - - static var platformDefault: Self { - guard platformSupportsStreaming else { return .buffering } - return .streaming( - requestBodyStreamBufferSize: 16 * 1024, - responseBodyStreamWatermarks: (low: 16 * 1024, high: 32 * 1024) - ) - } -} diff --git a/Sources/Helpers/HTTP/LoggerInterceptor.swift b/Sources/Helpers/HTTP/LoggerInterceptor.swift index e5881953..d39e2d75 100644 --- a/Sources/Helpers/HTTP/LoggerInterceptor.swift +++ b/Sources/Helpers/HTTP/LoggerInterceptor.swift @@ -7,6 +7,7 @@ import Foundation +@available(*, deprecated, message: "Use `LoggingMiddleware instead.") package struct LoggerInterceptor: HTTPClientInterceptor { let logger: any SupabaseLogger diff --git a/Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift b/Sources/Helpers/HTTP/LoggingMiddleware.swift similarity index 52% rename from Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift rename to Sources/Helpers/HTTP/LoggingMiddleware.swift index 044f7191..cc0d7afd 100644 --- a/Sources/Helpers/HTTP/HTTPClient/Middlewares/LoggingMiddleware.swift +++ b/Sources/Helpers/HTTP/LoggingMiddleware.swift @@ -1,4 +1,5 @@ import Logging +import OpenAPIRuntime #if canImport(Darwin) import struct Foundation.URL @@ -19,14 +20,38 @@ struct LoggingMiddleware: ClientMiddleware { _ request: HTTPTypes.HTTPRequest, body: HTTPBody?, baseURL: URL, - next: (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) + operationID: String, + next: (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> ( + HTTPTypes.HTTPResponse, HTTPBody? + ) ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { var logger = logger logger[metadataKey: "request-id"] = .string(UUID().uuidString) - + logger.trace("⬆️ \(request.prettyDescription)") let (response, body) = try await next(request, body, baseURL) logger.trace("⬇️ \(response.prettyDescription)") return (response, body) } } + +extension HTTPFields { + fileprivate var prettyDescription: String { + sorted(by: { + $0.name.canonicalName.localizedCompare($1.name.canonicalName) == .orderedAscending + }) + .map { "\($0.name.canonicalName): \($0.value)" }.joined(separator: "; ") + } +} + +extension HTTPTypes.HTTPRequest { + fileprivate var prettyDescription: String { + "\(method.rawValue) \(path ?? "") [\(headerFields.prettyDescription)]" + } +} + +extension HTTPTypes.HTTPResponse { + fileprivate var prettyDescription: String { "\(status.code) [\(headerFields.prettyDescription)]" } +} + +extension HTTPBody { fileprivate var prettyDescription: String { String(describing: self) } } diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index 74fe6fae..db2337d2 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "006a8b9f8235c1a40761e0042fa559d6cbd9369399d90879c3f4aa5a087d194f", + "originHash" : "eb8870b1127e99897481c021b781eada7e8131a09465fb0cafa63e5a3fc7dc0d", "pins" : [ { "identity" : "appauth-ios", @@ -181,6 +181,24 @@ "version" : "1.6.4" } }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", + "version" : "1.1.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", From 201f2b98c6c5c8b638c29ba54b7fedbe0e3ac3ab Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 13:39:11 -0300 Subject: [PATCH 03/16] chore: sort dependencies # Conflicts: # Package.resolved --- Package.resolved | 38 +++++++++++++++++++++++++++++++++++++- Package.swift | 8 ++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Package.resolved b/Package.resolved index 3e33ade0..c2da59fe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8f9a7a274a65e1e858bc4af7d28200df656048be2796fc6bcc0b5712f7429bde", + "originHash" : "3d786f6f5c84b30f9fdd7d72ec12b837890313fec033addfe2ed8fbcc54600f3", "pins" : [ { "identity" : "mocker", @@ -28,6 +28,15 @@ "version" : "1.0.6" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", @@ -64,6 +73,33 @@ "version" : "1.3.1" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", + "version" : "1.1.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 9914cc05..4bb0fbf4 100644 --- a/Package.swift +++ b/Package.swift @@ -24,10 +24,10 @@ let package = Package( targets: ["Supabase", "Functions", "PostgREST", "Auth", "Realtime", "Storage"]), ], dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-log", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), @@ -41,15 +41,15 @@ let package = Package( .target( name: "Helpers", dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "DequeModule", package: "swift-collections"), .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "HTTPTypesFoundation", package: "swift-http-types"), .product(name: "Logging", package: "swift-log"), - .product(name: "DequeModule", package: "swift-collections"), - .product(name: "Clocks", package: "swift-clocks"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ] ), .testTarget( From ac2ed305dd953c46a704324134c96c88a57131f0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 14:29:22 -0300 Subject: [PATCH 04/16] refactor(functions): migrate FunctionsClient to use new HTTP layer --- Sources/Functions/FunctionsClient.swift | 176 ++++++++++-------------- Sources/Helpers/HTTP/Client.swift | 16 ++- 2 files changed, 81 insertions(+), 111 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 214c208c..7a55442e 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,6 +1,8 @@ import ConcurrencyExtras import Foundation import HTTPTypes +import HTTPTypesFoundation +import OpenAPIURLSession #if canImport(FoundationNetworking) import FoundationNetworking @@ -31,6 +33,7 @@ public final class FunctionsClient: Sendable { var headers = HTTPFields() } + private let client: Client private let http: any HTTPClientType private let mutableState = LockIsolated(MutableState()) private let sessionConfiguration: URLSessionConfiguration @@ -85,6 +88,7 @@ public final class FunctionsClient: Sendable { headers: headers, region: region, http: http, + client: Client(serverURL: url, transport: URLSessionTransport()), sessionConfiguration: sessionConfiguration ) } @@ -94,11 +98,13 @@ public final class FunctionsClient: Sendable { headers: [String: String], region: String?, http: any HTTPClientType, + client: Client, sessionConfiguration: URLSessionConfiguration = .default ) { self.url = url self.region = region self.http = http + self.client = client self.sessionConfiguration = sessionConfiguration mutableState.withValue { @@ -140,6 +146,39 @@ public final class FunctionsClient: Sendable { } } + /// Inokes a functions returns the raw response and body. + /// - Parameters: + /// - functionName: The name of the function to invoke. + /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) + /// - Returns: The raw response and body. + public func invoke( + _ functionName: String, + options: FunctionInvokeOptions = .init() + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody) { + try await self.invoke(functionName, options: options) { ($0, $1) } + } + + /// Invokes a function and decodes the response. + /// + /// - Parameters: + /// - functionName: The name of the function to invoke. + /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) + /// - decode: A closure to decode the response data and `HTTPResponse` into a `Response` + /// object. + /// - Returns: The decoded `Response` object. + public func invoke( + _ functionName: String, + options: FunctionInvokeOptions = .init(), + decode: (HTTPTypes.HTTPResponse, HTTPBody) async throws -> Response + ) async throws -> Response { + let (_, response, body) = try await _invoke( + functionName: functionName, + invokeOptions: options + ) + + return try await decode(response, body) + } + /// Invokes a function and decodes the response. /// /// - Parameters: @@ -148,15 +187,20 @@ public final class FunctionsClient: Sendable { /// - decode: A closure to decode the response data and HTTPURLResponse into a `Response` /// object. /// - Returns: The decoded `Response` object. + @available(*, deprecated, message: "Use `invoke` with HTTPBody instead.") public func invoke( _ functionName: String, options: FunctionInvokeOptions = .init(), decode: (Data, HTTPURLResponse) throws -> Response ) async throws -> Response { - let response = try await rawInvoke( - functionName: functionName, invokeOptions: options + let (request, response, body) = try await _invoke( + functionName: functionName, + invokeOptions: options ) - return try decode(response.data, response.underlyingResponse) + + let data = try await Data(collecting: body, upTo: .max) + + return try decode(data, HTTPURLResponse(httpResponse: response, url: request.url ?? self.url)!) } /// Invokes a function and decodes the response as a specific type. @@ -171,8 +215,9 @@ public final class FunctionsClient: Sendable { options: FunctionInvokeOptions = .init(), decoder: JSONDecoder = JSONDecoder() ) async throws -> T { - try await invoke(functionName, options: options) { data, _ in - try decoder.decode(T.self, from: data) + try await invoke(functionName, options: options) { _, body in + let data = try await Data(collecting: body, upTo: .max) + return try decoder.decode(T.self, from: data) } } @@ -185,124 +230,47 @@ public final class FunctionsClient: Sendable { _ functionName: String, options: FunctionInvokeOptions = .init() ) async throws { - try await invoke(functionName, options: options) { _, _ in () } + try await invoke(functionName, options: options) { (_, _: HTTPBody) in () } } - private func rawInvoke( + private func _invoke( functionName: String, invokeOptions: FunctionInvokeOptions - ) async throws -> Helpers.HTTPResponse { - let request = buildRequest(functionName: functionName, options: invokeOptions) - let response = try await http.send(request) + ) async throws -> (HTTPTypes.HTTPRequest, HTTPTypes.HTTPResponse, HTTPBody) { + let (request, requestBody) = buildRequest(functionName: functionName, options: invokeOptions) + let (response, responseBody) = try await client.send(request, body: requestBody) - guard 200..<300 ~= response.statusCode else { - throw FunctionsError.httpError(code: response.statusCode, data: response.data) + guard response.status.kind == .successful else { + let data = try await Data(collecting: responseBody, upTo: .max) + throw FunctionsError.httpError(code: response.status.code, data: data) } - let isRelayError = response.headers[.xRelayError] == "true" + let isRelayError = response.headerFields[.xRelayError] == "true" if isRelayError { throw FunctionsError.relayError } - return response - } - - /// Invokes a function with streamed response. - /// - /// Function MUST return a `text/event-stream` content type for this method to work. - /// - /// - Parameters: - /// - functionName: The name of the function to invoke. - /// - invokeOptions: Options for invoking the function. - /// - Returns: A stream of Data. - /// - /// - Warning: Experimental method. - /// - Note: This method doesn't use the same underlying `URLSession` as the remaining methods in the library. - public func _invokeWithStreamedResponse( - _ functionName: String, - options invokeOptions: FunctionInvokeOptions = .init() - ) -> AsyncThrowingStream { - let (stream, continuation) = AsyncThrowingStream.makeStream() - let delegate = StreamResponseDelegate(continuation: continuation) - - let session = URLSession( - configuration: sessionConfiguration, delegate: delegate, delegateQueue: nil) - - let urlRequest = buildRequest(functionName: functionName, options: invokeOptions).urlRequest - - let task = session.dataTask(with: urlRequest) - task.resume() - - continuation.onTermination = { _ in - task.cancel() - - // Hold a strong reference to delegate until continuation terminates. - _ = delegate - } - - return stream + return (request, response, responseBody) } - private func buildRequest(functionName: String, options: FunctionInvokeOptions) - -> Helpers.HTTPRequest - { - var request = HTTPRequest( - url: url.appendingPathComponent(functionName), + private func buildRequest( + functionName: String, + options: FunctionInvokeOptions + ) -> (HTTPTypes.HTTPRequest, HTTPBody?) { + var request = HTTPTypes.HTTPRequest( method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, - query: options.query, - headers: mutableState.headers.merging(with: options.headers), - body: options.body, - timeoutInterval: FunctionsClient.requestIdleTimeout + url: url.appendingPathComponent(functionName).appendingQueryItems(options.query), + headerFields: mutableState.headers.merging(with: options.headers) ) - if let region = options.region ?? region { - request.headers[.xRegion] = region - } - - return request - } -} - -final class StreamResponseDelegate: NSObject, URLSessionDataDelegate, Sendable { - let continuation: AsyncThrowingStream.Continuation - - init(continuation: AsyncThrowingStream.Continuation) { - self.continuation = continuation - } - - func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive data: Data) { - continuation.yield(data) - } - - func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: (any Error)?) { - continuation.finish(throwing: error) - } + // TODO: Check how to assign FunctionsClient.requestIdleTimeout - func urlSession( - _: URLSession, dataTask _: URLSessionDataTask, didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - defer { - completionHandler(.allow) + if let region = options.region ?? region { + request.headerFields[.xRegion] = region } - guard let httpResponse = response as? HTTPURLResponse else { - continuation.finish(throwing: URLError(.badServerResponse)) - return - } + let body = options.body.map(HTTPBody.init) - guard 200..<300 ~= httpResponse.statusCode else { - let error = FunctionsError.httpError( - code: httpResponse.statusCode, - data: Data() - ) - continuation.finish(throwing: error) - return - } - - let isRelayError = httpResponse.value(forHTTPHeaderField: "x-relay-error") == "true" - if isRelayError { - continuation.finish(throwing: FunctionsError.relayError) - } + return (request, body) } } diff --git a/Sources/Helpers/HTTP/Client.swift b/Sources/Helpers/HTTP/Client.swift index 28cb4c73..317aaa22 100644 --- a/Sources/Helpers/HTTP/Client.swift +++ b/Sources/Helpers/HTTP/Client.swift @@ -8,7 +8,7 @@ import OpenAPIRuntime #endif /// A client that can send HTTP requests and receive HTTP responses. -struct Client: Sendable { +package struct Client: Sendable { /// The URL of the server, used as the base URL for requests made by the /// client. @@ -21,7 +21,7 @@ struct Client: Sendable { var middlewares: [any ClientMiddleware] /// Creates a new client. - init( + package init( serverURL: URL, transport: any ClientTransport, middlewares: [any ClientMiddleware] = [] @@ -38,33 +38,35 @@ struct Client: Sendable { /// - body: The HTTP request body to send. /// - Returns: The HTTP response and its body. /// - Throws: An error if any part of the HTTP operation process fails. - func send( + package func send( _ request: HTTPTypes.HTTPRequest, body: HTTPBody? = nil - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody) { let baseURL = serverURL var next: @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> ( - HTTPTypes.HTTPResponse, HTTPBody? + HTTPTypes.HTTPResponse, HTTPBody ) = { (_request, _body, _url) in - try await transport.send( + let (response, body) = try await transport.send( _request, body: _body, baseURL: _url, operationID: "" ) + return (response, body ?? HTTPBody()) } for middleware in middlewares.reversed() { let tmp = next next = { (_request, _body, _url) in - try await middleware.intercept( + let (response, body) = try await middleware.intercept( _request, body: _body, baseURL: _url, operationID: "", next: tmp ) + return (response, body ?? HTTPBody()) } } return try await next(request, body, baseURL) From 45abd97159f6a522c5c857099899c07e46a43eff Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 14:34:14 -0300 Subject: [PATCH 05/16] fix: remove ambiguous invoke method --- Sources/Functions/FunctionsClient.swift | 13 +-- .../FunctionsTests/FunctionsClientTests.swift | 82 ------------------- 2 files changed, 1 insertion(+), 94 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 7a55442e..f46e2244 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -151,6 +151,7 @@ public final class FunctionsClient: Sendable { /// - functionName: The name of the function to invoke. /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) /// - Returns: The raw response and body. + @discardableResult public func invoke( _ functionName: String, options: FunctionInvokeOptions = .init() @@ -221,18 +222,6 @@ public final class FunctionsClient: Sendable { } } - /// Invokes a function without expecting a response. - /// - /// - Parameters: - /// - functionName: The name of the function to invoke. - /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) - public func invoke( - _ functionName: String, - options: FunctionInvokeOptions = .init() - ) async throws { - try await invoke(functionName, options: options) { (_, _: HTTPBody) in () } - } - private func _invoke( functionName: String, invokeOptions: FunctionInvokeOptions diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 2d19c5d2..828a86f2 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -316,86 +316,4 @@ final class FunctionsClientTests: XCTestCase { sut.setAuth(token: nil) XCTAssertNil(sut.headers[.authorization]) } - - func testInvokeWithStreamedResponse() async throws { - Mock( - url: url.appendingPathComponent("stream"), - statusCode: 200, - data: [.post: Data("hello world".utf8)] - ) - .snapshotRequest { - #""" - curl \ - --request POST \ - --header "X-Client-Info: functions-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:5432/functions/v1/stream" - """# - } - .register() - - let stream = sut._invokeWithStreamedResponse("stream") - - for try await value in stream { - XCTAssertEqual(String(decoding: value, as: UTF8.self), "hello world") - } - } - - func testInvokeWithStreamedResponseHTTPError() async throws { - Mock( - url: url.appendingPathComponent("stream"), - statusCode: 300, - data: [.post: Data()] - ) - .snapshotRequest { - #""" - curl \ - --request POST \ - --header "X-Client-Info: functions-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:5432/functions/v1/stream" - """# - } - .register() - - let stream = sut._invokeWithStreamedResponse("stream") - - do { - for try await _ in stream { - XCTFail("should throw error") - } - } catch let FunctionsError.httpError(code, _) { - XCTAssertEqual(code, 300) - } - } - - func testInvokeWithStreamedResponseRelayError() async throws { - Mock( - url: url.appendingPathComponent("stream"), - statusCode: 200, - data: [.post: Data()], - additionalHeaders: [ - "x-relay-error": "true" - ] - ) - .snapshotRequest { - #""" - curl \ - --request POST \ - --header "X-Client-Info: functions-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:5432/functions/v1/stream" - """# - } - .register() - - let stream = sut._invokeWithStreamedResponse("stream") - - do { - for try await _ in stream { - XCTFail("should throw error") - } - } catch FunctionsError.relayError { - } - } } From 415fd24177396723f49b49bb3c7cbde3044f3c00 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 15:17:32 -0300 Subject: [PATCH 06/16] clean up and deprecate init with fetch --- Sources/Functions/FunctionsClient.swift | 100 +++++++++++++----- .../FunctionsTests/FunctionsClientTests.swift | 31 +++--- 2 files changed, 90 insertions(+), 41 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index f46e2244..75c421b0 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -10,6 +10,41 @@ import OpenAPIURLSession let version = Helpers.version +/// A ClientTransport implementation that adapts the old Fetch api. +struct FetchTransportAdapter: ClientTransport { + let fetch: FunctionsClient.FetchHandler + + init(fetch: @escaping FunctionsClient.FetchHandler) { + self.fetch = fetch + } + + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + guard var urlRequest = URLRequest(httpRequest: request) else { + throw URLError(.badURL) + } + + if let body { + urlRequest.httpBody = try await Data(collecting: body, upTo: .max) + } + + let (data, response) = try await fetch(urlRequest) + + guard let httpURLResponse = response as? HTTPURLResponse, + let httpResponse = httpURLResponse.httpResponse + else { + throw URLError(.badServerResponse) + } + + let body = HTTPBody(data) + return (httpResponse, body) + } +} + /// An actor representing a client for invoking functions. public final class FunctionsClient: Sendable { /// Fetch handler used to make requests. @@ -34,9 +69,7 @@ public final class FunctionsClient: Sendable { } private let client: Client - private let http: any HTTPClientType private let mutableState = LockIsolated(MutableState()) - private let sessionConfiguration: URLSessionConfiguration var headers: HTTPFields { mutableState.headers @@ -50,62 +83,51 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) + @available(*, deprecated, message: "Fetch handler is deprecated, use init with `transport` instead.") @_disfavoredOverload public convenience init( url: URL, headers: [String: String] = [:], region: String? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + fetch: @escaping FetchHandler ) { self.init( url: url, headers: headers, region: region, logger: logger, - fetch: fetch, - sessionConfiguration: .default + client: Client(serverURL: url, transport: FetchTransportAdapter(fetch: fetch)) ) } - convenience init( + @_disfavoredOverload + public convenience init( url: URL, headers: [String: String] = [:], region: String? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, - sessionConfiguration: URLSessionConfiguration + transport: (any ClientTransport)? = nil ) { - var interceptors: [any HTTPClientInterceptor] = [] - if let logger { - interceptors.append(LoggerInterceptor(logger: logger)) - } - - let http = HTTPClient(fetch: fetch, interceptors: interceptors) - self.init( url: url, headers: headers, region: region, - http: http, - client: Client(serverURL: url, transport: URLSessionTransport()), - sessionConfiguration: sessionConfiguration + logger: logger, + client: Client(serverURL: url, transport: transport ?? URLSessionTransport()) ) } init( url: URL, - headers: [String: String], - region: String?, - http: any HTTPClientType, - client: Client, - sessionConfiguration: URLSessionConfiguration = .default + headers: [String: String] = [:], + region: String? = nil, + logger: (any SupabaseLogger)? = nil, + client: Client ) { self.url = url self.region = region - self.http = http self.client = client - self.sessionConfiguration = sessionConfiguration mutableState.withValue { $0.headers = HTTPFields(headers) @@ -123,14 +145,38 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) + + @available(*, deprecated, message: "Fetch handler is deprecated, use init with `transport` instead.") + public convenience init( + url: URL, + headers: [String: String] = [:], + region: FunctionRegion? = nil, + logger: (any SupabaseLogger)? = nil, + fetch: @escaping FetchHandler + ) { + self.init( + url: url, + headers: headers, + region: region?.rawValue, + logger: logger, + fetch: fetch + ) + } + public convenience init( url: URL, headers: [String: String] = [:], region: FunctionRegion? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + transport: (any ClientTransport)? = nil ) { - self.init(url: url, headers: headers, region: region?.rawValue, logger: logger, fetch: fetch) + self.init( + url: url, + headers: headers, + region: region?.rawValue, + logger: logger, + transport: transport + ) } /// Updates the authorization header. diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 828a86f2..afaa64ed 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -2,6 +2,7 @@ import ConcurrencyExtras import HTTPTypes import InlineSnapshotTesting import Mocker +import OpenAPIURLSession import TestHelpers import XCTest @@ -11,19 +12,19 @@ import XCTest import FoundationNetworking #endif +extension URLSessionConfiguration { + static var mocking: URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + return configuration + } +} + final class FunctionsClientTests: XCTestCase { let url = URL(string: "http://localhost:5432/functions/v1")! let apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" - let sessionConfiguration: URLSessionConfiguration = { - let sessionConfiguration = URLSessionConfiguration.default - sessionConfiguration.protocolClasses = [MockingURLProtocol.self] - return sessionConfiguration - }() - - lazy var session = URLSession(configuration: sessionConfiguration) - var region: String? lazy var sut = FunctionsClient( @@ -32,15 +33,18 @@ final class FunctionsClientTests: XCTestCase { "apikey": apiKey ], region: region, - fetch: { request in - try await self.session.data(for: request) - }, - sessionConfiguration: sessionConfiguration + client: Client( + serverURL: URL(string: "http://localhost:5432")!, + transport: URLSessionTransport( + configuration: URLSessionTransport.Configuration( + session: URLSession(configuration: .mocking) + ) + ) + ) ) override func setUp() { super.setUp() - // isRecording = true } func testInit() async { @@ -65,7 +69,6 @@ final class FunctionsClientTests: XCTestCase { #""" curl \ --request POST \ - --header "Content-Length: 19" \ --header "Content-Type: application/json" \ --header "X-Client-Info: functions-swift/0.0.0" \ --header "X-Custom-Key: value" \ From 33a88bf4e8a3131d7a6366327ef83d64d0964db0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 4 Aug 2025 15:25:58 -0300 Subject: [PATCH 07/16] inject transport in FunctionsClient --- Sources/Supabase/SupabaseClient.swift | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index b419a94e..5857cb5d 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -2,11 +2,30 @@ import ConcurrencyExtras import Foundation import HTTPTypes import IssueReporting +import OpenAPIURLSession #if canImport(FoundationNetworking) import FoundationNetworking #endif +struct AuthClientTransport: ClientTransport { + let transport: any ClientTransport + let accessToken: @Sendable () async -> String? + + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + var request = request + if let token = await accessToken() { + request.headerFields[.authorization] = "Bearer \(token)" + } + return try await transport.send(request, body: body, baseURL: baseURL, operationID: operationID) + } +} + /// Supabase Client. public final class SupabaseClient: Sendable { let options: SupabaseClientOptions @@ -16,6 +35,7 @@ public final class SupabaseClient: Sendable { let databaseURL: URL let functionsURL: URL + private let transport: any ClientTransport private let _auth: AuthClient /// Supabase Auth allows you to create and manage user sessions for access to data that is secured by access policies. @@ -89,7 +109,10 @@ public final class SupabaseClient: Sendable { headers: headers, region: options.functions.region, logger: options.global.logger, - fetch: fetchWithAuth + transport: AuthClientTransport( + transport: transport, + accessToken: { try? await self._getAccessToken() } + ) ) } @@ -149,6 +172,12 @@ public final class SupabaseClient: Sendable { self.supabaseKey = supabaseKey self.options = options + self.transport = URLSessionTransport( + configuration: URLSessionTransport.Configuration( + session: options.global.session + ) + ) + storageURL = supabaseURL.appendingPathComponent("/storage/v1") databaseURL = supabaseURL.appendingPathComponent("/rest/v1") functionsURL = supabaseURL.appendingPathComponent("/functions/v1") From d51bde376f8096f87737b6854cad17d07750bd72 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 08:23:22 -0300 Subject: [PATCH 08/16] test: add integration tests for functions and fix bug with `any` function region --- Sources/Functions/FunctionsClient.swift | 56 ++++-- Sources/Functions/Types.swift | 1 + Sources/Helpers/HTTP/Exports.swift | 8 +- Sources/Helpers/HTTP/LoggingMiddleware.swift | 9 +- Tests/IntegrationTests/DotEnv.swift | 2 +- .../FunctionsIntegrationTests.swift | 165 ++++++++++++++++++ .../supabase/.temp/cli-latest | 2 +- Tests/IntegrationTests/supabase/config.toml | 11 ++ .../supabase/functions/mirror/.npmrc | 3 + .../supabase/functions/mirror/deno.json | 3 + .../supabase/functions/mirror/index.ts | 66 +++++++ 11 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 Tests/IntegrationTests/FunctionsIntegrationTests.swift create mode 100644 Tests/IntegrationTests/supabase/functions/mirror/.npmrc create mode 100644 Tests/IntegrationTests/supabase/functions/mirror/deno.json create mode 100644 Tests/IntegrationTests/supabase/functions/mirror/index.ts diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 75c421b0..eb096e82 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -2,6 +2,7 @@ import ConcurrencyExtras import Foundation import HTTPTypes import HTTPTypesFoundation +import Logging import OpenAPIURLSession #if canImport(FoundationNetworking) @@ -45,6 +46,20 @@ struct FetchTransportAdapter: ClientTransport { } } +extension URL { + /// Returns a new URL which contains only `{scheme}://{host}:{port}`. + fileprivate var baseURL: URL { + guard let components = URLComponents(string: self.absoluteString) else { return self } + + var newComponents = URLComponents() + newComponents.scheme = components.scheme + newComponents.host = components.host + newComponents.port = components.port + + return newComponents.url ?? self + } +} + /// An actor representing a client for invoking functions. public final class FunctionsClient: Sendable { /// Fetch handler used to make requests. @@ -83,7 +98,11 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) - @available(*, deprecated, message: "Fetch handler is deprecated, use init with `transport` instead.") + @available( + *, + deprecated, + message: "Fetch handler is deprecated, use init with `transport` instead." + ) @_disfavoredOverload public convenience init( url: URL, @@ -114,7 +133,11 @@ public final class FunctionsClient: Sendable { headers: headers, region: region, logger: logger, - client: Client(serverURL: url, transport: transport ?? URLSessionTransport()) + client: Client( + serverURL: url.baseURL, + transport: transport ?? URLSessionTransport(), + middlewares: [LoggingMiddleware(logger: Logger(label: "functions"))] + ) ) } @@ -146,7 +169,11 @@ public final class FunctionsClient: Sendable { /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) - @available(*, deprecated, message: "Fetch handler is deprecated, use init with `transport` instead.") + @available( + *, + deprecated, + message: "Fetch handler is deprecated, use init with `transport` instead." + ) public convenience init( url: URL, headers: [String: String] = [:], @@ -292,18 +319,27 @@ public final class FunctionsClient: Sendable { functionName: String, options: FunctionInvokeOptions ) -> (HTTPTypes.HTTPRequest, HTTPBody?) { - var request = HTTPTypes.HTTPRequest( - method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, - url: url.appendingPathComponent(functionName).appendingQueryItems(options.query), - headerFields: mutableState.headers.merging(with: options.headers) - ) + var region = options.region + var queryItems = options.query + var headers = options.headers // TODO: Check how to assign FunctionsClient.requestIdleTimeout - if let region = options.region ?? region { - request.headerFields[.xRegion] = region + if region == nil { + region = self.region + } + + if let region, region != "any" { + headers[.xRegion] = region + queryItems.append(URLQueryItem(name: "forceFunctionRegion", value: region)) } + let request = HTTPTypes.HTTPRequest( + method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, + url: url.appendingPathComponent(functionName).appendingQueryItems(queryItems), + headerFields: mutableState.headers.merging(with: headers) + ) + let body = options.body.map(HTTPBody.init) return (request, body) diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index e53f06fd..440c9116 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -131,6 +131,7 @@ public enum FunctionRegion: String, Sendable { case usEast1 = "us-east-1" case usWest1 = "us-west-1" case usWest2 = "us-west-2" + case any = "any" } extension FunctionInvokeOptions { diff --git a/Sources/Helpers/HTTP/Exports.swift b/Sources/Helpers/HTTP/Exports.swift index 37ba2f2a..59f51d5f 100644 --- a/Sources/Helpers/HTTP/Exports.swift +++ b/Sources/Helpers/HTTP/Exports.swift @@ -1,3 +1,7 @@ @_exported import HTTPTypes -@_exported import protocol OpenAPIRuntime.ClientTransport -@_exported import class OpenAPIRuntime.HTTPBody + +import protocol OpenAPIRuntime.ClientTransport +import class OpenAPIRuntime.HTTPBody + +public typealias ClientTransport = OpenAPIRuntime.ClientTransport +public typealias HTTPBody = OpenAPIRuntime.HTTPBody diff --git a/Sources/Helpers/HTTP/LoggingMiddleware.swift b/Sources/Helpers/HTTP/LoggingMiddleware.swift index cc0d7afd..744e526b 100644 --- a/Sources/Helpers/HTTP/LoggingMiddleware.swift +++ b/Sources/Helpers/HTTP/LoggingMiddleware.swift @@ -1,5 +1,6 @@ import Logging import OpenAPIRuntime +import HTTPTypesFoundation #if canImport(Darwin) import struct Foundation.URL @@ -9,14 +10,14 @@ import OpenAPIRuntime @preconcurrency import struct Foundation.UUID #endif -struct LoggingMiddleware: ClientMiddleware { +package struct LoggingMiddleware: ClientMiddleware { let logger: Logger - init(logger: Logger) { + package init(logger: Logger) { self.logger = logger } - func intercept( + package func intercept( _ request: HTTPTypes.HTTPRequest, body: HTTPBody?, baseURL: URL, @@ -46,7 +47,7 @@ extension HTTPFields { extension HTTPTypes.HTTPRequest { fileprivate var prettyDescription: String { - "\(method.rawValue) \(path ?? "") [\(headerFields.prettyDescription)]" + "\(method.rawValue) \(self.url?.absoluteString ?? "") [\(headerFields.prettyDescription)]" } } diff --git a/Tests/IntegrationTests/DotEnv.swift b/Tests/IntegrationTests/DotEnv.swift index 678b89b3..3e9f4bd8 100644 --- a/Tests/IntegrationTests/DotEnv.swift +++ b/Tests/IntegrationTests/DotEnv.swift @@ -1,5 +1,5 @@ enum DotEnv { - static let SUPABASE_URL = "http://localhost:54321" + static let SUPABASE_URL = "http://127.0.0.1:54321" static let SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" static let SUPABASE_SERVICE_ROLE_KEY = diff --git a/Tests/IntegrationTests/FunctionsIntegrationTests.swift b/Tests/IntegrationTests/FunctionsIntegrationTests.swift new file mode 100644 index 00000000..802b270e --- /dev/null +++ b/Tests/IntegrationTests/FunctionsIntegrationTests.swift @@ -0,0 +1,165 @@ +// +// FunctionsIntegrationTests.swift +// Supabase +// +// Created by Guilherme Souza on 04/08/25. +// + +import Supabase +import XCTest + +final class FunctionsIntegrationTests: XCTestCase { + let client = SupabaseClient( + supabaseURL: URL(string: DotEnv.SUPABASE_URL) ?? URL(string: "http://127.0.0.1:54321")!, + supabaseKey: DotEnv.SUPABASE_ANON_KEY + ) + + func testInvokeMirror() async throws { + let response: MirrorResponse = try await client.functions.invoke("mirror") + XCTAssertTrue(response.url.contains("/mirror")) + XCTAssertEqual(response.method, "POST") + } + + func testInvokeMirrorWithClientHeader() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ] + ) + let response: MirrorResponse = try await client.invoke("mirror") + XCTAssertEqual(response.headersDictionary["customheader"], "check me") + } + + func testInvokeMirrorWithInvokeHeader() async throws { + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions(headers: ["Custom-Header": "check me"]) + ) + XCTAssertEqual(response.headersDictionary["custom-header"], "check me") + } + + func testInvokeMirrorSetValidRegionOnRequest() async throws { + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions(region: .apNortheast1) + ) + XCTAssertEqual(response.headersDictionary["x-region"], "ap-northeast-1") + XCTAssertTrue(response.url.contains("forceFunctionRegion=ap-northeast-1")) + } + + func testInvokeWithRegionOverridesRegionInTheClinet() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ], + region: .apNortheast1 + ) + let response: MirrorResponse = try await client.invoke( + "mirror", + options: FunctionInvokeOptions(region: .apSoutheast1) + ) + XCTAssertEqual(response.headersDictionary["x-region"], "ap-southeast-1") + XCTAssertTrue(response.url.contains("forceFunctionRegion=ap-southeast-1")) + } + + func testStartClientWithDefaultRegionInvokeRevertsToAny() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ], + region: .apSoutheast1 + ) + let response: MirrorResponse = try await client.invoke( + "mirror", + options: FunctionInvokeOptions(region: .any) + ) + XCTAssertNil(response.headersDictionary["x-region"]) + } + + func testInvokeRegionSetOnlyOnTheConstructor() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ], + region: .apSoutheast1 + ) + let response: MirrorResponse = try await client.invoke("mirror") + XCTAssertEqual(response.headersDictionary["x-region"], "ap-southeast-1") + } + + func testInvokeMirrorWithBodyFormData() async throws { + throw XCTSkip("Unsupported body type.") + } + + func testInvokeMirrowWithEncodableBody() async throws { + let body = Body(one: "one", two: "two", three: "three", num: 11, flag: false) + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions( + headers: [ + "response-type": "json" + ], + body: body + ) + ) + let responseBody = try response.body.decode(as: Body.self, decoder: JSONDecoder()) + XCTAssertEqual(responseBody, body) + + XCTAssertEqual(response.headersDictionary["content-type"], "application/json") + XCTAssertEqual(response.headersDictionary["response-type"], "json") + } + + func testInvokeMirrowWithDataBody() async throws { + let body = Body(one: "one", two: "two", three: "three", num: 11, flag: false) + + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions( + headers: [ + "response-type": "blob" + ], + body: try JSONEncoder().encode(body) + ) + ) + + guard let responseBodyData = response.body.stringValue?.data(using: .utf8), + let responseBody = try? JSONDecoder().decode(Body.self, from: responseBodyData) + else { + XCTFail("Expected to receive body response as JSON string.") + return + } + + XCTAssertEqual(responseBody, body) + + XCTAssertEqual(response.headersDictionary["content-type"], "application/octet-stream") + XCTAssertEqual(response.headersDictionary["response-type"], "blob") + } +} + +struct MirrorResponse: Decodable { + let url: String + let method: String + let headers: AnyJSON + let body: AnyJSON + + var headersDictionary: [String: String] { + Dictionary( + uniqueKeysWithValues: headers.arrayValue?.compactMap { + $0.arrayValue?.compactMap(\.stringValue) ?? [] + }.map { ($0[0], $0[1]) } ?? [] + ) + } +} +struct Body: Codable, Equatable { + let one, two, three: String + let num: Int + let flag: Bool +} diff --git a/Tests/IntegrationTests/supabase/.temp/cli-latest b/Tests/IntegrationTests/supabase/.temp/cli-latest index f47ab084..8e00c6d6 100644 --- a/Tests/IntegrationTests/supabase/.temp/cli-latest +++ b/Tests/IntegrationTests/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.22.12 \ No newline at end of file +v2.33.9 \ No newline at end of file diff --git a/Tests/IntegrationTests/supabase/config.toml b/Tests/IntegrationTests/supabase/config.toml index ceb35515..f579e146 100644 --- a/Tests/IntegrationTests/supabase/config.toml +++ b/Tests/IntegrationTests/supabase/config.toml @@ -306,3 +306,14 @@ s3_region = "env(S3_REGION)" s3_access_key = "env(S3_ACCESS_KEY)" # Configures AWS_SECRET_ACCESS_KEY for S3 bucket s3_secret_key = "env(S3_SECRET_KEY)" + +[functions.mirror] +enabled = true +verify_jwt = true +import_map = "./functions/mirror/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/mirror/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/mirror/*.html" ] diff --git a/Tests/IntegrationTests/supabase/functions/mirror/.npmrc b/Tests/IntegrationTests/supabase/functions/mirror/.npmrc new file mode 100644 index 00000000..48c63886 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/mirror/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Tests/IntegrationTests/supabase/functions/mirror/deno.json b/Tests/IntegrationTests/supabase/functions/mirror/deno.json new file mode 100644 index 00000000..f6ca8454 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/mirror/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Tests/IntegrationTests/supabase/functions/mirror/index.ts b/Tests/IntegrationTests/supabase/functions/mirror/index.ts new file mode 100644 index 00000000..16246c7b --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/mirror/index.ts @@ -0,0 +1,66 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "jsr:@supabase/functions-js/edge-runtime.d.ts" +import { serve } from 'https://deno.land/std/http/server.ts' + +serve(async (request: Request) => { + let body + let contentType = 'application/json' + switch (request.headers.get('response-type')) { + case 'json': { + body = await request.json() + break + } + case 'form': { + const formBody = await request.formData() + body = [] + for (const e of formBody.entries()) { + body.push(e) + } + break + } + case 'blob': { + const data = await request.blob() + body = await data.text() + contentType = 'application/octet-stream' + break + } + case 'arrayBuffer': { + const data = await request.arrayBuffer() + body = new TextDecoder().decode(data || new Uint8Array()) + contentType = 'application/octet-stream' + break + } + default: { + body = await request.text() + contentType = 'text/plain' + break + } + } + const headers = [] + for (const h of request.headers.entries()) { + headers.push(h) + } + const resp = { + url: request.url ?? 'empty', + method: request.method ?? 'empty', + headers: headers ?? 'empty', + body: body ?? 'empty', + } + + let responseData + if (request.headers.get('response-type') === 'blob') { + responseData = new Blob([JSON.stringify(resp)], { type: 'application/json' }) + } else { + responseData = JSON.stringify(resp) + } + return new Response(responseData, { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +}) From 7a66e71b78618275427908e0648e9b67adcd2042 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 08:30:36 -0300 Subject: [PATCH 09/16] move baseURL to Client type --- Sources/Functions/FunctionsClient.swift | 18 ++---------------- Sources/Functions/Logger.swift | 13 +++++++++++++ Sources/Helpers/HTTP/Client.swift | 18 +++++++++++++++++- 3 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 Sources/Functions/Logger.swift diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index eb096e82..aae7106f 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -46,20 +46,6 @@ struct FetchTransportAdapter: ClientTransport { } } -extension URL { - /// Returns a new URL which contains only `{scheme}://{host}:{port}`. - fileprivate var baseURL: URL { - guard let components = URLComponents(string: self.absoluteString) else { return self } - - var newComponents = URLComponents() - newComponents.scheme = components.scheme - newComponents.host = components.host - newComponents.port = components.port - - return newComponents.url ?? self - } -} - /// An actor representing a client for invoking functions. public final class FunctionsClient: Sendable { /// Fetch handler used to make requests. @@ -134,9 +120,9 @@ public final class FunctionsClient: Sendable { region: region, logger: logger, client: Client( - serverURL: url.baseURL, + serverURL: url, transport: transport ?? URLSessionTransport(), - middlewares: [LoggingMiddleware(logger: Logger(label: "functions"))] + middlewares: [LoggingMiddleware(logger: .functions)] ) ) } diff --git a/Sources/Functions/Logger.swift b/Sources/Functions/Logger.swift new file mode 100644 index 00000000..a0638114 --- /dev/null +++ b/Sources/Functions/Logger.swift @@ -0,0 +1,13 @@ +// +// Logger.swift +// Supabase +// +// Created by Guilherme Souza on 05/08/25. +// + +import Logging + +extension Logger { + /// A Logger instance for the Functions module. + static let functions = Logger(label: "Functions") +} diff --git a/Sources/Helpers/HTTP/Client.swift b/Sources/Helpers/HTTP/Client.swift index 317aaa22..01bc676b 100644 --- a/Sources/Helpers/HTTP/Client.swift +++ b/Sources/Helpers/HTTP/Client.swift @@ -3,8 +3,10 @@ import OpenAPIRuntime #if canImport(Darwin) import struct Foundation.URL + import struct Foundation.URLComponents #else @preconcurrency import struct Foundation.URL + @preconcurrency import struct Foundation.URLComponents #endif /// A client that can send HTTP requests and receive HTTP responses. @@ -26,7 +28,7 @@ package struct Client: Sendable { transport: any ClientTransport, middlewares: [any ClientMiddleware] = [] ) { - self.serverURL = serverURL + self.serverURL = serverURL.baseURL self.transport = transport self.middlewares = middlewares } @@ -72,3 +74,17 @@ package struct Client: Sendable { return try await next(request, body, baseURL) } } + +extension URL { + /// Returns a new URL which contains only `{scheme}://{host}:{port}`. + fileprivate var baseURL: URL { + guard let components = URLComponents(string: self.absoluteString) else { return self } + + var newComponents = URLComponents() + newComponents.scheme = components.scheme + newComponents.host = components.host + newComponents.port = components.port + + return newComponents.url ?? self + } +} From 8080bcb269b7f0d3e623217f78f5d9b804456825 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 08:35:36 -0300 Subject: [PATCH 10/16] ci: comment out library-evolution step --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c3fc5c..2fb97f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: run: rm -r Tests/IntegrationTests/* - name: "Build Swift Package" run: swift build - + # android: # name: Android # runs-on: ubuntu-latest @@ -139,18 +139,18 @@ jobs: # # tests are not yet passing on Android # run-tests: false - library-evolution: - name: Library (evolution) - runs-on: macos-15 - strategy: - matrix: - xcode: ["16.3"] - steps: - - uses: actions/checkout@v4 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: Build for library evolution - run: make build-for-library-evolution + # library-evolution: + # name: Library (evolution) + # runs-on: macos-15 + # strategy: + # matrix: + # xcode: ["16.3"] + # steps: + # - uses: actions/checkout@v4 + # - name: Select Xcode ${{ matrix.xcode }} + # run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + # - name: Build for library evolution + # run: make build-for-library-evolution examples: name: Examples From 8c89c31a8612eafebd44ec37ec3e97dc858f0946 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 08:45:21 -0300 Subject: [PATCH 11/16] remove Mocks on teardown --- Tests/FunctionsTests/FunctionsClientTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index afaa64ed..2e0ffa93 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -47,6 +47,11 @@ final class FunctionsClientTests: XCTestCase { super.setUp() } + override func tearDown() { + super.tearDown() + Mocker.removeAll() + } + func testInit() async { let client = FunctionsClient( url: url, From dfbced20461be5ac7aeb7611267cad43f6d25399 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 09:35:03 -0300 Subject: [PATCH 12/16] fix tests --- .../__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt index b7ebf5c7..72ba071c 100644 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt +++ b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt @@ -3,4 +3,4 @@ curl \ --header "apikey: supabase.anon.key" \ --header "x-client-info: functions-swift/x.y.z" \ --header "x-region: ap-northeast-1" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ap-northeast-1" \ No newline at end of file From 710d282fbdff538f7102f5552cd2e38b6bebf101 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 09:40:29 -0300 Subject: [PATCH 13/16] fix tests --- Tests/FunctionsTests/FunctionsClientTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 2e0ffa93..209700b1 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -172,6 +172,7 @@ final class FunctionsClientTests: XCTestCase { Mock( url: url.appendingPathComponent("hello-world"), + ignoreQuery: true, statusCode: 200, data: [.post: Data()] ) @@ -182,7 +183,7 @@ final class FunctionsClientTests: XCTestCase { --header "X-Client-Info: functions-swift/0.0.0" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ --header "x-region: ca-central-1" \ - "http://localhost:5432/functions/v1/hello-world" + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ca-central-1" """# } .register() @@ -193,6 +194,7 @@ final class FunctionsClientTests: XCTestCase { func testInvokeWithRegion() async throws { Mock( url: url.appendingPathComponent("hello-world"), + ignoreQuery: true, statusCode: 200, data: [.post: Data()] ) @@ -203,7 +205,7 @@ final class FunctionsClientTests: XCTestCase { --header "X-Client-Info: functions-swift/0.0.0" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ --header "x-region: ca-central-1" \ - "http://localhost:5432/functions/v1/hello-world" + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ca-central-1" """# } .register() From 21adc60480b04c78bc93def4f754487ef5bd51e1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 09:52:23 -0300 Subject: [PATCH 14/16] use specific import --- Sources/Supabase/SupabaseClient.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 5857cb5d..4dd640f1 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -2,7 +2,8 @@ import ConcurrencyExtras import Foundation import HTTPTypes import IssueReporting -import OpenAPIURLSession + +import struct OpenAPIURLSession.URLSessionTransport #if canImport(FoundationNetworking) import FoundationNetworking From 8196dce16fb78a20636f579cfda5af3b4dc1bfe2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 5 Aug 2025 11:05:25 -0300 Subject: [PATCH 15/16] store authTransport instance for shared use later --- Sources/Supabase/AuthClientTransport.swift | 30 +++++++++++++++ Sources/Supabase/SupabaseClient.swift | 43 +++++++++++----------- 2 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 Sources/Supabase/AuthClientTransport.swift diff --git a/Sources/Supabase/AuthClientTransport.swift b/Sources/Supabase/AuthClientTransport.swift new file mode 100644 index 00000000..69341dd0 --- /dev/null +++ b/Sources/Supabase/AuthClientTransport.swift @@ -0,0 +1,30 @@ +// +// AuthClientTransport.swift +// Supabase +// +// Created by Guilherme Souza on 05/08/25. +// + +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +struct AuthClientTransport: ClientTransport { + let transport: any ClientTransport + let accessToken: @Sendable () async -> String? + + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + var request = request + if let token = await accessToken() { + request.headerFields[.authorization] = "Bearer \(token)" + } + return try await transport.send(request, body: body, baseURL: baseURL, operationID: operationID) + } +} diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 4dd640f1..99699fcc 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -9,24 +9,6 @@ import struct OpenAPIURLSession.URLSessionTransport import FoundationNetworking #endif -struct AuthClientTransport: ClientTransport { - let transport: any ClientTransport - let accessToken: @Sendable () async -> String? - - func send( - _ request: HTTPTypes.HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { - var request = request - if let token = await accessToken() { - request.headerFields[.authorization] = "Bearer \(token)" - } - return try await transport.send(request, body: body, baseURL: baseURL, operationID: operationID) - } -} - /// Supabase Client. public final class SupabaseClient: Sendable { let options: SupabaseClientOptions @@ -36,7 +18,26 @@ public final class SupabaseClient: Sendable { let databaseURL: URL let functionsURL: URL + /// The base transport used by all modules. + /// + /// Use this instance when no authentication is needed. private let transport: any ClientTransport + + /// The transport which injects the access token before forwarding request to `transport`. + /// + /// Use this instance when authentication is needed. + private var authTransport: any ClientTransport { + mutableState.withValue { + if $0.authTransport == nil { + $0.authTransport = AuthClientTransport( + transport: transport, + accessToken: { try? await self._getAccessToken() } + ) + } + return $0.authTransport! + } + } + private let _auth: AuthClient /// Supabase Auth allows you to create and manage user sessions for access to data that is secured by access policies. @@ -110,10 +111,7 @@ public final class SupabaseClient: Sendable { headers: headers, region: options.functions.region, logger: options.global.logger, - transport: AuthClientTransport( - transport: transport, - accessToken: { try? await self._getAccessToken() } - ) + transport: authTransport ) } @@ -137,6 +135,7 @@ public final class SupabaseClient: Sendable { var realtime: RealtimeClientV2? var changedAccessToken: String? + var authTransport: AuthClientTransport? } let mutableState = LockIsolated(MutableState()) From 4af940ad6396cb74e992a95a6fcad1108af8852d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 11 Aug 2025 12:27:05 -0300 Subject: [PATCH 16/16] refactor: move FetchTransportAdapter to Helpers module for reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move FetchTransportAdapter from Functions to Sources/Helpers/HTTP/ - Remove duplicate implementation from FunctionsClient - Keep package visibility for sharing across modules - Enables reuse for other module migrations in future PRs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Sources/Functions/FunctionsClient.swift | 34 --------------- .../Helpers/HTTP/FetchTransportAdapter.swift | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+), 34 deletions(-) create mode 100644 Sources/Helpers/HTTP/FetchTransportAdapter.swift diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index aae7106f..d6266e68 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -11,40 +11,6 @@ import OpenAPIURLSession let version = Helpers.version -/// A ClientTransport implementation that adapts the old Fetch api. -struct FetchTransportAdapter: ClientTransport { - let fetch: FunctionsClient.FetchHandler - - init(fetch: @escaping FunctionsClient.FetchHandler) { - self.fetch = fetch - } - - func send( - _ request: HTTPTypes.HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String - ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { - guard var urlRequest = URLRequest(httpRequest: request) else { - throw URLError(.badURL) - } - - if let body { - urlRequest.httpBody = try await Data(collecting: body, upTo: .max) - } - - let (data, response) = try await fetch(urlRequest) - - guard let httpURLResponse = response as? HTTPURLResponse, - let httpResponse = httpURLResponse.httpResponse - else { - throw URLError(.badServerResponse) - } - - let body = HTTPBody(data) - return (httpResponse, body) - } -} /// An actor representing a client for invoking functions. public final class FunctionsClient: Sendable { diff --git a/Sources/Helpers/HTTP/FetchTransportAdapter.swift b/Sources/Helpers/HTTP/FetchTransportAdapter.swift new file mode 100644 index 00000000..4c602f7a --- /dev/null +++ b/Sources/Helpers/HTTP/FetchTransportAdapter.swift @@ -0,0 +1,43 @@ +import Foundation +import HTTPTypes +import HTTPTypesFoundation +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A ClientTransport implementation that adapts the old Fetch api. +package struct FetchTransportAdapter: ClientTransport { + let fetch: @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) + + package init(fetch: @escaping @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse)) { + self.fetch = fetch + } + + package func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + guard var urlRequest = URLRequest(httpRequest: request) else { + throw URLError(.badURL) + } + + if let body { + urlRequest.httpBody = try await Data(collecting: body, upTo: .max) + } + + let (data, response) = try await fetch(urlRequest) + + guard let httpURLResponse = response as? HTTPURLResponse, + let httpResponse = httpURLResponse.httpResponse + else { + throw URLError(.badServerResponse) + } + + let body = HTTPBody(data) + return (httpResponse, body) + } +} \ No newline at end of file