Swiftpack.co - Package - Flinesoft/Microya

Microya

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

Installation

Installation is only supported via SwiftPM.

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 }
}

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).

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
    }
}

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)

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
)

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 plugins: [Plugin<Self>] { [] }

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

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

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

protocol Endpoint {
    associatedtype ClientErrorType: Decodable
    var baseUrl: URL { get }
    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.

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.

Github

link
Stars: 16

Dependencies

Releases

-

Added

  • Microya now supports Combine publishers, just call publisher(on:decodeBodyTo:) or publisher(on:) instead of performRequest(on:decodeBodyTo:) or performRequest(on:) and you'll get 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.

Changed

  • The queryParameters is no longer of type [String: String], but [String: QueryParameterValue] now. Existing code like ["search": searchTerm] will need to be updated to ["search": .string(searchTerm)]. Apart from .string this now also allows specifying an array of strings like so: ["tags": .array(userSelectedTags)]. String & array literals are supported directly, e.g. ["sort": "createdAt"] or ["sort": ["createdAt", "id"]].

-

Added

  • New ApiProvider type encapsulating different request methods with support for plugins.
  • New asynchronous performRequest methods with completion callbacks. (#2)
  • New Plugin class to subclass for integrating into the networking logic via callbacks.
  • New pre-implemented plugins: HttpBasicAuth, ProgressIndicator, RequestLogger, ResponseLogger.
  • New EmptyResponseBody type to use whenever the body is expected to be empty or should be ignored.

Changed

  • Renamed JsonApi protocol to Endpoint.
  • Renamed Method to HttpMethod and added body: Data to the cases post and patch.
  • Moved the request method from JsonApi to the new ApiProvider & renamed to performRequestAndWait.
  • Generally improved the cases in JsonApiError & renamed the type to ApiError.
  • Moved CI from Bitrise to GitHub Actions.

Removed

  • The bodyData: Data? requirement on JsonApi (bodies are not part of HttpMethod, see above).
  • Installation is no longer possible via CocoaPods or Carthage. Please use SwiftPM instead.

-

Changed

  • Make some fields of the JsonApi protocol optional by providing default implementation.

-

Added

  • Add JsonApi type similar to TargetType in Moya with additional JSON Codable support.
  • Add basic usage documentation based on the Microsoft Translator API.