diff --git a/APIKit.xcodeproj/project.pbxproj b/APIKit.xcodeproj/project.pbxproj index e460ab57..816644ba 100644 --- a/APIKit.xcodeproj/project.pbxproj +++ b/APIKit.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 7F7048CD1D9D89BE003C99F6 /* APIKit.h in Headers */ = {isa = PBXBuildFile; fileRef = 7F7048C61D9D89BE003C99F6 /* APIKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; 7F7048CE1D9D89BE003C99F6 /* CallbackQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048C71D9D89BE003C99F6 /* CallbackQueue.swift */; }; 7F7048CF1D9D89BE003C99F6 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048C81D9D89BE003C99F6 /* HTTPMethod.swift */; }; - 7F7048D11D9D89BE003C99F6 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048CA1D9D89BE003C99F6 /* Request.swift */; }; 7F7048D21D9D89BE003C99F6 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048CB1D9D89BE003C99F6 /* Session.swift */; }; 7F7048D31D9D89BE003C99F6 /* Unavailable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */; }; 7F7048D61D9D89F2003C99F6 /* SessionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7048D41D9D89F2003C99F6 /* SessionAdapter.swift */; }; @@ -49,7 +48,14 @@ 7FA1690D1D9D8C80006C982B /* HTTPStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FA1690C1D9D8C80006C982B /* HTTPStub.swift */; }; C5725F4B28D8C36500810D7C /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5725F4A28D8C36500810D7C /* Concurrency.swift */; }; C5B144D828D8D7DC00E30ECD /* ConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */; }; + C5F9A3482903E138000CB6C4 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F9A3472903E138000CB6C4 /* Request.swift */; }; + C5F9A34A2903E147000CB6C4 /* JSONRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F9A3492903E147000CB6C4 /* JSONRequest.swift */; }; + C5F9A34C2905073D000CB6C4 /* NonSerializedJSONDataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F9A34B2905073D000CB6C4 /* NonSerializedJSONDataParser.swift */; }; + C5F9A34E29050A96000CB6C4 /* NonSerializedJSONDataParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F9A34D29050A96000CB6C4 /* NonSerializedJSONDataParserTests.swift */; }; C5FF1DC128A80FFD0059573D /* test.json in Resources */ = {isa = PBXBuildFile; fileRef = C5FF1DC028A80FFD0059573D /* test.json */; }; + C5FF1DCF28A835600059573D /* QueryParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5FF1DCD28A835600059573D /* QueryParameters.swift */; }; + C5FF1DD028A835600059573D /* URLEncodedQueryParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5FF1DCE28A835600059573D /* URLEncodedQueryParameters.swift */; }; + C5FF1DD328A835680059573D /* URLEncodedQueryParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5FF1DD228A835680059573D /* URLEncodedQueryParametersTests.swift */; }; ECA831481DE4DDBF004EB1B5 /* ProtobufDataParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */; }; ECA8314A1DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA831491DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift */; }; ECA8314C1DE4E677004EB1B5 /* ProtobufBodyParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA8314B1DE4E677004EB1B5 /* ProtobufBodyParameters.swift */; }; @@ -109,7 +115,6 @@ 7F7048C71D9D89BE003C99F6 /* CallbackQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CallbackQueue.swift; path = APIKit/CallbackQueue.swift; sourceTree = ""; }; 7F7048C81D9D89BE003C99F6 /* HTTPMethod.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HTTPMethod.swift; path = APIKit/HTTPMethod.swift; sourceTree = ""; }; 7F7048C91D9D89BE003C99F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = APIKit/Info.plist; sourceTree = ""; }; - 7F7048CA1D9D89BE003C99F6 /* Request.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Request.swift; path = APIKit/Request.swift; sourceTree = ""; }; 7F7048CB1D9D89BE003C99F6 /* Session.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Session.swift; path = APIKit/Session.swift; sourceTree = ""; }; 7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Unavailable.swift; path = APIKit/Unavailable.swift; sourceTree = ""; }; 7F7048D41D9D89F2003C99F6 /* SessionAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SessionAdapter.swift; path = Sources/APIKit/SessionAdapter/SessionAdapter.swift; sourceTree = SOURCE_ROOT; }; @@ -132,7 +137,14 @@ 7FA1690C1D9D8C80006C982B /* HTTPStub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPStub.swift; sourceTree = ""; }; C5725F4A28D8C36500810D7C /* Concurrency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Concurrency.swift; sourceTree = ""; }; C5B144D728D8D7DC00E30ECD /* ConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyTests.swift; sourceTree = ""; }; + C5F9A3472903E138000CB6C4 /* Request.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + C5F9A3492903E147000CB6C4 /* JSONRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONRequest.swift; sourceTree = ""; }; + C5F9A34B2905073D000CB6C4 /* NonSerializedJSONDataParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonSerializedJSONDataParser.swift; sourceTree = ""; }; + C5F9A34D29050A96000CB6C4 /* NonSerializedJSONDataParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonSerializedJSONDataParserTests.swift; sourceTree = ""; }; C5FF1DC028A80FFD0059573D /* test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test.json; sourceTree = ""; }; + C5FF1DCD28A835600059573D /* QueryParameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QueryParameters.swift; sourceTree = ""; }; + C5FF1DCE28A835600059573D /* URLEncodedQueryParameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLEncodedQueryParameters.swift; sourceTree = ""; }; + C5FF1DD228A835680059573D /* URLEncodedQueryParametersTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLEncodedQueryParametersTests.swift; sourceTree = ""; }; ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProtobufDataParser.swift; path = Sources/APIKit/DataParser/ProtobufDataParser.swift; sourceTree = SOURCE_ROOT; }; ECA831491DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProtobufDataParserTests.swift; sourceTree = ""; }; ECA8314B1DE4E677004EB1B5 /* ProtobufBodyParameters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProtobufBodyParameters.swift; path = Sources/APIKit/BodyParameters/ProtobufBodyParameters.swift; sourceTree = SOURCE_ROOT; }; @@ -246,6 +258,7 @@ 7F698E4A1D9D680C00F1561D /* SessionTests.swift */, C5B144D628D8D7D000E30ECD /* Concurrency */, 0973EE33259E2DD000879BA2 /* Combine */, + C5FF1DD128A835680059573D /* QueryParameters */, 7F698E3B1D9D680C00F1561D /* BodyParametersType */, 7F698E401D9D680C00F1561D /* DataParserType */, 7F698E461D9D680C00F1561D /* SessionAdapterType */, @@ -273,6 +286,7 @@ children = ( 7F698E411D9D680C00F1561D /* FormURLEncodedDataParserTests.swift */, 7F698E421D9D680C00F1561D /* JSONDataParserTests.swift */, + C5F9A34D29050A96000CB6C4 /* NonSerializedJSONDataParserTests.swift */, ECA831491DE4DEBE004EB1B5 /* ProtobufDataParserTests.swift */, 7F698E431D9D680C00F1561D /* StringDataParserTests.swift */, ); @@ -312,12 +326,13 @@ children = ( 7F7048C71D9D89BE003C99F6 /* CallbackQueue.swift */, 7F7048C81D9D89BE003C99F6 /* HTTPMethod.swift */, - 7F7048CA1D9D89BE003C99F6 /* Request.swift */, 7F7048CB1D9D89BE003C99F6 /* Session.swift */, 7F7048CC1D9D89BE003C99F6 /* Unavailable.swift */, C5725F4928D8C36500810D7C /* Concurrency */, 0969AE0D259DEC3C00C498AF /* Combine */, + C5F9A3462903E125000CB6C4 /* Request */, 7F85FB8B1C9D317300CEE132 /* SessionAdapter */, + C5FF1DCC28A835600059573D /* QueryParameters */, 7F18BD0D1C972C38003A31DF /* BodyParameters */, 7FA19A441C9CC9A2005D25AE /* DataParser */, 7F18BD161C9730ED003A31DF /* Serializations */, @@ -363,6 +378,7 @@ 7F7048E41D9D8A08003C99F6 /* DataParser.swift */, 7F7048E51D9D8A08003C99F6 /* FormURLEncodedDataParser.swift */, 7F7048E61D9D8A08003C99F6 /* JSONDataParser.swift */, + C5F9A34B2905073D000CB6C4 /* NonSerializedJSONDataParser.swift */, ECA831471DE4DDBF004EB1B5 /* ProtobufDataParser.swift */, 7F7048E71D9D8A08003C99F6 /* StringDataParser.swift */, ); @@ -387,6 +403,16 @@ path = Concurrency; sourceTree = ""; }; + C5F9A3462903E125000CB6C4 /* Request */ = { + isa = PBXGroup; + children = ( + C5F9A3472903E138000CB6C4 /* Request.swift */, + C5F9A3492903E147000CB6C4 /* JSONRequest.swift */, + ); + name = Request; + path = APIKit/Request; + sourceTree = ""; + }; C5FF1DBF28A80FFD0059573D /* Resources */ = { isa = PBXGroup; children = ( @@ -395,6 +421,24 @@ path = Resources; sourceTree = ""; }; + C5FF1DCC28A835600059573D /* QueryParameters */ = { + isa = PBXGroup; + children = ( + C5FF1DCD28A835600059573D /* QueryParameters.swift */, + C5FF1DCE28A835600059573D /* URLEncodedQueryParameters.swift */, + ); + name = QueryParameters; + path = APIKit/QueryParameters; + sourceTree = ""; + }; + C5FF1DD128A835680059573D /* QueryParameters */ = { + isa = PBXGroup; + children = ( + C5FF1DD228A835680059573D /* URLEncodedQueryParametersTests.swift */, + ); + path = QueryParameters; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -507,14 +551,15 @@ buildActionMask = 2147483647; files = ( 7F7048D31D9D89BE003C99F6 /* Unavailable.swift in Sources */, - 7F7048D11D9D89BE003C99F6 /* Request.swift in Sources */, 7F7048E81D9D8A08003C99F6 /* DataParser.swift in Sources */, 7F7048CE1D9D89BE003C99F6 /* CallbackQueue.swift in Sources */, + C5F9A34A2903E147000CB6C4 /* JSONRequest.swift in Sources */, 7F7048DE1D9D89FB003C99F6 /* AbstractInputStream.m in Sources */, 7F7048E31D9D89FB003C99F6 /* MultipartFormDataBodyParameters.swift in Sources */, 7F7048F01D9D8A12003C99F6 /* ResponseError.swift in Sources */, 7F7048EA1D9D8A08003C99F6 /* JSONDataParser.swift in Sources */, 7F7048D21D9D89BE003C99F6 /* Session.swift in Sources */, + C5FF1DCF28A835600059573D /* QueryParameters.swift in Sources */, 7F7048E01D9D89FB003C99F6 /* Data+InputStream.swift in Sources */, 7F7048DF1D9D89FB003C99F6 /* BodyParameters.swift in Sources */, 7F7048E21D9D89FB003C99F6 /* JSONBodyParameters.swift in Sources */, @@ -522,11 +567,14 @@ 7F7048D61D9D89F2003C99F6 /* SessionAdapter.swift in Sources */, 7F7048EF1D9D8A12003C99F6 /* RequestError.swift in Sources */, 7F7048E91D9D8A08003C99F6 /* FormURLEncodedDataParser.swift in Sources */, + C5F9A3482903E138000CB6C4 /* Request.swift in Sources */, ECA8314C1DE4E677004EB1B5 /* ProtobufBodyParameters.swift in Sources */, + C5FF1DD028A835600059573D /* URLEncodedQueryParameters.swift in Sources */, 7F7048E11D9D89FB003C99F6 /* FormURLEncodedBodyParameters.swift in Sources */, 7F7048F11D9D8A12003C99F6 /* SessionTaskError.swift in Sources */, ECA831481DE4DDBF004EB1B5 /* ProtobufDataParser.swift in Sources */, 7F7048F31D9D8A1F003C99F6 /* URLEncodedSerialization.swift in Sources */, + C5F9A34C2905073D000CB6C4 /* NonSerializedJSONDataParser.swift in Sources */, 7F7048D71D9D89F2003C99F6 /* URLSessionAdapter.swift in Sources */, 0969AE0F259DEC6D00C498AF /* Combine.swift in Sources */, 7F7048EB1D9D8A08003C99F6 /* StringDataParser.swift in Sources */, @@ -548,6 +596,8 @@ C5B144D828D8D7DC00E30ECD /* ConcurrencyTests.swift in Sources */, 7F698E601D9D680C00F1561D /* TestSessionTask.swift in Sources */, 0973EE35259E2DDC00879BA2 /* CombineTests.swift in Sources */, + C5F9A34E29050A96000CB6C4 /* NonSerializedJSONDataParserTests.swift in Sources */, + C5FF1DD328A835680059573D /* URLEncodedQueryParametersTests.swift in Sources */, 7FA1690D1D9D8C80006C982B /* HTTPStub.swift in Sources */, 7F698E5A1D9D680C00F1561D /* URLSessionAdapterTests.swift in Sources */, 7F698E561D9D680C00F1561D /* StringDataParserTests.swift in Sources */, diff --git a/Demo.playground/Contents.swift b/Demo.playground/Contents.swift index aea5177c..c18ec928 100644 --- a/Demo.playground/Contents.swift +++ b/Demo.playground/Contents.swift @@ -5,32 +5,37 @@ import APIKit PlaygroundPage.current.needsIndefiniteExecution = true //: Step 1: Define request protocol -protocol GitHubRequest: Request { - -} +protocol GitHubRequest: Request {} extension GitHubRequest { var baseURL: URL { return URL(string: "https://api.github.com")! } + + var dataParser: NonSerializedJSONDataParser { + return NonSerializedJSONDataParser() + } } //: Step 2: Create model object -struct RateLimit { +struct RateLimit: Decodable { let count: Int let resetDate: Date - init?(dictionary: [String: AnyObject]) { - guard let count = dictionary["rate"]?["limit"] as? Int else { - return nil - } - - guard let resetDateString = dictionary["rate"]?["reset"] as? TimeInterval else { - return nil - } + enum CodingKeys: String, CodingKey { + case rate + } + enum RateCodingKeys: String, CodingKey { + case limit + case reset + } - self.count = count - self.resetDate = Date(timeIntervalSince1970: resetDateString) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rateContainer = try container.nestedContainer(keyedBy: RateCodingKeys.self, forKey: .rate) + self.count = try rateContainer.decode(Int.self, forKey: .limit) + let resetTimeInterval = try rateContainer.decode(TimeInterval.self, forKey: .reset) + self.resetDate = Date(timeIntervalSince1970: resetTimeInterval) } } @@ -39,34 +44,27 @@ struct RateLimit { struct GetRateLimitRequest: GitHubRequest { typealias Response = RateLimit - var method: HTTPMethod { - return .get - } - - var path: String { - return "/rate_limit" - } + let method: HTTPMethod = .get + let path: String = "/rate_limit" - func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response { - guard let dictionary = object as? [String: AnyObject], - let rateLimit = RateLimit(dictionary: dictionary) else { - throw ResponseError.unexpectedObject(object) - } - - return rateLimit + func response(from object: Data, urlResponse: HTTPURLResponse) throws -> Response { + return try JSONDecoder().decode(Response.self, from: object) } } //: Step 4: Send request let request = GetRateLimitRequest() -Session.send(request) { result in +Session.send(request, uploadProgressHandler: { progress in + print("upload progress: \(progress.fractionCompleted)") +}, downloadProgressHandler: { progress in + print("download progress: \(progress.fractionCompleted) %") +}, completionHandler: { result in switch result { case .success(let rateLimit): print("count: \(rateLimit.count)") print("reset: \(rateLimit.resetDate)") - case .failure(let error): print("error: \(error)") } -} +}) diff --git a/Documentation/APIKit6MigrationGuide.md b/Documentation/APIKit6MigrationGuide.md new file mode 100644 index 00000000..85197181 --- /dev/null +++ b/Documentation/APIKit6MigrationGuide.md @@ -0,0 +1 @@ +# APIKit 6 Migration Guide \ No newline at end of file diff --git a/Sources/APIKit/Combine/Combine.swift b/Sources/APIKit/Combine/Combine.swift index 6e3223c0..2c525e5c 100644 --- a/Sources/APIKit/Combine/Combine.swift +++ b/Sources/APIKit/Combine/Combine.swift @@ -47,7 +47,7 @@ public struct SessionTaskPublisher: Publisher { assert(demand > 0) guard let downstream = self.downstream else { return } self.downstream = nil - task = session.send(request, callbackQueue: callbackQueue) { result in + task = session.send(request, callbackQueue: callbackQueue, completionHandler: { result in switch result { case .success(let response): _ = downstream.receive(response) @@ -55,7 +55,7 @@ public struct SessionTaskPublisher: Publisher { case .failure(let error): downstream.receive(completion: .failure(error)) } - } + }) } func cancel() { diff --git a/Sources/APIKit/Concurrency/Concurrency.swift b/Sources/APIKit/Concurrency/Concurrency.swift index c0518e35..82a6d458 100644 --- a/Sources/APIKit/Concurrency/Concurrency.swift +++ b/Sources/APIKit/Concurrency/Concurrency.swift @@ -23,7 +23,7 @@ public extension Session { return try await withTaskCancellationHandler(operation: { return try await withCheckedThrowingContinuation { continuation in Task { - let sessionTask = createSessionTask(request, callbackQueue: callbackQueue) { result in + let sessionTask = createSessionTask(request, callbackQueue: callbackQueue, uploadProgressHandler: { _ in }, downloadProgressHandler: { _ in }) { result in continuation.resume(with: result) } await cancellationHandler.register(with: sessionTask) diff --git a/Sources/APIKit/DataParser/DataParser.swift b/Sources/APIKit/DataParser/DataParser.swift index 304db092..7e0ffbc3 100644 --- a/Sources/APIKit/DataParser/DataParser.swift +++ b/Sources/APIKit/DataParser/DataParser.swift @@ -2,10 +2,12 @@ import Foundation /// `DataParser` protocol provides interface to parse HTTP response body and to state Content-Type to accept. public protocol DataParser { + associatedtype Parsed + /// Value for `Accept` header field of HTTP request. var contentType: String? { get } /// Return `Any` that expresses structure of response such as JSON and XML. /// - Throws: `Error` when parser encountered invalid format data. - func parse(data: Data) throws -> Any + func parse(data: Data) throws -> Parsed } diff --git a/Sources/APIKit/DataParser/FormURLEncodedDataParser.swift b/Sources/APIKit/DataParser/FormURLEncodedDataParser.swift index fc46457e..34182f38 100644 --- a/Sources/APIKit/DataParser/FormURLEncodedDataParser.swift +++ b/Sources/APIKit/DataParser/FormURLEncodedDataParser.swift @@ -21,9 +21,9 @@ public class FormURLEncodedDataParser: DataParser { return "application/x-www-form-urlencoded" } - /// Return `Any` that expresses structure of response. + /// Return `[String: Any]` that expresses structure of response. /// - Throws: `FormURLEncodedDataParser.Error` when the parser fails to initialize `String` from `Data`. - public func parse(data: Data) throws -> Any { + public func parse(data: Data) throws -> [String: Any] { guard let string = String(data: data, encoding: encoding) else { throw Error.cannotGetStringFromData(data) } diff --git a/Sources/APIKit/DataParser/NonSerializedJSONDataParser.swift b/Sources/APIKit/DataParser/NonSerializedJSONDataParser.swift new file mode 100644 index 00000000..6e8b5096 --- /dev/null +++ b/Sources/APIKit/DataParser/NonSerializedJSONDataParser.swift @@ -0,0 +1,19 @@ +import Foundation + +/// `NonSerializedJSONDataParser` response Data data. +public class NonSerializedJSONDataParser: DataParser { + /// Returns `NonSerializedJSONDataParser`. + public init() {} + + // MARK: - DataParser + + /// Value for `Accept` header field of HTTP request. + public var contentType: String? { + return "application/json" + } + + /// Return `Data` that expresses structure of Data response. + public func parse(data: Data) throws -> Data { + return data + } +} diff --git a/Sources/APIKit/DataParser/ProtobufDataParser.swift b/Sources/APIKit/DataParser/ProtobufDataParser.swift index 7e2a6d47..a140077e 100644 --- a/Sources/APIKit/DataParser/ProtobufDataParser.swift +++ b/Sources/APIKit/DataParser/ProtobufDataParser.swift @@ -12,8 +12,8 @@ public class ProtobufDataParser: DataParser { return "application/protobuf" } - /// Return `Any` that expresses structure of Data response. - public func parse(data: Data) throws -> Any { + /// Return `Data` that expresses structure of Data response. + public func parse(data: Data) throws -> Data { return data } } diff --git a/Sources/APIKit/DataParser/StringDataParser.swift b/Sources/APIKit/DataParser/StringDataParser.swift index 6ae648f6..48d0994d 100644 --- a/Sources/APIKit/DataParser/StringDataParser.swift +++ b/Sources/APIKit/DataParser/StringDataParser.swift @@ -23,7 +23,7 @@ public class StringDataParser: DataParser { /// Return `String` that converted from `Data`. /// - Throws: `StringDataParser.Error` when the parser fails to initialize `String` from `Data`. - public func parse(data: Data) throws -> Any { + public func parse(data: Data) throws -> String { guard let string = String(data: data, encoding: encoding) else { throw Error.invalidData(data) } diff --git a/Sources/APIKit/QueryParameters/QueryParameters.swift b/Sources/APIKit/QueryParameters/QueryParameters.swift new file mode 100644 index 00000000..6e9f16a5 --- /dev/null +++ b/Sources/APIKit/QueryParameters/QueryParameters.swift @@ -0,0 +1,7 @@ +import Foundation + +/// `QueryParameters` provides interface to generate HTTP URL query strings. +public protocol QueryParameters { + /// Generate URL query strings. + func encode() -> String? +} diff --git a/Sources/APIKit/QueryParameters/URLEncodedQueryParameters.swift b/Sources/APIKit/QueryParameters/URLEncodedQueryParameters.swift new file mode 100644 index 00000000..e4c0f476 --- /dev/null +++ b/Sources/APIKit/QueryParameters/URLEncodedQueryParameters.swift @@ -0,0 +1,20 @@ +import Foundation + +/// `URLEncodedQueryParameters` serializes form object for HTTP URL query. +public struct URLEncodedQueryParameters: QueryParameters { + /// The parameters to be url encoded. + public let parameters: Any + + /// Returns `URLEncodedQueryParameters` that is initialized with parameters. + public init(parameters: Any) { + self.parameters = parameters + } + + /// Generate url encoded `String`. + public func encode() -> String? { + guard let parameters = parameters as? [String: Any], !parameters.isEmpty else { + return nil + } + return URLEncodedSerialization.string(from: parameters) + } +} diff --git a/Sources/APIKit/Request/JSONRequest.swift b/Sources/APIKit/Request/JSONRequest.swift new file mode 100644 index 00000000..b19508fb --- /dev/null +++ b/Sources/APIKit/Request/JSONRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public protocol JSONRequest: Request {} + +public extension JSONRequest { + var dataParser: JSONDataParser { + return JSONDataParser(readingOptions: []) + } +} diff --git a/Sources/APIKit/Request.swift b/Sources/APIKit/Request/Request.swift similarity index 86% rename from Sources/APIKit/Request.swift rename to Sources/APIKit/Request/Request.swift index ea6c5dae..1a2ff4fb 100644 --- a/Sources/APIKit/Request.swift +++ b/Sources/APIKit/Request/Request.swift @@ -10,6 +10,7 @@ import Foundation public protocol Request { /// The response type associated with the request type. associatedtype Response + associatedtype DataParser: APIKit.DataParser /// The base URL. var baseURL: URL { get } @@ -28,7 +29,7 @@ public protocol Request { /// The actual parameters for the URL query. The values of this property will be escaped using `URLEncodedSerialization`. /// If this property is not implemented and `method.prefersQueryParameter` is `true`, the value of this property /// will be computed from `parameters`. - var queryParameters: [String: Any]? { get } + var queryParameters: QueryParameters? { get } /// The actual parameters for the HTTP body. If this property is not implemented and `method.prefersQueryParameter` is `false`, /// the value of this property will be computed from `parameters` using `JSONBodyParameters`. @@ -52,12 +53,12 @@ public protocol Request { /// The default implementation of this method is provided to throw `ResponseError.unacceptableStatusCode` /// if the HTTP status code is not in `200..<300`. /// - Throws: `Error` - func intercept(object: Any, urlResponse: HTTPURLResponse) throws -> Any + func intercept(object: DataParser.Parsed, urlResponse: HTTPURLResponse) throws -> DataParser.Parsed /// Build `Response` instance from raw response object. This method is called after /// `intercept(object:urlResponse:)` if it does not throw any error. /// - Throws: `Error` - func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response + func response(from object: DataParser.Parsed, urlResponse: HTTPURLResponse) throws -> Response } public extension Request { @@ -65,12 +66,12 @@ public extension Request { return nil } - var queryParameters: [String: Any]? { - guard let parameters = parameters as? [String: Any], method.prefersQueryParameters else { + var queryParameters: QueryParameters? { + guard let parameters = parameters, method.prefersQueryParameters else { return nil } - return parameters + return URLEncodedQueryParameters(parameters: parameters) } var bodyParameters: BodyParameters? { @@ -85,15 +86,11 @@ public extension Request { return [:] } - var dataParser: DataParser { - return JSONDataParser(readingOptions: []) - } - func intercept(urlRequest: URLRequest) throws -> URLRequest { return urlRequest } - func intercept(object: Any, urlResponse: HTTPURLResponse) throws -> Any { + func intercept(object: DataParser.Parsed, urlResponse: HTTPURLResponse) throws -> DataParser.Parsed { guard 200..<300 ~= urlResponse.statusCode else { throw ResponseError.unacceptableStatusCode(urlResponse.statusCode) } @@ -110,8 +107,8 @@ public extension Request { var urlRequest = URLRequest(url: url) - if let queryParameters = queryParameters, !queryParameters.isEmpty { - components.percentEncodedQuery = URLEncodedSerialization.string(from: queryParameters) + if let queryString = queryParameters?.encode(), !queryString.isEmpty { + components.percentEncodedQuery = queryString } if let bodyParameters = bodyParameters { @@ -147,7 +144,7 @@ public extension Request { } public extension Request where Response == Void { - func response(from object: Any, urlResponse: HTTPURLResponse) throws { + func response(from object: DataParser.Parsed, urlResponse: HTTPURLResponse) throws { return } } diff --git a/Sources/APIKit/Session.swift b/Sources/APIKit/Session.swift index 85fb9615..0f202db1 100644 --- a/Sources/APIKit/Session.swift +++ b/Sources/APIKit/Session.swift @@ -10,6 +10,9 @@ open class Session { /// The default callback queue for `send(_:handler:)`. public let callbackQueue: CallbackQueue + /// Closure type executed when the upload or download progress of a request. + public typealias ProgressHandler = (Progress) -> Void + /// Returns `Session` instance that is initialized with `adapter`. /// - parameter adapter: The adapter that connects lower level backend with Session interface. /// - parameter callbackQueue: The default callback queue for `send(_:handler:)`. @@ -33,11 +36,13 @@ open class Session { /// Calls `send(_:callbackQueue:handler:)` of `Session.shared`. /// - parameter request: The request to be sent. /// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used. - /// - parameter handler: The closure that receives result of the request. + /// - parameter uploadProgressHandler: The closure that receives upload progress of the request. + /// - parameter downloadProgressHandler: The closure that receives download progress of the request. + /// - parameter completionHandler: The closure that receives result of the request. /// - returns: The new session task. @discardableResult - open class func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { - return shared.send(request, callbackQueue: callbackQueue, handler: handler) + open class func send(_ request: Request, callbackQueue: CallbackQueue? = nil, uploadProgressHandler: @escaping ProgressHandler = { _ in }, downloadProgressHandler: @escaping ProgressHandler = { _ in }, completionHandler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { + return shared.send(request, callbackQueue: callbackQueue, uploadProgressHandler: uploadProgressHandler, downloadProgressHandler: downloadProgressHandler, completionHandler: completionHandler) } /// Calls `cancelRequests(with:passingTest:)` of `Session.shared`. @@ -51,11 +56,13 @@ open class Session { /// `Request.Response` is inferred from `Request` type parameter, the it changes depending on the request type. /// - parameter request: The request to be sent. /// - parameter callbackQueue: The queue where the handler runs. If this parameters is `nil`, default `callbackQueue` of `Session` will be used. - /// - parameter handler: The closure that receives result of the request. + /// - parameter uploadProgressHandler: The closure that receives upload progress of the request. + /// - parameter downloadProgressHandler: The closure that receives download progress of the request. + /// - parameter completionHandler: The closure that receives result of the request. /// - returns: The new session task. @discardableResult - open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, handler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { - let task = createSessionTask(request, callbackQueue: callbackQueue, handler: handler) + open func send(_ request: Request, callbackQueue: CallbackQueue? = nil, uploadProgressHandler: @escaping ProgressHandler = { _ in }, downloadProgressHandler: @escaping ProgressHandler = { _ in }, completionHandler: @escaping (Result) -> Void = { _ in }) -> SessionTask? { + let task = createSessionTask(request, callbackQueue: callbackQueue, uploadProgressHandler: uploadProgressHandler, downloadProgressHandler: downloadProgressHandler, completionHandler: completionHandler) task?.resume() return task } @@ -77,40 +84,52 @@ open class Session { } } - internal func createSessionTask(_ request: Request, callbackQueue: CallbackQueue?, handler: @escaping (Result) -> Void) -> SessionTask? { + internal func createSessionTask(_ request: Request, callbackQueue: CallbackQueue?, uploadProgressHandler: @escaping ProgressHandler, downloadProgressHandler: @escaping ProgressHandler, completionHandler: @escaping (Result) -> Void) -> SessionTask? { let callbackQueue = callbackQueue ?? self.callbackQueue let urlRequest: URLRequest do { urlRequest = try request.buildURLRequest() } catch { callbackQueue.execute { - handler(.failure(.requestError(error))) + completionHandler(.failure(.requestError(error))) } return nil } - let task = adapter.createTask(with: urlRequest) { data, urlResponse, error in - let result: Result - - switch (data, urlResponse, error) { - case (_, _, let error?): - result = .failure(.connectionError(error)) - - case (let data?, let urlResponse as HTTPURLResponse, _): - do { - result = .success(try request.parse(data: data as Data, urlResponse: urlResponse)) - } catch { - result = .failure(.responseError(error)) + let task = adapter.createTask(with: urlRequest, + uploadProgressHandler: { progress in + callbackQueue.execute { + uploadProgressHandler(progress) } + }, + downloadProgressHandler: { progress in + callbackQueue.execute { + downloadProgressHandler(progress) + } + }, + completionHandler: { data, urlResponse, error in + let result: Result + + switch (data, urlResponse, error) { + case (_, _, let error?): + result = .failure(.connectionError(error)) + + case (let data?, let urlResponse as HTTPURLResponse, _): + do { + result = .success(try request.parse(data: data as Data, urlResponse: urlResponse)) + } catch { + result = .failure(.responseError(error)) + } - default: - result = .failure(.responseError(ResponseError.nonHTTPURLResponse(urlResponse))) - } + default: + result = .failure(.responseError(ResponseError.nonHTTPURLResponse(urlResponse))) + } - callbackQueue.execute { - handler(result) + callbackQueue.execute { + completionHandler(result) + } } - } + ) setRequest(request, forTask: task) diff --git a/Sources/APIKit/SessionAdapter/SessionAdapter.swift b/Sources/APIKit/SessionAdapter/SessionAdapter.swift index aa71ac5d..a487108f 100644 --- a/Sources/APIKit/SessionAdapter/SessionAdapter.swift +++ b/Sources/APIKit/SessionAdapter/SessionAdapter.swift @@ -11,7 +11,7 @@ public protocol SessionTask: AnyObject { /// with `Session`. public protocol SessionAdapter { /// Returns instance that conforms to `SessionTask`. `handler` must be called after success or failure. - func createTask(with URLRequest: URLRequest, handler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask + func createTask(with URLRequest: URLRequest, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask /// Collects tasks from backend networking stack. `handler` must be called after collecting. func getTasks(with handler: @escaping ([SessionTask]) -> Void) diff --git a/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift b/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift index 38ac15ef..93cb58e7 100644 --- a/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift +++ b/Sources/APIKit/SessionAdapter/URLSessionAdapter.swift @@ -6,6 +6,8 @@ extension URLSessionTask: SessionTask { private var dataTaskResponseBufferKey = 0 private var taskAssociatedObjectCompletionHandlerKey = 0 +private var taskAssociatedObjectUploadProgressHandlerKey = 0 +private var taskAssociatedObjectDownloadProgressHandlerKey = 0 /// `URLSessionAdapter` connects `URLSession` with `Session`. /// @@ -25,11 +27,13 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS } /// Creates `URLSessionDataTask` instance using `dataTaskWithRequest(_:completionHandler:)`. - open func createTask(with URLRequest: URLRequest, handler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask { + open func createTask(with URLRequest: URLRequest, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> SessionTask { let task = urlSession.dataTask(with: URLRequest) setBuffer(NSMutableData(), forTask: task) - setHandler(handler, forTask: task) + setHandler(completionHandler, forTask: task) + setUploadProgressHandler(uploadProgressHandler, forTask: task) + setDownloadProgressHandler(downloadProgressHandler, forTask: task) return task } @@ -58,13 +62,45 @@ open class URLSessionAdapter: NSObject, SessionAdapter, URLSessionDelegate, URLS return objc_getAssociatedObject(task, &taskAssociatedObjectCompletionHandlerKey) as? (Data?, URLResponse?, Error?) -> Void } + private func setUploadProgressHandler(_ progressHandler: @escaping Session.ProgressHandler, forTask task: URLSessionTask) { + objc_setAssociatedObject(task, &taskAssociatedObjectUploadProgressHandlerKey, progressHandler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func uploadProgressHandler(for task: URLSessionTask) -> Session.ProgressHandler? { + return objc_getAssociatedObject(task, &taskAssociatedObjectUploadProgressHandlerKey) as? Session.ProgressHandler + } + + private func setDownloadProgressHandler(_ progressHandler: @escaping Session.ProgressHandler, forTask task: URLSessionTask) { + objc_setAssociatedObject(task, &taskAssociatedObjectDownloadProgressHandlerKey, progressHandler as Any, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func downloadProgressHandler(for task: URLSessionTask) -> Session.ProgressHandler? { + return objc_getAssociatedObject(task, &taskAssociatedObjectDownloadProgressHandlerKey) as? Session.ProgressHandler + } + // MARK: URLSessionTaskDelegate open func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { handler(for: task)?(buffer(for: task) as Data?, task.response, error) } + open func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + let progress = Progress(totalUnitCount: totalBytesExpectedToSend) + progress.completedUnitCount = totalBytesSent + uploadProgressHandler(for: task)?(progress) + } + // MARK: URLSessionDataDelegate open func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { buffer(for: dataTask)?.append(data) + updateDownloadProgress(dataTask) + } + + private func updateDownloadProgress(_ task: URLSessionTask) { + let receivedData = buffer(for: task) as Data? + let totalBytesReceived = Int64(receivedData?.count ?? 0) + let totalBytesExpected = task.response?.expectedContentLength ?? NSURLSessionTransferSizeUnknown + let progress = Progress(totalUnitCount: totalBytesExpected) + progress.completedUnitCount = totalBytesReceived + downloadProgressHandler(for: task)?(progress) } } diff --git a/Tests/APIKitTests/DataParserType/FormURLEncodedDataParserTests.swift b/Tests/APIKitTests/DataParserType/FormURLEncodedDataParserTests.swift index cb2f5357..359e267f 100644 --- a/Tests/APIKitTests/DataParserType/FormURLEncodedDataParserTests.swift +++ b/Tests/APIKitTests/DataParserType/FormURLEncodedDataParserTests.swift @@ -14,10 +14,9 @@ class FormURLEncodedDataParserTests: XCTestCase { let parser = FormURLEncodedDataParser(encoding: .utf8) let object = try parser.parse(data: data) - let dictionary = object as? [String: String] - XCTAssertEqual(dictionary?["foo"], "1") - XCTAssertEqual(dictionary?["bar"], "2") - XCTAssertEqual(dictionary?["baz"], "3") + XCTAssertEqual(object["foo"] as? String, "1") + XCTAssertEqual(object["bar"] as? String, "2") + XCTAssertEqual(object["baz"] as? String, "3") } func testInvalidString() { diff --git a/Tests/APIKitTests/DataParserType/JSONDataParserTests.swift b/Tests/APIKitTests/DataParserType/JSONDataParserTests.swift index 3cf81427..312f92d7 100644 --- a/Tests/APIKitTests/DataParserType/JSONDataParserTests.swift +++ b/Tests/APIKitTests/DataParserType/JSONDataParserTests.swift @@ -8,7 +8,7 @@ class JSONDataParserTests: XCTestCase { XCTAssertEqual(parser.contentType, "application/json") } - func testJSONSuccess() throws { + func testDictionaryJSONSuccess() throws { let string = "{\"foo\": 1, \"bar\": 2, \"baz\": 3}" let data = string.data(using: .utf8, allowLossyConversion: false)! let parser = JSONDataParser(readingOptions: []) @@ -19,4 +19,16 @@ class JSONDataParserTests: XCTestCase { XCTAssertEqual(dictionary?["bar"], 2) XCTAssertEqual(dictionary?["baz"], 3) } + + func testArrayJSONSuccess() throws { + let string = "[1, 2, 3]" + let data = string.data(using: .utf8, allowLossyConversion: false)! + let parser = JSONDataParser(readingOptions: []) + + let object = try parser.parse(data: data) + let array = object as? [Int] + XCTAssertEqual(array?[0], 1) + XCTAssertEqual(array?[1], 2) + XCTAssertEqual(array?[2], 3) + } } diff --git a/Tests/APIKitTests/DataParserType/NonSerializedJSONDataParserTests.swift b/Tests/APIKitTests/DataParserType/NonSerializedJSONDataParserTests.swift new file mode 100644 index 00000000..48451dd4 --- /dev/null +++ b/Tests/APIKitTests/DataParserType/NonSerializedJSONDataParserTests.swift @@ -0,0 +1,18 @@ +import XCTest +import APIKit +import XCTest + +class NonSerializedJSONDataParserTests: XCTestCase { + func testContentType() { + let parser = NonSerializedJSONDataParser() + XCTAssertEqual(parser.contentType, "application/json") + } + + func testJSONSuccess() throws { + let data = try XCTUnwrap("data".data(using: .utf8)) + let parser = NonSerializedJSONDataParser() + + let object = try parser.parse(data: data) + XCTAssertEqual(object, data) + } +} diff --git a/Tests/APIKitTests/DataParserType/ProtobufDataParserTests.swift b/Tests/APIKitTests/DataParserType/ProtobufDataParserTests.swift index 63e91853..a175d5fe 100644 --- a/Tests/APIKitTests/DataParserType/ProtobufDataParserTests.swift +++ b/Tests/APIKitTests/DataParserType/ProtobufDataParserTests.swift @@ -12,7 +12,7 @@ class ProtobufDataParserTests: XCTestCase { let data = try XCTUnwrap("data".data(using: .utf8)) let parser = ProtobufDataParser() - let object = try XCTUnwrap(try parser.parse(data: data) as? Data) + let object = try parser.parse(data: data) let string = String(data: object, encoding: .utf8) XCTAssertEqual(string, "data") } diff --git a/Tests/APIKitTests/DataParserType/StringDataParserTests.swift b/Tests/APIKitTests/DataParserType/StringDataParserTests.swift index d932501a..bc4a597d 100644 --- a/Tests/APIKitTests/DataParserType/StringDataParserTests.swift +++ b/Tests/APIKitTests/DataParserType/StringDataParserTests.swift @@ -14,7 +14,7 @@ class StringDataParserTests: XCTestCase { let parser = StringDataParser(encoding: .utf8) let object = try parser.parse(data: data) - XCTAssertEqual(object as? String, string) + XCTAssertEqual(object, string) } func testInvalidString() { diff --git a/Tests/APIKitTests/QueryParameters/URLEncodedQueryParametersTests.swift b/Tests/APIKitTests/QueryParameters/URLEncodedQueryParametersTests.swift new file mode 100644 index 00000000..a764e00c --- /dev/null +++ b/Tests/APIKitTests/QueryParameters/URLEncodedQueryParametersTests.swift @@ -0,0 +1,19 @@ +import XCTest +import APIKit + +class URLEncodedQueryParametersTests: XCTestCase { + func testURLEncodedSuccess() { + let object: [String: Any] = ["foo": "string", "bar": 1, "q": "こんにちは"] + let parameters = URLEncodedQueryParameters(parameters: object) + guard let query = parameters.encode() else { + XCTFail() + return + } + + let items = query.components(separatedBy: "&") + XCTAssertEqual(items.count, 3) + XCTAssertTrue(items.contains("foo=string")) + XCTAssertTrue(items.contains("bar=1")) + XCTAssertTrue(items.contains("q=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF")) + } +} diff --git a/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift b/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift index 4820dde3..dd9decc0 100644 --- a/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift +++ b/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterSubclassTests.swift @@ -15,6 +15,11 @@ class URLSessionAdapterSubclassTests: XCTestCase { functionCallFlags[(#function)] = true super.urlSession(session, dataTask: dataTask, didReceive: data) } + + override func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + functionCallFlags[(#function)] = true + super.urlSession(session, task: task, didSendBodyData: bytesSent, totalBytesSent: totalBytesSent, totalBytesExpectedToSend: totalBytesExpectedToSend) + } } var adapter: SessionAdapter! @@ -37,17 +42,33 @@ class URLSessionAdapterSubclassTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in if case .failure = result { XCTFail() } expectation.fulfill() - } + }) waitForExpectations(timeout: 10.0, handler: nil) XCTAssertEqual(adapter.functionCallFlags["urlSession(_:task:didCompleteWithError:)"], true) XCTAssertEqual(adapter.functionCallFlags["urlSession(_:dataTask:didReceive:)"], true) } + + // Limitation: 'urlSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:' delegate method will never be called when you stub the request using subclass of URLProtocol. + func testDelegateProgressMethodCall() { + let expectation = self.expectation(description: "wait for response") + let request = TestRequest(baseURL: "https://httpbin.org", path: "/post", method: .post) + let configuration = URLSessionConfiguration.default + let adapter = SessionAdapter(configuration: configuration) + let session = Session(adapter: adapter) + + session.send(request, uploadProgressHandler: { _ in + expectation.fulfill() + }) + + waitForExpectations(timeout: 30.0, handler: nil) + XCTAssertEqual(adapter.functionCallFlags["urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:)"], true) + } } diff --git a/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterTests.swift b/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterTests.swift index 8b9be0be..6e78afdd 100644 --- a/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterTests.swift +++ b/Tests/APIKitTests/SessionAdapterType/URLSessionAdapterTests.swift @@ -24,7 +24,7 @@ class URLSessionAdapterTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { response in + session.send(request, completionHandler: { response in switch response { case .success(let dictionary): XCTAssertEqual((dictionary as? [String: String])?["key"], "value") @@ -32,9 +32,9 @@ class URLSessionAdapterTests: XCTestCase { case .failure: XCTFail() } - + expectation.fulfill() - } + }) waitForExpectations(timeout: 10.0, handler: nil) } @@ -46,11 +46,11 @@ class URLSessionAdapterTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { response in + session.send(request, completionHandler: { response in switch response { case .success: XCTFail() - + case .failure(let error): switch error { case .connectionError(let error as NSError): @@ -62,7 +62,7 @@ class URLSessionAdapterTests: XCTestCase { } expectation.fulfill() - } + }) waitForExpectations(timeout: 10.0, handler: nil) } @@ -74,7 +74,7 @@ class URLSessionAdapterTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in guard case .failure(let error) = result, case .connectionError(let connectionError as NSError) = error else { XCTFail() @@ -84,7 +84,7 @@ class URLSessionAdapterTests: XCTestCase { XCTAssertEqual(connectionError.code, NSURLErrorCancelled) expectation.fulfill() - } + }) DispatchQueue.main.async { self.session.cancelRequests(with: TestRequest.self) diff --git a/Tests/APIKitTests/SessionCallbackQueueTests.swift b/Tests/APIKitTests/SessionCallbackQueueTests.swift index b9d961f9..97db037e 100644 --- a/Tests/APIKitTests/SessionCallbackQueueTests.swift +++ b/Tests/APIKitTests/SessionCallbackQueueTests.swift @@ -19,10 +19,10 @@ class SessionCallbackQueueTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request, callbackQueue: .main) { result in + session.send(request, callbackQueue: .main, completionHandler: { result in XCTAssert(Thread.isMainThread) expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -31,11 +31,11 @@ class SessionCallbackQueueTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request, callbackQueue: .sessionQueue) { result in + session.send(request, callbackQueue: .sessionQueue, completionHandler: { result in // This depends on implementation of TestSessionAdapter XCTAssertTrue(Thread.isMainThread) expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -45,10 +45,10 @@ class SessionCallbackQueueTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request, callbackQueue: .operationQueue(operationQueue)) { result in + session.send(request, callbackQueue: .operationQueue(operationQueue), completionHandler: { result in XCTAssertEqual(OperationQueue.current, operationQueue) expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -58,11 +58,11 @@ class SessionCallbackQueueTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request, callbackQueue: .dispatchQueue(dispatchQueue)) { result in + session.send(request, callbackQueue: .dispatchQueue(dispatchQueue), completionHandler: { result in // There is no way to test current dispatch queue. XCTAssertFalse(Thread.isMainThread) expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -75,10 +75,10 @@ class SessionCallbackQueueTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in XCTAssertEqual(OperationQueue.current, operationQueue) expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -90,10 +90,10 @@ class SessionCallbackQueueTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request, callbackQueue: nil) { result in + session.send(request, callbackQueue: nil, completionHandler: { result in XCTAssertEqual(OperationQueue.current, operationQueue) expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } diff --git a/Tests/APIKitTests/SessionTests.swift b/Tests/APIKitTests/SessionTests.swift index 40b887fc..8e4838d6 100644 --- a/Tests/APIKitTests/SessionTests.swift +++ b/Tests/APIKitTests/SessionTests.swift @@ -20,7 +20,7 @@ class SessionTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { response in + session.send(request, completionHandler: { response in switch response { case .success(let dictionary): XCTAssertEqual((dictionary as? [String: String])?["key"], "value") @@ -28,9 +28,9 @@ class SessionTests: XCTestCase { case .failure: XCTFail() } - + expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -42,7 +42,7 @@ class SessionTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in if case .failure(let error) = result, case .responseError(let responseError as NSError) = error { XCTAssertEqual(responseError.domain, NSCocoaErrorDomain) @@ -50,9 +50,9 @@ class SessionTests: XCTestCase { } else { XCTFail() } - + expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -63,7 +63,7 @@ class SessionTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in if case .failure(let error) = result, case .responseError(let responseError as ResponseError) = error, case .unacceptableStatusCode(let statusCode) = responseError { @@ -73,7 +73,7 @@ class SessionTests: XCTestCase { } expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -84,7 +84,7 @@ class SessionTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in if case .failure(let error) = result, case .responseError(let responseError as ResponseError) = error, case .nonHTTPURLResponse(let urlResponse) = responseError { @@ -94,7 +94,7 @@ class SessionTests: XCTestCase { } expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) } @@ -108,7 +108,7 @@ class SessionTests: XCTestCase { throw Error() } - session.send(request) { result in + session.send(request, completionHandler: { result in if case .failure(let error) = result, case .requestError(let requestError) = error { XCTAssert(requestError is Error) @@ -117,7 +117,7 @@ class SessionTests: XCTestCase { } expectation.fulfill() - } + }) waitForExpectations(timeout: 1.0, handler: nil) @@ -128,7 +128,7 @@ class SessionTests: XCTestCase { let expectation = self.expectation(description: "wait for response") let request = TestRequest() - session.send(request) { result in + session.send(request, completionHandler: { result in if case .failure(let error) = result, case .connectionError(let connectionError as NSError) = error { XCTAssertEqual(connectionError.code, 0) @@ -137,7 +137,7 @@ class SessionTests: XCTestCase { } expectation.fulfill() - } + }) session.cancelRequests(with: TestRequest.self) @@ -148,24 +148,24 @@ class SessionTests: XCTestCase { let successExpectation = expectation(description: "wait for response") let successRequest = TestRequest(path: "/success") - session.send(successRequest) { result in + session.send(successRequest, completionHandler: { result in if case .failure = result { XCTFail() } successExpectation.fulfill() - } + }) let failureExpectation = expectation(description: "wait for response") let failureRequest = TestRequest(path: "/failure") - session.send(failureRequest) { result in + session.send(failureRequest, completionHandler: { result in if case .success = result { XCTFail() } failureExpectation.fulfill() - } + }) session.cancelRequests(with: TestRequest.self) { request in return request.path == failureRequest.path @@ -174,7 +174,7 @@ class SessionTests: XCTestCase { waitForExpectations(timeout: 1.0, handler: nil) } - struct AnotherTestRequest: Request { + struct AnotherTestRequest: JSONRequest { typealias Response = Void var baseURL: URL { @@ -194,30 +194,60 @@ class SessionTests: XCTestCase { let successExpectation = expectation(description: "wait for response") let successRequest = AnotherTestRequest() - session.send(successRequest) { result in + session.send(successRequest, completionHandler: { result in if case .failure = result { XCTFail() } successExpectation.fulfill() - } + }) let failureExpectation = expectation(description: "wait for response") let failureRequest = TestRequest() - session.send(failureRequest) { result in + session.send(failureRequest, completionHandler: { result in if case .success = result { XCTFail() } failureExpectation.fulfill() - } + }) session.cancelRequests(with: TestRequest.self) waitForExpectations(timeout: 1.0, handler: nil) } + func testUploadProgress() { + let dictionary = ["key": "value"] + adapter.data = try! JSONSerialization.data(withJSONObject: dictionary, options: []) + + let expectation = self.expectation(description: "wait for response") + let request = TestRequest(method: .post) + + session.send(request, uploadProgressHandler: { progress in + XCTAssertNotNil(progress) + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testDownloadProgress() { + let dictionary = ["key": "value"] + adapter.data = try! JSONSerialization.data(withJSONObject: dictionary, options: []) + + let expectation = self.expectation(description: "wait for response") + let request = TestRequest(method: .post) + + session.send(request, downloadProgressHandler: { progress in + XCTAssertNotNil(progress) + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + } + // MARK: Class methods func testSharedSession() { XCTAssert(Session.shared === Session.shared) @@ -233,12 +263,13 @@ class SessionTests: XCTestCase { return testSesssion } - override func send(_ request: Request, callbackQueue: CallbackQueue?, handler: @escaping (Result) -> Void) -> SessionTask? { + override func send(_ request: Request, callbackQueue: CallbackQueue?, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Result) -> Void) -> SessionTask? { + functionCallFlags[(#function)] = true return super.send(request) } - override func cancelRequests(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool) { + override func cancelRequests(with requestType: Request.Type, passingTest test: @escaping (Request) -> Bool) { functionCallFlags[(#function)] = true } } @@ -247,7 +278,7 @@ class SessionTests: XCTestCase { SessionSubclass.send(TestRequest()) SessionSubclass.cancelRequests(with: TestRequest.self) - XCTAssertEqual(testSession.functionCallFlags["send(_:callbackQueue:handler:)"], true) + XCTAssertEqual(testSession.functionCallFlags["send(_:callbackQueue:uploadProgressHandler:downloadProgressHandler:completionHandler:)"], true) XCTAssertEqual(testSession.functionCallFlags["cancelRequests(with:passingTest:)"], true) } } diff --git a/Tests/APIKitTests/TestComponents/TestRequest.swift b/Tests/APIKitTests/TestComponents/TestRequest.swift index e3bc69aa..32cd5317 100644 --- a/Tests/APIKitTests/TestComponents/TestRequest.swift +++ b/Tests/APIKitTests/TestComponents/TestRequest.swift @@ -1,7 +1,7 @@ import Foundation import APIKit -struct TestRequest: Request { +struct TestRequest: JSONRequest { var absoluteURL: URL? { let urlRequest = try? buildURLRequest() return urlRequest?.url diff --git a/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift b/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift index 4813c23c..bddeba0c 100644 --- a/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift +++ b/Tests/APIKitTests/TestComponents/TestSessionAdapter.swift @@ -40,17 +40,19 @@ class TestSessionAdapter: SessionAdapter { func executeAllTasks() { for task in tasks { if task.cancelled { - task.handler(nil, nil, Error.cancelled) + task.completionHandler(nil, nil, Error.cancelled) } else { - task.handler(data, urlResponse, error) + task.uploadProgressHandler(Progress(totalUnitCount: 1)) + task.downloadProgressHandler(Progress(totalUnitCount: 1)) + task.completionHandler(data, urlResponse, error) } } tasks = [] } - func createTask(with URLRequest: URLRequest, handler: @escaping (Data?, URLResponse?, Swift.Error?) -> Void) -> SessionTask { - let task = TestSessionTask(handler: handler) + func createTask(with URLRequest: URLRequest, uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Swift.Error?) -> Void) -> SessionTask { + let task = TestSessionTask(uploadProgressHandler: uploadProgressHandler, downloadProgressHandler: downloadProgressHandler, completionHandler: completionHandler) tasks.append(task) return task diff --git a/Tests/APIKitTests/TestComponents/TestSessionTask.swift b/Tests/APIKitTests/TestComponents/TestSessionTask.swift index 5bf7927a..a6f4e658 100644 --- a/Tests/APIKitTests/TestComponents/TestSessionTask.swift +++ b/Tests/APIKitTests/TestComponents/TestSessionTask.swift @@ -2,16 +2,19 @@ import Foundation import APIKit class TestSessionTask: SessionTask { - - var handler: (Data?, URLResponse?, Error?) -> Void + + var completionHandler: (Data?, URLResponse?, Error?) -> Void + var uploadProgressHandler: Session.ProgressHandler + var downloadProgressHandler: Session.ProgressHandler var cancelled = false - init(handler: @escaping (Data?, URLResponse?, Error?) -> Void) { - self.handler = handler + init(uploadProgressHandler: @escaping Session.ProgressHandler, downloadProgressHandler: @escaping Session.ProgressHandler, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { + self.completionHandler = completionHandler + self.uploadProgressHandler = uploadProgressHandler + self.downloadProgressHandler = downloadProgressHandler } func resume() { - } func cancel() {