diff --git a/LICENSE b/LICENSE index 3ee3924..335db93 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 Alexander Grebenyuk +Copyright (c) 2021-2023 Alexander Grebenyuk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Sources/Get/APIClient.swift b/Sources/Get/APIClient.swift index 4e0ba5d..df4f0bb 100644 --- a/Sources/Get/APIClient.swift +++ b/Sources/Get/APIClient.swift @@ -1,12 +1,14 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +#warning("add Response to Error.responseValidationFailed") + /// Performs network requests constructed using ``Request``. public actor APIClient { /// The configuration with which the client was initialized with. @@ -89,6 +91,31 @@ public actor APIClient { // MARK: Sending Requests +#warning("should we keep basic send?") +#warning("more way to create DataTask?, e.g. with URL/URLRequest?") + + public func dataTask(with request: Request) -> DataTask { + let task = DataTask() + task.task = Task.Response, Error> { + defer { task.task = nil } + let request = try await makeURLRequest(for: request, task.configure) + return try await performRequest { + var request = request + try await self.delegate.client(self, willSendRequest: &request) + let dataTask = session.dataTask(with: request) + do { + let response = try await dataLoader.startDataTask(dataTask, session: session, delegate: Box(task.delegate)) + try validate(response) +#warning("remove this") + return DataTask.Response(data: response.data, response: response.response, task: dataTask, metrics: response.metrics) + } catch { + throw DataLoaderError(task: dataTask, error: error) + } + } + } + return task + } + /// Sends the given request and returns a decoded response. /// /// - parameters: @@ -146,7 +173,7 @@ public actor APIClient { try await self.delegate.client(self, willSendRequest: &request) let task = session.dataTask(with: request) do { - let response = try await dataLoader.startDataTask(task, session: session, delegate: delegate) + let response = try await dataLoader.startDataTask(task, session: session, delegate: Box(delegate)) try validate(response) return response } catch { diff --git a/Sources/Get/APIClientDelegate.swift b/Sources/Get/APIClientDelegate.swift index f324e14..05089e8 100644 --- a/Sources/Get/APIClientDelegate.swift +++ b/Sources/Get/APIClientDelegate.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import Foundation #if canImport(FoundationNetworking) diff --git a/Sources/Get/DataLoader.swift b/Sources/Get/DataLoader.swift index fd37863..0ebdedc 100644 --- a/Sources/Get/DataLoader.swift +++ b/Sources/Get/DataLoader.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import Foundation #if canImport(FoundationNetworking) @@ -28,10 +28,10 @@ final class DataLoader: NSObject, URLSessionDataDelegate, URLSessionDownloadDele return url }() - func startDataTask(_ task: URLSessionDataTask, session: URLSession, delegate: URLSessionDataDelegate?) async throws -> Response { + func startDataTask(_ task: URLSessionDataTask, session: URLSession, delegate: Box) async throws -> Response { try await withTaskCancellationHandler(operation: { try await withUnsafeThrowingContinuation { continuation in - let handler = DataTaskHandler(delegate: delegate) + let handler = DataTaskHandler(delegate: delegate.value) handler.completion = continuation.resume(with:) self.handlers[task] = handler diff --git a/Sources/Get/DataTask.swift b/Sources/Get/DataTask.swift new file mode 100644 index 0000000..e961e66 --- /dev/null +++ b/Sources/Get/DataTask.swift @@ -0,0 +1,90 @@ +// The MIT License (MIT) +// +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Combine + +public final class DataTask: @unchecked Sendable { + var task: Task! + + public var delegate: URLSessionDataDelegate? + +#warning("implement progress") + public var progress: some Publisher { _progress } + var _progress = CurrentValueSubject(0.0) + + public func cancel() { + task.cancel() + } + + public var response: Response { + get async throws { try await result.get() } + } + + public var result: Result { + get async { + await withTaskCancellationHandler(operation: { + await task.result + }, onCancel: { + cancel() + }) + } + } + +#warning("add publisher in addition to Async/Await?") + +#warning("this isn't thread-safe") + public var configure: (@Sendable (inout URLRequest) -> Void)? + + /// A response with an associated value and metadata. + public struct Response: TaskDesponse { + /// Original response. + public let response: URLResponse + /// Original response data. + public let data: Data + /// Completed task. + public let task: URLSessionTask + /// Task metrics collected for the request. + public let metrics: URLSessionTaskMetrics? + + /// Initializes the response. + public init(data: Data, response: URLResponse, task: URLSessionTask, metrics: URLSessionTaskMetrics? = nil) { + self.data = data + self.response = response + self.task = task + self.metrics = metrics + } + } +} + +// Pros: this approach will allow users to extend the task with custom decoders + +extension DataTask where T: Decodable { + public var value: T { + get async throws { try await response.decode(T.self) } + } +} + +extension DataTask.Response where T: Decodable { + public var value: T { + get async throws { try await decode(T.self) } + } +} + +extension DataTask.Response { + public func decode(_ type: T.Type, using decoder: JSONDecoder = JSONDecoder()) async throws -> T { + try await Get.decode(data, using: decoder) + } +} + +// Silences Sendable warnings in some Foundation APIs. +struct Box: @unchecked Sendable { + let value: T + + init(_ value: T) { + self.value = value + } +} + +#warning("add in docs that you can easily add custom decoders to the requests withot modifying the client iself") diff --git a/Sources/Get/Request.swift b/Sources/Get/Request.swift index b154502..003c3ca 100644 --- a/Sources/Get/Request.swift +++ b/Sources/Get/Request.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import Foundation @@ -8,6 +8,7 @@ import Foundation import FoundationNetworking #endif +#warning("does it need to be generic at all?") /// An HTTP network request. public struct Request: @unchecked Sendable { /// HTTP method, e.g. "GET". @@ -62,6 +63,7 @@ public struct Request: @unchecked Sendable { self.method = method } +#warning("we no longer need to change the reponse type?") /// Changes the response type keeping the rest of the request parameters. public func withResponse(_ type: T.Type) -> Request { var copy = Request(optionalUrl: url, method: method) diff --git a/Sources/Get/Response.swift b/Sources/Get/Response.swift index e061612..5521afd 100644 --- a/Sources/Get/Response.swift +++ b/Sources/Get/Response.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import Foundation @@ -8,6 +8,27 @@ import Foundation import FoundationNetworking #endif +#warning("adding error to reponse it not a good idea because its inconvenient") + +public protocol TaskDesponse: Sendable { + /// Original response. + var response: URLResponse { get } + /// Completed task. + var task: URLSessionTask { get } + /// Task metrics collected for the request. + var metrics: URLSessionTaskMetrics? { get } +} + +extension TaskDesponse { + /// Response HTTP status code. + public var statusCode: Int? { (response as? HTTPURLResponse)?.statusCode } + /// Original request. + public var originalRequest: URLRequest? { task.originalRequest } + /// The URL request object currently being handled by the task. May be + /// different from the original request. + public var currentRequest: URLRequest? { task.currentRequest } +} + /// A response with an associated value and metadata. public struct Response { /// Decoded response value. diff --git a/Tests/GetTests/ClientAuthorizationTests.swift b/Tests/GetTests/ClientAuthorizationTests.swift index 9c1cfbf..5fd761c 100644 --- a/Tests/GetTests/ClientAuthorizationTests.swift +++ b/Tests/GetTests/ClientAuthorizationTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest #if canImport(FoundationNetworking) diff --git a/Tests/GetTests/ClientDelegateTests.swift b/Tests/GetTests/ClientDelegateTests.swift index dde832b..e497eb1 100644 --- a/Tests/GetTests/ClientDelegateTests.swift +++ b/Tests/GetTests/ClientDelegateTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get diff --git a/Tests/GetTests/ClientIIntegrationTests.swift b/Tests/GetTests/ClientIIntegrationTests.swift index 48c1620..306521d 100644 --- a/Tests/GetTests/ClientIIntegrationTests.swift +++ b/Tests/GetTests/ClientIIntegrationTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get diff --git a/Tests/GetTests/ClientMakeRequestsTests.swift b/Tests/GetTests/ClientMakeRequestsTests.swift index 87a9fd2..8ba0f66 100644 --- a/Tests/GetTests/ClientMakeRequestsTests.swift +++ b/Tests/GetTests/ClientMakeRequestsTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get diff --git a/Tests/GetTests/ClientMiscTests.swift b/Tests/GetTests/ClientMiscTests.swift index 4803102..b806313 100644 --- a/Tests/GetTests/ClientMiscTests.swift +++ b/Tests/GetTests/ClientMiscTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get diff --git a/Tests/GetTests/ClientSendingRequestsTests.swift b/Tests/GetTests/ClientSendingRequestsTests.swift index 9ba177f..c31ae44 100644 --- a/Tests/GetTests/ClientSendingRequestsTests.swift +++ b/Tests/GetTests/ClientSendingRequestsTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get diff --git a/Tests/GetTests/ClientSessionDelegateTests.swift b/Tests/GetTests/ClientSessionDelegateTests.swift index ea7663e..f55d534 100644 --- a/Tests/GetTests/ClientSessionDelegateTests.swift +++ b/Tests/GetTests/ClientSessionDelegateTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get diff --git a/Tests/GetTests/CodeSamplesTests.swift b/Tests/GetTests/CodeSamplesTests.swift index 8943a15..7882fab 100644 --- a/Tests/GetTests/CodeSamplesTests.swift +++ b/Tests/GetTests/CodeSamplesTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest import Get diff --git a/Tests/GetTests/DataTaskTests.swift b/Tests/GetTests/DataTaskTests.swift new file mode 100644 index 0000000..2c963f1 --- /dev/null +++ b/Tests/GetTests/DataTaskTests.swift @@ -0,0 +1,174 @@ +// The MIT License (MIT) +// +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). + +import XCTest +@testable import Get + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +final class DataTaskTests: XCTestCase { + var client: APIClient! + + override func setUp() { + super.setUp() + + self.client = .mock() + } + + // MARK: Basic Requests + + // You don't need to provide a predefined list of resources in your app. + // You can define the requests inline instead. + func testDefiningRequestInline() async throws { + // GIVEN + let url = URL(string: "https://api.github.com/user")! + Mock.get(url: url, json: "user").register() + + // WHEN + let dataTask = await client.dataTask(with: Request(path: "/user")) + let user = try await dataTask.response.decode(User.self) + + // THEN + XCTAssertEqual(user.login, "kean") + } + + func testResponseMetadata() async throws { + // GIVEN + let url = URL(string: "https://api.github.com/user")! + Mock.get(url: url, json: "user").register() + + // WHEN + let response = try await client.dataTask(with: Paths.user.get).response + + // THEN the client returns not just the value, but data, original + // request, and more + let value = try await response.decode(User.self) + XCTAssertEqual(value.login, "kean") + XCTAssertEqual(response.data.count, 1321) + XCTAssertEqual(response.originalRequest?.url, url) + XCTAssertEqual(response.statusCode, 200) +#if !os(Linux) + let metrics = try XCTUnwrap(response.metrics) + let transaction = try XCTUnwrap(metrics.transactionMetrics.first) + XCTAssertEqual(transaction.request.url, URL(string: "https://api.github.com/user")) +#endif + } + + // MARK: Failures + + func testFailingRequest() async throws { + // GIVEN + let url = URL(string: "https://api.github.com/user")! + Mock(url: url, dataType: .json, statusCode: 500, data: [ + .get: "nope".data(using: .utf8)! + ]).register() + + // WHEN + do { + let _ = try await client.dataTask(with: Request(path: "/user")).response + } catch { + // THEN + let error = try XCTUnwrap(error as? APIError) + switch error { + case .unacceptableStatusCode(let code): + XCTAssertEqual(code, 500) + } + } + } + +#warning("easier way to ignore response?") + func testSendingRequestWithInvalidURL() async throws { + // GIVEN + let request = Request(path: "https://api.github.com ---invalid") + + // WHEN + do { + try await client.dataTask(with: request).response + } catch { + // THEN + let error = try XCTUnwrap(error as? URLError) + XCTAssertEqual(error.code, .badURL) + } + } + + // MARK: Cancellation + + func testCancellingRequests() async throws { + // Given + let url = URL(string: "https://api.github.com/users/kean")! + var mock = Mock.get(url: url, json: "user") + mock.delay = DispatchTimeInterval.seconds(60) + mock.register() + + // When + let task = Task { [client] in + try await client!.dataTask(with: Request(path: "/users/kean")).response + } + + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(100)) { + task.cancel() + } + + // Then + do { + _ = try await task.value + } catch { + XCTAssertTrue(error is URLError) + XCTAssertEqual((error as? URLError)?.code, .cancelled) + } + } + + // MARK: Decoding + + func testDecodeCurrentValueFromResponse() async throws { + // GIVEN + let url = URL(string: "https://api.github.com/user")! + Mock.get(url: url, json: "user").register() + + let request = Request(path: "/user") + + // WHEN + let response = try await client.dataTask(with: request).response + let user = try await response.value + + // THEN + XCTAssertEqual(user.login, "kean") + } + + func testDecodeSpecificValueFromResponse() async throws { + // GIVEN + let url = URL(string: "https://api.github.com/user")! + Mock.get(url: url, json: "user").register() + + let request = Request(path: "/user") + + // WHEN + let response = try await client.dataTask(with: request).response + let user = try await response.decode(User.self) + + // THEN + XCTAssertEqual(user.login, "kean") + } + + // You don't need to provide a predefined list of resources in your app. + // You can define the requests inline instead. + func testConvenienceValueAccessors() async throws { + // GIVEN + let url = URL(string: "https://api.github.com/user")! + Mock.get(url: url, json: "user").register() + + let request = Request(path: "/user") + + // WHEN + let user = try await client.dataTask(with: request).value + + // THEN + XCTAssertEqual(user.login, "kean") + } +} + +#warning("test cancelling during waiting for retry") diff --git a/Tests/GetTests/GitHubAPI.swift b/Tests/GetTests/GitHubAPI.swift index a7e18d1..a1a44e4 100644 --- a/Tests/GetTests/GitHubAPI.swift +++ b/Tests/GetTests/GitHubAPI.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import Foundation #if canImport(FoundationNetworking) diff --git a/Tests/GetTests/Helpers.swift b/Tests/GetTests/Helpers.swift index 9b71eec..8780039 100644 --- a/Tests/GetTests/Helpers.swift +++ b/Tests/GetTests/Helpers.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest import Get diff --git a/Tests/GetTests/ResponseTests.swift b/Tests/GetTests/ResponseTests.swift index 2f1d3f4..ccc6f10 100644 --- a/Tests/GetTests/ResponseTests.swift +++ b/Tests/GetTests/ResponseTests.swift @@ -1,6 +1,6 @@ // The MIT License (MIT) // -// Copyright (c) 2021-2022 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2021-2023 Alexander Grebenyuk (github.com/kean). import XCTest @testable import Get