diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..5eb92fc5d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "flyingfox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/FlyingFox", + "state" : { + "revision" : "ed86fc6d68ec1467aaab3e494b581e66dd7a4512", + "version" : "0.12.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "f0525da24dc3c6cbb2b6b338b65042bc91cbc4bb", + "version" : "3.3.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 406b1c19e..2e61e4e55 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,11 @@ let libwordpressFFI: Target = .systemLibrary( let libwordpressFFI: Target = .binaryTarget(name: "libwordpressFFI", path: "target/libwordpressFFI.xcframework") #endif +let supportBackgroundURLSession: SwiftSetting = .define( + "WP_SUPPORT_BACKGROUND_URL_SESSION", + .when(platforms: [.macOS, .iOS, .tvOS, .watchOS]) +) + let package = Package( name: "wordpress", platforms: [ @@ -27,14 +32,20 @@ let package = Package( targets: ["wordpress-api"] ) ], - dependencies: [], + dependencies: [ + .package(url: "https://github.com/apple/swift-crypto", .upToNextMajor(from: "3.3.0")), + .package(url: "https://github.com/swhitty/FlyingFox", exact: "0.12.2"), + ], targets: [ .target( name: "wordpress-api", dependencies: [ .target(name: "wordpress-api-wrapper") ], - path: "native/swift/Sources/wordpress-api" + path: "native/swift/Sources/wordpress-api", + swiftSettings: [ + supportBackgroundURLSession + ] ), .target( name: "wordpress-api-wrapper", @@ -51,9 +62,14 @@ let package = Package( name: "wordpress-api-tests", dependencies: [ .target(name: "wordpress-api"), - .target(name: "libwordpressFFI") + .target(name: "libwordpressFFI"), + .product(name: "Crypto", package: "swift-crypto"), + "FlyingFox", ], - path: "native/swift/Tests/wordpress-api" + path: "native/swift/Tests/wordpress-api", + swiftSettings: [ + supportBackgroundURLSession + ] ) ] ) diff --git a/native/swift/Sources/wordpress-api/Either.swift b/native/swift/Sources/wordpress-api/Either.swift new file mode 100644 index 000000000..4b895f7a8 --- /dev/null +++ b/native/swift/Sources/wordpress-api/Either.swift @@ -0,0 +1,15 @@ +import Foundation + +enum Either { + case left(L) + case right(R) + + func map(left: (L) -> T, right: (R) -> T) -> T { + switch self { + case let .left(value): + return left(value) + case let .right(value): + return right(value) + } + } +} diff --git a/native/swift/Sources/wordpress-api/URLSession+WordPressAPI.swift b/native/swift/Sources/wordpress-api/URLSession+WordPressAPI.swift new file mode 100644 index 000000000..c07bf2b62 --- /dev/null +++ b/native/swift/Sources/wordpress-api/URLSession+WordPressAPI.swift @@ -0,0 +1,379 @@ +import Foundation +import wordpress_api_wrapper + +#if os(Linux) +import FoundationNetworking +#endif + +typealias WordPressAPIResult = Result + +extension URLSession { + + /// Send a HTTP request and return its response as a `WordPressAPIResult` instance. + /// + /// ## Progress Tracking and Cancellation + /// + /// You can track the HTTP request's overall progress by passing a `Progress` instance to the `fulfillingProgress` + /// parameter, which must satisify following requirements: + /// - `totalUnitCount` must not be zero. + /// - `completedUnitCount` must be zero. + /// - It's used exclusivity for tracking the HTTP request overal progress: No children in its progress tree. + /// - `cancellationHandler` must be nil. You can call `fulfillingProgress.cancel()` to cancel the ongoing HTTP + /// equest. + /// + /// Upon completion, the HTTP request's progress fulfills the `fulfillingProgress`. + /// + /// Please note: `parentProgress` may be updated from a background thread. + /// + /// - Parameters: + /// - request: A `WpNetworkRequest` instance that represents an HTTP request to be sent. + /// - parentProgress: A `Progress` instance that will be used as the parent progress of the HTTP request's + /// overall progress. See the function documentation regarding requirements on this argument. + func perform( + request: WpNetworkRequest, + fulfilling parentProgress: Progress? = nil + ) async -> WordPressAPIResult { +#if WP_SUPPORT_BACKGROUND_URL_SESSION + if configuration.identifier != nil { + assert( + delegate is BackgroundURLSessionDelegate, + "Unexpected `URLSession` delegate type. See the `backgroundSession(configuration:)`" + ) + } +#endif + + if let parentProgress { + assert( + parentProgress.completedUnitCount == 0 && parentProgress.totalUnitCount > 0, + "Invalid parent progress" + ) + assert( + parentProgress.cancellationHandler == nil, + "The progress instance's cancellationHandler property must be nil" + ) + } + + return await withCheckedContinuation { continuation in + let completion: @Sendable (Data?, URLResponse?, Error?) -> Void = { data, response, error in + let result: WordPressAPIResult = Self.parseResponse( + data: data, + response: response, + error: error + ) + + continuation.resume(returning: result) + } + + let task: URLSessionTask + + do { + task = try self.task(for: request, completion: completion) + } catch { + continuation.resume(returning: .failure(.requestEncodingFailure(underlyingError: error))) + return + } + + task.resume() + + if let parentProgress, parentProgress.totalUnitCount > parentProgress.completedUnitCount { + let pending = parentProgress.totalUnitCount - parentProgress.completedUnitCount + parentProgress.addChild(task.progress, withPendingUnitCount: pending) + + parentProgress.cancellationHandler = { [weak task] in + task?.cancel() + } + } + } + } + + private func task( + for wpRequest: WpNetworkRequest, + completion: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) throws -> URLSessionTask { + let request = try wpRequest.asURLRequest() + + if let body = wpRequest.body { + return createUploadTask(with: request, body: body, completion: completion) + } else { + return createDataTask(with: request, completion: completion) + } + } + + private static func parseResponse( + data: Data?, + response: URLResponse?, + error: Error? + ) -> WordPressAPIResult { + let result: WordPressAPIResult + + if let error { + if let urlError = error as? URLError { + result = .failure(.connection(urlError)) + } else { + result = .failure(.unknown(underlyingError: error)) + } + } else { + if let httpResponse = response as? HTTPURLResponse { + result = .success( + .init( + body: data ?? Data(), + statusCode: UInt16(httpResponse.statusCode), + headerMap: httpResponse.httpHeaders) + ) + } else { + result = .failure( + .unparsableResponse(response: nil, body: data, underlyingError: URLError(.badServerResponse)) + ) + } + } + + return result + } + +} + +// MARK: - Background URL Session Support + +#if WP_SUPPORT_BACKGROUND_URL_SESSION + +private extension URLSession { + + func createDataTask( + with request: URLRequest, + completion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void + ) -> URLSessionDataTask { + // This additional `callCompletionFromDelegate` is added to unit test `BackgroundURLSessionDelegate`. + // Background `URLSession` doesn't work on unit tests, we have to create a non-background `URLSession` + // which has a `BackgroundURLSessionDelegate` delegate in order to test `BackgroundURLSessionDelegate`. + // + // In reality, `callCompletionFromDelegate` and `isBackgroundSession` have the same value. + let callCompletionFromDelegate = delegate is BackgroundURLSessionDelegate + + let task: URLSessionDataTask + if callCompletionFromDelegate { + task = dataTask(with: request) + set(completion: completion, forTaskWithIdentifier: task.taskIdentifier) + } else { + task = dataTask(with: request, completionHandler: completion) + } + + return task + } + + func createUploadTask( + with request: URLRequest, + body: Either, + completion originalCompletion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void + ) -> URLSessionUploadTask { + // This additional `callCompletionFromDelegate` is added to unit test `BackgroundURLSessionDelegate`. + // Background `URLSession` doesn't work on unit tests, we have to create a non-background `URLSession` + // which has a `BackgroundURLSessionDelegate` delegate in order to test `BackgroundURLSessionDelegate`. + // + // In reality, `callCompletionFromDelegate` and `isBackgroundSession` have the same value. + let callCompletionFromDelegate = delegate is BackgroundURLSessionDelegate + + var completion = originalCompletion + + let task = body.map( + left: { + if callCompletionFromDelegate { + return uploadTask(with: request, from: $0) + } else { + return uploadTask(with: request, from: $0, completionHandler: completion) + } + }, + right: { tempFileURL in + // Remove the temp file, which contains request body, once the HTTP request completes. + completion = { data, response, error in + try? FileManager.default.removeItem(at: tempFileURL) + originalCompletion(data, response, error) + } + + if callCompletionFromDelegate { + return uploadTask(with: request, fromFile: tempFileURL) + } else { + return uploadTask(with: request, fromFile: tempFileURL, completionHandler: completion) + } + } + ) + + if callCompletionFromDelegate { + set(completion: completion, forTaskWithIdentifier: task.taskIdentifier) + } + + return task + } + +} + +#else + +private extension URLSession { + + func createDataTask( + with request: URLRequest, + completion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void + ) -> URLSessionDataTask { + dataTask(with: request, completionHandler: completion) + } + + func createUploadTask( + with request: URLRequest, + body: Either, + completion originalCompletion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void + ) -> URLSessionUploadTask { + body.map( + left: { + uploadTask(with: request, from: $0, completionHandler: originalCompletion) + }, + right: { tempFileURL in + // Remove the temp file, which contains request body, once the HTTP request completes. + let completion = { data, response, error in + try? FileManager.default.removeItem(at: tempFileURL) + originalCompletion(data, response, error) + } + return uploadTask(with: request, fromFile: tempFileURL, completionHandler: completion) + } + ) + } + +} + +#endif + +#if WP_SUPPORT_BACKGROUND_URL_SESSION + +extension URLSession { + + /// Create a background URLSession instance that can be used in the `perform(request:...)` async function. + /// + /// The `perform(request:...)` async function can be used in all non-background `URLSession` instances without any + /// extra work. However, there is a requirement to make the function works with with background `URLSession` + /// instances. That is the `URLSession` must have a delegate of `BackgroundURLSessionDelegate` type. + static func backgroundSession(configuration: URLSessionConfiguration) -> URLSession { + assert(configuration.identifier != nil) + // Pass `delegateQueue: nil` to get a serial queue, which is required to ensure thread safe access to + // `WordPressKitSessionDelegate` instances. + return URLSession(configuration: configuration, delegate: BackgroundURLSessionDelegate(), delegateQueue: nil) + } + +} + +private final class SessionTaskData { + var responseBody = Data() + var completion: ((Data?, URLResponse?, Error?) -> Void)? +} + +class BackgroundURLSessionDelegate: NSObject, URLSessionDataDelegate { + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + session.received(data, forTaskWithIdentifier: dataTask.taskIdentifier) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + session.completed(with: error, response: task.response, forTaskWithIdentifier: task.taskIdentifier) + } + +} + +private extension URLSession { + + static var taskDataKey = 0 + + // A map from `URLSessionTask` identifier to in-memory data of the given task. + // + // This property is in `URLSession` not `BackgroundURLSessionDelegate` because task id (the key) is unique within + // the context of a `URLSession` instance. And in theory `BackgroundURLSessionDelegate` can be used by multiple + // `URLSession` instances. + var taskData: [Int: SessionTaskData] { + get { + objc_getAssociatedObject(self, &URLSession.taskDataKey) as? [Int: SessionTaskData] ?? [:] + } + set { + objc_setAssociatedObject(self, &URLSession.taskDataKey, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + func updateData(forTaskWithIdentifier taskID: Int, using closure: (SessionTaskData) -> Void) { + let task = self.taskData[taskID] ?? SessionTaskData() + closure(task) + self.taskData[taskID] = task + } + + func set(completion: @escaping (Data?, URLResponse?, Error?) -> Void, forTaskWithIdentifier taskID: Int) { + updateData(forTaskWithIdentifier: taskID) { + $0.completion = completion + } + } + + func received(_ data: Data, forTaskWithIdentifier taskID: Int) { + updateData(forTaskWithIdentifier: taskID) { task in + task.responseBody.append(data) + } + } + + func completed(with error: Error?, response: URLResponse?, forTaskWithIdentifier taskID: Int) { + guard let task = taskData[taskID] else { + return + } + + if let error { + task.completion?(nil, response, error) + } else { + task.completion?(task.responseBody, response, nil) + } + + self.taskData.removeValue(forKey: taskID) + } + +} + +#endif + +// MARK: - wordpress_api_wrapper helpers + +extension WpNetworkRequest { + func asURLRequest() throws -> URLRequest { + guard let url = URL(string: self.url), url.scheme == "http" || url.scheme == "https" else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = self.method.rawValue + request.allHTTPHeaderFields = self.headerMap + return request + } + + var body: Either? { + // To be implemented + return nil + } +} + +extension HTTPURLResponse { + + var httpHeaders: [String: String] { + allHeaderFields.reduce(into: [String: String]()) { + guard + let key = $1.key as? String, + let value = $1.value as? String + else { + return + } + + $0.updateValue(value, forKey: key) + } + } +} + +// MARK: - Debug / unit test supprt + +extension URLSession { + var debugNumberOfTaskData: Int { +#if WP_SUPPORT_BACKGROUND_URL_SESSION + self.taskData.count +#else + 0 +#endif + } +} diff --git a/native/swift/Sources/wordpress-api/WordPressAPI.swift b/native/swift/Sources/wordpress-api/WordPressAPI.swift index 8790ce1f2..3517ae274 100644 --- a/native/swift/Sources/wordpress-api/WordPressAPI.swift +++ b/native/swift/Sources/wordpress-api/WordPressAPI.swift @@ -6,57 +6,40 @@ import FoundationNetworking #endif public struct WordPressAPI { - - enum Errors: Error { - case unableToParseResponse - } - private let urlSession: URLSession package let helper: WpApiHelperProtocol - public init(urlSession: URLSession, baseUrl: URL, authenticationStategy: WpAuthentication) { + public init(urlSession: URLSession, baseUrl: URL, authenticationStrategy: WpAuthentication) { +#if WP_SUPPORT_BACKGROUND_URL_SESSION + // We use URLSession APIs that accept completion block, which doesn't work with background URLSession. + // See `URLSession.backgroundSession(configuration:)` in `URLSession+WordPressAPI.swift`. + assert( + urlSession.configuration.identifier == nil || urlSession.delegate is BackgroundURLSessionDelegate, + "Background URLSession must use BackgroundURLSessionDelegate" + ) +#else + assert( + urlSession.configuration.identifier == nil, + "Background URLSession are not supported" + ) +#endif self.urlSession = urlSession - self.helper = WpApiHelper(siteUrl: baseUrl.absoluteString, authentication: authenticationStategy) + self.helper = WpApiHelper(siteUrl: baseUrl.absoluteString, authentication: authenticationStrategy) } package func perform(request: WpNetworkRequest) async throws -> WpNetworkResponse { - try await withCheckedThrowingContinuation { continuation in - self.perform(request: request) { result in - continuation.resume(with: result) - } - } + try await self.urlSession.perform(request: request).get() } package func perform(request: WpNetworkRequest, callback: @escaping (Result) -> Void) { - let task = self.urlSession.dataTask(with: request.asURLRequest()) { data, response, error in - if let error { - callback(.failure(error)) - return - } - - guard let data = data, let response = response else { - callback(.failure(Errors.unableToParseResponse)) - return - } - + Task { do { - let response = try WpNetworkResponse.from(data: data, response: response) - callback(.success(response)) + let result = try await self.perform(request: request) + callback(.success(result)) } catch { callback(.failure(error)) } } - task.resume() - } -} - -public extension WpNetworkRequest { - func asURLRequest() -> URLRequest { - let url = URL(string: self.url)! - var request = URLRequest(url: url) - request.httpMethod = self.method.rawValue - request.allHTTPHeaderFields = self.headerMap - return request } } @@ -77,37 +60,6 @@ extension Result { } } -extension WpNetworkResponse { - static func from(data: Data, response: URLResponse) throws -> WpNetworkResponse { - guard let response = response as? HTTPURLResponse else { - abort() - } - - return WpNetworkResponse( - body: data, - statusCode: UInt16(response.statusCode), - headerMap: response.httpHeaders - ) - - } -} - -extension HTTPURLResponse { - - var httpHeaders: [String: String] { - allHeaderFields.reduce(into: [String: String]()) { - guard - let key = $1.key as? String, - let value = $1.value as? String - else { - return - } - - $0.updateValue(value, forKey: key) - } - } -} - // Note: Everything below this line should be moved into the Rust layer public extension WpAuthentication { init(username: String, password: String) { diff --git a/native/swift/Sources/wordpress-api/WordPressAPIError.swift b/native/swift/Sources/wordpress-api/WordPressAPIError.swift new file mode 100644 index 000000000..6e36acc1d --- /dev/null +++ b/native/swift/Sources/wordpress-api/WordPressAPIError.swift @@ -0,0 +1,51 @@ +import Foundation + +#if os(Linux) +import FoundationNetworking +#endif + +public enum WordPressAPIError: Error { + static var unknownErrorMessage: String { + NSLocalizedString( + "wordpress-rs.api.error.unknown", + value: "Something went wrong, please try again later.", + comment: "Error message that describes an unknown error had occured" + ) + } + + /// Can't encode the request arguments into a valid HTTP request. This is a programming error. + case requestEncodingFailure(underlyingError: Error) + /// Error occured in the HTTP connection. + case connection(URLError) + /// The API call returned an HTTP response that WordPressKit can't parse. Receiving this error could be an + /// indicator that there is an error response that's not handled properly by WordPressKit. + case unparsableResponse(response: HTTPURLResponse?, body: Data?, underlyingError: Error) + /// Other error occured. + case unknown(underlyingError: Error) +} + +extension WordPressAPIError: LocalizedError { + + public var errorDescription: String? { + // Considering `WordPressAPIError` is the error that's surfaced from this library to the apps, its instanes + // may be displayed on UI directly. To prevent Swift's default error message (i.e. "This operation can't be + // completed. (code=...)") from being displayed, we need to make sure this implementation + // always returns a non-nil value. + let localizedErrorMessage: String + switch self { + case .requestEncodingFailure, .unparsableResponse: + // These are usually programming errors. + localizedErrorMessage = Self.unknownErrorMessage + case let .connection(error): + localizedErrorMessage = error.localizedDescription + case let .unknown(underlyingError): + if let msg = (underlyingError as? LocalizedError)?.errorDescription { + localizedErrorMessage = msg + } else { + localizedErrorMessage = Self.unknownErrorMessage + } + } + return localizedErrorMessage + } + +} diff --git a/native/swift/Tests/wordpress-api/TestSupport/HTTPStub.swift b/native/swift/Tests/wordpress-api/TestSupport/HTTPStub.swift new file mode 100644 index 000000000..a3af9b379 --- /dev/null +++ b/native/swift/Tests/wordpress-api/TestSupport/HTTPStub.swift @@ -0,0 +1,136 @@ +import Foundation +import XCTest +import FlyingFox + +#if os(Linux) +import FoundationNetworking +#endif + +final class HTTPStub { + let serverURL: URL + + private let server: HTTPServer + private var stubs: [(condition: RequestFilter, response: ResponseBlock)] + + fileprivate init() async throws { + let port: UInt16 = (8000...9999).randomElement()! + let server = HTTPServer(port: port) + Task.detached { try await server.start() } + try await server.waitUntilListening() + + self.server = server + self.serverURL = URL(string: "http://localhost:\(port)")! + self.stubs = [] + + let handler = { @Sendable [weak self] (request: HTTPRequest) async throws -> HTTPResponse in + guard let self else { return HTTPResponse(statusCode: .serviceUnavailable) } + + let urlRequest = try await request.asURLRequest(serverURL: serverURL) + guard let stub = self.stubs.first(where: { condition, _ in condition(urlRequest) })?.response else { + return HTTPResponse(statusCode: .notFound) + } + + return try await stub(urlRequest).response(for: urlRequest) + } + await server.appendRoute("*", handler: handler) + } + + func terminate() async { + await server.stop() + } +} + +extension HTTPRequest { + + func asURLRequest(serverURL: URL) async throws -> URLRequest { + var components = try XCTUnwrap(URLComponents(url: serverURL, resolvingAgainstBaseURL: true)) + components.path = path + components.queryItems = query.map { + URLQueryItem(name: $0.name, value: $0.value) + } + let url = try XCTUnwrap(components.url) + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name.rawValue) + } + request.httpBody = try await bodyData + return request + } + +} + +extension XCTestCase { + + func launchHTTPStub() async throws -> HTTPStub { + for _ in 1...5 { + do { + let stub = try await HTTPStub() + addTeardownBlock { + await stub.terminate() + } + return stub + } catch { + print("Failed to create an HTTP server: \(error)") + } + } + + // Final attempt + return try await HTTPStub() + } + +} + +struct HTTPStubsResponse { + var fileURL: URL? + var data: Data? + var statusCode: Int? + var headers: [String: String]? + + var responseTime: TimeInterval? + + func response(for request: URLRequest) async throws -> HTTPResponse { + if let responseTime { + let milliseconds: UInt64 = UInt64(1_000 * responseTime) + try await Task.sleep(nanoseconds: milliseconds * 1_000_000) + } + + let body: HTTPBodySequence + if let data { + body = .init(data: data) + } else if let fileURL = fileURL { + body = try .init(file: fileURL) + } else { + body = .init(data: Data()) + } + + let headers: [HTTPHeader: String]? = headers?.reduce(into: [:]) { result, pair in + result[HTTPHeader(rawValue: pair.key)] = pair.value + } + let code = statusCode.flatMap { HTTPStatusCode($0, phrase: "Stubbed") } ?? .ok + + return HTTPResponse(statusCode: code, headers: headers ?? [:], body: body) + } +} + +// MARK: - Creating HTTP stubs + +typealias RequestFilter = (URLRequest) -> Bool +typealias ResponseBlock = (URLRequest) -> HTTPStubsResponse + +extension HTTPStub { + + func stub(condition: @escaping RequestFilter, response: @escaping ResponseBlock) { + stubs.append((condition, response)) + } + +} + +func isPath(_ path: String) -> RequestFilter { + { $0.url?.path == path } +} + +func hasHeaderNamed(_ name: String, value: String?) -> RequestFilter { + { $0.value(forHTTPHeaderField: name) == value } +} diff --git a/native/swift/Tests/wordpress-api/URLSessionHelperTests.swift b/native/swift/Tests/wordpress-api/URLSessionHelperTests.swift new file mode 100644 index 000000000..2d2a4a56c --- /dev/null +++ b/native/swift/Tests/wordpress-api/URLSessionHelperTests.swift @@ -0,0 +1,345 @@ +import Foundation +import Crypto +import XCTest + +#if os(Linux) +import FoundationNetworking +#endif + +@testable import wordpress_api + +class URLSessionHelperTests: XCTestCase { + + var session: URLSession! + var stub: HTTPStub! + + override func setUp() async throws { + try await super.setUp() + + session = .shared + stub = try await launchHTTPStub() + } + + func test200() async throws { + stub.stub(condition: isPath("/hello")) { _ in + HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200) + } + let result = await session.perform(request: .init(url: URL(string: "\(stub.serverURL.absoluteString)/hello")!)) + + // The result is a successful result. This line should not throw + let response = try result.get() + + XCTAssertEqual(String(data: response.body, encoding: .utf8), "success") + } + + func test500() async throws { + stub.stub(condition: isPath("/hello")) { _ in + HTTPStubsResponse(data: "Internal server error".data(using: .utf8)!, statusCode: 500, headers: nil) + } + + let result = await session + .perform(request: .init(url: URL(string: "\(stub.serverURL.absoluteString)/hello")!)) + try XCTAssertEqual(result.get().statusCode, 500) + } + + func testHeader() async throws { + stub.stub(condition: hasHeaderNamed("X-Request", value: "Ping")) { _ in + HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, headers: ["X-Response": "Pong"]) + } + + let result = await session + .perform( + request: .init( + method: .get, + url: "\(stub.serverURL.absoluteString)/hello", + headerMap: ["X-Request": "Ping"] + ) + ) + try XCTAssertEqual(result.get().statusCode, 200) + try XCTAssertEqual(result.get().headerMap?["X-Response"], "Pong") + } + +// swiftlint:disable line_length +// URLSessionTask on Linux doesn't appear to support tracking progress. +// See https://github.com/apple/swift-corelibs-foundation/blob/swift-5.10-RELEASE/Sources/FoundationNetworking/URLSession/URLSessionTask.swift#L42 +// swiftlint:enable line_length +#if !os(Linux) + func testProgressTracking() async throws { + stub.stub(condition: isPath("/hello")) { _ in + HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, headers: nil) + } + + let progress = Progress.discreteProgress(totalUnitCount: 20) + XCTAssertEqual(progress.completedUnitCount, 0) + XCTAssertEqual(progress.fractionCompleted, 0) + + _ = await session.perform( + request: .init(url: URL(string: "\(stub.serverURL.absoluteString)/hello")!), + fulfilling: progress + ) + XCTAssertEqual(progress.completedUnitCount, 20) + XCTAssertEqual(progress.fractionCompleted, 1) + } +#endif + + func testCancellation() async throws { + // Give a slow HTTP request that takes 0.5 second to complete + stub.stub(condition: isPath("/hello")) { _ in + var response = HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, headers: nil) + response.responseTime = 0.5 + return response + } + + // and cancelling it (in 0.1 second) before it completes + let progress = Progress.discreteProgress(totalUnitCount: 20) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + progress.cancel() + } + + // The result should be an cancellation result + let result = await session.perform( + request: .init(url: URL(string: "\(stub.serverURL.absoluteString)/hello")!), + fulfilling: progress + ) + if case let .failure(.connection(urlError)) = result, urlError.code == .cancelled { + // Do nothing + } else { + XCTFail("Unexpected result: \(result)") + } + } + +// Re-evaluate this test once WpNetworkRequest supports HTTP body +// func testEncodingError() async { +// let underlyingError = NSError(domain: "test", code: 123) +// let builder = HTTPRequestBuilder(url: URL(string: "\(stub.serverURL.absoluteString)")!) +// .method(.post) +// .body(json: { throw underlyingError }) +// let result = await session.perform(request: builder, errorType: TestError.self) +// +// if case let .failure(.requestEncodingFailure(underlyingError: error)) = result { +// XCTAssertEqual(error as NSError, underlyingError) +// } else { +// XCTFail("Unexpected result: \(result)") +// } +// } + +// Re-evaluate this test once WpNetworkRequest supports HTTP body +// func testMultipartForm() async throws { +// var req: URLRequest? +// stub.stub(condition: isPath("/hello")) { +// req = $0 +// return HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, headers: nil) +// } +// +// let builder = HTTPRequestBuilder(url: URL(string: "\(stub.serverURL.absoluteString)/hello")!) +// .method(.post) +// .body(form: [MultipartFormField(text: "value", name: "name", filename: nil)]) +// +// let _ = await session.perform(request: builder, errorType: TestError.self) +// +// let request = try XCTUnwrap(req) +// let boundary = try XCTUnwrap( +// request +// .value(forHTTPHeaderField: "Content-Type")?.split(separator: ";") +// .map { $0.trimmingCharacters(in: .whitespaces) } +// .reduce(into: [String: String]()) { +// let pair = $1.split(separator: "=") +// if pair.count == 2 { +// $0[String(pair[0])] = String(pair[1]) +// } +// }["boundary"] +// ) +// +// let requestBody = try XCTUnwrap(request.httpBody ?? request.httpBodyStream?.readToEnd()) +// +// let expectedBody +// = "--\(boundary)\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nvalue\r\n--\(boundary)--\r\n" +// XCTAssertEqual(String(data: requestBody, encoding: .utf8), expectedBody) +// } + + func testGetLargeData() async throws { + let file = try self.createLargeFile(megaBytes: 100) + defer { + try? FileManager.default.removeItem(at: file) + } + + stub.stub(condition: isPath("/hello")) { _ in + HTTPStubsResponse(fileURL: file, statusCode: 200, headers: nil) + } + + let response = try await session + .perform( + request: .init(url: URL(string: "\(stub.serverURL.absoluteString)/hello")!) + ) + .get() + + try XCTAssertEqual( + sha256(XCTUnwrap(InputStream(url: file))), + sha256(InputStream(data: response.body)) + ) + } + +// Re-evaluate this test once WpNetworkRequest supports HTTP body +// func testTempFileRemovedAfterMultipartUpload() async throws { +// stub.stub(condition: isPath("/upload")) { _ in +// HTTPStubsResponse(data: "success".data(using: .utf8)!, statusCode: 200, headers: nil) +// } +// +// // Create a large file which will be uploaded. The file size needs to be larger than the hardcoded threshold +// // of creating a temporary file for upload. +// let file = try self.createLargeFile(megaBytes: 30) +// defer { +// try? FileManager.default.removeItem(at: file) +// } +// +// // Capture a list of files in temp dirs, before calling the upload function. +// let tempFilesBeforeUpload = existingTempFiles() +// +// // Perform upload HTTP request +// let builder = try HTTPRequestBuilder(url: URL(string: "\(stub.serverURL.absoluteString)/upload")!) +// .method(.post) +// .body( +// form: [ +// MultipartFormField( +// fileAtPath: file.path, +// name: "file", +// filename: "file.txt", +// mimeType: "text/plain" +// ) +// ] +// ) +// let _ = await session.perform(request: builder, errorType: TestError.self) +// +// // Capture a list of files in the temp dirs, after calling the upload function. +// let tempFilesAfterUpload = existingTempFiles() +// +// // There should be no new files after the HTTP request returns. This assertion relies on an implementation +// // detail where the multipart form content is put into a file in temp dirs. +// let newFiles = tempFilesAfterUpload.subtracting(tempFilesBeforeUpload) +// XCTAssertEqual(newFiles.count, 0) +// } + +// Re-evaluate this test once WpNetworkRequest supports HTTP body +// func testTempFileRemovedAfterMultipartUploadError() async throws { +// stub.stub(condition: isPath("/upload")) { _ in +// HTTPStubsResponse(error: URLError(.networkConnectionLost)) +// } +// +// // Create a large file which will be uploaded. The file size needs to be larger than the hardcoded threshold +// // of creating a temporary file for upload. +// let file = try self.createLargeFile(megaBytes: 30) +// defer { +// try? FileManager.default.removeItem(at: file) +// } +// +// // Capture a list of files in temp dirs, before calling the upload function. +// let tempFilesBeforeUpload = existingTempFiles() +// +// // Perform upload HTTP request +// let builder = try HTTPRequestBuilder(url: URL(string: "\(stub.serverURL.absoluteString)/upload")!) +// .method(.post) +// .body( +// form: [ +// MultipartFormField( +// fileAtPath: file.path, +// name: "file", +// filename: "file.txt", +// mimeType: "text/plain") +// ] +// ) +// let _ = await session.perform(request: builder, errorType: TestError.self) +// +// // Capture a list of files in the temp dirs, after calling the upload function. +// let tempFilesAfterUpload = existingTempFiles() +// +// // There should be no new files after the HTTP request returns. This assertion relies on an implementation +// // detail where the multipart form content is put into a file in temp dirs. +// let newFiles = tempFilesAfterUpload.subtracting(tempFilesBeforeUpload) +// XCTAssertEqual(newFiles.count, 0) +// } + + private func existingTempFiles() -> Set { + let fileManager = FileManager.default + let enumerators = [ + fileManager.enumerator(atPath: NSTemporaryDirectory()), + fileManager.enumerator(atPath: fileManager.temporaryDirectory.path) + ].compactMap { $0 } + + var result: Set = [] + for enumerator in enumerators { + while let file = enumerator.nextObject() as? String { + result.insert(file) + } + } + return result + } + + private func createLargeFile(megaBytes: Int) throws -> URL { + let file = try FileManager.default + .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("large-file-\(UUID().uuidString).txt") + + try Data(repeating: 46, count: 1024 * 1000 * megaBytes).write(to: file) + + return file + } + + private func sha256(_ stream: InputStream) -> SHA256Digest { + stream.open() + defer { stream.close() } + + var hash = SHA256() + let maxLength = 50 * 1024 + var buffer = [UInt8](repeating: 0, count: maxLength) + while stream.hasBytesAvailable { + let bytes = stream.read(&buffer, maxLength: maxLength) + let data = Data(bytesNoCopy: &buffer, count: bytes, deallocator: .none) + hash.update(data: data) + } + return hash.finalize() + } +} + +// MARK: - Background URLSession Tests - Only available on Apple platforms + +#if WP_SUPPORT_BACKGROUND_URL_SESSION + +class BackgroundURLSessionHelperTests: URLSessionHelperTests { + + // swiftlint:disable weak_delegate + private var delegate: TestBackgroundURLSessionDelegate! + // swiftlint:enable weak_delegate + + override func setUp() { + super.setUp() + + delegate = TestBackgroundURLSessionDelegate() + session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + } + + override func tearDown() { + super.tearDown() + + if delegate.startedReceivingResponse { + XCTAssertTrue(delegate.completionCalled) + } + } + +} + +private class TestBackgroundURLSessionDelegate: BackgroundURLSessionDelegate { + var startedReceivingResponse = false + var completionCalled = false + + override func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + startedReceivingResponse = true + super.urlSession(session, dataTask: dataTask, didReceive: data) + } + + override func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + completionCalled = true + super.urlSession(session, task: task, didCompleteWithError: error) + } +} + +#endif diff --git a/native/swift/Tests/wordpress-api/WpNetworkRequest.swift b/native/swift/Tests/wordpress-api/WpNetworkRequest.swift new file mode 100644 index 000000000..3045db438 --- /dev/null +++ b/native/swift/Tests/wordpress-api/WpNetworkRequest.swift @@ -0,0 +1,8 @@ +import Foundation +import wordpress_api_wrapper + +extension WpNetworkRequest { + init(url: URL) { + self.init(method: .get, url: url.absoluteString, headerMap: nil) + } +}