From ba487ef2f580428452e3e5009ae7f3978dd30769 Mon Sep 17 00:00:00 2001 From: Franz Busch Date: Tue, 7 Jan 2025 11:43:11 +0100 Subject: [PATCH] Move foundation extensions behind traits # Motivation Currently this package extends various Foundation types in a separate module. One problem with this module is that it unconditionally imports `FoundationNetworking`. This is undesirable on Linux since it brings `curl` as a dependency. Additionally, the discoverability of having to import an additional module isn't great. # Modification This PR introduces two new traits that enable extensions on types from `FoundationEssentials` and `FoundationNetworking` respectively. Giving adopters fine control of what they want to link against. It also moves all the extensions into the main `HTTPTypes` module. # Result Only a single module that is configurable through package traits. --- Package.swift | 6 +- .../HTTPRequest+URL.swift | 214 ++++++++++++++++++ .../HTTPTypes+ISOLatin1.swift | 46 ++++ .../URLRequest+HTTPTypes.swift | 71 ++++++ .../URLResponse+HTTPTypes.swift | 63 ++++++ .../URLSession+HTTPTypes.swift | 209 +++++++++++++++++ 6 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 Sources/HTTPTypes/FoundationExtensions/HTTPRequest+URL.swift create mode 100644 Sources/HTTPTypes/FoundationExtensions/HTTPTypes+ISOLatin1.swift create mode 100644 Sources/HTTPTypes/FoundationExtensions/URLRequest+HTTPTypes.swift create mode 100644 Sources/HTTPTypes/FoundationExtensions/URLResponse+HTTPTypes.swift create mode 100644 Sources/HTTPTypes/FoundationExtensions/URLSession+HTTPTypes.swift diff --git a/Package.swift b/Package.swift index a7d53db..c3961ff 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.1 import PackageDescription @@ -8,6 +8,10 @@ let package = Package( .library(name: "HTTPTypes", targets: ["HTTPTypes"]), .library(name: "HTTPTypesFoundation", targets: ["HTTPTypesFoundation"]), ], + traits: [ + "FoundationEssentialExtensions", + "FoundationNetworkingExtensions", + ], targets: [ .target(name: "HTTPTypes"), .target( diff --git a/Sources/HTTPTypes/FoundationExtensions/HTTPRequest+URL.swift b/Sources/HTTPTypes/FoundationExtensions/HTTPRequest+URL.swift new file mode 100644 index 0000000..646da3e --- /dev/null +++ b/Sources/HTTPTypes/FoundationExtensions/HTTPRequest+URL.swift @@ -0,0 +1,214 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if FoundationExtensions +#if canImport(FoundationEssentials) +public import struct FoundationEssentials.URL +#else +public import struct Foundation.URL +#endif + +#if canImport(CoreFoundation) +public import CoreFoundation +#endif // canImport(CoreFoundation) + +extension HTTPRequest { + /// The URL of the request synthesized from the scheme, authority, and path pseudo header + /// fields. + public var url: URL? { + get { + if let schemeField = self.pseudoHeaderFields.scheme, + let authorityField = self.pseudoHeaderFields.authority, + let pathField = self.pseudoHeaderFields.path + { + return schemeField.withUnsafeBytesOfValue { scheme in + authorityField.withUnsafeBytesOfValue { authority in + pathField.withUnsafeBytesOfValue { path in + URL(scheme: scheme, authority: authority, path: path) + } + } + } + } else { + return nil + } + } + set { + if let newValue { + let (scheme, authority, path) = newValue.httpRequestComponents + self.scheme = String(decoding: scheme, as: UTF8.self) + self.authority = authority.map { String(decoding: $0, as: UTF8.self) } + self.path = String(decoding: path, as: UTF8.self) + } else { + self.pseudoHeaderFields.scheme = nil + self.pseudoHeaderFields.authority = nil + self.pseudoHeaderFields.path = nil + } + } + } + + /// Create an HTTP request with a method, a URL, and header fields. + /// - Parameters: + /// - method: The request method, defaults to GET. + /// - url: The URL to populate the scheme, authority, and path pseudo header fields. + /// - headerFields: The request header fields. + public init(method: Method = .get, url: URL, headerFields: HTTPFields = [:]) { + let (scheme, authority, path) = url.httpRequestComponents + let schemeString = String(decoding: scheme, as: UTF8.self) + let authorityString = authority.map { String(decoding: $0, as: UTF8.self) } + let pathString = String(decoding: path, as: UTF8.self) + + self.init( + method: method, + scheme: schemeString, + authority: authorityString, + path: pathString, + headerFields: headerFields + ) + } +} + +extension URL { + fileprivate init?(scheme: some Collection, authority: some Collection, path: some Collection) { + var buffer = [UInt8]() + buffer.reserveCapacity(scheme.count + 3 + authority.count + path.count) + buffer.append(contentsOf: scheme) + buffer.append(contentsOf: "://".utf8) + buffer.append(contentsOf: authority) + buffer.append(contentsOf: path) + + #if canImport(CoreFoundation) + if let url = buffer.withUnsafeBytes({ buffer in + CFURLCreateAbsoluteURLWithBytes( + kCFAllocatorDefault, + buffer.baseAddress, + buffer.count, + CFStringBuiltInEncodings.ASCII.rawValue, + nil, + false + ).map { unsafeBitCast($0, to: NSURL.self) as URL } + }) { + self = url + } else { + return nil + } + #else // canImport(CoreFoundation) + // This initializer does not preserve WHATWG URLs + self.init(string: String(decoding: buffer, as: UTF8.self)) + #endif // canImport(CoreFoundation) + } + + fileprivate var httpRequestComponents: (scheme: [UInt8], authority: [UInt8]?, path: [UInt8]) { + #if canImport(CoreFoundation) + // CFURL parser based on byte ranges does not unnecessarily percent-encode WHATWG URL + let url = unsafeBitCast(self.absoluteURL as NSURL, to: CFURL.self) + let length = CFURLGetBytes(url, nil, 0) + return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: length) { buffer in + CFURLGetBytes(url, buffer.baseAddress, buffer.count) + + func unionRange(_ first: CFRange, _ second: CFRange) -> CFRange { + if first.location == kCFNotFound { return second } + if second.location == kCFNotFound { return first } + return CFRange(location: first.location, length: second.location + second.length - first.location) + } + + func bufferSlice(_ range: CFRange) -> UnsafeMutableBufferPointer { + UnsafeMutableBufferPointer(rebasing: buffer[range.location..? + if let lowerBound = pathRange?.lowerBound ?? queryRange?.lowerBound, + let upperBound = queryRange?.upperBound ?? pathRange?.upperBound + { + requestPathRange = lowerBound.. UInt8.max { + buffer[index] = 0x20 + } else { + buffer[index] = UInt8(truncatingIfNeeded: scalar.value) + } + } + return HTTPField(name: name, value: buffer) + } + } + } + + var isoLatin1Value: String { + if self.value.isASCII { + return self.value + } else { + return self.withUnsafeBytesOfValue { buffer in + let scalars = buffer.lazy.map { UnicodeScalar(UInt32($0))! } + var string = "" + string.unicodeScalars.append(contentsOf: scalars) + return string + } + } + } +} diff --git a/Sources/HTTPTypes/FoundationExtensions/URLRequest+HTTPTypes.swift b/Sources/HTTPTypes/FoundationExtensions/URLRequest+HTTPTypes.swift new file mode 100644 index 0000000..84af4c0 --- /dev/null +++ b/Sources/HTTPTypes/FoundationExtensions/URLRequest+HTTPTypes.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if !os(WASI) +#if FoundationNetworkingExtensions + +#if canImport(FoundationNetworking) +public import struct FoundationNetworking.URLRequest +#else +public import struct Foundation.URLRequest +#endif + +extension URLRequest { + /// Create a `URLRequest` from an `HTTPRequest`. + /// - Parameter httpRequest: The HTTP request to convert from. + public init?(httpRequest: HTTPRequest) { + guard let url = httpRequest.url else { + return nil + } + var request = URLRequest(url: url) + request.httpMethod = httpRequest.method.rawValue + var combinedFields = [HTTPField.Name: String](minimumCapacity: httpRequest.headerFields.count) + for field in httpRequest.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 + } + request.allHTTPHeaderFields = headerFields + self = request + } + + /// Convert the `URLRequest` into an `HTTPRequest`. + public var httpRequest: HTTPRequest? { + guard let method = HTTPRequest.Method(self.httpMethod ?? "GET"), + let url + else { + return nil + } + var request = HTTPRequest(method: method, url: url) + if let allHTTPHeaderFields = self.allHTTPHeaderFields { + request.headerFields.reserveCapacity(allHTTPHeaderFields.count) + for (name, value) in allHTTPHeaderFields { + if let name = HTTPField.Name(name) { + request.headerFields.append(HTTPField(name: name, isoLatin1Value: value)) + } + } + } + return request + } +} + +#endif +#endif diff --git a/Sources/HTTPTypes/FoundationExtensions/URLResponse+HTTPTypes.swift b/Sources/HTTPTypes/FoundationExtensions/URLResponse+HTTPTypes.swift new file mode 100644 index 0000000..db97f3e --- /dev/null +++ b/Sources/HTTPTypes/FoundationExtensions/URLResponse+HTTPTypes.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if !os(WASI) +#if FoundationNetworkingExtensions + +#if canImport(FoundationNetworking) +public import struct FoundationNetworking.HTTPURLResponse +#else +public import struct Foundation.HTTPURLResponse +#endif + +extension HTTPURLResponse { + /// Create an `HTTPURLResponse` from an `HTTPResponse`. + /// - Parameter httpResponse: The HTTP response to convert from. + /// - Parameter url: The URL of the response. + public convenience init?(httpResponse: HTTPResponse, url: URL) { + var combinedFields = [HTTPField.Name: String](minimumCapacity: httpResponse.headerFields.count) + for field in httpResponse.headerFields { + if let existingValue = combinedFields[field.name] { + combinedFields[field.name] = "\(existingValue), \(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.init(url: url, statusCode: httpResponse.status.code, httpVersion: "HTTP/1.1", headerFields: headerFields) + } + + /// Convert the `HTTPURLResponse` into an `HTTPResponse`. + public var httpResponse: HTTPResponse? { + guard (0...999).contains(self.statusCode) else { + return nil + } + var response = HTTPResponse(status: .init(code: self.statusCode)) + if let fields = self.allHeaderFields as? [String: String] { + response.headerFields.reserveCapacity(fields.count) + for (name, value) in fields { + if let name = HTTPField.Name(name) { + response.headerFields.append(HTTPField(name: name, isoLatin1Value: value)) + } + } + } + return response + } +} + +#endif +#endif diff --git a/Sources/HTTPTypes/FoundationExtensions/URLSession+HTTPTypes.swift b/Sources/HTTPTypes/FoundationExtensions/URLSession+HTTPTypes.swift new file mode 100644 index 0000000..8010e84 --- /dev/null +++ b/Sources/HTTPTypes/FoundationExtensions/URLSession+HTTPTypes.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if FoundationNetworkingExtensions +#if !os(WASI) + +#if canImport(FoundationNetworking) +public import struct FoundationNetworking.URLSessionTask +#else +public import struct Foundation.URLSessionTask +#endif + +extension URLSessionTask { + /// The original HTTP request this task was created with. + public var originalHTTPRequest: HTTPRequest? { + self.originalRequest?.httpRequest + } + + /// The current HTTP request -- may differ from the `originalHTTPRequest` due to HTTP redirection. + public var currentHTTPRequest: HTTPRequest? { + self.currentRequest?.httpRequest + } + + /// The HTTP response received from the server. + public var httpResponse: HTTPResponse? { + (self.response as? HTTPURLResponse)?.httpResponse + } +} + +private enum HTTPTypeConversionError: Error { + case failedToConvertHTTPRequestToURLRequest + case failedToConvertURLResponseToHTTPResponse +} + +#endif + +#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || compiler(>=6) || (compiler(>=6) && os(visionOS)) +public import struct Foundation.URLSession +public import struct Foundation.URLSessionTaskDelegate +public import struct Foundation.HTTPURLResponse +public import struct Foundation.URLRequest +public import struct Foundation.Data + +@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) +extension URLSession { + /// Convenience method to load data using an `HTTPRequest`; creates and resumes a `URLSessionDataTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to load data. + /// - Parameter delegate: Task-specific delegate. + /// - Returns: Data and response. + public func data( + for request: HTTPRequest, + delegate: URLSessionTaskDelegate? = nil + ) async throws -> (Data, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.data(for: urlRequest, delegate: delegate) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } + + /// Convenience method to upload data using an `HTTPRequest`; creates and resumes a `URLSessionUploadTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to upload data. + /// - Parameter fileURL: File to upload. + /// - Parameter delegate: Task-specific delegate. + /// - Returns: Data and response. + public func upload( + for request: HTTPRequest, + fromFile fileURL: URL, + delegate: URLSessionTaskDelegate? = nil + ) async throws -> (Data, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.upload(for: urlRequest, fromFile: fileURL, delegate: delegate) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } + + /// Convenience method to upload data using an `HTTPRequest`, creates and resumes a `URLSessionUploadTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to upload data. + /// - Parameter bodyData: Data to upload. + /// - Parameter delegate: Task-specific delegate. + /// - Returns: Data and response. + public func upload( + for request: HTTPRequest, + from bodyData: Data, + delegate: URLSessionTaskDelegate? = nil + ) async throws -> (Data, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.upload(for: urlRequest, from: bodyData, delegate: delegate) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } + + /// Convenience method to download using an `HTTPRequest`; creates and resumes a `URLSessionDownloadTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to download. + /// - Parameter delegate: Task-specific delegate. + /// - Returns: Downloaded file URL and response. The file will not be removed automatically. + public func download( + for request: HTTPRequest, + delegate: URLSessionTaskDelegate? = nil + ) async throws -> (URL, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (location, urlResponse) = try await self.download(for: urlRequest, delegate: delegate) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (location, response) + } + + #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || (compiler(>=6) && os(visionOS)) + /// Returns a byte stream that conforms to AsyncSequence protocol. + /// + /// - Parameter request: The `HTTPRequest` for which to load data. + /// - Parameter delegate: Task-specific delegate. + /// - Returns: Data stream and response. + public func bytes( + for request: HTTPRequest, + delegate: URLSessionTaskDelegate? = nil + ) async throws -> (AsyncBytes, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.bytes(for: urlRequest, delegate: delegate) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } + #endif +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension URLSession { + /// Convenience method to load data using an `HTTPRequest`; creates and resumes a `URLSessionDataTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to load data. + /// - Returns: Data and response. + public func data(for request: HTTPRequest) async throws -> (Data, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.data(for: urlRequest) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } + + /// Convenience method to upload data using an `HTTPRequest`; creates and resumes a `URLSessionUploadTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to upload data. + /// - Parameter fileURL: File to upload. + /// - Returns: Data and response. + public func upload(for request: HTTPRequest, fromFile fileURL: URL) async throws -> (Data, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.upload(for: urlRequest, fromFile: fileURL) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } + + /// Convenience method to upload data using an `HTTPRequest`, creates and resumes a `URLSessionUploadTask` internally. + /// + /// - Parameter request: The `HTTPRequest` for which to upload data. + /// - Parameter bodyData: Data to upload. + /// - Returns: Data and response. + public func upload(for request: HTTPRequest, from bodyData: Data) async throws -> (Data, HTTPResponse) { + guard let urlRequest = URLRequest(httpRequest: request) else { + throw HTTPTypeConversionError.failedToConvertHTTPRequestToURLRequest + } + let (data, urlResponse) = try await self.upload(for: urlRequest, from: bodyData) + guard let response = (urlResponse as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLResponseToHTTPResponse + } + return (data, response) + } +} +#endif +#endif