• Stars
    star
    735
  • Rank 61,652 (Top 2 %)
  • Language
    Kotlin
  • License
    Apache License 2.0
  • Created about 4 years ago
  • Updated about 2 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A pluggable sealed API result type for modeling Retrofit responses.

EitherNet

A pluggable sealed API result type for modeling Retrofit responses.

Usage

By default, Retrofit uses exceptions to propagate any errors. This library leverages Kotlin sealed types to better model these responses with a type-safe single point of return and no exception handling needed!

The core type for this is ApiResult<out T, out E>, where T is the success type and E is a possible error type.

ApiResult has two sealed subtypes: Success and Failure. Success is typed to T with no error type and Failure is typed to E with no success type. Failure in turn is represented by four sealed subtypes of its own: Failure.NetworkFailure, Failure.ApiFailure, Failure.HttpFailure, and Failure.UnknownFailure. This allows for simple handling of results through a consistent, non-exceptional flow via sealed when branches.

when (val result = myApi.someEndpoint()) {
  is Success -> doSomethingWith(result.response)
  is Failure -> when (result) {
    is NetworkFailure -> showError(result.error)
    is HttpFailure -> showError(result.code)
    is ApiFailure -> showError(result.error)
    is UnknownFailure -> showError(result.error)
  }
}

Usually, user code for this could just simply show a generic error message for a Failure case, but the sealed subtypes also allow for more specific error messaging or pluggability of error types.

Simply change your endpoint return type to the typed ApiResult and include our call adapter and delegating converter factory.

interface TestApi {
  @GET("/")
  suspend fun getData(): ApiResult<SuccessResponse, ErrorResponse>
}

val api = Retrofit.Builder()
  .addConverterFactory(ApiResultConverterFactory)
  .addCallAdapterFactory(ApiResultCallAdapterFactory)
  .build()
  .create<TestApi>()

If you don't have custom error return types, simply use Unit for the error type.

Decoding Error Bodies

If you want to decode error types in HttpFailures, annotate your endpoint with @DecodeErrorBody:

interface TestApi {
  @DecodeErrorBody
  @GET("/")
  suspend fun getData(): ApiResult<SuccessResponse, ErrorResponse>
}

Now a 4xx or 5xx response will try to decode its error body (if any) as ErrorResponse. If you want to contextually decode the error body based on the status code, you can retrieve a @StatusCode annotation from annotations in a custom Retrofit Converter.

// In your own converter factory.
override fun responseBodyConverter(
  type: Type,
  annotations: Array<out Annotation>,
  retrofit: Retrofit
): Converter<ResponseBody, *>? {
  val (statusCode, nextAnnotations) = annotations.statusCode()
    ?: return null
  val errorType = when (statusCode.value) {
    401 -> Unauthorized::class.java
    404 -> NotFound::class.java
    // ...
  }
  val errorDelegate = retrofit.nextResponseBodyConverter<Any>(this, errorType.toType(), nextAnnotations)
  return MyCustomBodyConverter(errorDelegate)
}

Note that error bodies with a content length of 0 will be skipped.

Plugability

A common pattern for some APIs is to return a polymorphic 200 response where the data needs to be dynamically parsed. Consider this example:

{
  "ok": true,
  "data": {
    ...
  }
}

The same API may return this structure in an error event

{
  "ok": false,
  "error_message": "Please try again."
}

This is hard to model with a single concrete type, but easy to handle with ApiResult. Simply throw an ApiException with the decoded error type in a custom Retrofit Converter and it will be automatically surfaced as a Failure.ApiFailure type with that error instance.

@GET("/")
suspend fun getData(): ApiResult<SuccessResponse, ErrorResponse>

// In your own converter factory.
class ErrorConverterFactory : Converter.Factory() {
  override fun responseBodyConverter(
    type: Type,
    annotations: Array<out Annotation>,
    retrofit: Retrofit
  ): Converter<ResponseBody, *>? {
    // This returns a `@ResultType` instance that can be used to get the error type via toType()
    val (errorType, nextAnnotations) = annotations.errorType() ?: return null
    return ResponseBodyConverter(errorType.toType())
  }

  class ResponseBodyConverter(
    private val errorType: Type
  ) : Converter<ResponseBody, *> {
    override fun convert(value: ResponseBody): String {
      if (value.isErrorType()) {
        val errorResponse = ...
        throw ApiException(errorResponse)
      } else {
        return SuccessResponse(...)
      }
    }
  }
}

Retries

A common pattern in making network requests is to retry with exponential backoff. EitherNet ships with a highly configurable retryWithExponentialBackoff() function for this case.

// Defaults for reference
val result = retryWithExponentialBackoff(
  maxAttempts = 3,
  initialDelay = 500.milliseconds,
  delayFactor = 2.0,
  maxDelay = 10.seconds,
  jitterFactor = 0.25,
  onFailure = null, // Optional Failure callback for logging
) {
    api.getData()
}

Testing

EitherNet ships with a Test Fixtures artifact containing a EitherNetController API to allow for easy testing with EitherNet APIs. This is similar to OkHttp’s MockWebServer, where results can be enqueued for specific endpoints.

Simply create a new controller instance in your test using one of the newEitherNetController() functions.

val controller = newEitherNetController<PandaApi>() // reified type

Then you can access the underlying faked api property from it and pass that on to whatever’s being tested.

// Take the api instance from the controller and pass it to whatever's being tested
val provider = PandaDataProvider(controller.api)

Finally, enqueue results for endpoints as needed.

// Later in a test you can enqueue results for specific endpoints
controller.enqueue(PandaApi::getPandas, ApiResult.success("Po"))

You can also optionally pass in full suspend functions if you need dynamic behavior

controller.enqueue(PandaApi::getPandas) {
  // This is a suspend function!
  delay(1000)
  ApiResult.success("Po")
}

In instrumentation tests with DI, you can provide the controller and its underlying API in a test module and replace the standard one. This works particularly well with Anvil.

@ContributesTo(
  scope = UserScope::class,
  replaces = [PandaApiModule::class] // Replace the standard module
)
@Module
object TestPandaApiModule {
  @Provides
  fun providePandaApiController(): EitherNetController<PandaApi> = newEitherNetController()

  @Provides
  fun providePandaApi(
    controller: EitherNetController<PandaApi>
  ): PandaApi = controller.api
}

Then you can inject the controller in your test while users of PandaApi will get your test instance.

Java Interop

For Java interop, there is a limited API available at JavaEitherNetControllers.enqueueFromJava.

Validation

EitherNetController will run some small validation on API endpoints under the hood. If you want to add your own validations on top of this, you can provide implementations of ApiValidator via ServiceLoader. See ApiValidator's docs for more information.

Installation

Maven Central

dependencies {
  implementation("com.slack.eithernet:eithernet:<version>")

  // Test fixtures
  testImplementation(testFixtures("com.slack.eithernet:eithernet:<version>"))
}

Snapshots of the development version are available in Sonatype's snapshots repository.

License

Copyright 2020 Slack Technologies, LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

More Repositories

1

nebula

A scalable overlay networking tool with a focus on performance, simplicity and security
Go
14,374
star
2

SlackTextViewController

⛔️**DEPRECATED** ⛔️ A drop-in UIViewController subclass with a growing text input view and other useful messaging features
Objective-C
8,330
star
3

PanModal

An elegant and highly customizable presentation API for constructing bottom sheet modals on iOS.
Swift
3,631
star
4

go-audit

go-audit is an alternative to the auditd daemon that ships with many distros
Go
1,569
star
5

circuit

⚑️ A Compose-driven architecture for Kotlin and Android applications.
Kotlin
1,432
star
6

goSDL

goSDL
PHP
522
star
7

foundry

Gradle and IntelliJ build tooling used in Slack's Android repo
Kotlin
429
star
8

slack-api-docs

API Docs for Slack.com
427
star
9

compose-lints

Lint checks to aid with a healthy adoption of Compose
Kotlin
387
star
10

keeper

A Gradle plugin that infers Proguard/R8 keep rules for androidTest sources.
Kotlin
259
star
11

slack-lints

A collection of custom Android/Kotlin lint checks we use in our Android and Kotlin code bases at Slack.
Kotlin
231
star
12

astra

Astra is a structured log search and analytics engine developed by Slack and Salesforce
Java
209
star
13

magic-cli

Ruby
199
star
14

simple-kubernetes-webhook

This project is aimed at illustrating how to build a fully functioning kubernetes admission webhook in the simplest way possible.
Go
183
star
15

csp-html-webpack-plugin

A plugin which, when combined with HTMLWebpackPlugin, adds CSP tags to the HTML output.
JavaScript
159
star
16

hakana

Another typechecker for Hack, built by Slack
Rust
75
star
17

hack-sql-fake

A library for testing database driven code in Hack
Hack
74
star
18

vscode-hack

Hack language & HHVM debugger support for Visual Studio Code
TypeScript
73
star
19

gsuite-oauth-third-party-app-report

Start enforcing G Suite third-party apps via OAuth
JavaScript
55
star
20

backend-interview-prep-questions

A few questions & data to help you prepare for the Slack HQ backend interview
PLpgSQL
45
star
21

moshi-gson-interop

An interop tool for safely mixing Moshi and Gson models in JSON serialization.
Kotlin
43
star
22

kotlin-cli-util

Kotlin CLI utilities, mostly intended for use with Clikt
Kotlin
36
star
23

tree-sitter-hack

Hack grammar for tree-sitter
JavaScript
33
star
24

hack-json-schema

Generate Hack JSON Schema validators based on a JSON Schema.
Hack
27
star
25

auto-value-kotlin

An AutoValue extension that generates binary and source compatible equivalent Kotlin data classes of AutoValue models.
Kotlin
26
star
26

deanimator

Go package that can detect animated images and "deanimate" them by rendering just the first frame as a static image.
Go
25
star
27

es-query-simple

A tiny command line utility to query elasticsearch. "
Python
23
star
28

go-rsyslog-pstats

Parses and forwards rsyslog process stats to a local statsite, statsd, or wire protocol compatible service.
Go
21
star
29

tiny-thumb

Novel, efficient, and practical image compression with visually appealing results. 🀏 ✨
Go
15
star
30

backend-interview-prerequisites

A project to ensure that your backend onsite interview at Slack runs smoothly.
Go
12
star
31

sqlite-go-connect

A simple go app that connects to a sqlite3 database
Go
11
star
32

sqlite-python-connect

Short bit of code to connect to a sqlite db and run a query in python
Python
10
star
33

hack-graphql

Playground for a hack graphql server
Hack
8
star
34

protoc-gen-ts

A Typescript Protocol Buffer Implementation from the Future ✨
TypeScript
8
star
35

htmlsanitizer-hack

A port of the PHP HTML Purifier originally developed by Edward Z. Yang into Hacklang
Hack
7
star
36

sqlite-java-connect

This is a minimal repo project that connects to a sqlite3 database and returns a single row.
Java
6
star
37

slack-astra-app

Grafana plugin that adds support for Astra
TypeScript
6
star
38

grpc-hack

A gRPC extension for HHVM
C++
4
star
39

sqlite-ruby-connect

Just a tiny lil something to connect to SQLite using Ruby
PLpgSQL
3
star
40

proto-hack

hacklang generator for protobuf
Hack
3
star
41

snow

Python
2
star
42

.github

1
star
43

go-metrics-prometheus

Go
1
star
44

quota

1
star