Why build a new wheel

just for fun!

Ha ha, in fact, I still recommend you to use in the real project of network library, such as Moya/Alamofire AFNetworking, after all, the function is strong enough, proven, code, don’t want to say faults may be slightly bloated, not convenient to use with the SDK, And for the latter two generally need secondary encapsulation. This time, the goal is to create a network library that is light enough and powerful enough.

Let’s start implementing a simple and powerful network library directly based on the system URLSession.

The target

What I want to achieve is this:

let client = HTTPClient(session: .shared)
let req = HTTPBinPostRequest(foo: "bar")
client.send(req) { (result) in
    switch result {
    case .success(let response):
        print(response)
    case .failure(let error):
        print(error)
    }
}
Copy the code

It needs to be minimal, pass only the necessary variable parameters, and be completely type safe, all types are determined, and no judgment is required for final processing.

abstract

What objects are involved in making a network request? Nothing more than requests, responses, data-processing operations, and objects that connect those things.

A request, in fact, is an interface, at least the request address, request method, request parameters, parameter type, it is a complete request. When the client and the back-end agree on an interface, in addition to the value of the parameter uncertainty, other (including return structure) are generally will not change, the other parameters is also just the interface itself, implementation should be within the request, and should not by the caller to tell the caller calls the only need to describe this interface.

public protocol Request {
    associatedtype Response: Decodable
    
    var url: URL { get }
    var method: HTTPMethod { get }
    var parameters: [String: Any] { get }
    var contentType: ContentType { get}}Copy the code

The request has been abstracted out, but this is just for higher level use. At the bottom level we have to convert it to URLRequest to actually request it, so:

public extension Request{
    func buildRequest(a) throws -> URLRequest {
        let req = URLRequest(url: url)
      // There are various assignments to req, various ifelse, which can be easily written in noodle code
        return request
    }
}
Copy the code

We need to modify the basic URLRequest in various ways, such as the assignment request method, various header fields, query fields, and request body. Obviously, if you write all the logic into buildRequest, it’s going to be complicated. We need to abstract out a unified interface for handling URLRequest modifications.

public protocol RequestAdapter {
    func adapted(_ request: URLRequest) throws -> URLRequest
}
Copy the code

Now buildRequest will be simple enough:

func buildRequest(a) throws -> URLRequest {
        let req = URLRequest(url: url)
        let request = try adapters.reduce(req) { try $1.adapted($0)}return request
}
Copy the code

Now that we have URLRequest, it’s time to send it to URLSession:

public struct HTTPClient {
public func send<Req: Request>(_ request: Req,
                            desicions: [Decision]? = nil,
                            handler: @escaping (Result<Req.Response, Error>) -> Void) - >URLSessionDataTask? {
        let urlRequest: URLRequest
        do {
            urlRequest = try request.buildRequest()
        } catch {
            handler(.failure(error))
            return nil
        }
        
        let task = session.dataTask(with: urlRequest) { (data, response, error) in
                 // Check whether there is data and response, whether response is valid, and parse data
        }
        task.resume()
        return task
    }
}
Copy the code

Obviously, there are many possibilities for the response and data, and the code here will get more complex. As above, we need to abstract out the behavior of processing the response data:

public protocol Decision: AnyObject {
    // Whether the decision should be made and whether the response data meets the conditions for the decision to be executed
    func shouldApply<Req: Request>(request: Req, data: Data, response: HTTPURLResponse) -> Bool
    func apply<Req: Request>(request: Req,
                             data: Data,
                             response: HTTPURLResponse,
                             done: @escaping (DecisionAction<Req>) -> Void)}Copy the code

There may be more than one processing of the response data for a request, and we need to process it sequentially, so we also need to know the status of that processing or the next action:

public enum DecisionAction<Req: Request> {
    case continueWith(Data.HTTPURLResponse)
    case restartWith([Decision])
    case error(Error)
    case done(Req.Response)}Copy the code

Then you can handle it correctly:

let task = session.dataTask(with: urlRequest) { (data, response, error) in
            guard let data = data else {
                handler(.failure(error ?? ResponseError.nilData))
                return
            }
            guard let response = response as? HTTPURLResponse else {
                handler(.failure(ResponseError.nonHTTPResponse))
                return
            }
            self.handleDecision(request, data: data, response: response, decisions: desicions ?? request.decisions,
                                handler: handler)
            
        }
Copy the code
func handleDecision<Req: Request>(_ request: Req,
                                      data: Data,
                                      response: HTTPURLResponse,
                                      decisions: [Decision],
                                      handler: @escaping (Result<Req.Response, Error>) -> Void) {
        guard! decisions.isEmptyelse {
            fatalError("No decision left but did not reach a stop")}var decisions = decisions
        let first = decisions.removeFirst()
        
        guard first.shouldApply(request: request, data: data, response: response) else {
            handleDecision(request, data: data, response: response, decisions: decisions, handler: handler)
            return
        }
        first.apply(request: request, data: data, response: response) { (action) in
            switch action {
            case let .continueWith(data, response):
                self.handleDecision(request, data: data, response: response, decisions: decisions, handler: handler)
            case .restartWith(let decisions):
                self.send(request, desicions: decisions, handler: handler)
            case .error(let error):
                handler(.failure(error))
            case .done(let value):
                handler(.success(value))
            }
        }
    }
Copy the code

implementation

After the abstraction, let’s start to realize the actual scene.

If you need to make a POST request, the request body should be JSON:

struct JSONRequestDataAdapter: RequestAdapter {
    let data: [String: Any]
    func adapted(_ request: URLRequest) throws -> URLRequest {
        var request = request
        request.httpBody = try JSONSerialization.data(withJSONObject: data, options: [])
        return request
    }
}
Copy the code

If it’s a form request:

struct URLFormRequestDataAdapter: RequestAdapter {
    let data: [String: Any]
    func adapted(_ request: URLRequest) throws -> URLRequest {
        var request = request
        request.httpBody =
            data.map({ (pair) -> String in
            "\(pair.key)=\(pair.value)"
            })
            .joined(separator: "&").data(using: .utf8)
        return request
    }
}
Copy the code

When the response data is returned, retry if statusCode is incorrect:

public class RetryDecision: Decision {
    let count: Int
    public init(count: Int) {
        self.count = count
    }

    public func shouldApply<Req>(request: Req, data: Data, response: HTTPURLResponse) -> Bool where Req : Request {
        let isStatusCodeValid = (200..<300).contains(response.statusCode)
        return! isStatusCodeValid &&count > 0
    }

    public func apply<Req>(request: Req, data: Data, response: HTTPURLResponse, done: @escaping (DecisionAction<Req>) -> Void) where Req : Request {
        let nextRetry = RetryDecision(count: count - 1)
        let newDecisions = request.decisions.replacing(self, with: nextRetry)
        done(.restartWith(newDecisions))
    }
}
Copy the code

Real data parsing operations:

public class ParseResultDecision: Decision {
    public func shouldApply<Req>(request: Req, data: Data, response: HTTPURLResponse) -> Bool where Req : Request {
        return true
    }
    
    public func apply<Req>(request: Req, data: Data, response: HTTPURLResponse, done: (DecisionAction<Req>) -> Void) where Req : Request {
        do {
            let value = try JSONDecoder().decode(Req.Response.self, from: data)
            done(.done(value))
        } catch {
            done(.error(error))
        }
    }
}
Copy the code

use

Now I’ll define a real request to try out this power:

struct HTTPBinPostRequest: Request {
    typealias Response = HTTPBinPostResponse

    var url: URL = URL(string: "https://httpbin.org/post")!
    var method: HTTPMethod=.POST
    var contentType: ContentType = .json
    var parameters: [String : Any] {
        return ["foo": foo]
    }
    
    let foo: String
  
    var decisions: [Decision] {
        return [RetryDecision(count: 2),
                BadResponseStatusCodeDecision(valid: 200..<300),
                DataMappingDecision(condition: { (data) -> Bool in
            return data.isEmpty
        }, transform: { (data) -> Data in
            "{}".data(using: .utf8)!
        }),
        ParseResultDecision()]}}struct HTTPBinPostResponse: Codable {
    struct Form: Codable { let foo: String? }
    let form: Form
    let json: Form
}
Copy the code

Obviously, we’ve achieved what we set out to do. Nice!

extension

If the interface requires token authentication, we just need to add a TokenAdapter

struct TokenAdapter: RequestAdapter {
    let token: String?
    init(token: String?). {self.token = token
    }

    func adapted(_ request: URLRequest) throws -> URLRequest {
        guard let token = token else {
            return request
        }
        var request = request
        request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
        return request
    }
}
Copy the code
public extension Request{
    var adapters: [RequestAdapter] {
        return [TokenAdapter(token: "token"),method.adapter,
                RequestContentAdapter(method: method, content: parameters, contentType: contentType)]
    }
}
Copy the code

Each request will carry a token.

Let’s look at one more common scenario that implements a Decision.

You may have experienced a similar problem on your client: a nice JSON structure returns null on the back end, which then defaults to NSNull, and you may crash on your cidcidr or cidCIDr for an exception.

public class DataMappingDecision: Decision {
    let condition: (Data) - >Bool
    let transform: (Data) - >Data
    
    public init(condition: @escaping (Data) - >Bool, transform: @escaping (Data) - >Data) {
        self.condition = condition
        self.transform = transform
    }

    public func shouldApply<Req>(request: Req, data: Data, response: HTTPURLResponse) -> Bool where Req : Request {
        return condition(data)
    }
    
    public func apply<Req>(request: Req, data: Data, response: HTTPURLResponse, done: (DecisionAction<Req>) -> Void) where Req : Request {
        done(.continueWith(transform(data), response))
    }
}
Copy the code

Interested students may have noticed that HTTPBinPostRequest is already used above:

DataMappingDecision(condition: { (data) -> Bool in
            return data.isEmpty
        }, transform: { (data) -> Data in
            "{}".data(using: .utf8)!
        })
Copy the code

The DataMappingDecision can also be used to mock data to facilitate upfront client development.

conclusion

The whole implementation of the single responsibility, interface isolation, open and close principle and protocol oriented, the use of command mode, adapter mode, combination mode and appearance mode, flexible and extensible, the main method is pure function, can be tested, when defining the interface is declarative combination.

The complete code

Reference: wang wei’s speech on iPlayground

LineSDK source