Skip to content

Commit

Permalink
Add generic handler for requests in APIBaseTask
Browse files Browse the repository at this point in the history
Unifies request + response handling for APIBaseTask instead of having individual methods for get, post, and delete.
  • Loading branch information
bjtitus committed Jan 31, 2024
1 parent 3162572 commit badf2ce
Show file tree
Hide file tree
Showing 3 changed files with 236 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import Foundation
import PocketCastsDataModel
import PocketCastsUtils

enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case delete = "DELETE"
}

class ApiBaseTask: Operation {
private let syncTimeout = 60 as TimeInterval
private let isoDateFormatter = ISO8601DateFormatter()
Expand Down Expand Up @@ -41,29 +47,9 @@ class ApiBaseTask: Operation {

private func performPostToServer(url: String, token: String, data: Data, retryOnUnauthorized: Bool = true) -> (Data?, Int) {
let requestUrl = ServerHelper.asUrl(url)
let method = "POST"
var request = createRequest(url: requestUrl, method: method, token: token)
do {
request.httpBody = data

let (responseData, response) = try urlConnection.sendSynchronousRequest(with: request)
guard let httpResponse = response as? HTTPURLResponse else { return (nil, ServerConstants.HttpConstants.serverError) }
if httpResponse.statusCode == ServerConstants.HttpConstants.unauthorized {
if retryOnUnauthorized, let newToken = tokenHelper.acquireToken() {
return performPostToServer(url: url, token: newToken, data: data, retryOnUnauthorized: false)
}

// our token may have expired, remove it so next time a sync happens we'll acquire a new one
KeychainHelper.removeKey(ServerConstants.Values.syncingV2TokenKey)
return (nil, httpResponse.statusCode)
}

return (responseData, httpResponse.statusCode)
} catch {
logFailure(method: method, url: url, error: error)
}

return (nil, ServerConstants.HttpConstants.serverError)
let (data, response) = requestToServer(url: requestUrl, method: .post, token: token, retryOnUnauthorized: retryOnUnauthorized, data: data)
return (data, response?.statusCode ?? ServerConstants.HttpConstants.serverError)
}

func getToServer(url: String, token: String, customHeaders: [String: String]? = nil) -> (Data?, HTTPURLResponse?) {
Expand All @@ -72,20 +58,38 @@ class ApiBaseTask: Operation {

func performGetToServer(url: String, token: String, retryOnUnauthorized: Bool = true, customHeaders: [String: String]? = nil) -> (Data?, HTTPURLResponse?) {
let requestUrl = ServerHelper.asUrl(url)
let method = "GET"
var request = createRequest(url: requestUrl, method: method, token: token)

return requestToServer(url: requestUrl, method: .get, token: token, retryOnUnauthorized: retryOnUnauthorized, customHeaders: customHeaders)
}

func deleteToServer(url: String, token: String?, data: Data) -> (Data?, Int) {
let url = ServerHelper.asUrl(url)

let (data, response) = requestToServer(url: url, method: .delete, token: token, data: data)
return (data, response?.statusCode ?? ServerConstants.HttpConstants.serverError)
}

func requestToServer(url: URL, method: HTTPMethod, token: String?, retryOnUnauthorized: Bool = true, customHeaders: [String: String]? = nil, data: Data? = nil) -> (Data?, HTTPURLResponse?) {
var request = createRequest(url: url, method: method.rawValue, token: token)

if let customHeaders = customHeaders {
for header in customHeaders {
request.setValue(header.value, forHTTPHeaderField: header.key)
}
}

do {
request.httpBody = data

let (responseData, response) = try urlConnection.sendSynchronousRequest(with: request)
guard let httpResponse = response as? HTTPURLResponse else { return (nil, nil) }
guard let httpResponse = response as? HTTPURLResponse else {
return (nil, HTTPURLResponse(url: url, statusCode: ServerConstants.HttpConstants.serverError, httpVersion: nil, headerFields: nil))
}

if httpResponse.statusCode == ServerConstants.HttpConstants.unauthorized {

if retryOnUnauthorized, let newToken = tokenHelper.acquireToken() {
return performGetToServer(url: url, token: newToken, retryOnUnauthorized: false, customHeaders: customHeaders)
return requestToServer(url: url, method: method, token: newToken, retryOnUnauthorized: retryOnUnauthorized, customHeaders: customHeaders, data: data)
}

// our token may have expired, remove it so next time a sync happens we'll acquire a new one
Expand All @@ -95,33 +99,10 @@ class ApiBaseTask: Operation {

return (responseData, httpResponse)
} catch {
logFailure(method: method, url: url, error: error)
}

return (nil, nil)
}

func deleteToServer(url: String, token: String?, data: Data) -> (Data?, Int) {
let url = ServerHelper.asUrl(url)
let method = "DELETE"
var request = createRequest(url: url, method: method, token: token)
do {
request.httpBody = data

let (responseData, response) = try urlConnection.sendSynchronousRequest(with: request)
guard let httpResponse = response as? HTTPURLResponse else { return (nil, ServerConstants.HttpConstants.serverError) }
if httpResponse.statusCode == ServerConstants.HttpConstants.unauthorized {
// our token may have expired, remove it so next time a sync happens we'll acquire a new one
KeychainHelper.removeKey(ServerConstants.Values.syncingV2TokenKey)
return (nil, httpResponse.statusCode)
}

return (responseData, httpResponse.statusCode)
} catch {
logFailure(method: method, url: url.absoluteString, error: error)
logFailure(method: method.rawValue, url: url.absoluteString, error: error)
}

return (nil, ServerConstants.HttpConstants.serverError)
return (nil, HTTPURLResponse(url: url, statusCode: ServerConstants.HttpConstants.serverError, httpVersion: nil, headerFields: nil))
}

func formatDate(_ date: Date?) -> String {
Expand Down
173 changes: 173 additions & 0 deletions Modules/Server/Tests/PocketCastsServerTests/APIBaseTaskTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
@testable import PocketCastsServer
import XCTest

final class APIBaseTaskTests: XCTestCase {

func testGetRequest() {

let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in
XCTAssertEqual(request.httpMethod, "GET")
expectation.fulfill()
return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil))
})

let (_, response) = task.getToServer(url: "http://pocketcasts.com", token: "")

XCTAssertEqual(response?.statusCode, 200)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)
}

func testGetHeadersRequest() {
let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in
XCTAssertEqual(request.value(forHTTPHeaderField: "Test"), "TestValue")
XCTAssertEqual(request.httpMethod, "GET")
expectation.fulfill()
return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil))
})

let (_, response) = task.getToServer(url: "http://pocketcasts.com", token: "", customHeaders: ["Test": "TestValue"])

XCTAssertEqual(response?.statusCode, 200)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)
}

func testPostRequest() {
let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in
XCTAssertEqual(request.httpMethod, "POST")
expectation.fulfill()
return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil))
})

let (_, statusCode) = task.postToServer(url: "http://pocketcasts.com", token: "", data: Data())

XCTAssertEqual(statusCode, 200)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)
}

func testEmptyResponsePostRequest() {
let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in
XCTAssertEqual(request.httpMethod, "POST")
expectation.fulfill()
return (nil, nil)
})

let (data, statusCode) = task.postToServer(url: "http://pocketcasts.com", token: "", data: Data())

XCTAssertNil(data)
XCTAssertEqual(statusCode, 500)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)
}

func testDeleteRequest() {
let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in
XCTAssertEqual(request.httpMethod, "DELETE")
expectation.fulfill()
return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil))
})

let (_, statusCode) = task.deleteToServer(url: "http://pocketcasts.com", token: "", data: Data())

XCTAssertEqual(statusCode, 200)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)
}

func testEmptyResponseDeleteRequest() {
let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, URLResponse?) in
XCTAssertEqual(request.httpMethod, "DELETE")
expectation.fulfill()
return (nil, nil)
})

let (data, statusCode) = task.deleteToServer(url: "http://pocketcasts.com", token: nil, data: Data())

XCTAssertNil(data)
XCTAssertEqual(statusCode, 500)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)
}

// MARK: Generic Request tests

/// Mocks requests for testing with standard checks for HTTPMethod and expectation to wait on response from callbacks.
/// - Parameters:
/// - httpMethod: The httpMethod to send
/// - handler: An optional handler to produce a Data and HTTPURLResponse for a given URLRequest
/// - Returns: Data and HTTPURLResponse
private func genericRequest(httpMethod: HTTPMethod, handler: ((URLRequest) -> (Data?, HTTPURLResponse?))? = nil) -> (Data?, HTTPURLResponse?) {
let expectation = self.expectation(description: "APIBaseTask should complete")
let task = ApiBaseTask(urlConnection: URLConnection { request -> (Data?, HTTPURLResponse?) in
XCTAssertEqual(request.httpMethod, httpMethod.rawValue)
expectation.fulfill()
if let response = handler?(request) {
return response
} else {
return (nil, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil))
}
})

let url = URL(string: "http://pocketcasts.com")!
let (data, response) = task.requestToServer(url: url, method: httpMethod, token: nil, retryOnUnauthorized: false, customHeaders: nil, data: nil)

OperationQueue.main.addOperation(task)
self.waitForExpectations(timeout: 5)

return (data, response)
}

func testGenericGetRequest() {
let (data, response) = genericRequest(httpMethod: .get)

XCTAssertNil(data)
XCTAssertEqual(response?.statusCode, 200)
}

func testGenericPostRequest() {
let (data, response) = genericRequest(httpMethod: .post)

XCTAssertNil(data)
XCTAssertEqual(response?.statusCode, 200)
}

func testGenericEmptyResponsePostRequest() {
let (data, response) = genericRequest(httpMethod: .post) { _ in
return (nil, nil)
}

XCTAssertNil(data)
XCTAssertEqual(response?.statusCode, 500)
}

func testGenericDeleteRequest() {
let (data, response) = genericRequest(httpMethod: .delete)

XCTAssertNil(data)
XCTAssertEqual(response?.statusCode, 200)
}

func testGenericEmptyResponseDeleteRequest() {
let (data, response) = genericRequest(httpMethod: .delete) { _ in
return (nil, nil)
}

XCTAssertNil(data)
XCTAssertEqual(response?.statusCode, 500)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
@testable import PocketCastsServer
import XCTest

struct MockRequestHandler {
typealias Handler = ((URLRequest) throws -> (Data?, URLResponse?))

let handler: Handler

init(handler: @escaping ((URLRequest) throws -> (Data?, URLResponse?))) {
self.handler = handler
}
}

extension MockRequestHandler: RequestHandler {
func send(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
do {
let (data, response) = try handler(request)
completion(data, response, nil)
} catch let error {
completion(nil, nil, error)
}
}
}

extension URLConnection {
/// A convenient initializer to pass a block which returns data, response, and error for a given URLRequest.
/// - Parameter mockHandler: The handler block (URLRequest) throws -> (Data, URLResponse?)
convenience init(mockHandler: @escaping MockRequestHandler.Handler) {
self.init(handler: MockRequestHandler(handler: mockHandler))
}
}

0 comments on commit badf2ce

Please sign in to comment.