Skip to content
This repository has been archived by the owner on Sep 30, 2024. It is now read-only.
/ Microya Public archive

Micro version of the Moya network abstraction layer written in Swift.

License

Notifications You must be signed in to change notification settings

FlineDev/Microya

Repository files navigation

CI Status codebeat badge Version: 0.7.0 Swift: 5.4 Platforms: Apple | Linux License: MIT
PayPal: Donate GitHub: Become a sponsor Patreon: Become a patron

InstallationUsageDonationIssuesContributingLicense

Microya

A micro version of the Moya network abstraction layer written in Swift.

Installation

Installation is only supported via SwiftPM.

⚠️ If you need to support platform where the Combine framework is not available (< iOS/tvOS 13, < macOS 10.15), please use the support/without-combine branch instead.

Usage

Step 1: Defining your Endpoints

Create an Api enum with all supported endpoints as cases with the request parameters/data specified as parameters.

For example, when writing a client for the Microsoft Translator API:

enum MicrosoftTranslatorApi {
    case languages
    case translate(texts: [String], from: Language, to: [Language])
}

Step 2: Making your Api Endpoint compliant

Add an extension for your Api enum that makes it Endpoint compliant, which means you need to add implementations for the following protocol:

public protocol Endpoint {
    associatedtype ClientErrorType: Decodable
    var decoder: JSONDecoder { get }
    var encoder: JSONEncoder { get }
    var baseUrl: URL { get }
    var headers: [String: String] { get }
    var subpath: String { get }
    var method: HttpMethod { get }
    var queryParameters: [String: QueryParameterValue] { get }
    var mockedResponse: MockedResponse? { get }
}

Use switch statements over self to differentiate between the cases (if needed) and to provide the appropriate data the protocol asks for (using Value Bindings).

Toggle me to see an example
extension MicrosoftTranslatorEndpoint: Endpoint {
    typealias ClientErrorType = EmptyResponseType

    var decoder: JSONDecoder {
        return JSONDecoder()
    }

    var encoder: JSONEncoder {
        return JSONEncoder()
    }

    var baseUrl: URL {
        return URL(string: "https://api.cognitive.microsofttranslator.com")!
    }

    var headers: [String: String] {
        switch self {
        case .languages:
            return [:]

        case .translate:
            return [
                "Ocp-Apim-Subscription-Key": "<SECRET>",
                "Content-Type": "application/json"
            ]
        }
    }

    var subpath: String {
        switch self {
        case .languages:
            return "/languages"

        case .translate:
            return "/translate"
        }
    }

    var method: HttpMethod {
        switch self {
        case .languages:
            return .get

        case let .translate(texts, _, _, _):
            return .post(try! encoder.encode(texts))
        }
    }

    var queryParameters: [String: QueryParameterValue] {
        var queryParameters: [String: QueryParameterValue] = ["api-version": "3.0"]

        switch self {
        case .languages:
            break

        case let .translate(_, sourceLanguage, targetLanguages):
            queryParameters["from"] = .string(sourceLanguage.rawValue)
            queryParameters["to"] = .array(targetLanguages.map { $0.rawValue })
        }

        return queryParameters
    }

    var mockedResponse: MockedResponse? {
      switch self {
      case .languages:
        return mock(status: .ok, bodyJson: #"{ "languages: ["de", "en", "fr", "ja"] }"#)

      case let .translate(texts, _, _):
        let pseudoTranslationsJson = texts.map { $0.reversed() }.joined(separator: ",")
        return mock(status: .ok, bodyJson: "[\(pseudoTranslationsJson)]")
      }
    }
}

Step 3: Calling your API endpoint with the Result type

Call an API endpoint providing a Decodable type of the expected result (if any) by using one of the methods pre-implemented in the ApiProvider type:

/// Performs the asynchornous request for the chosen endpoint and calls the completion closure with the result.
performRequest<ResultType: Decodable>(
    on endpoint: EndpointType,
    decodeBodyTo: ResultType.Type,
    completion: @escaping (Result<ResultType, ApiError<ClientErrorType>>) -> Void
)

/// Performs the request for the chosen endpoint synchronously (waits for the result) and returns the result.
public func performRequestAndWait<ResultType: Decodable>(
    on endpoint: EndpointType,
    decodeBodyTo bodyType: ResultType.Type
)

There's also extra methods for endpoints where you don't expect a response body:

/// Performs the asynchronous request for the chosen write-only endpoint and calls the completion closure with the result.
performRequest(on endpoint: EndpointType, completion: @escaping (Result<EmptyBodyResponse, ApiError<ClientErrorType>>) -> Void)

/// Performs the request for the chosen write-only endpoint synchronously (waits for the result).
performRequestAndWait(on endpoint: EndpointType) -> Result<EmptyBodyResponse, ApiError<ClientErrorType>>

The EmptyBodyResponse returned here is just an empty type, so you can just ignore it.

Here's a full example of a call you could make with Mircoya:

let provider = ApiProvider<MicrosoftTranslatorEndpoint>()
let endpoint = MicrosoftTranslatorEndpoint.translate(texts: ["Test"], from: .english, to: [.german, .japanese, .turkish])

provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { result in
    switch result {
    case let .success(translationsByLanguage):
        // use the already decoded `[String: String]` result

    case let .failure(apiError):
        // error handling
    }
}

// OR, if you prefer a synchronous call, use the `AndWait` variant

switch provider.performRequestAndWait(on: endpoint, decodeBodyTo: [String: String].self) {
case let .success(translationsByLanguage):
    // use the already decoded `[String: String]` result

case let .failure(apiError):
    // error handling
}

Note that you can also use the throwing get() function of Swift 5's Result type instead of using a switch:

provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { result in
    let translationsByLanguage = try result.get()
    // use the already decoded `[String: String]` result
}

// OR, if you prefer a synchronous call, use the `AndWait` variant

let translationsByLanguage = try provider.performRequestAndWait(on: endpoint, decodeBodyTo: [String: String].self).get()
// use the already decoded `[String: String]` result

There's even useful functional methods defined on the Result type like map(), flatMap() or mapError() and flatMapError(). See the "Transforming Result" section in this article for more information.

Combine Support

If you are using Combine in your project (e.g. because you're using SwiftUI), you might want to replace the calls to performRequest(on:decodeBodyTo:) or performRequest(on:) with the Combine calls publisher(on:decodeBodyTo:) or publisher(on:). This will give you an AnyPublisher request stream to subscribe to. In success cases you will receive the decoded typed object, in error cases an ApiError object exactly like within the performRequest completion closure. But instead of a Result type you can use sink or catch from the Combine framework.

For example, the usage with Combine might look something like this:

var cancellables: Set<AnyCancellable> = []

provider.publisher(on: endpoint, decodeBodyTo: TranslationsResponse.self)
  .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
  .subscribe(on: DispatchQueue.global())
  .receive(on: DispatchQueue.main)
  .sink(
    receiveCompletion: { _ in }
    receiveValue: { (translationsResponse: TranslationsResponse) in
      // do something with the success response object
    }
  )
  .catch { apiError in
    switch apiError {
    case let .clientError(statusCode, clientError):
      // show an alert to customer with status code & data from clientError body
    default:
      logger.handleApiError(apiError)
    }
  }
  .store(in: &cancellables)

Concurrency Support

If you are using Swift 5.5 in your project and your minimum target is iOS/tvOS 15+, macOS 12+ or watchOS 8+, you might want to use the async method response instead. For example, the usage might look something like this:

let result = await provider.response(on: endpoint, decodeBodyTo: TranslationsResponse.self)

switch result {
case let .success(translationsByLanguage):
    // use the already decoded `[String: String]` result

case let .failure(apiError):
    // error handling
}

Plugins

The initializer of ApiProvider accepts an array of Plugin objects. You can implement your own plugins or use one of the existing ones in the Plugins directory. Here's are the callbacks a custom Plugin subclass can override:

/// Called to modify a request before sending.
modifyRequest(_ request: inout URLRequest, endpoint: EndpointType)

/// Called immediately before a request is sent.
willPerformRequest(_ request: URLRequest, endpoint: EndpointType)

/// Called after a response has been received & decoded, but before calling the completion handler.
didPerformRequest<ResultType: Decodable>(
    urlSessionResult: (data: Data?, response: URLResponse?, error: Error?),
    typedResult: Result<ResultType, ApiError<EndpointType.ClientErrorType>>,
    endpoint: EndpointType
)
Toggle me to see a full custom plugin example

Here's a possible implementation of a RequestResponseLoggerPlugin that logs using print:

class RequestResponseLoggerPlugin<EndpointType: Endpoint>: Plugin<EndpointType> {
    override func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) {
        print("Endpoint: \(endpoint), Request: \(request)")
    }

    override func didPerformRequest<ResultType: Decodable>(
        urlSessionResult: ApiProvider<EndpointType>.URLSessionResult,
        typedResult: ApiProvider<EndpointType>.TypedResult<ResultType>,
        endpoint: EndpointType
    ) {
        print("Endpoint: \(endpoint), URLSession result: \(urlSessionResult), Typed result: \(typedResult)")
    }
}

Shortcuts

Endpoint provides default implementations for most of its required methods, namely:

public var decoder: JSONDecoder { JSONDecoder() }

public var encoder: JSONEncoder { JSONEncoder() }

public var headers: [String: String] {
    [
        "Content-Type": "application/json",
        "Accept": "application/json",
        "Accept-Language": Locale.current.languageCode ?? "en"
    ]
}

public var queryParameters: [String: QueryParameterValue] { [:] }

public var mockedResponse: MockedResponse? { nil }

So technically, the Endpoint type only requires you to specify the following 4 things:

protocol Endpoint {
    associatedtype ClientErrorType: Decodable
    var subpath: String { get }
    var method: HttpMethod { get }
}

This can be a time (/ code) saver for simple APIs you want to access. You can also use EmptyBodyResponse type for ClientErrorType to ignore the client error body structure.

Testing

Microya supports mocking responses in your tests. To do that, just initialize a different ApiProvider in your tests and specify with a given delay and scheduler as the mockingBehavior parameter.

Now, instead of making actual calls, Microya will respond with the provided mockedResponse computed property in your Endpoint type.

Note that the .delay mocking behavior is designed for use with Combine schedulers. Use DispatchQueue.test from the combine-schedulers library (which is included with Microya) to control time in your tests so you don't need to actually wait for the requests when using .delay.

For example, you might want to add an extension in your tests to provide a .mocked property to use whenever you need an ApiProvider like so:

import CombineSchedulers
import Foundation
import Microya

let testScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.test

extension ApiProvider {
  static var mocked: ApiProvider<MicrosoftTranslatorEndpoint> {
    ApiProvider<MicrosoftTranslatorEndpoint>(
      baseUrl: URL(string: "https://api.cognitive.microsofttranslator.com")!,
      mockingBehavior: MockingBehavior(delay: .seconds(0.5), scheduler: testScheduler.eraseToAnyScheduler()
    )
  }
}

Now, in your tests you can just call testScheduler.advance(by: .milliseconds(300)) fast-forward the time so your tests stay fast.

Donation

Microya was brought to you by Cihat Gündüz in his free time. If you want to thank me and support the development of this project, please make a small donation on PayPal. In case you also like my other open source contributions and articles, please consider motivating me by becoming a sponsor on GitHub or a patron on Patreon.

Thank you very much for any donation, it really helps out a lot! 💯

Contributing

See the file CONTRIBUTING.md.

License

This library is released under the MIT License. See LICENSE for details.