-
-
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) | |
Hey. This example is pretty rough, but I wrote an article that might be usefull to understand the approach. https://danielbernal.co/writing-a-networking-library-with-combine-codable-and-swift-5/
You can also use Wirekit, for easier networking.. https://github.com/afterxleep/WireKit
any way to make reusable .map to your .decode , my json is used in android as well and I receive a {
"votes": [
{
"id": "66248",
}
]
}
normalt i create a :
struct VoteResponse: Encodable, Decodable {
let votes: [ReturnVote]
}
and the in my . decode ...... I do .map(.votes) to get the dict before the array..... but I don't know how to ad a reusable map to your code ..... hope you can help , hummm the editor removes my "backslash" in the . map :-D
In this case, you can update your model to reflect the change in the Model.
// Our Model
struct Data: Codable {
var votes: [Vote]
}
struct Vote: Codable {
var id: .....
}
Then if you need to map over the parsed results, you can do that later down the stream in:
receiveValue: { value in
arrr okay ill try :-) thanks
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
I'm new to swift, was looking for a good example of how to perform an API call when I saw your post.
I'm trying to modify your example to see if I'm able to call an API of my own.
The problem seems to be that your code does not emit any errors at all.
For instance changing "https://jsonplaceholder.typicode.com" to "https://google.com"
Gives no output at all, no error.