Skip to content

Commit

Permalink
Adding download on RequestExecutor (#133)
Browse files Browse the repository at this point in the history
* Adding download on RequestExecutor

* Remove space
  • Loading branch information
barrault01 committed Mar 10, 2021
1 parent 1dc6fdc commit d5ca03e
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 21 deletions.
41 changes: 39 additions & 2 deletions Sources/APIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public final class APIProvider {
case unknownResponseType
case requestFailure(StatusCode, Data?)
case decodingError(Swift.Error, Data)
case downloadError
case dateDecodingError(String)
case requestExecutorError(Swift.Error)

Expand All @@ -59,6 +60,8 @@ public final class APIProvider {
return "Failed to decode response:\n\(response)\nError: \(error)."
}
return "Failed to decode data."
case .downloadError:
return "File url not generated."
case .dateDecodingError(let date):
return "Failed to decode date: \(date)"
case .requestExecutorError(let error):
Expand Down Expand Up @@ -140,6 +143,20 @@ public final class APIProvider {

requestExecutor.execute(request) { completion(self.mapResponse($0)) }
}

/// Performs a download request to the given API endpoint
///
/// - Parameters:
/// - endpoint: The API endpoint to request.
/// - completion: The completion callback which will be called on completion containing the result.
public func download<T: Decodable>(_ endpoint: APIEndpoint<T>, completion: @escaping RequestCompletionHandler<URL>) {
guard let request = try? requestsAuthenticator.adapt(endpoint.asURLRequest()) else {
completion(.failure(Error.requestGeneration))
return
}

requestExecutor.download(request) { completion(self.mapResponse($0)) }
}

/// Performs a data request to the given ResourceLinks
///
Expand All @@ -160,7 +177,7 @@ private extension APIProvider {
///
/// - Parameter result: A result type containing either the network response or an error
/// - Returns: A result type containing either the decoded type or an error
func mapResponse<T: Decodable>(_ result: Result<Response, Swift.Error>) -> Result<T, Swift.Error> {
func mapResponse<T: Decodable>(_ result: Result<Response<Data>, Swift.Error>) -> Result<T, Swift.Error> {
switch result {
case .success(let response):
guard let data = response.data, 200..<300 ~= response.statusCode else {
Expand All @@ -186,7 +203,7 @@ private extension APIProvider {
///
/// - Parameter result: A result type containing either the network response or an error
/// - Returns: A result type containing either void or an error
func mapVoidResponse(_ result: Result<Response, Swift.Error>) -> Result<Void, Swift.Error> {
func mapVoidResponse(_ result: Result<Response<Data>, Swift.Error>) -> Result<Void, Swift.Error> {
switch result {
case .success(let response):
guard 200..<300 ~= response.statusCode else {
Expand All @@ -198,4 +215,24 @@ private extension APIProvider {
return .failure(Error.requestExecutorError(error))
}
}

/// Maps a download response to a URL type
///
/// - Parameter result: A result type containing either the network response or an error
/// - Returns: A result type containing either the decoded type or an error
func mapResponse(_ result: Result<Response<URL>, Swift.Error>) -> Result<URL, Swift.Error> {
switch result {
case .success(let response):
guard 200..<300 ~= response.statusCode else {
return .failure(Error.requestFailure(response.statusCode, nil))
}
if let data = response.data {
return .success(data)
}
return .failure(Error.downloadError)
case .failure(let error):
return .failure(Error.requestExecutorError(error))
}
}

}
37 changes: 34 additions & 3 deletions Sources/DefaultRequestExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public final class DefaultRequestExecutor: RequestExecutor {
/// - Parameters:
/// - urlRequest: The URLRequest to execute
/// - completion: A result type containing eiter the response or an error
public func execute(_ urlRequest: URLRequest, completion: @escaping (Result<Response, Swift.Error>) -> Void) {
public func execute(_ urlRequest: URLRequest, completion: @escaping (Result<Response<Data>, Swift.Error>) -> Void) {
urlSession.dataTask(with: urlRequest) { data, response, error in
completion(mapResponse(data: data, urlResponse: response, error: error))
}.resume()
Expand All @@ -37,11 +37,23 @@ public final class DefaultRequestExecutor: RequestExecutor {
/// - Parameters:
/// - url: The URL where the resource is located
/// - completion: A result type containing eiter the response or an error
public func retrieve(_ url: URL, completion: @escaping (Result<Response, Swift.Error>) -> Void) {
public func retrieve(_ url: URL, completion: @escaping (Result<Response<Data>, Swift.Error>) -> Void) {
urlSession.dataTask(with: url) { data, response, error in
completion(mapResponse(data: data, urlResponse: response, error: error))
}.resume()
}

/// Download report as file
///
/// - Parameters:
/// - urlRequest: The URLRequest to execute
/// - completion: A result type containing eiter the response or an error
public func download(_ urlRequest: URLRequest, completion: @escaping (Result<Response<URL>, Swift.Error>) -> Void) {
urlSession.downloadTask(with: urlRequest) { fileUrl, response, error in
completion(mapResponse(fileUrl: fileUrl, urlResponse: response, error: error))
}.resume()
}

}

// MARK: - Private
Expand All @@ -53,7 +65,7 @@ public final class DefaultRequestExecutor: RequestExecutor {
/// - urlResponse: URLResponse returned from an URLSession data task
/// - error: Error returned from an URLSession data task
/// - completion: A result type containing eiter the response or an error
func mapResponse(data: Data?, urlResponse: URLResponse?, error: Error?) -> Result<Response, Swift.Error> {
func mapResponse(data: Data?, urlResponse: URLResponse?, error: Error?) -> Result<Response<Data>, Swift.Error> {
if let error = error {
return .failure(error)
} else {
Expand All @@ -64,3 +76,22 @@ func mapResponse(data: Data?, urlResponse: URLResponse?, error: Error?) -> Resul
return .success(.init(statusCode: httpUrlResponse.statusCode, data: data))
}
}

/// Maps the result of an URLSession downloadTask to a response type
///
/// - Parameters:
/// - fileUrl: The file URL of the arquive containing the data returned from an URLSession download task
/// - urlResponse: URLResponse returned from an URLSession data task
/// - error: Error returned from an URLSession data task
/// - completion: A result type containing eiter the response or an error
func mapResponse(fileUrl: URL?, urlResponse: URLResponse?, error: Error?) -> Result<Response<URL>, Swift.Error> {
if let error = error {
return .failure(error)
} else {
guard let httpUrlResponse = urlResponse as? HTTPURLResponse else {
return .failure(DefaultRequestExecutor.Error.unknownResponseType)
}

return .success(.init(statusCode: httpUrlResponse.statusCode, data: fileUrl))
}
}
14 changes: 9 additions & 5 deletions Sources/RequestExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
import Foundation

/// The result type delivered from a successful URLRequest
public struct Response {
public struct Response<T> {
public typealias StatusCode = Int

public let statusCode: Int
public let data: Data?
public let data: T?

public init(statusCode: StatusCode, data: Data?) {
public init(statusCode: StatusCode, data: T?) {
self.statusCode = statusCode
self.data = data
}
Expand All @@ -24,8 +24,12 @@ public struct Response {
public protocol RequestExecutor {

/// Performs a URLRequest and returns a result
func execute(_ urlRequest: URLRequest, completion: @escaping (Result<Response, Swift.Error>) -> Void)
func execute(_ urlRequest: URLRequest, completion: @escaping (Result<Response<Data>, Swift.Error>) -> Void)

/// Retrieves the content of a given URL
func retrieve(_ url: URL, completion: @escaping (Result<Response, Swift.Error>) -> Void)
func retrieve(_ url: URL, completion: @escaping (Result<Response<Data>, Swift.Error>) -> Void)

/// Download report as file a given URL
func download(_ urlRequest: URLRequest, completion: @escaping (Result<Response<URL>, Swift.Error>) -> Void)

}
78 changes: 67 additions & 11 deletions Tests/APIProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,40 @@ import XCTest

final class APIProviderTests: XCTestCase {

private struct MockRequestExecutor: RequestExecutor {
private struct MockRequestExecutor<T>: RequestExecutor {

let expectedResponse: Result<Response, Swift.Error>
let expectedResponse: Result<Response<T>, Swift.Error>

init(expectedResponse: Result<Response, Swift.Error>) {
init(expectedResponse: Result<Response<T>, Swift.Error>) {
self.expectedResponse = expectedResponse
}

func execute(_ urlRequest: URLRequest, completion: @escaping (Result<Response, Swift.Error>) -> Void) {
completion(expectedResponse)
func execute(_ urlRequest: URLRequest, completion: @escaping (Result<Response<Data>, Swift.Error>) -> Void) {
if let response = expectedResponse as? Result<Response<Data>, Swift.Error> {
completion(response)
}
}

func retrieve(_ url: URL, completion: @escaping (Result<Response, Swift.Error>) -> Void) {
completion(expectedResponse)
func retrieve(_ url: URL, completion: @escaping (Result<Response<Data>, Swift.Error>) -> Void) {
if let response = expectedResponse as? Result<Response<Data>, Swift.Error> {
completion(response)
}
}

func download(_ urlRequest: URLRequest, completion: @escaping (Result<Response<URL>, Error>) -> Void) {
if let response = expectedResponse as? Result<Response<URL>, Swift.Error> {
completion(response)
}
}

}

private let configuration = APIConfiguration(issuerID: UUID().uuidString, privateKeyID: UUID().uuidString, privateKey: "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgPaXyFvZfNydDEjxgjUCUxyGjXcQxiulEdGxoVbasV3GgCgYIKoZIzj0DAQehRANCAASflx/DU3TUWAoLmqE6hZL9A7i0DWpXtmIDCDiITRznC6K4/WjdIcuMcixy+m6O0IrffxJOablIX2VM8sHRscdr")

// MARK: - Tests

func testRequestExecutionWithVoidResponse() {
let response = Response(statusCode: 200, data: nil)
let response = Response<Data>(statusCode: 200, data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))
let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)

Expand All @@ -44,7 +55,7 @@ final class APIProviderTests: XCTestCase {
}

func testRequestExecutionErrorResponse() {
let response = Response(statusCode: 500, data: nil)
let response = Response<Data>(statusCode: 500, data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))
let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)

Expand Down Expand Up @@ -150,7 +161,7 @@ final class APIProviderTests: XCTestCase {
}

func testRequestForResourceLinkWithFailure() {
let response = Response(statusCode: 500, data: nil)
let response = Response<Data>(statusCode: 500, data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))
let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
let resourceLink = ResourceLinks<BetaTester>(self: URL(string: "https://api.appstoreconnect.com?cursor=FIRST")!)
Expand All @@ -174,12 +185,57 @@ final class APIProviderTests: XCTestCase {
}
}

func testDownloadRequestWithResultSuccess() {
let response = Response(statusCode: 200, data: URL(fileURLWithPath: "randompath"))
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))

let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
let reportEndpoint = APIEndpoint.downloadSalesAndTrendsReports()
apiProvider.download(reportEndpoint) { result in
// using the mock request executor the block is called sync
XCTAssertTrue(result.isSuccess)
XCTAssertEqual(result.value!, URL(fileURLWithPath: "randompath"))
}
}

func testDownloadRequestWithProblemOnFileCreation() {
let response = Response<URL>(statusCode: 200, data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))

let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
let reportEndpoint = APIEndpoint.downloadSalesAndTrendsReports()
apiProvider.download(reportEndpoint) { result in
// using the mock request executor the block is called sync
XCTAssertTrue(result.isFailure)
guard
let error = result.error as? APIProvider.Error else {
XCTFail("We expect a requestFailure error")
return
}
XCTAssert(error.debugDescription == APIProvider.Error.downloadError.debugDescription)

}
}

func testDownloadRequestWithFailure() {
let response = Response<URL>(statusCode: 500, data: nil)
let mockRequestExecutor = MockRequestExecutor(expectedResponse: Result.success(response))

let apiProvider = APIProvider(configuration: configuration, requestExecutor: mockRequestExecutor)
let reportEndpoint = APIEndpoint.downloadSalesAndTrendsReports()
apiProvider.download(reportEndpoint) { result in
// using the mock request executor the block is called sync
XCTAssertTrue(result.isFailure)

}
}

func testDateDecoding() throws {
let decoder = APIProvider.jsonDecoder
let outputFormatter = DateFormatter()
outputFormatter.dateFormat = "E, d MMM yyyy HH:mm:ss Z"
outputFormatter.locale = Locale(identifier: "en_US_POSIX")

outputFormatter.timeZone = TimeZone(identifier: "GMT")
let dateFrom: (String) throws -> String = { dateString in
let date = try decoder.decode(Date.self, from: "\"\(dateString)\"".data(using: .utf8)!)
return outputFormatter.string(from: date)
Expand Down

0 comments on commit d5ca03e

Please sign in to comment.