From f3154b0b031cee193998ded7b6e3b6e321bd006c Mon Sep 17 00:00:00 2001 From: Subeom Choi Date: Wed, 15 Nov 2023 21:20:21 +0900 Subject: [PATCH] feature: add timeout to SabyNetwork --- Package.swift | 2 +- Source/Network/Client.swift | 22 ++++++++++- .../Network/Implement/Client/DataClient.swift | 20 ++++++++++ .../Network/Implement/Client/JSONClient.swift | 9 ++++- Source/TestMock/MockURLProtocol.swift | 38 ++++++++++--------- Test/Network/DataClientTest.swift | 30 +++++++++++++++ Test/Network/JSONClientTest.swift | 30 +++++++++++++++ 7 files changed, 129 insertions(+), 22 deletions(-) diff --git a/Package.swift b/Package.swift index 2371107..bd16c4d 100644 --- a/Package.swift +++ b/Package.swift @@ -81,7 +81,7 @@ var package = Package( path: "Source/TestFake"), .target( name: "SabyNetwork", - dependencies: ["SabyConcurrency", "SabyJSON", "SabySafe"], + dependencies: ["SabyConcurrency", "SabyJSON", "SabySafe", "SabyTime"], path: "Source/Network"), .target( name: "SabyNumeric", diff --git a/Source/Network/Client.swift b/Source/Network/Client.swift index 244f01c..589ceb8 100644 --- a/Source/Network/Client.swift +++ b/Source/Network/Client.swift @@ -7,6 +7,7 @@ import Foundation +import SabyTime import SabyConcurrency public protocol Client { @@ -18,6 +19,7 @@ public protocol Client { method: ClientMethod, header: ClientHeader, body: Request, + timeout: Interval?, optionBlock: (inout URLRequest) -> Void ) -> Promise, Error> } @@ -27,9 +29,17 @@ extension Client { _ url: URL, method: ClientMethod = .get, header: ClientHeader = [:], + timeout: Interval? = nil, optionBlock: (inout URLRequest) -> Void = { _ in } ) -> Promise, Error> where RequestValue? == Request { - request(url: url, method: method, header: header, body: nil, optionBlock: optionBlock) + request( + url: url, + method: method, + header: header, + body: nil, + timeout: timeout, + optionBlock: optionBlock + ) } public func request( @@ -37,9 +47,17 @@ extension Client { method: ClientMethod = .get, header: ClientHeader = [:], body: Request, + timeout: Interval? = nil, optionBlock: (inout URLRequest) -> Void = { _ in } ) -> Promise, Error> { - request(url: url, method: method, header: header, body: body, optionBlock: optionBlock) + request( + url: url, + method: method, + header: header, + body: body, + timeout: timeout, + optionBlock: optionBlock + ) } } diff --git a/Source/Network/Implement/Client/DataClient.swift b/Source/Network/Implement/Client/DataClient.swift index 5787da1..ac30a35 100644 --- a/Source/Network/Implement/Client/DataClient.swift +++ b/Source/Network/Implement/Client/DataClient.swift @@ -9,6 +9,7 @@ import Foundation import SabyConcurrency import SabyJSON +import SabyTime public final class DataClient: Client { public typealias Request = Data? @@ -37,6 +38,7 @@ extension DataClient { method: ClientMethod = .get, header: ClientHeader = [:], body: Data? = nil, + timeout: Interval? = nil, optionBlock: (inout URLRequest) -> Void = { _ in } ) -> Promise, Error> { let pending = Promise, Error>.pending() @@ -73,6 +75,23 @@ extension DataClient { pending.onCancel { task.cancel() } + if let timeout { + let item = DispatchWorkItem { + pending.reject(DataClientError.timeout) + task.cancel() + } + DispatchQueue.global().asyncAfter( + deadline: .now() + timeout.dispatchTime, + execute: item + ) + pending.promise.finally { + item.cancel() + } + pending.onCancel { + item.cancel() + } + } + task.resume() return pending.promise @@ -80,6 +99,7 @@ extension DataClient { } public enum DataClientError: Error { + case timeout case statusCodeNotFound case statusCodeNot2XX(codeNot2XX: Int, body: Data?) } diff --git a/Source/Network/Implement/Client/JSONClient.swift b/Source/Network/Implement/Client/JSONClient.swift index 7f77d00..d90b8ca 100644 --- a/Source/Network/Implement/Client/JSONClient.swift +++ b/Source/Network/Implement/Client/JSONClient.swift @@ -10,6 +10,7 @@ import Foundation import SabyConcurrency import SabySafe import SabyJSON +import SabyTime public final class JSONClient: Client { let client: DataClient @@ -42,6 +43,7 @@ extension JSONClient { method: ClientMethod = .get, header: ClientHeader = [:], body: JSON? = nil, + timeout: Interval? = nil, optionBlock: (inout URLRequest) -> Void = { _ in } ) -> Promise, Error> { guard let body = try? self.encoder.encode(body) else { @@ -53,6 +55,7 @@ extension JSONClient { method: method, header: header, body: body, + timeout: timeout, optionBlock: optionBlock ) .then { code2XX, data -> ClientResult in @@ -64,7 +67,10 @@ extension JSONClient { return (code2XX, body) } .catch { error in - if case DataClientError.statusCodeNotFound = error { + if case DataClientError.timeout = error { + throw JSONClientError.timeout + } + else if case DataClientError.statusCodeNotFound = error { throw JSONClientError.statusCodeNotFound } else if case DataClientError.statusCodeNot2XX(let codeNot2XX, let data) = error { @@ -78,6 +84,7 @@ extension JSONClient { } public enum JSONClientError: Error { + case timeout case statusCodeNotFound case statusCodeNot2XX(codeNot2XX: Int, body: JSON) case bodyIsNotEncodable diff --git a/Source/TestMock/MockURLProtocol.swift b/Source/TestMock/MockURLProtocol.swift index 7805a80..93d086c 100644 --- a/Source/TestMock/MockURLProtocol.swift +++ b/Source/TestMock/MockURLProtocol.swift @@ -8,6 +8,7 @@ import Foundation import SabyJSON +import SabyConcurrency public protocol URLResultStorage { static var results: [URLResult] { get } @@ -43,15 +44,16 @@ public final class MockURLProtocol: URLProtocol { client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) } - if let data = result.data { - client?.urlProtocol(self, didLoad: data) - } - if let error = result.error { client?.urlProtocol(self, didFailWithError: error) } - else { - client?.urlProtocolDidFinishLoading(self) + + result.data.then { data in + guard let data else { return } + self.client?.urlProtocol(self, didLoad: data) + } + .then { + self.client?.urlProtocolDidFinishLoading(self) } } @@ -67,10 +69,14 @@ extension MockURLProtocol { public struct URLResult { let url: URL? let response: URLResponse? - let data: Data? + let data: Promise let error: Error? public init(url: URL, code: Int, data: Data?) { + self.init(url: url, code: code, data: .resolved(data)) + } + + public init(url: URL, code: Int, data: Promise) { let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil) self.url = url @@ -80,26 +86,22 @@ public struct URLResult { } public init(url: URL, code: Int, json: JSON) { + self.init(url: url, code: code, json: .resolved(json)) + } + + public init(url: URL, code: Int, json: Promise) { let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil) - let (data, error) = { () -> (Data?, Error?) in - do { - return (try json.datafy(), nil) - } - catch { - return (nil, error) - } - }() self.url = url self.response = response - self.data = data - self.error = error + self.data = json.then { try? $0.datafy() } + self.error = nil } public init(url: URL, error: Error) { self.url = url self.response = nil - self.data = nil + self.data = .resolved(nil) self.error = error } } diff --git a/Test/Network/DataClientTest.swift b/Test/Network/DataClientTest.swift index 957c735..d562821 100644 --- a/Test/Network/DataClientTest.swift +++ b/Test/Network/DataClientTest.swift @@ -10,6 +10,7 @@ import XCTest import SabyTestMock import SabyTestExpect +import SabyConcurrency final class DataClientTest: XCTestCase { func test__init() { @@ -88,4 +89,33 @@ final class DataClientTest: XCTestCase { Expect.promise(response, state: .rejected(Expect.SampleError.one), timeout: .seconds(2)) } + + func test__request_timeout() { + final class MockURLResultStorage: URLResultStorage { + static var results: [URLResult] = [ + URLResult( + url: URL(string: "https://mock.api.ab180.co/request")!, + code: 200, + data: Promise.delay(.milliseconds(2000)).then { Data() } + ) + ] + } + let client = DataClient() { + $0.protocolClasses = [MockURLProtocol.self] + } + + let response = client.request( + URL(string: "https://mock.api.ab180.co/request")!, + timeout: .millisecond(50) + ) + + response.then { data in + print(data) + } + .catch { error in + print(error) + } + + Expect.promise(response, state: .rejected(DataClientError.timeout), timeout: .seconds(2)) + } } diff --git a/Test/Network/JSONClientTest.swift b/Test/Network/JSONClientTest.swift index b4dc862..c166438 100644 --- a/Test/Network/JSONClientTest.swift +++ b/Test/Network/JSONClientTest.swift @@ -11,6 +11,7 @@ import XCTest import SabyJSON import SabyTestMock import SabyTestExpect +import SabyConcurrency final class JSONClientTest: XCTestCase { func test__init() { @@ -182,4 +183,33 @@ final class JSONClientTest: XCTestCase { timeout: .seconds(2) ) } + + func test__request_timeout() { + final class MockURLResultStorage: URLResultStorage { + static var results: [URLResult] = [ + URLResult( + url: URL(string: "https://mock.api.ab180.co/request")!, + code: 200, + data: Promise.delay(.milliseconds(2000)).then { try! JSON.from([:]).datafy() } + ) + ] + } + let client = JSONClient() { + $0.protocolClasses = [MockURLProtocol.self] + } + + let response = client.request( + URL(string: "https://mock.api.ab180.co/request")!, + timeout: .millisecond(50) + ) + + response.then { data in + print(data) + } + .catch { error in + print(error) + } + + Expect.promise(response, state: .rejected(JSONClientError.timeout), timeout: .seconds(2)) + } }