/Dyson

Primary LanguageSwiftMIT LicenseMIT

Dyson

Dyson is a layer-based networking framework that operates on top of network modules, similar to Moya. Dyson is designed to simplify the management and usage of network communication code, such as APIs, for your convenience.

Requirements

  • iOS 13.0+
  • macOS 10.15+

Installation

Swift Package Manager

dependencies: [
    .package(url: "https://github.com/wlsdms0122/Dyson.git", .upToNextMajor("2.0.0"))
]

Basic Usage

You should start by creating a Dyson object. Dyson takes parameters such as NetworkProvider, Responser, and Interceptor, which will be discussed below.

let dyson = Dyson(
    provider: .url(),
    responser: MyResponser(),
    defaultHeaders: [
        "Content-Type": "application/json"
    ],
    interceptors: [
        LogInterceptor()
    ]
)

And Dyson provides two methods for API requests, response(_:pogress:requestModifier:completion:) and data(_:pogress:requestModifier:completion:).

The difference between the two is the return type. Use response() if you want to get the network raw response itself from the provider, or data() if you want to get the decoded result of the response.

dyson.response(GetInfoSpec(.init())) { result in
    // result's type is Result<(Data, URLResponse), any Error>
}

dyson.data(GetInfoSpec(.init())) { result in
    // result's type is Result<GetInfoSpec.Result, any Error>
}

NetworkProvider

NetworkProvider is the protocol that abstracts the functionality of network communication.

Dyson is a layer that works on top of these communication modules, so you can create different providers just like you would with Alamofire.

public protocol NetworkProvider {
    func dataTask(with request: URLRequest) -> any DataSessionTask
    func uploadTask(with request: URLRequest, from data: Data) -> any DataSessionTask
    func downloadTask(with request: URLRequest) -> any DataSessionTask
}

Dyson serve default network provider, URLNetworkProvider that implemented using URLSession.

Responser

Responder is a protocol that abstracts how to respond to a network response.

Responsible for the implementation of how to handle the conventions for network responses between servers and clients.

public protocol Responser {
    func response<S: Spec>(
        _ response: Result<(Data, URLResponse), any Error>,
        spec: S
    ) throws -> S.Result
}

For example, you might need to filter by a range of status codes with your own communication protocols, or parse response data and header values together.

These characteristics usually depend on the server you're communicating with(just as different OpenAPIs have different communication protocols).

Typically, the implementation of a 'responder' looks like this.

func response<S: Spec>(
    _ response: Result<(Data, URLResponse), any Error>,
    spec: S
) throws -> S.Result {
    switch response {
        case let .success((data, response)):
            guard let httpResponse = response as? HTTPURLResponse else {
                throw NetworkError.unknown
            }
            
            // Check valid status code.
            guard (200..<300).contains(httpResponse.statusCode) else {
                // Parse to error model.
                do {
                    return try spec.error.map(data)
                } catch {
                    throw error
                }
            }
            
            // Parse to result model.
            do {
                return try spec.result.map(data)
            } catch {
                throw error
            }
            
        case let .failure(error):
            throw error
        }
}

Interceptor

The interceptor is the most important feature of Dyson. Basically, an interceptor can modify a request before it is made, or intercept a response before it is completed.

public protocol Interceptor {
    /// Called before the request.
    func request(
        _ request: URLRequest,
        dyson: Dyson,
        spec: some Spec,
        sessionTask: ContainerSessionTask,
        continuation: Continuation<URLRequest>
    )
    /// Called after receiving the response and before completion.
    func response(
        _ response: Result<(Data, URLResponse), any Error>,
        dyson: Dyson,
        spec: some Spec,
        sessionTask: ContainerSessionTask,
        continuation: Continuation<Result<(Data, URLResponse), any Error>>
    )
    /// When request with `data(_:)`, called after receiving the response and before completion.
    /// `response(_:dyson:sepc:sessionTask:continuation:)` shoule be call before this.
    func result<S: Spec>(
        _ result: Result<S.Result, any Error>,
        dyson: Dyson,
        spec: S,
        sessionTask: ContainerSessionTask,
        continuation: Continuation<Result<S.Result, any Error>>
    )
}

Registered all interceptors are called in the order request -> response -> result(only data(_:) request).

Request intercept

The request intercept is useful for modifying requests. Such as modifying or adding header fields for authentication.

public struct HeaderInterceptor: Interceptor {
    // MARK: - Property
    private let key: String
    private let value: () -> String?
    
    // MARK: - Initializer
    public init(key: String, value: @escaping () -> String?) {
        self.key = key
        self.value = value
    }
    
    public init(key: String, value: String) {
        self.init(key: key) { value }
    }
    
    // MARK: - Public
    public func request(
        _ request: URLRequest,
        dyson: Dyson,
        spec: some Spec,
        sessionTask: ContainerSessionTask,
        continuation: Continuation<URLRequest>
    ) {
        var request = request
        request.setValue(value(), forHTTPHeaderField: key)
        
        continuation(request)
    }
    
    // MARK: - Private
}

Response intercept

The response intercept is useful when you need to do additional work before completing the response.

A good example would be for processes like JWT authentication.

func response(
    _ response: Result<(Data, URLResponse), any Error>,
    dyson: Dyson,
    spec: some Spec,
    sessionTask: ContainerSessionTask,
    continuation: Continuation<Result<(Data, URLResponse), any Error>>
) {
    guard case let .success((_, r)) = response,
        let httpResponse = r as? HTTPURLResponse
    else {
        // Finish this intercept and keep response process.
        continuation(response)
        return
    }
    
    guard httpResponse.statusCode == 401 else {
        // Finish this intercept and keep response process.
        continuation(response)
        return
    }
    
    sessionTask {
        // Request refresh spec for refreshing token.
        dyson.data(RefreshSpec(.init())) { result in
            switch result {
            case let .success(result):
                // Store new token.
                tokenStorage.set(result.token)
                
                sessionTask {
                    // Retry origin request.
                    dyson.response(spec) { result in
                        // Finish this intercept with new response.
                        continuation(result)
                    }
                }
                
            case let .failure(error):
                // Finish this intercept with throwing error.
                continuation(throwing: error)
            }
        }
    }
}

The first point of the above example is to show that you can initiate a new API request from an interceptor, because all of the interceptor's methods passed a continuation object for the process asynchronous task.

You must pass a result or an error via continue, and passing an error will cause this request to stop immediately.

The second is that the ContainerSessionTask can have child session tasks, which allow you to manage the entire request and ensure that when the request is canceled, all related requests are canceled.

Data intercept

The data intercept is simillar with the response intercept.

It call only you request using data(_:) method. and all feature is same with the response intercept.

Spec

The Spec represents a single API request. Most options for networking are set via Spec.

public protocol Spec {
    associatedtype Parameter
    associatedtype Result
    associatedtype Error: Swift.Error
    
    var parameter: Parameter { get }
    
    var baseURL: String { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var transaction: Transaction { get }
    
    var headers: HTTPHeaders { get }
    
    var request: any Request { get }
    var result: Mapper<Result> { get }
    var error: Mapper<Error> { get }
}

Here is the sample Spec.

public struct GetInfoSpec: Spec {
    var baseURL: String { "https://your-server-url.com" }
    var path: String { "/info" }
    var method: HTTPMethod { .get }
    var transaction: Transaction { .data }
    var headers: HTTPSHeaders {
        [
            "Content-Type": "application/json"
        ]
    }
    var request: any Request { 
        .query([
            "id": parameter.id
        ])
    }
    var result: Mapper<Result> { .codable }
    var error: Mapper<Error> { .codable }

    let parameter: Parameter

    public init(_ parameter: Parameter) {
        self.parameter = parameter
    }
}

public extension GetInfoSpec {
    public struct Parameter {
        let id: String

        public init(id: String) {
            self.id = id
        }
    }

    public struct Result: Decodable {
        let name: String
    }
}

Transaction

The Transaction is type of request. It offers three types, such as URLSession.

  • data
  • upload(Data)
  • download

Request

The Request indicates the method for creating the request.

public protocol Request {
    func make(url: URL) throws -> URLRequest
}

The Dyson serve some requests.

  • none
    Not exist additional process.
  • query(_:)
    Add the query parameters as a query to the URL.
  • body(_:encoder:)
    After encoding with the encoder, add the parameters as data to the HTTP body.
    You can create your own encoder that adopts the Encode protocol.

Mapper

The Mapper is a type that represents how you want to transform your data.

public protocol Map<Value> {
    associatedtype Value
    
    func map(_ data: Data) throws -> Value
}

Indicates the decode method, and Dyson provides the Codable type by default.

You can create your own Mapper that adopts the Map protocol.

Contribution

Any ideas, issues, opinions are welcome.

License

Dyson is available under the MIT license. See the LICENSE file for more info.