Skip to content

Instantly share code, notes, and snippets.

@afterxleep
Created January 11, 2021 04:21
Show Gist options
  • Save afterxleep/29c9af650deadf779e15bb00a8643ee6 to your computer and use it in GitHub Desktop.
Save afterxleep/29c9af650deadf779e15bb00a8643ee6 to your computer and use it in GitHub Desktop.
A Playground with example code for Wirekit
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)
@afterxleep
Copy link
Author

afterxleep commented Jun 17, 2021

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

@Skovie
Copy link

Skovie commented Sep 5, 2021

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

@afterxleep
Copy link
Author

afterxleep commented Sep 6, 2021

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

@Skovie
Copy link

Skovie commented Sep 6, 2021

arrr okay ill try :-) thanks

@LukaszDziwosz
Copy link

LukaszDziwosz commented Oct 29, 2021

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?

@LukaszDziwosz
Copy link

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.

@LukaszDziwosz
Copy link

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.

@alloc33
Copy link

alloc33 commented Nov 20, 2021

Perfect

@Peterkrol12
Copy link

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!

@elomonaco
Copy link

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