Created
January 11, 2021 04:21
-
-
Save afterxleep/29c9af650deadf779e15bb00a8643ee6 to your computer and use it in GitHub Desktop.
A Playground with example code for Wirekit
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import Combine | |
// The Request Method | |
enum HTTPMethod: String { | |
case get = "GET" | |
case post = "POST" | |
case put = "PUT" | |
case delete = "DELETE" | |
} | |
enum NetworkRequestError: LocalizedError, Equatable { | |
case invalidRequest | |
case badRequest | |
case unauthorized | |
case forbidden | |
case notFound | |
case error4xx(_ code: Int) | |
case serverError | |
case error5xx(_ code: Int) | |
case decodingError | |
case urlSessionFailed(_ error: URLError) | |
case unknownError | |
} | |
// Extending Encodable to Serialize a Type into a Dictionary | |
extension Encodable { | |
var asDictionary: [String: Any] { | |
guard let data = try? JSONEncoder().encode(self) else { return [:] } | |
guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { | |
return [:] | |
} | |
return dictionary | |
} | |
} | |
// Our Request Protocol | |
protocol Request { | |
var path: String { get } | |
var method: HTTPMethod { get } | |
var contentType: String { get } | |
var body: [String: Any]? { get } | |
var headers: [String: String]? { get } | |
associatedtype ReturnType: Codable | |
} | |
// Defaults and Helper Methods | |
extension Request { | |
// Defaults | |
var method: HTTPMethod { return .get } | |
var contentType: String { return "application/json" } | |
var queryParams: [String: String]? { return nil } | |
var body: [String: Any]? { return nil } | |
var headers: [String: String]? { return nil } | |
/// Serializes an HTTP dictionary to a JSON Data Object | |
/// - Parameter params: HTTP Parameters dictionary | |
/// - Returns: Encoded JSON | |
private func requestBodyFrom(params: [String: Any]?) -> Data? { | |
guard let params = params else { return nil } | |
guard let httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) else { | |
return nil | |
} | |
return httpBody | |
} | |
/// Transforms an Request into a standard URL request | |
/// - Parameter baseURL: API Base URL to be used | |
/// - Returns: A ready to use URLRequest | |
func asURLRequest(baseURL: String) -> URLRequest? { | |
guard var urlComponents = URLComponents(string: baseURL) else { return nil } | |
urlComponents.path = "\(urlComponents.path)\(path)" | |
guard let finalURL = urlComponents.url else { return nil } | |
var request = URLRequest(url: finalURL) | |
request.httpMethod = method.rawValue | |
request.httpBody = requestBodyFrom(params: body) | |
request.allHTTPHeaderFields = headers | |
return request | |
} | |
} | |
struct NetworkDispatcher { | |
let urlSession: URLSession! | |
public init(urlSession: URLSession = .shared) { | |
self.urlSession = urlSession | |
} | |
/// Dispatches an URLRequest and returns a publisher | |
/// - Parameter request: URLRequest | |
/// - Returns: A publisher with the provided decoded data or an error | |
func dispatch<ReturnType: Codable>(request: URLRequest) -> AnyPublisher<ReturnType, NetworkRequestError> { | |
return urlSession | |
.dataTaskPublisher(for: request) | |
// Map on Request response | |
.tryMap({ data, response in | |
// If the response is invalid, throw an error | |
if let response = response as? HTTPURLResponse, | |
!(200...299).contains(response.statusCode) { | |
throw httpError(response.statusCode) | |
} | |
// Return Response data | |
return data | |
}) | |
// Decode data using our ReturnType | |
.decode(type: ReturnType.self, decoder: JSONDecoder()) | |
// Handle any decoding errors | |
.mapError { error in | |
handleError(error) | |
} | |
// And finally, expose our publisher | |
.eraseToAnyPublisher() | |
} | |
/// Parses a HTTP StatusCode and returns a proper error | |
/// - Parameter statusCode: HTTP status code | |
/// - Returns: Mapped Error | |
private func httpError(_ statusCode: Int) -> NetworkRequestError { | |
switch statusCode { | |
case 400: return .badRequest | |
case 401: return .unauthorized | |
case 403: return .forbidden | |
case 404: return .notFound | |
case 402, 405...499: return .error4xx(statusCode) | |
case 500: return .serverError | |
case 501...599: return .error5xx(statusCode) | |
default: return .unknownError | |
} | |
} | |
/// Parses URLSession Publisher errors and return proper ones | |
/// - Parameter error: URLSession publisher error | |
/// - Returns: Readable NetworkRequestError | |
private func handleError(_ error: Error) -> NetworkRequestError { | |
switch error { | |
case is Swift.DecodingError: | |
return .decodingError | |
case let urlError as URLError: | |
return .urlSessionFailed(urlError) | |
case let error as NetworkRequestError: | |
return error | |
default: | |
return .unknownError | |
} | |
} | |
} | |
struct APIClient { | |
var baseURL: String! | |
var networkDispatcher: NetworkDispatcher! | |
init(baseURL: String, | |
networkDispatcher: NetworkDispatcher = NetworkDispatcher()) { | |
self.baseURL = baseURL | |
self.networkDispatcher = networkDispatcher | |
} | |
/// Dispatches a Request and returns a publisher | |
/// - Parameter request: Request to Dispatch | |
/// - Returns: A publisher containing decoded data or an error | |
func dispatch<R: Request>(_ request: R) -> AnyPublisher<R.ReturnType, NetworkRequestError> { | |
guard let urlRequest = request.asURLRequest(baseURL: baseURL) else { | |
return Fail(outputType: R.ReturnType.self, failure: NetworkRequestError.badRequest).eraseToAnyPublisher() | |
} | |
typealias RequestPublisher = AnyPublisher<R.ReturnType, NetworkRequestError> | |
let requestPublisher: RequestPublisher = networkDispatcher.dispatch(request: urlRequest) | |
return requestPublisher.eraseToAnyPublisher() | |
} | |
} | |
// Performing some requests | |
// Our Model | |
struct Todo: Codable { | |
var title: String | |
var completed: Bool | |
} | |
// Request | |
struct FindTodos: Request { | |
typealias ReturnType = [Todo] | |
var path: String = "/todos" | |
} | |
// POST Request | |
struct AddTodo: Request { | |
typealias ReturnType = [Todo] | |
var path: String = "/todos" | |
var method: HTTPMethod = .post | |
var body: [String: Any] | |
init(body: [String: Any]) { | |
self.body = body | |
} | |
} | |
private var cancellables = [AnyCancellable]() | |
let dispatcher = NetworkDispatcher() | |
let apiClient = APIClient(baseURL: "https://jsonplaceholder.typicode.com") | |
// Simple GET Request | |
apiClient.dispatch(FindTodos()) | |
.sink(receiveCompletion: { _ in }, | |
receiveValue: { value in | |
print(value) | |
}) | |
.store(in: &cancellables) | |
// A Simple Post Request | |
let otherTodo: Todo = Todo(title: "Test", completed: true) | |
apiClient.dispatch(AddTodo(body: otherTodo.asDictionary)) | |
.sink(receiveCompletion: { result in | |
// Do something after adding... | |
}, | |
receiveValue: { _ in }) | |
.store(in: &cancellables) | |
Perfect
Thanks for the playground!
I was also experiencing some problems with POST requests and the body. I solved it by making var body: [String : Any]?
in the Request structs an optional. This way you only need to modify the Request structs actually altering/using the body. Nevertheless thanks for documenting your research and solution Lukasz, it helped me find my solution!
This feels a lot like retrofit for Android.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I figure it out! If you remove
var body: [String: Any]? { return nil }
from Defaults and keep it in protocol itself with optional than you can set body to nil whenever you don't need it in the Request struct and do init whenever you do.