-
Notifications
You must be signed in to change notification settings - Fork 4
Land AHC HTTPClient conformance #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
29e27d3
Land AHC conformance
fabianfett 584d1b9
fix some tests
fabianfett 5a9a5c0
Module rename
fabianfett 04828da
PR review
fabianfett 324b21d
Merge branch 'main' into ff-add-ahc-conformance
fabianfett 84be931
Revert adding drip test
fabianfett 52ab961
Merge branch 'main' into ff-add-ahc-conformance
fabianfett f9fc591
swift format
fabianfett 7e3dfc8
header fixes.
fabianfett 47dd373
fix test method
fabianfett b81f5e6
Update Tests/AsyncHTTPClientConformanceTests/Suite.swift
fabianfett 04df7ed
Merge branch 'main' into ff-add-ahc-conformance
fabianfett 5f8f31a
swift format
fabianfett 36662e5
Merge branch 'main' into ff-add-ahc-conformance
fabianfett 9dc7d77
Fix source
fabianfett File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,265 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // This source file is part of the Swift HTTP API Proposal open source project | ||
| // | ||
| // Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors | ||
| // Licensed under Apache License v2.0 | ||
| // | ||
| // See LICENSE.txt for license information | ||
| // See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
|
||
| @_spi(ExperimentalHTTPAPIsSupport) public import AsyncHTTPClient | ||
| import BasicContainers | ||
| import Foundation | ||
| public import HTTPAPIs | ||
| import HTTPTypes | ||
| import NIOCore | ||
| import NIOHTTP1 | ||
| import Synchronization | ||
|
|
||
| @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, *) | ||
| extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { | ||
| public typealias RequestWriter = RequestBodyWriter | ||
| public typealias ResponseConcludingReader = ResponseReader | ||
|
|
||
| public struct RequestOptions: HTTPClientCapability.RequestOptions { | ||
|
|
||
| } | ||
|
|
||
| public struct RequestBodyWriter: AsyncWriter, ~Copyable { | ||
| public typealias WriteElement = UInt8 | ||
| public typealias WriteFailure = any Error | ||
|
|
||
| let requestWriter: HTTPClientRequest.Body.RequestWriter | ||
| var byteBuffer: ByteBuffer | ||
| var rigidArray: RigidArray<UInt8> | ||
|
|
||
| init(_ requestWriter: HTTPClientRequest.Body.RequestWriter) { | ||
| self.requestWriter = requestWriter | ||
| self.byteBuffer = ByteBuffer() | ||
| self.byteBuffer.reserveCapacity(2 ^ 16) | ||
| self.rigidArray = RigidArray(capacity: 2 ^ 16) // ~ 65k bytes | ||
| } | ||
|
|
||
| public mutating func write<Result, Failure>( | ||
| _ body: (inout OutputSpan<UInt8>) async throws(Failure) -> Result | ||
| ) async throws(AsyncStreaming.EitherError<WriteFailure, Failure>) -> Result where Failure: Error { | ||
| let result: Result | ||
| do { | ||
| // TODO: rigidArray needs a clear all | ||
| self.rigidArray.removeAll() | ||
| self.rigidArray.reserveCapacity(1024) | ||
| result = try await self.rigidArray.append(count: 1024) { (span) async throws(Failure) -> Result in | ||
| try await body(&span) | ||
| } | ||
|
|
||
| if self.rigidArray.isEmpty { | ||
| return result | ||
| } | ||
| } catch { | ||
| throw .second(error) | ||
| } | ||
|
|
||
| do { | ||
| self.byteBuffer.clear() | ||
|
|
||
| // we need to use an uninitilized helper rigidarray here to make the compiler happy | ||
| // with regards overlapping memory access. | ||
| var localArray = RigidArray<UInt8>(capacity: 0) | ||
| swap(&localArray, &self.rigidArray) | ||
| unsafe self.byteBuffer.writeBytes(localArray.span.bytes) | ||
| swap(&localArray, &self.rigidArray) | ||
| try await self.requestWriter.writeRequestBodyPart(self.byteBuffer) | ||
| } catch { | ||
| throw .first(error) | ||
| } | ||
|
|
||
| return result | ||
| } | ||
| } | ||
|
|
||
| public struct ResponseReader: ConcludingAsyncReader { | ||
| public typealias Underlying = ResponseBodyReader | ||
|
|
||
| let underlying: HTTPClientResponse.Body | ||
|
|
||
| public typealias FinalElement = HTTPFields? | ||
|
|
||
| init(underlying: HTTPClientResponse.Body) { | ||
| self.underlying = underlying | ||
| } | ||
|
|
||
| public consuming func consumeAndConclude<Return, Failure>( | ||
| body: (consuming sending ResponseBodyReader) async throws(Failure) -> Return | ||
| ) async throws(Failure) -> (Return, HTTPFields?) where Failure: Error { | ||
| let iterator = self.underlying.makeAsyncIterator() | ||
| let reader = ResponseBodyReader(underlying: iterator) | ||
| let returnValue = try await body(reader) | ||
|
|
||
| let t = self.underlying.trailers?.compactMap { | ||
| if let name = HTTPField.Name($0.name) { | ||
| HTTPField(name: name, value: $0.value) | ||
| } else { | ||
| nil | ||
| } | ||
| } | ||
| return (returnValue, t.flatMap({ HTTPFields($0) })) | ||
| } | ||
| } | ||
|
|
||
| public struct ResponseBodyReader: AsyncReader, ~Copyable { | ||
| public typealias ReadElement = UInt8 | ||
| public typealias ReadFailure = any Error | ||
|
|
||
| var underlying: HTTPClientResponse.Body.AsyncIterator | ||
| var out = RigidArray<UInt8>() | ||
| var readerIndex = 0 | ||
|
|
||
| public mutating func read<Return, Failure>( | ||
| maximumCount: Int?, | ||
| body: (consuming Span<UInt8>) async throws(Failure) -> Return | ||
| ) async throws(AsyncStreaming.EitherError<ReadFailure, Failure>) -> Return where Failure: Error { | ||
| do { | ||
| // if have enough data for the read request available, hand it to the user right away | ||
| if let maximumCount, maximumCount <= self.out.count - self.readerIndex { | ||
| defer { | ||
| self.readerIndex += maximumCount | ||
| self.reallocateIfNeeded() | ||
| } | ||
| return try await body(self.out.span.extracting(self.readerIndex..<(self.readerIndex + maximumCount))) | ||
| } | ||
|
|
||
| // we have data remaining in the local buffer. hand that to the user next. | ||
| if self.readerIndex < self.out.count { | ||
| defer { | ||
| self.readerIndex = self.out.count | ||
| self.reallocateIfNeeded() | ||
| } | ||
| return try await body(self.out.span.extracting(self.readerIndex..<self.out.count)) | ||
| } | ||
|
|
||
| // we don't have enough data | ||
| let buffer = try await self.underlying.next(isolation: #isolation) | ||
| guard let buffer else { // eof received | ||
| let array = InlineArray<0, UInt8> { _ in } | ||
| return try await body(array.span) | ||
| } | ||
|
|
||
| let readLength = maximumCount != nil ? min(maximumCount!, buffer.readableBytes) : buffer.readableBytes | ||
| self.out.reserveCapacity(self.out.count + buffer.readableBytes) | ||
| let alreadyRead = self.out.count | ||
| unsafe buffer.withUnsafeReadableBytes { rawBufferPtr in | ||
| let usbptr = unsafe rawBufferPtr.assumingMemoryBound(to: UInt8.self) | ||
| unsafe self.out.append(copying: usbptr) | ||
| } | ||
| defer { | ||
| self.readerIndex = alreadyRead + readLength | ||
| self.reallocateIfNeeded() | ||
| } | ||
| return try await body(self.out.span.extracting(alreadyRead..<(alreadyRead + readLength))) | ||
| } catch let error as Failure { | ||
| throw .second(error) | ||
| } catch { | ||
| throw .first(error) | ||
| } | ||
| } | ||
|
|
||
| private mutating func reallocateIfNeeded() { | ||
| guard self.readerIndex > 2 ^ 16 else { | ||
| return | ||
| } | ||
|
|
||
| let newCapacity = max(self.out.count - self.readerIndex, 2 ^ 16) | ||
|
|
||
| self.out = RigidArray<UInt8>(capacity: newCapacity) { | ||
| // this is probably super slow. | ||
| for i in self.readerIndex..<self.out.count { | ||
| $0.append(self.out[i]) | ||
| } | ||
| } | ||
| self.readerIndex = 0 | ||
| } | ||
| } | ||
|
|
||
| public var defaultRequestOptions: RequestOptions { | ||
| RequestOptions() | ||
| } | ||
|
|
||
| public func perform<Return: ~Copyable>( | ||
| request: HTTPRequest, | ||
| body: consuming HTTPClientRequestBody<RequestBodyWriter>?, | ||
| options: RequestOptions, | ||
| responseHandler: (HTTPResponse, consuming ResponseReader) async throws -> Return | ||
| ) async throws -> Return { | ||
| guard let url = request.url else { | ||
| fatalError() | ||
| } | ||
|
|
||
| var result: Result<Return, any Error>? | ||
| await withTaskGroup(of: Void.self) { taskGroup in | ||
|
|
||
| var ahcRequest = HTTPClientRequest(url: url.absoluteString) | ||
| ahcRequest.method = .init(rawValue: request.method.rawValue) | ||
| if !request.headerFields.isEmpty { | ||
| let sequence = request.headerFields.lazy.map({ ($0.name.rawName, $0.value) }) | ||
| ahcRequest.headers.add(contentsOf: sequence) | ||
| } | ||
|
|
||
| if let body, body.knownLength != 0 { | ||
| let (asyncStream, startUploadContinuation) = AsyncStream.makeStream(of: HTTPClientRequest.Body.RequestWriter.self) | ||
|
|
||
| taskGroup.addTask { | ||
| // TODO: We might want to allow multiple body restarts here. | ||
|
|
||
| for await ahcWriter in asyncStream { | ||
| do { | ||
| let writer = RequestWriter(ahcWriter) | ||
| let maybeTrailers = try await body.produce(into: writer) | ||
| let trailers: HTTPHeaders? = | ||
| if let trailers = maybeTrailers { | ||
| HTTPHeaders(.init(trailers.lazy.map({ ($0.name.rawName, $0.value) }))) | ||
| } else { | ||
| nil | ||
| } | ||
| ahcWriter.requestBodyStreamFinished(trailers: trailers) | ||
| break // the loop | ||
| } catch let error { | ||
| // if we fail because the user throws in upload, we have to cancel the | ||
| // upload and fail the request I guess. | ||
| ahcWriter.fail(error) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| ahcRequest.body = .init(length: body.knownLength, startUpload: startUploadContinuation) | ||
| } | ||
|
|
||
| do { | ||
| let ahcResponse = try await self.execute(ahcRequest, timeout: .seconds(30)) | ||
|
|
||
| var responseFields = HTTPFields() | ||
| for (name, value) in ahcResponse.headers { | ||
| if let name = HTTPField.Name(name) { | ||
| // Add a new header field | ||
| responseFields.append(.init(name: name, value: value)) | ||
| } | ||
| } | ||
|
|
||
| let response = HTTPResponse( | ||
| status: .init(code: Int(ahcResponse.status.code)), | ||
| headerFields: responseFields | ||
| ) | ||
|
|
||
| result = .success(try await responseHandler(response, .init(underlying: ahcResponse.body))) | ||
| } catch { | ||
| result = .failure(error) | ||
| } | ||
| } | ||
|
|
||
| return try result!.get() | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // This source file is part of the Swift HTTP API Proposal open source project | ||
| // | ||
| // Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors | ||
| // Licensed under Apache License v2.0 | ||
| // | ||
| // See LICENSE.txt for license information | ||
| // See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
|
||
| import AsyncHTTPClient | ||
| import AsyncHTTPClientConformance | ||
| import HTTPAPIs | ||
| import HTTPClient | ||
| import HTTPClientConformance | ||
| import Testing | ||
|
|
||
| @Suite struct AsyncHTTPClientTests { | ||
| @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) | ||
| @Test func conformance() async throws { | ||
| var config = HTTPClient.Configuration() | ||
| config.connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit = 1 | ||
| config.httpVersion = .automatic | ||
| config.decompression = .enabled(limit: .none) | ||
| let httpClient = HTTPClient(eventLoopGroup: .singletonMultiThreadedEventLoopGroup, configuration: config) | ||
| defer { Task { try await httpClient.shutdown() } } | ||
|
|
||
| try await runConformanceTests(excluding: [ | ||
| // TODO: AHC does not support cookies | ||
| .testBasicCookieSetAndUse, | ||
| // TODO: AHC does not support caching | ||
| .testETag, | ||
| ]) { | ||
| httpClient | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @available(macOS 26.2, *) | ||
| extension AsyncHTTPClient.HTTPClient.RequestOptions: HTTPClientCapability.RedirectionHandler { | ||
| @available(macOS 26.2, *) | ||
| public var redirectionHandler: (any HTTPClientRedirectionHandler)? { | ||
| get { | ||
| nil | ||
| } | ||
| set { | ||
|
|
||
| } | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we just do this by using https://github.com/apple/swift-nio/blob/1e51266e86d27cd9bdfa567dc7794b10bd5adabe/Sources/NIOCore/ByteBuffer-aux.swift#L572
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still need the
localArrayswap. But the rest can be replaced.