-
-
Save afterxleep/29c9af650deadf779e15bb00a8643ee6 to your computer and use it in GitHub Desktop.
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) | |
I got big problem when trying to Post to get token, httpBody
is always nil and I'm getting "Expression implicitly coerced from '[String : Any]?' to 'Any"
Normally this works when trying to get token:
let json: [String: Any] = ["password": "1111111111111"]
let jsonData = try? JSONSerialization.data(withJSONObject: json)
let url = URL(string: "https://some.url")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
I can't seems to replicate that with this playground. Anybody can help?
Ok I found that I needed to add
request.addValue(contentType, forHTTPHeaderField: "Content-Type")
to request and now sort of works. Problem is that if I leave var body: [String: Any]? { get }
as optional in protocol and doesn't give it default value in extension it stays nil forever. Even if you later do
var body: [String: Any]
init(body: [String: Any]) {
self.body = body
}
but if you remove optional and assign default value like you did var method: HTTPMethod { return .get }
than you can assign new value without any problems.
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.
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.
what about adding this:
if let queryParams = queryParams {
urlComponents.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
}
in asURLRequest method?
Yea, that can work.
I did something about that in the library I wrote based on this:
https://github.com/afterxleep/WireKit/blob/main/Sources/WireKit/Protocols/WKRequest.swift#L86
arrr okay ill try :-) thanks