Network layer abstraction using “strategies”

    All my previous implementations of the network layer left the impression that there is still room to grow. This publication aims to provide one of the architectural solutions for building the network layer of the application. This is not about the next way to use the next network framework.


    Part 1. A look at existing approaches


    To start with, from the publication of 21 Amazing Open Source iOS Apps Written in Swift , the Artsy application is taken . It uses the popular Moya framework , on the basis of which the entire network layer is built. I will note a number of major shortcomings that I met in this project and often meet in other applications and publications.


    Repetition of response conversion chains


    let endpoint: ArtsyAPI = ArtsyAPI.activeAuctions
    provider.request(endpoint)
        .filterSuccessfulStatusCodes()
        .mapJSON()
        .mapTo(arrayOf: Sale.self)

    The developer with this code has designated a certain logical chain in which the response to the activeAuctions request is converted into an array of Sale objects . When reusing this request in other ViewModel or ViewController, the developer will have to copy the request along with the response conversion chain. To avoid copying the repetitive conversion logic, the request and response can be connected with a certain contract, which will be described exactly once.


    A large number of dependencies


    Often , the Alamofire , Moya, and other frameworks are used to work with the network . Ideally, the application should be minimally dependent on these frameworks. If you type import Moya in the Artsy repository search , you can see dozens of matches. If suddenly the project decides to abandon the use of Moya - a lot of code will have to be refactored.


    It is not difficult to assess how much each project depends on the network framework, if you remove this dependency and try to modify the application to a healthy state.


    General request manager class


    A possible way out of the dependency situation is to create a special class that will be the only one to know about the frameworks and about all the possible ways to get data from the network. These methods will be described by functions with strictly typed incoming and outgoing parameters, which in turn will be the contract mentioned above, and will help to cope with the problem of repetition of response conversion chains . This approach is also quite common. Its practical application can also be found in the applications in the list of 21 Amazing Open Source iOS Apps Written in Swift . For example, in DesignerNewsApp . Such a class looks like this:


    struct DesignerNewsService {
        static func storiesForSection(..., response: ([Story]) -> ()) {
            // parameters
            Alamofire.request(...).response { _ in
                // parsing
            }
        }
        static func loginWithEmail(..., response: (token: String?) -> ()) {
            // parameters
            Alamofire.request(...).response { _ in
                // parsing
            }
        }
    }

    This approach also has disadvantages. The number of duties assigned to this class is greater than that required by the principle of sole responsibility. It will have to be changed when changing the method for executing requests (replacing Alamofire ), when changing the framework for parsing, when changing request parameters. In addition, such a class can grow into a god object or be used as a singleton with all the ensuing consequences.


    Do you know the feeling of gloom when you need to integrate a project with the next RESTful API? This is when once again you need to create some kind of APIManager and fill it with Alamofire requests ... (link)

    Part 2. Strategy Based Approach


    Given all the shortcomings described in the 1st part of the publication, I formulated for myself a number of requirements for the future layer of working with the network:


    • Reduce dependency on external network frameworks
    • Provide the ability to quickly and easily replace network frameworks with each other
    • Maximize generic classes / structures and protocols with related types
    • Prevent repetition of conversion chains and minimize code repeatability

    What happened as a result:


    Basic Network Layer Protocols


    The ApiTarget protocol defines all the data that is necessary to form a request (parameters, path, method ... etc.)


    protocol ApiTarget {
        var parameters: [String : String] { get }
    }

    The generic ApiResponseConvertible protocol defines how to convert the resulting object (in this case, Data ) into an object of a related type .


    protocol ApiResponseConvertible {
        associatedtype ResultType
        func map(data: Data) throws -> ResultType
    }

    The ApiService protocol defines how to send requests. Typically, a function declared in a protocol accepts a closure containing the response object and possible errors. In the current implementation, the function returns Observable , an object of the RxSwift reactive framework .


    protocol ApiService: class {
        func request(with target: T) -> Observable where T: ApiResponseConvertible, T: ApiTarget
    }

    Strategies


    I call a strategy the contract mentioned at the beginning of publication that links several types of data. A strategy is a protocol and looks like this in the simplest case:


    protocol Strategy {
        associatedtype ObjectType
        associatedtype ResultType
    }

    For the needs of the network layer, the strategy should be able to create an object that can be passed to an instance of a class that conforms to the ApiService protocol . Add an object creation function to the ApiStrategy protocol .


    protocol ApiStrategy {
        associatedtype ObjectType
        associatedtype ResultType
        static func target(with object: ObjectType) -> AnyTarget
    }

    The introduction of the new universal AnyTarget structure is due to the fact that we cannot use the generic ApiResponseConvertible protocol as the type of the object returned by the function, because the protocol has an associated type .


    struct AnyTarget: ApiResponseConvertible, ApiTarget {
        private let _map: (Data) throws -> T
        let parameters: [String : String]
        init(with target: U) where U: ApiResponseConvertible, U: ApiTarget, U.ResultType == T {
            _map = target.map
            parameters = target.parameters
        }
        func map(data: Data) throws -> T {
            return try _map(data)
        }
    }

    This is what the most primitive implementation of the strategy looks like:


    struct SimpleStrategy: ApiStrategy {
        typealias ObjectType = Int
        typealias ResultType = String
        static func target(with object: Int) -> AnyTarget {
            let target = Target(value: object)
            return AnyTarget(with: target)
        }
    }
    private struct Target {
        let value: Int
    }
    extension Target: ApiTarget {
        var parameters: [String : String] {
            return [:]
        }
    }
    extension Target: ApiResponseConvertible {
        public func map(data: Data) throws -> String {
            return "\(value)" // map value from data
        }
    }

    It is worth noting that the Target structure is private, as it will not be used outside the file. It is only needed to initialize the universal AnyTarget structure .


    The conversion of the object also occurs within the file, so ApiService will not know anything about the tools used for parsing.


    Using strategies and service


    let service: ApiService = ...
    let target = SimpleStrategy.target(with: ...)
    let request = service.request(with: target)

    The strategy will tell you which object is needed to implement the request and which object will be output. Everything is strictly typed by strategy and it is not required to specify types as in the case with universal functions.


    ApiService Implementation


    As you can see, in this approach, the network framework has remained beyond the basic logic of building a service. At first, you can not use it at all. For example, if in the implementation of the map function of the ApiResponseConvertible protocol , a mock object is returned, then the service can be a very primitive class:


    class MockService: ApiService {
        func request(with target: T) -> Observable where T : ApiResponseConvertible, T : ApiTarget {
            return Observable
                .just(Data())
                .map({ [map = target.map] (data) -> T.ResultType in
                    return try map(data)
                })
        }
    }

    The test implementation and application of the ApiService protocol based on the real Moya network framework can be seen in the spoiler:


    ApiService + Moya + Implementation
    public extension Api {
        public class Service {
            public enum Kind {
                case failing(Api.Error)
                case normal
                case test
            }
            let kind: Api.Service.Kind
            let logs: Bool
            fileprivate lazy var provider: MoyaProvider = self.getProvider()
            public init(kind: Api.Service.Kind, logs: Bool) {
                self.kind = kind
                self.logs = logs
            }
            fileprivate func getProvider() -> MoyaProvider {
                return MoyaProvider(
                    stubClosure: stubClosure,
                    plugins: plugins
                )
            }
            private var plugins: [PluginType] {
                return logs ? [RequestPluginType()] : []
            }
            private func stubClosure(_ target: Target) -> Moya.StubBehavior {
                switch kind {
                case .failing, .normal:
                    return Moya.StubBehavior.never
                case .test:
                    return Moya.StubBehavior.immediate
                }
            }
        }
    }
    extension Api.Service: ApiService {
        public func dispose() {
            //
        }
        public func request(headers: [Api.Header: String], scheduler: ImmediateSchedulerType, target: T) -> Observable where T: ApiResponseConvertible, T: ApiTarget {
            switch kind {
            case .failing(let error):
                return Observable.error(error)
            default:
                return Observable
                    .just((), scheduler: scheduler)
                    .map({ [weak self] _ -> MoyaProvider? in
                        return self?.provider
                    })
                    .filterNil()
                    .flatMap({ [headers, target] provider -> Observable in
                        let api = Target(headers: headers, target: target)
                        return provider.rx
                            .request(api)
                            .asObservable()
                    })
                    .map({ [map = target.map] (response: Moya.Response) -> T.ResultType in
                        switch response.statusCode {
                        case 200:
                            return try map(response.data)
                        case 401:
                            throw Api.Error.invalidToken
                        case 404:
                            do {
                                let json: JSON = try response.data.materialize()
                                let message: String = try json["ErrorMessage"].materialize()
                                throw Api.Error.failedWithMessage(message)
                            } catch let error {
                                if case .some(let error) = error as? Api.Error, case .failedWithMessage = error {
                                    throw error
                                } else {
                                    throw Api.Error.failedWithMessage(nil)
                                }
                            }
                        case 500:
                            throw Api.Error.serverInteralError
                        case 501:
                            throw Api.Error.appUpdateRequired
                        default:
                            throw Api.Error.unknown(nil)
                        }
                    })
                    .catchError({ (error) -> Observable in
                        switch error as? Api.Error {
                        case .some(let error):
                            return Observable.error(error)
                        default:
                            let error = Api.Error.unknown(error)
                            return Observable.error(error)
                        }
                    })
            }
        }
    }

    ApiService + Moya + Usage
    func observableRequest(_ observableCancel: Observable, _ observableTextPrepared: Observable) -> Observable> {
        let factoryApiService = base.factoryApiService
        let factoryIndicator = base.factoryIndicator
        let factorySchedulerConcurrent = base.factorySchedulerConcurrent
        return observableTextPrepared
            .observeOn(base.factorySchedulerConcurrent())
            .flatMapLatest(observableCancel: observableCancel, observableFactory: { (text) -> Observable> in
                return Observable
                    .using(factoryApiService) { (service: Api.Service) -> Observable> in
                        let object = Api.Request.Categories.Name(text: text)
                        let target = Api.Strategy.Categories.Auto.target(with: object)
                        let headers = [Api.Header.authorization: ""]
                        let request = service
                            .request(headers: headers, scheduler: factorySchedulerConcurrent(), target: target)
                            .map({ Objects(text: text, manual: true, objects: $0) })
                            .map({ Result(value: $0) })
                            .shareReplayLatestWhileConnected()
                        switch factoryIndicator() {
                        case .some(let activityIndicator):
                            return request.trackActivity(activityIndicator)
                        default:
                            return request
                        }
                    }
                    .catchError({ (error) -> Observable> in
                        switch error as? Api.Error {
                        case .some(let error):
                            return Observable.just(Result(error: error))
                        default:
                            return Observable.just(Result(error: Api.Error.unknown(nil)))
                        }
                    })
            })
            .observeOn(base.factorySchedulerConcurrent())
            .shareReplayLatestWhileConnected()
    }

    Conclusion


    The resulting network layer can successfully exist without strategies. Similarly, strategies can be applied to other goals and objectives. Their combined use has made the network layer understandable and convenient to use.


    Also popular now: