Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Sources/EZNetworking/Other/CancellableRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

public class CancellableRequest {
private let onResume: () -> Void
private let onCancel: () -> Void
private(set) var hasStarted = false

init(
onResume: @escaping @Sendable () -> Void,
onCancel: @escaping @Sendable () -> Void
) {
self.onResume = onResume
self.onCancel = onCancel
}

public func resume() {
guard !hasStarted else { return }
hasStarted = true
onResume()
}

public func cancel() {
onCancel()
}
}
22 changes: 22 additions & 0 deletions Sources/EZNetworking/Other/TaskBox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// Thread-safe wrapper for capturing and mutating a Task reference in `@Sendable` closures.
/// Required for Swift 6 concurrency compliance where direct `var` capture and mutation
/// across concurrent closures is not allowed. Uses `NSLock` for synchronization.
final class TaskBox: @unchecked Sendable {
private let lock = NSLock()
private var _task: Task<Void, Never>?

var task: Task<Void, Never>? {
get {
lock.lock()
defer { lock.unlock() }
return _task
}
set {
lock.lock()
defer { lock.unlock() }
_task = newValue
}
}
}
2 changes: 1 addition & 1 deletion Sources/EZNetworking/Other/URLSessionProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

public protocol URLSessionProtocol {
func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
func data(for request: URLRequest) async throws -> (Data, URLResponse)

func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@ import Combine
import Foundation

public protocol RequestPerformable {
func perform<T: Decodable>(request: Request, decodeTo decodableObject: T.Type) async throws -> T
func performTask<T: Decodable>(request: Request, decodeTo decodableObject: T.Type, completion: @escaping ((Result<T, NetworkingError>) -> Void)) -> URLSessionDataTask?
func performPublisher<T: Decodable>(request: Request, decodeTo decodableObject: T.Type) -> AnyPublisher<T, NetworkingError>
func perform<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type
) async throws -> T

func performTask<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type,
completion: @escaping ((Result<T, NetworkingError>) -> Void)
) -> CancellableRequest

func performPublisher<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type
) -> AnyPublisher<T, NetworkingError>
}
85 changes: 49 additions & 36 deletions Sources/EZNetworking/Util/Performers/RequestPerformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,68 +18,81 @@ public struct RequestPerformer: RequestPerformable {

// MARK: Async Await

public func perform<T: Decodable>(request: Request, decodeTo decodableObject: T.Type) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
performDataTask(request: request, decodeTo: decodableObject, completion: { result in
switch result {
case let .success(success):
continuation.resume(returning: success)
case let .failure(failure):
continuation.resume(throwing: failure)
}
})
public func perform<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type
) async throws -> T {
do {
let urlRequest = try request.getURLRequest()
let (data, urlResponse) = try await session.urlSession.data(for: urlRequest)
try validator.validateStatus(from: urlResponse)
let validData = try validator.validateData(data)
return try decoder.decode(decodableObject, from: validData)
} catch {
throw mapError(error)
}
}

// MARK: Completion Handler

@discardableResult
public func performTask<T: Decodable>(request: Request, decodeTo decodableObject: T.Type, completion: @escaping ((Result<T, NetworkingError>) -> Void)) -> URLSessionDataTask? {
performDataTask(request: request, decodeTo: decodableObject, completion: completion)
public func performTask<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type,
completion: @escaping (Result<T, NetworkingError>) -> Void
) -> CancellableRequest {
let taskBox = TaskBox()
let cancellableRequest = CancellableRequest {
taskBox.task = createTaskAndPerform(request: request, decodeTo: decodableObject, completion: completion)
} onCancel: {
taskBox.task?.cancel()
}
cancellableRequest.resume()
return cancellableRequest
}

// MARK: Publisher

public func performPublisher<T: Decodable>(request: Request, decodeTo decodableObject: T.Type) -> AnyPublisher<T, NetworkingError> {
Future { promise in
performDataTask(request: request, decodeTo: decodableObject) { result in
promise(result)
public func performPublisher<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type
) -> AnyPublisher<T, NetworkingError> {
Deferred {
var task: Task<Void, Never>?
return Future<T, NetworkingError> { promise in
task = createTaskAndPerform(request: request, decodeTo: decodableObject, completion: { promise($0) })
}
.handleEvents(receiveCancel: {
task?.cancel()
})
}
.eraseToAnyPublisher()
}

// MARK: Core
// MARK: Helpers

@discardableResult
private func performDataTask<T: Decodable>(request: Request, decodeTo decodableObject: T.Type, completion: @escaping ((Result<T, NetworkingError>) -> Void)) -> URLSessionDataTask? {
guard let urlRequest = getURLRequest(from: request) else {
completion(.failure(.internalError(.noRequest)))
return nil
}
let task = session.urlSession.dataTask(with: urlRequest) { data, urlResponse, error in
private func createTaskAndPerform<T: Decodable & Sendable>(
request: Request,
decodeTo decodableObject: T.Type,
completion: @escaping ((Result<T, NetworkingError>) -> Void)
) -> Task<Void, Never> {
Task {
do {
try validator.validateNoError(error)
try validator.validateStatus(from: urlResponse)
let validData = try validator.validateData(data)

let result = try decoder.decode(decodableObject, from: validData)
let result = try await perform(request: request, decodeTo: decodableObject)
guard !Task.isCancelled else { return }
completion(.success(result))
} catch is CancellationError {
// Task has been cancelled, do not return
} catch {
guard !Task.isCancelled else { return }
completion(.failure(mapError(error)))
}
}
task.resume()
return task
}

private func mapError(_ error: Error) -> NetworkingError {
if let networkError = error as? NetworkingError { return networkError }
if let urlError = error as? URLError { return .urlError(urlError) }
return .internalError(.unknown)
}

private func getURLRequest(from request: Request) -> URLRequest? {
do { return try request.getURLRequest() } catch { return nil }
return .internalError(.requestFailed(error))
}
}
68 changes: 68 additions & 0 deletions Tests/EZNetworkingTests/Other/CancellableRequestTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
@testable import EZNetworking
import Foundation
import Testing

@Suite("Test CancellableRequest")
final class CancellableRequestTests {
@Test("test resume callsOnResumeOnce")
func resumeCallsOnResumeOnce() {
var resumeCalled = 0
var cancelCalled = 0

let request = CancellableRequest(
onResume: { resumeCalled += 1 },
onCancel: { cancelCalled += 1 }
)

request.resume()
request.resume() // should not call again

#expect(resumeCalled == 1, "resume should only call onResume once")
#expect(cancelCalled == 0, "cancel should not be called yet")
}

@Test("test cancel callsOnCancel")
func cancelCallsOnCancel() {
var resumeCalled = 0
var cancelCalled = 0

let request = CancellableRequest(
onResume: { resumeCalled += 1 },
onCancel: { cancelCalled += 1 }
)

request.cancel()

#expect(cancelCalled == 1, "cancel should call onCancel once")
#expect(resumeCalled == 0, "resume should not be called")
}

@Test("test resume then cancel")
func resumeThenCancel() {
var resumeCalled = false
var cancelCalled = false

let request = CancellableRequest(
onResume: { resumeCalled = true },
onCancel: { cancelCalled = true }
)

request.resume()
request.cancel()

#expect(resumeCalled, "resume should have been called")
#expect(cancelCalled, "cancel should have been called")
}

@Test("test hasStarted flag")
func hasStartedFlag() {
let request = CancellableRequest(
onResume: {},
onCancel: {}
)

#expect(!request.hasStarted)
request.resume()
#expect(request.hasStarted)
}
}
19 changes: 19 additions & 0 deletions Tests/EZNetworkingTests/Other/TaskBoxTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@testable import EZNetworking
import Foundation
import Testing

@Suite("Test TaskBox")
final class TaskBoxTests {
@Test("test TaskBox initial value is nil")
func taskBox_initialValue_isNil() {
let box = TaskBox()
#expect(box.task == nil)
}

@Test("test TaskBox setter")
func taskBox_setter() {
let box = TaskBox()
box.task = Task {}
#expect(box.task != nil)
}
}
70 changes: 70 additions & 0 deletions Tests/EZNetworkingTests/SwiftTestingHelpers/Expectation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation
import Testing

final class Expectation: Sendable {
private let continuation: AsyncStream<Void>.Continuation
private let stream: AsyncStream<Void>
private let expectedFulfillmentCount: Int
private let isInverted: Bool

/// Creates a new expectation.
///
/// - Parameters:
/// - expectedFulfillmentCount: The number of times `fulfill()` must be called. Default is 1.
/// - isInverted: If true, the expectation fails if fulfilled. Default is false.
init(expectedFulfillmentCount: Int = 1, isInverted: Bool = false) {
precondition(expectedFulfillmentCount > 0, "expectedFulfillmentCount must be greater than 0")

self.expectedFulfillmentCount = expectedFulfillmentCount
self.isInverted = isInverted

var continuation: AsyncStream<Void>.Continuation!
stream = AsyncStream<Void> { cont in
continuation = cont
}
self.continuation = continuation
}

/// Marks the expectation as fulfilled.
///
/// Call this method when the asynchronous operation you're testing completes.
/// If `expectedFulfillmentCount` is greater than 1, you must call this method
/// that many times for the expectation to be considered fulfilled.
func fulfill() {
continuation.yield()
}

/// Waits for the expectation to be fulfilled within the specified timeout.
///
/// - Parameter timeout: The maximum time to wait for fulfillment.
/// - Throws: If the expectation is not fulfilled within the timeout period.
func fulfillment(within timeout: Duration) async {
let timeoutTask = Task {
try? await Task.sleep(for: timeout)
}

let fulfillmentTask = Task {
var count = 0
for await _ in stream {
count += 1
if count >= expectedFulfillmentCount {
break
}
}
return count
}

let result = await fulfillmentTask.value
timeoutTask.cancel()

if isInverted {
if result >= expectedFulfillmentCount {
Issue.record("Inverted expectation was fulfilled")
}
} else {
if result < expectedFulfillmentCount {
Issue.record("Expectation was not fulfilled within \(timeout). Fulfilled \(result)/\(expectedFulfillmentCount) times.")
}
}
}
}
Loading