├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── maintainers_guide.md └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── build.gradle.kts ├── eithernet ├── api │ ├── eithernet.api │ └── eithernet.klib.api ├── build.gradle.kts ├── gradle.properties ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── slack │ │ │ └── eithernet │ │ │ ├── ApiException.kt │ │ │ ├── ApiResult.kt │ │ │ ├── DecodeErrorBody.kt │ │ │ ├── ExperimentalEitherNetApi.kt │ │ │ ├── Extensions.kt │ │ │ ├── InternalEitherNetApi.kt │ │ │ ├── KTypes.kt │ │ │ ├── ResultType.kt │ │ │ ├── StatusCode.kt │ │ │ ├── annotations.kt │ │ │ ├── platform.kt │ │ │ ├── retries.kt │ │ │ ├── tags.kt │ │ │ └── util.kt │ ├── commonTest │ │ └── kotlin │ │ │ └── com │ │ │ └── slack │ │ │ └── eithernet │ │ │ ├── ExtensionsTest.kt │ │ │ ├── ResultTypeTest.kt │ │ │ └── RetriesTest.kt │ ├── jsMain │ │ └── kotlin │ │ │ └── com │ │ │ └── slack │ │ │ └── eithernet │ │ │ └── platform.js.kt │ ├── jvmMain │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── slack │ │ │ │ └── eithernet │ │ │ │ ├── Types.kt │ │ │ │ ├── Util.kt │ │ │ │ ├── annotations.jvm.kt │ │ │ │ └── platform.jvm.kt │ │ └── resources │ │ │ └── META-INF │ │ │ └── proguard │ │ │ └── eithernet.pro │ ├── jvmTest │ │ └── kotlin │ │ │ └── com │ │ │ └── slack │ │ │ └── eithernet │ │ │ ├── EitherNetControllersTest.kt │ │ │ └── ResultTypeTest.kt │ ├── nativeMain │ │ └── kotlin │ │ │ └── com │ │ │ └── slack │ │ │ └── eithernet │ │ │ └── platform.native.kt │ └── wasmJsMain │ │ └── kotlin │ │ └── com │ │ └── slack │ │ └── eithernet │ │ └── platform.wasmJs.kt └── test-fixtures │ ├── api │ ├── test-fixtures.api │ └── test-fixtures.klib.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── slack │ │ └── eithernet │ │ └── test │ │ ├── ApiValidator.kt │ │ ├── EitherNetController.kt │ │ ├── EitherNetControllers.kt │ │ ├── EitherNetTestOrchestrator.kt │ │ ├── EndpointKey.kt │ │ ├── ParameterKey.kt │ │ ├── Platform.kt │ │ ├── RealEitherNetController.kt │ │ └── Util.kt │ ├── jsMain │ └── kotlin │ │ └── com │ │ └── slack │ │ └── eithernet │ │ └── test │ │ └── Platform.js.kt │ ├── jvmMain │ └── java │ │ └── com │ │ └── slack │ │ └── eithernet │ │ └── test │ │ ├── CoroutineTransformer.java │ │ ├── EitherNetController.jvm.kt │ │ ├── EitherNetControllers.jvm.kt │ │ ├── JavaEitherNetControllers.kt │ │ ├── JvmUtil.kt │ │ ├── Platform.jvm.kt │ │ └── createEndpointKey.kt │ ├── nativeMain │ └── kotlin │ │ └── com │ │ └── slack │ │ └── eithernet │ │ └── test │ │ └── Platform.native.kt │ └── wasmJsMain │ └── kotlin │ └── com │ └── slack │ └── eithernet │ └── test │ └── Platform.wasmJs.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── integrations └── retrofit │ ├── api │ └── retrofit.api │ ├── build.gradle.kts │ ├── gradle.properties │ └── src │ ├── main │ └── kotlin │ │ └── com │ │ └── slack │ │ └── eithernet │ │ └── integration │ │ └── retrofit │ │ ├── ApiResultCallAdapterFactory.kt │ │ ├── ApiResultConverterFactory.kt │ │ └── tags.jvm.kt │ └── test │ └── kotlin │ └── com │ └── slack │ └── eithernet │ └── integration │ └── retrofit │ └── RetrofitIntegrationTest.kt ├── kotlin-js-store └── yarn.lock ├── release.sh ├── settings.gradle.kts └── spotless └── spotless.kt /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. 6 | 7 | Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. 8 | 9 | This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. 10 | 11 | For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) 12 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors Guide 2 | 3 | Interested in contributing? Awesome! Before you do though, please read our 4 | [Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as 5 | well. 6 | 7 | There are many ways you can contribute! :heart: 8 | 9 | ### Bug Reports and Fixes :bug: 10 | - If you find a bug, please search for it in the [Issues](https://github.com/slackhq/eithernet/issues), and if it isn't already tracked, 11 | [create a new issue](https://github.com/slackhq/eithernet/issues/new). Fill out the "Bug Report" section of the issue template. Even if an Issue is closed, feel free to comment and add details, it will still 12 | be reviewed. 13 | - Issues that have already been identified as a bug (note: able to reproduce) will be labelled `bug`. 14 | - If you'd like to submit a fix for a bug, [send a Pull Request](#creating_a_pull_request) and mention the Issue number. 15 | - Include tests that isolate the bug and verifies that it was fixed. 16 | 17 | ### New Features :bulb: 18 | - If you'd like to add new functionality to this project, describe the problem you want to solve in a [new Issue](https://github.com/slackhq/eithernet/issues/new). 19 | - Issues that have been identified as a feature request will be labelled `enhancement`. 20 | - If you'd like to implement the new feature, please wait for feedback from the project 21 | maintainers before spending too much time writing the code. In some cases, `enhancement`s may 22 | not align well with the project objectives at the time. 23 | 24 | ### Tests :mag:, Documentation :books:, Miscellaneous :sparkles: 25 | - If you'd like to improve the tests, you want to make the documentation clearer, you have an 26 | alternative implementation of something that may have advantages over the way its currently 27 | done, or you have any other change, we would be happy to hear about it! 28 | - If its a trivial change, go ahead and [send a Pull Request](#creating_a_pull_request) with the changes you have in mind. 29 | - If not, [open an Issue](https://github.com/slackhq/eithernet/issues/new) to discuss the idea first. 30 | 31 | If you're new to our project and looking for some way to make your first contribution, look for 32 | Issues labelled `good first contribution`. 33 | 34 | ## Requirements 35 | 36 | For your contribution to be accepted: 37 | 38 | - [x] You must have signed the [Contributor License Agreement (CLA)](https://cla-assistant.io/slackhq/eithernet). 39 | - [x] The test suite must be complete and pass. 40 | - [x] The changes must be approved by code review. 41 | - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. 42 | 43 | If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. 44 | 45 | [Interested in knowing more about about pull requests at Slack?](https://slack.engineering/on-empathy-pull-requests-979e4257d158#.awxtvmb2z) 46 | 47 | ## Creating a Pull Request 48 | 49 | 1. :fork_and_knife: Fork the repository on GitHub. 50 | 2. :runner: Clone/fetch your fork to your local development machine. It's a good idea to run the tests just 51 | to make sure everything is in order. 52 | 3. :herb: Create a new branch and check it out. 53 | 4. :crystal_ball: Make your changes and commit them locally. Magic happens here! 54 | 5. :arrow_heading_up: Push your new branch to your fork. (e.g. `git push username fix-issue-16`). 55 | 6. :inbox_tray: Open a Pull Request on github.com from your new branch on your fork to `main` in this 56 | repository. 57 | 58 | ## Maintainers 59 | 60 | There are more details about processes and workflow in the [Maintainer's Guide](./maintainers_guide.md). 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ### Requirements (place an `x` in each of the `[ ]`)** 14 | * [ ] I've read and understood the [Contributing guidelines](../CONTRIBUTING.md) and have done my best effort to follow them. 15 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). 16 | * [ ] I've searched for any related issues and avoided creating a duplicate issue. 17 | 18 | ### To Reproduce 19 | Steps to reproduce the behavior: 20 | 21 | ### Expected behavior 22 | A clear and concise description of what you expected to happen. 23 | 24 | #### Screenshots 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | #### Reproducible in: 28 | 29 | Project version: 30 | 31 | OS version(s): 32 | 33 | #### Additional context 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Code of Conduct 4 | url: https://slackhq.github.io/code-of-conduct 5 | about: Code of Conduct 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Description 11 | 12 | Describe your request here. 13 | 14 | ### Requirements (place an `x` in each of the `[ ]`) 15 | * [ ] I've read and understood the [Contributing guidelines](../CONTRIBUTING.md) and have done my best effort to follow them. 16 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). 17 | * [ ] I've searched for any related issues and avoided creating a duplicate issue. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | Describe the goal of this PR. Mention any related Issue numbers. 4 | 5 | ### Requirements (place an `x` in each `[ ]`) 6 | 7 | * [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackhq/eithernet/blob/main/.github/CONTRIBUTING.md) and have done my best effort to follow them. 8 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). -------------------------------------------------------------------------------- /.github/maintainers_guide.md: -------------------------------------------------------------------------------- 1 | # Maintainers Guide 2 | 3 | This document describes tools, tasks and workflow that one needs to be familiar with in order to effectively maintain 4 | this project. If you use this package within your own software as is but don't plan on modifying it, this guide is 5 | **not** for you. 6 | 7 | ## Tools (optional) 8 | 9 | > Are there any build tools, dependencies, or other programs someone maintaining this project 10 | > needs to be familiar with? 11 | 12 | ## Tasks 13 | 14 | ### Testing 15 | 16 | > How do you run the tests? 17 | 18 | ### Generating Documentation (optional) 19 | 20 | > If the documentation is generated from source, how does someone run the generation? 21 | > Are the docs published on a website (GitHub Pages)? 22 | 23 | ### Releasing 24 | 25 | > A description of the process to make a release for this project. Do not share any secrets here. 26 | 27 | ## Workflow 28 | 29 | ### Versioning and Tags 30 | 31 | > Does this project use semver? What does the numbering system look like? Are releases tagged in git? 32 | 33 | ### Branches 34 | 35 | > Describe any specific branching workflow. For example: 36 | > `main` is where active development occurs. 37 | > Long running branches named feature branches are occasionally created for collaboration on a feature that has a large scope (because everyone cannot push commits to another person's open Pull Request) 38 | > At some point in the future after a major version increment, there may be maintenance branches 39 | > for older major versions. 40 | 41 | ### Issue Management 42 | 43 | Labels are used to run issues through an organized workflow. Here are the basic definitions: 44 | 45 | * `bug`: A confirmed bug report. A bug is considered confirmed when reproduction steps have been 46 | documented and the issue has been reproduced. 47 | * `enhancement`: A feature request for something this package might not already do. 48 | * `docs`: An issue that is purely about documentation work. 49 | * `tests`: An issue that is purely about testing work. 50 | * `needs feedback`: An issue that may have claimed to be a bug but was not reproducible, or was otherwise missing some information. 51 | * `discussion`: An issue that is purely meant to hold a discussion. Typically the maintainers are looking for feedback in this issues. 52 | * `question`: An issue that is like a support request because the user's usage was not correct. 53 | * `semver:major|minor|patch`: Metadata about how resolving this issue would affect the version number. 54 | * `security`: An issue that has special consideration for security reasons. 55 | * `good first contribution`: An issue that has a well-defined relatively-small scope, with clear expectations. It helps when the testing approach is also known. 56 | * `duplicate`: An issue that is functionally the same as another issue. Apply this only if you've linked the other issue by number. 57 | 58 | > You may want to add more labels for subsystems of your project, depending on how complex it is. 59 | 60 | **Triage** is the process of taking new issues that aren't yet "seen" and marking them with a basic 61 | level of information with labels. An issue should have **one** of the following labels applied: 62 | `bug`, `enhancement`, `question`, `needs feedback`, `docs`, `tests`, or `discussion`. 63 | 64 | Issues are closed when a resolution has been reached. If for any reason a closed issue seems 65 | relevant once again, reopening is great and better than creating a duplicate issue. 66 | 67 | ## Everything else 68 | 69 | When in doubt, find the other maintainers and ask. 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Only run push on main 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - '**/*.md' 10 | # Always run on PRs 11 | pull_request: 12 | branches: [ main ] 13 | merge_group: 14 | 15 | concurrency: 16 | group: 'ci-${{ github.event.merge_group.head_ref || github.head_ref }}-${{ github.workflow }}' 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build: 21 | name: "Build" 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Configure JDK 29 | uses: actions/setup-java@v4 30 | with: 31 | distribution: 'zulu' 32 | java-version: '22' 33 | 34 | - name: Setup Gradle 35 | uses: gradle/actions/setup-gradle@v4 36 | 37 | - name: Test 38 | run: | 39 | # Run compileCommonMainKotlinMetadata to ensure metadata compilation works too, as it's 40 | # not covered under the normal check command 41 | ./gradlew check compileCommonMainKotlinMetadata 42 | 43 | - name: Publish (default branch only) 44 | if: github.repository == 'slackhq/EitherNet' && github.ref == 'refs/heads/main' 45 | run: ./gradlew publish -PmavenCentralUsername=${{ secrets.SONATYPEUSERNAME }} -PmavenCentralPassword=${{ secrets.SONATYPEPASSWORD }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | local.properties 3 | .idea/ 4 | *.iml 5 | .DS_Store 6 | build 7 | reports 8 | .cxx 9 | docs/ 10 | out/ 11 | .kotlin/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | **Unreleased** 5 | -------------- 6 | 7 | 2.0.0 8 | ----- 9 | 10 | _2024-12-10_ 11 | 12 | - Migrate to Kotlin Multiplatform. Structurally, the core EitherNet APIs now live in `common` code and are _implemented_ by integration modules. 13 | - Move Retrofit/OkHttp integration to separate `eithernet-integration-retrofit` artifact. 14 | - Move test fixtures to new `eithernet-test-fixtures` artifact. Most of its implementation is still JVM-only for now. 15 | - **Note**: Due to limitations in KMP, we no longer ship a native test fixtures attribute and Gradle's native `java-test-fixtures` plugin does not work on KMP JVM artifacts. Please star these issues: 16 | - https://youtrack.jetbrains.com/issue/KT-69482 17 | - https://youtrack.jetbrains.com/issue/KT-63142 18 | - Remove deprecated APIs. 19 | - Add `out` variance to `ApiResult.Failure` generic type. 20 | - Update Okio to `3.9.0`. 21 | - Update Kotlin to `2.1.0`. 22 | 23 | 1.9.0 24 | ----- 25 | 26 | _2024-06-05_ 27 | 28 | - Update to Kotlin `2.0.0`. 29 | 30 | 1.8.1 31 | ----- 32 | 33 | _2024-02-11_ 34 | 35 | - **Fix**: (hopefully) fix kdocs publishing for test fixtures. 36 | - Update to Kotlin `1.9.22`. 37 | 38 | 1.8.0 39 | ----- 40 | 41 | _2023-11-03_ 42 | 43 | - **Fix**: Deprecate old `fold()` functions and introduce new ones that use the underlying value rather than `Success`. This was an oversight in the previous implementation. Binary compatibility is preserved. 44 | - **Enhancement:** Mark functions `inline` where possible to allow carried over context (i.e. in suspend functions, etc) 45 | - **Enhancement:** Use contracts to inform the compiler about possible calls to lambdas. 46 | - **New:** Add fluent `onSuccess` and `onFailure*` functional extension APIs to `ApiResult`. 47 | 48 | 1.7.0 49 | ----- 50 | 51 | _2023-11-02_ 52 | 53 | - **Enhancement**: Add new `ApiResult<*, *>.successOrNothing()` and `ApiResult.Failure<*>.exceptionOrNull()` functional extension APIs. 54 | - Update to Kotlin `1.9.20`. 55 | 56 | 1.6.0 57 | ----- 58 | 59 | _2023-09-26_ 60 | 61 | - **Enhancement**: Add `shouldRetry` parameter to `retryWithExponentialBackoff()` to allow conditional short-circuiting of retries. 62 | 63 | 1.5.0 64 | ----- 65 | 66 | _2023-08-08_ 67 | 68 | - **New**: Add new `successOrNull`, `successOrElse`, and `fold` functional extension APIs to `ApiResult`. These allow easy happy path-ing in user code to coerce results into a concrete value. 69 | - Update to Kotlin `1.9.0`. 70 | 71 | 1.4.1 72 | ----- 73 | 74 | _2023-05-31_ 75 | 76 | - **Enhancement**: Gracefully handle `Unit`-returning endpoints when encountering a 204 or 205 response code. 77 | 78 | Thanks to [@JDSM01](https://github.com/JDSM01) for contributing to this release! 79 | 80 | 1.4.0 81 | ----- 82 | 83 | _2023-05-19_ 84 | 85 | Happy new year! 86 | 87 | A common pattern in making network requests is to retry with exponential backoff. EitherNet now ships with a highly configurable `retryWithExponentialBackoff()` function for this case. 88 | 89 | ```kotlin 90 | // Defaults for reference 91 | val result = retryWithExponentialBackoff( 92 | maxAttempts = 3, 93 | initialDelay = 500.milliseconds, 94 | delayFactor = 2.0, 95 | maxDelay = 10.seconds, 96 | jitterFactor = 0.25, 97 | onFailure = null, // Optional Failure callback for logging 98 | ) { 99 | api.getData() 100 | } 101 | ``` 102 | 103 | - Update to Kotlin `1.8.21`. 104 | - Update to Kotlin Coroutines `1.7.1`. 105 | - EitherNet now depends on `org.jetbrains.kotlinx:kotlinx-coroutines-core`. 106 | 107 | 1.3.1 108 | ----- 109 | 110 | _2022-12-31_ 111 | 112 | - Fix missing Gradle module metadata for test fixtures. 113 | 114 | 1.3.0 115 | ----- 116 | 117 | _2022-12-30_ 118 | 119 | - Update to Kotlin `1.8.0`. 120 | - **Fix:** Fix exception on annotation traversal when target is not present 121 | - **Fix:** Publish test-fixtures artifact sources. 122 | - Update to JVM target 11. 123 | 124 | 1.2.1 125 | ----- 126 | 127 | _2022-01-23_ 128 | 129 | * Update to Kotlin `1.6.10`. 130 | * Promote test-fixtures APIs to stable. 131 | * Update kotlinx-coroutines to `1.6.0` (test-fixtures only). 132 | * **Fix:** test-fixtures artifact module metadata using the wrong artifact ID. They should correctly resolve when using Gradle's `testFixtures(...)` syntax. 133 | 134 | 1.2.0 135 | ----- 136 | 137 | _2021-11-16_ 138 | 139 | * Update to Kotlin `1.6.0` 140 | * **New:** Directly instantiate intermediate EitherNet annotations. This is an internal change only. 141 | 142 | 1.1.0 143 | ----- 144 | 145 | _2021-09-22_ 146 | 147 | * Update Kotlin to `1.5.31` 148 | * **New:** This release introduces a new `EitherNetController` API for testing EitherNet APIs via [Test Fixtures](https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures). This is similar to OkHttp’s `MockWebServer`, where results can be enqueued for specific endpoints. 149 | 150 | Simply create a new controller instance in your test using one of the `newEitherNetController()` functions. 151 | 152 | ```kotlin 153 | val controller = newEitherNetController() // reified type 154 | ``` 155 | 156 | Then you can access the underlying faked `api` property from it and pass that on to whatever’s being tested. 157 | 158 | 159 | ```kotlin 160 | // Take the api instance from the controller and pass it to whatever's being tested 161 | val provider = PandaDataProvider(controller.api) 162 | ``` 163 | 164 | Finally, enqueue results for endpoints as needed. 165 | 166 | ```kotlin 167 | // Later in a test you can enqueue results for specific endpoints 168 | controller.enqueue(PandaApi::getPandas, ApiResult.success("Po")) 169 | ``` 170 | 171 | You can also optionally pass in full suspend functions if you need dynamic behavior 172 | 173 | ```kotlin 174 | controller.enqueue(PandaApi::getPandas) { 175 | // This is a suspend function! 176 | delay(1000) 177 | ApiResult.success("Po") 178 | } 179 | ``` 180 | 181 | See its [section in our README](https://github.com/slackhq/EitherNet#testing) for full more details. 182 | 183 | 1.0.0 184 | ----- 185 | 186 | _2021-09-9_ 187 | 188 | Stable release! 189 | 190 | * **Fix:** Embed proguard rules to keep relevant generics information on `ApiResult`. This is important for new versions of R8, which otherwise strips this information. 191 | * **Fix:** Require `ApiResult` type arguments to be non-null (i.e. `T : Any`). 192 | * **New:** Add a tags API for breadcrumbing information in `ApiResult`. We expose a few APIs through here, namely the original OkHttp `Request` or `Response` instances when relevant. 193 | * `ApiResult` subtypes are no longer `data` classes since many of their underlying properties don't reliably implement equals/hashCode/immutability. 194 | * The deprecated `ApiResult.response` property is now removed. 195 | 196 | Thanks to [@okamayana](https://github.com/okamayana) for contributing to this release! 197 | 198 | 1.0.0-rc01 199 | ---------- 200 | 201 | _2021-07-19_ 202 | 203 | This is our first (and hopefully final!) 1.0 release candidate. Please report any issues or API 204 | surface area issues now or forever hold your peace. 205 | 206 | _Note that we consider this stable for production use, this is mostly about API stability._ 207 | 208 | * **Breaking:** `ApiResult` and `ApiResult.Failure` are both now `sealed interface` types rather than sealed classes. For most consumers this shouldn't be a source breaking change! 209 | * **Breaking:** `ApiResult.Success` constructor is now `internal`, please use the `ApiResult.success()` factory. 210 | * Test up to JDK 17. 211 | 212 | Updated dependencies 213 | ``` 214 | Kotlin 1.5.21 215 | Coroutines 1.5.1 216 | Dokka 1.5.0 217 | ``` 218 | 219 | Special thanks to [@danieldisu](https://github.com/danieldisu) and [@JvmName](https://github.com/JvmName) for contributing to this release! 220 | 221 | 0.2.0 222 | ----- 223 | 224 | _2020-10-22_ 225 | 226 | **New:** Support for decoding error bodies. `HttpFailure` is now typed with the `E` error generic 227 | and decoding error bodies can be opted into via the `@DecodeErrorBody` annotation. 228 | 229 | ```kotlin 230 | interface TestApi { 231 | @DecodeErrorBody 232 | @GET("/") 233 | suspend fun testEndpoint(): ApiResult 234 | } 235 | ``` 236 | 237 | See the [README](https://github.com/slackhq/EitherNet/blob/main/README.md#decoding-error-bodies) section for more details. 238 | 239 | 0.1.0 240 | ----- 241 | 242 | _2020-10-22_ 243 | 244 | Initial release 245 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EitherNet 2 | 3 | A multiplatform, pluggable, and sealed API result type for modeling network API responses. Currently, this is 4 | only implemented for [Retrofit](https://github.com/square/retrofit) on the JVM, but the core API is defined in 5 | common code and can be implemented for other platforms/frameworks. 6 | 7 | The rest of the README below focuses on the Retrofit implementation. 8 | 9 | ## Usage 10 | 11 | By default, Retrofit uses exceptions to propagate any errors. This library leverages Kotlin sealed types 12 | to better model these responses with a type-safe single point of return and no exception handling needed! 13 | 14 | The core type for this is `ApiResult`, where `T` is the success type and `E` is a possible 15 | error type. 16 | 17 | `ApiResult` has two sealed subtypes: `Success` and `Failure`. `Success` is typed to `T` with no 18 | error type and `Failure` is typed to `E` with no success type. `Failure` in turn is represented by 19 | four sealed subtypes of its own: `Failure.NetworkFailure`, `Failure.ApiFailure`, `Failure.HttpFailure`, 20 | and `Failure.UnknownFailure`. This allows for simple handling of results through a consistent, 21 | non-exceptional flow via sealed `when` branches. 22 | 23 | ```kotlin 24 | when (val result = myApi.someEndpoint()) { 25 | is Success -> doSomethingWith(result.response) 26 | is Failure -> when (result) { 27 | is NetworkFailure -> showError(result.error) 28 | is HttpFailure -> showError(result.code) 29 | is ApiFailure -> showError(result.error) 30 | is UnknownFailure -> showError(result.error) 31 | } 32 | } 33 | ``` 34 | 35 | Usually, user code for this could just simply show a generic error message for a `Failure` 36 | case, but the sealed subtypes also allow for more specific error messaging or pluggability of error 37 | types. 38 | 39 | Simply change your endpoint return type to the typed `ApiResult` and include our call adapter and 40 | delegating converter factory. 41 | 42 | 43 | ```kotlin 44 | interface TestApi { 45 | @GET("/") 46 | suspend fun getData(): ApiResult 47 | } 48 | 49 | val api = Retrofit.Builder() 50 | .addConverterFactory(ApiResultConverterFactory) 51 | .addCallAdapterFactory(ApiResultCallAdapterFactory) 52 | .build() 53 | .create() 54 | ``` 55 | 56 | If you don't have custom error return types, simply use `Unit` for the error type. 57 | 58 | ### Decoding Error Bodies 59 | 60 | If you want to decode error types in `HttpFailure`s, annotate your endpoint with `@DecodeErrorBody`: 61 | 62 | ```kotlin 63 | interface TestApi { 64 | @DecodeErrorBody 65 | @GET("/") 66 | suspend fun getData(): ApiResult 67 | } 68 | ``` 69 | 70 | Now a 4xx or 5xx response will try to decode its error body (if any) as `ErrorResponse`. If you want to 71 | contextually decode the error body based on the status code, you can retrieve a `@StatusCode` annotation 72 | from annotations in a custom Retrofit `Converter`. 73 | 74 | ```kotlin 75 | // In your own converter factory. 76 | override fun responseBodyConverter( 77 | type: Type, 78 | annotations: Array, 79 | retrofit: Retrofit 80 | ): Converter? { 81 | val (statusCode, nextAnnotations) = annotations.statusCode() 82 | ?: return null 83 | val errorType = when (statusCode.value) { 84 | 401 -> Unauthorized::class.java 85 | 404 -> NotFound::class.java 86 | // ... 87 | } 88 | val errorDelegate = retrofit.nextResponseBodyConverter(this, errorType.toType(), nextAnnotations) 89 | return MyCustomBodyConverter(errorDelegate) 90 | } 91 | ``` 92 | 93 | Note that error bodies with a content length of 0 will be skipped. 94 | 95 | ### Plugability 96 | 97 | A common pattern for some APIs is to return a polymorphic `200` response where the data needs to be 98 | dynamically parsed. Consider this example: 99 | 100 | ```JSON 101 | { 102 | "ok": true, 103 | "data": { 104 | ... 105 | } 106 | } 107 | ``` 108 | 109 | The same API may return this structure in an error event 110 | 111 | ```JSON 112 | { 113 | "ok": false, 114 | "error_message": "Please try again." 115 | } 116 | ``` 117 | 118 | This is hard to model with a single concrete type, but easy to handle with `ApiResult`. Simply 119 | throw an `ApiException` with the decoded error type in a custom Retrofit `Converter` and it will be 120 | automatically surfaced as a `Failure.ApiFailure` type with that error instance. 121 | 122 | ```kotlin 123 | @GET("/") 124 | suspend fun getData(): ApiResult 125 | 126 | // In your own converter factory. 127 | class ErrorConverterFactory : Converter.Factory() { 128 | override fun responseBodyConverter( 129 | type: Type, 130 | annotations: Array, 131 | retrofit: Retrofit 132 | ): Converter? { 133 | // This returns a `@ResultType` instance that can be used to get the error type via toType() 134 | val (errorType, nextAnnotations) = annotations.errorType() ?: return null 135 | return ResponseBodyConverter(errorType.toType()) 136 | } 137 | 138 | class ResponseBodyConverter( 139 | private val errorType: Type 140 | ) : Converter { 141 | override fun convert(value: ResponseBody): String { 142 | if (value.isErrorType()) { 143 | val errorResponse = ... 144 | throw ApiException(errorResponse) 145 | } else { 146 | return SuccessResponse(...) 147 | } 148 | } 149 | } 150 | } 151 | ``` 152 | 153 | ### Retries 154 | 155 | A common pattern in making network requests is to retry with exponential backoff. EitherNet ships with a highly configurable `retryWithExponentialBackoff()` function for this case. 156 | 157 | ```kotlin 158 | // Defaults for reference 159 | val result = retryWithExponentialBackoff( 160 | maxAttempts = 3, 161 | initialDelay = 500.milliseconds, 162 | delayFactor = 2.0, 163 | maxDelay = 10.seconds, 164 | jitterFactor = 0.25, 165 | onFailure = null, // Optional Failure callback for logging 166 | ) { 167 | api.getData() 168 | } 169 | ``` 170 | 171 | ## Testing 172 | 173 | EitherNet ships with a [Test Fixtures](https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures) 174 | artifact containing a `EitherNetController` API to allow for easy testing with EitherNet APIs. This 175 | is similar to OkHttp’s `MockWebServer`, where results can be enqueued for specific endpoints. 176 | 177 | Simply create a new controller instance in your test using one of the `newEitherNetController()` functions. 178 | 179 | ```kotlin 180 | val controller = newEitherNetController() // reified type 181 | ``` 182 | 183 | Then you can access the underlying faked `api` property from it and pass that on to whatever’s being tested. 184 | 185 | 186 | ```kotlin 187 | // Take the api instance from the controller and pass it to whatever's being tested 188 | val provider = PandaDataProvider(controller.api) 189 | ``` 190 | 191 | Finally, enqueue results for endpoints as needed. 192 | 193 | ```kotlin 194 | // Later in a test you can enqueue results for specific endpoints 195 | controller.enqueue(PandaApi::getPandas, ApiResult.success("Po")) 196 | ``` 197 | 198 | You can also optionally pass in full suspend functions if you need dynamic behavior 199 | 200 | ```kotlin 201 | controller.enqueue(PandaApi::getPandas) { 202 | // This is a suspend function! 203 | delay(1000) 204 | ApiResult.success("Po") 205 | } 206 | ``` 207 | 208 | In instrumentation tests with DI, you can provide the controller and its underlying API in a test 209 | module and replace the standard one. This works particularly well with [Anvil](https://github.com/square/anvil). 210 | 211 | ```kotlin 212 | @ContributesTo( 213 | scope = UserScope::class, 214 | replaces = [PandaApiModule::class] // Replace the standard module 215 | ) 216 | @Module 217 | object TestPandaApiModule { 218 | @Provides 219 | fun providePandaApiController(): EitherNetController = newEitherNetController() 220 | 221 | @Provides 222 | fun providePandaApi( 223 | controller: EitherNetController 224 | ): PandaApi = controller.api 225 | } 226 | ``` 227 | 228 | Then you can inject the controller in your test while users of `PandaApi` will get your test instance. 229 | 230 | ### Java Interop 231 | 232 | For Java interop, there is a limited API available at `JavaEitherNetControllers.enqueueFromJava`. 233 | 234 | ### Validation 235 | 236 | `EitherNetController` will run some small validation on API endpoints under the hood. If you want to 237 | add your own validations on top of this, you can provide implementations of `ApiValidator` via 238 | `ServiceLoader`. See `ApiValidator`'s docs for more information. 239 | 240 | ## Installation 241 | 242 | [![Maven Central](https://img.shields.io/maven-central/v/com.slack.eithernet/eithernet.svg)](https://mvnrepository.com/artifact/com.slack.eithernet/eithernet) 243 | ```gradle 244 | dependencies { 245 | implementation("com.slack.eithernet:eithernet:") 246 | implementation("com.slack.eithernet:eithernet-integration-retrofit:") 247 | 248 | // Test fixtures 249 | testImplementation(testFixtures("com.slack.eithernet:eithernet:")) 250 | } 251 | ``` 252 | 253 | Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. 254 | 255 | License 256 | -------- 257 | 258 | Copyright 2020 Slack Technologies, LLC 259 | 260 | Licensed under the Apache License, Version 2.0 (the "License"); 261 | you may not use this file except in compliance with the License. 262 | You may obtain a copy of the License at 263 | 264 | http://www.apache.org/licenses/LICENSE-2.0 265 | 266 | Unless required by applicable law or agreed to in writing, software 267 | distributed under the License is distributed on an "AS IS" BASIS, 268 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 269 | See the License for the specific language governing permissions and 270 | limitations under the License. 271 | 272 | 273 | [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/slack/eithernet/ 274 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing 2 | ========= 3 | 4 | 1. Update the `CHANGELOG.md` for the impending release. 5 | 2. Run `./release.sh (--patch|--minor|--major)`. 6 | 3. Publish the release on the repo's releases tab. 7 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import com.vanniktech.maven.publish.MavenPublishBaseExtension 17 | import io.gitlab.arturbosch.detekt.Detekt 18 | import java.net.URI 19 | import kotlinx.validation.ExperimentalBCVApi 20 | import org.jetbrains.dokka.gradle.DokkaTask 21 | import org.jetbrains.dokka.gradle.DokkaTaskPartial 22 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 23 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 24 | import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions 25 | import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension 26 | import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension 27 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask 28 | 29 | plugins { 30 | alias(libs.plugins.kotlin.jvm) apply false 31 | alias(libs.plugins.kotlin.multiplatform) apply false 32 | alias(libs.plugins.dokka) 33 | alias(libs.plugins.ksp) apply false 34 | alias(libs.plugins.spotless) 35 | alias(libs.plugins.mavenPublish) apply false 36 | alias(libs.plugins.detekt) apply false 37 | alias(libs.plugins.binaryCompatibilityValidator) 38 | } 39 | 40 | apiValidation { 41 | @OptIn(ExperimentalBCVApi::class) 42 | klib.enabled = true 43 | nonPublicMarkers += "com.slack.eithernet.InternalEitherNetApi" 44 | } 45 | 46 | tasks.dokkaHtmlMultiModule { 47 | outputDirectory.set(rootDir.resolve("docs/api/2.x")) 48 | includes.from(project.layout.projectDirectory.file("README.md")) 49 | } 50 | 51 | val tomlJvmTarget = libs.versions.jvmTarget.get() 52 | 53 | subprojects { 54 | pluginManager.withPlugin("java") { 55 | configure { 56 | toolchain { languageVersion.set(libs.versions.jdk.map(JavaLanguageVersion::of)) } 57 | } 58 | 59 | project.tasks.withType().configureEach { 60 | options.release.set(tomlJvmTarget.toInt()) 61 | } 62 | } 63 | 64 | pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { 65 | configure { 66 | explicitApi() 67 | compilerOptions { 68 | progressiveMode.set(true) 69 | jvmTarget.set(libs.versions.jvmTarget.map(JvmTarget::fromTarget)) 70 | } 71 | } 72 | } 73 | 74 | pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { 75 | configure { 76 | explicitApi() 77 | @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { progressiveMode.set(true) } 78 | jvmToolchain { languageVersion.set(libs.versions.jvmTarget.map(JavaLanguageVersion::of)) } 79 | } 80 | tasks.withType>().configureEach { 81 | compilerOptions { 82 | freeCompilerArgs.addAll( 83 | "-Xwhen-guards", 84 | "-Xnon-local-break-continue", 85 | "-Xconsistent-data-class-copy-visibility", 86 | ) 87 | if (this is KotlinJvmCompilerOptions) { 88 | jvmTarget.set(libs.versions.jvmTarget.map(JvmTarget::fromTarget)) 89 | // Enable new JvmDefault behavior 90 | // https://blog.jetbrains.com/kotlin/2020/07/kotlin-1-4-m3-generating-default-methods-in-interfaces/ 91 | freeCompilerArgs.add("-Xjvm-default=all") 92 | } 93 | } 94 | } 95 | } 96 | 97 | apply(plugin = "io.gitlab.arturbosch.detekt") 98 | tasks.withType().configureEach { jvmTarget = tomlJvmTarget } 99 | 100 | pluginManager.withPlugin("com.vanniktech.maven.publish") { 101 | apply(plugin = "org.jetbrains.dokka") 102 | tasks.withType().configureEach { 103 | outputDirectory.set(layout.buildDirectory.dir("docs/partial")) 104 | dokkaSourceSets.configureEach { 105 | val readMeProvider = project.layout.projectDirectory.file("README.md") 106 | if (readMeProvider.asFile.exists()) { 107 | includes.from(readMeProvider) 108 | } 109 | skipDeprecated.set(true) 110 | sourceLink { 111 | localDirectory.set(layout.projectDirectory.dir("src").asFile) 112 | val relPath = rootProject.projectDir.toPath().relativize(projectDir.toPath()) 113 | remoteUrl.set( 114 | providers.gradleProperty("POM_SCM_URL").map { scmUrl -> 115 | URI("$scmUrl/tree/main/$relPath/src").toURL() 116 | } 117 | ) 118 | remoteLineSuffix.set("#L") 119 | } 120 | } 121 | } 122 | 123 | configure { 124 | publishToMavenCentral(automaticRelease = true) 125 | signAllPublications() 126 | } 127 | // Ref: https://github.com/slackhq/EitherNet/issues/58 128 | project.group = project.property("GROUP").toString() 129 | project.version = project.property("VERSION_NAME").toString() 130 | } 131 | } 132 | 133 | tasks.named("dokkaHtml") { 134 | outputDirectory.set(layout.projectDirectory.dir("docs/1.x")) 135 | dokkaSourceSets.configureEach { 136 | if (name == "testFixtures") { 137 | suppress.set(false) 138 | } 139 | 140 | skipDeprecated.set(true) 141 | externalDocumentationLink { 142 | url.set(URI("https://square.github.io/retrofit/2.x/retrofit/").toURL()) 143 | } 144 | } 145 | } 146 | 147 | val ktfmtVersion = libs.versions.ktfmt.get() 148 | 149 | allprojects { 150 | apply(plugin = "com.diffplug.spotless") 151 | 152 | spotless { 153 | format("misc") { 154 | target("*.md", ".gitignore") 155 | trimTrailingWhitespace() 156 | endWithNewline() 157 | } 158 | kotlin { 159 | target("**/*.kt") 160 | ktfmt(ktfmtVersion).googleStyle() 161 | trimTrailingWhitespace() 162 | endWithNewline() 163 | licenseHeaderFile(rootProject.layout.projectDirectory.file("spotless/spotless.kt")) 164 | targetExclude("**/spotless.kt") 165 | } 166 | kotlinGradle { 167 | ktfmt(ktfmtVersion).googleStyle() 168 | trimTrailingWhitespace() 169 | endWithNewline() 170 | licenseHeaderFile( 171 | rootProject.layout.projectDirectory.file("spotless/spotless.kt"), 172 | "(import|plugins|buildscript|dependencies|pluginManagement|rootProject)", 173 | ) 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /eithernet/api/eithernet.api: -------------------------------------------------------------------------------- 1 | public final class com/slack/eithernet/AnnotationsKt { 2 | public static final fun errorType ([Ljava/lang/annotation/Annotation;)Lkotlin/Pair; 3 | public static final fun statusCode ([Ljava/lang/annotation/Annotation;)Lkotlin/Pair; 4 | public static final fun toKType (Lcom/slack/eithernet/ResultType;)Lkotlin/reflect/KType; 5 | } 6 | 7 | public final class com/slack/eithernet/Annotations_jvmKt { 8 | public static final fun toType (Lcom/slack/eithernet/ResultType;)Ljava/lang/reflect/Type; 9 | } 10 | 11 | public final class com/slack/eithernet/ApiException : java/lang/Exception { 12 | public fun (Ljava/lang/Object;)V 13 | public final fun getError ()Ljava/lang/Object; 14 | } 15 | 16 | public abstract interface class com/slack/eithernet/ApiResult { 17 | public static final field Companion Lcom/slack/eithernet/ApiResult$Companion; 18 | } 19 | 20 | public final class com/slack/eithernet/ApiResult$Companion { 21 | public final fun apiFailure ()Lcom/slack/eithernet/ApiResult$Failure$ApiFailure; 22 | public final fun apiFailure (Ljava/lang/Object;)Lcom/slack/eithernet/ApiResult$Failure$ApiFailure; 23 | public static synthetic fun apiFailure$default (Lcom/slack/eithernet/ApiResult$Companion;Ljava/lang/Object;ILjava/lang/Object;)Lcom/slack/eithernet/ApiResult$Failure$ApiFailure; 24 | public final fun httpFailure (I)Lcom/slack/eithernet/ApiResult$Failure$HttpFailure; 25 | public final fun httpFailure (ILjava/lang/Object;)Lcom/slack/eithernet/ApiResult$Failure$HttpFailure; 26 | public static synthetic fun httpFailure$default (Lcom/slack/eithernet/ApiResult$Companion;ILjava/lang/Object;ILjava/lang/Object;)Lcom/slack/eithernet/ApiResult$Failure$HttpFailure; 27 | public final fun networkFailure (Ljava/io/IOException;)Lcom/slack/eithernet/ApiResult$Failure$NetworkFailure; 28 | public final fun success (Ljava/lang/Object;)Lcom/slack/eithernet/ApiResult$Success; 29 | public final fun unknownFailure (Ljava/lang/Throwable;)Lcom/slack/eithernet/ApiResult$Failure$UnknownFailure; 30 | } 31 | 32 | public abstract interface class com/slack/eithernet/ApiResult$Failure : com/slack/eithernet/ApiResult { 33 | } 34 | 35 | public final class com/slack/eithernet/ApiResult$Failure$ApiFailure : com/slack/eithernet/ApiResult$Failure { 36 | public final fun getError ()Ljava/lang/Object; 37 | public final fun withTags (Ljava/util/Map;)Lcom/slack/eithernet/ApiResult$Failure$ApiFailure; 38 | } 39 | 40 | public final class com/slack/eithernet/ApiResult$Failure$HttpFailure : com/slack/eithernet/ApiResult$Failure { 41 | public final fun getCode ()I 42 | public final fun getError ()Ljava/lang/Object; 43 | public final fun withTags (Ljava/util/Map;)Lcom/slack/eithernet/ApiResult$Failure$HttpFailure; 44 | } 45 | 46 | public final class com/slack/eithernet/ApiResult$Failure$NetworkFailure : com/slack/eithernet/ApiResult$Failure { 47 | public final fun getError ()Ljava/io/IOException; 48 | public final fun withTags (Ljava/util/Map;)Lcom/slack/eithernet/ApiResult$Failure$NetworkFailure; 49 | } 50 | 51 | public final class com/slack/eithernet/ApiResult$Failure$UnknownFailure : com/slack/eithernet/ApiResult$Failure { 52 | public final fun getError ()Ljava/lang/Throwable; 53 | public final fun withTags (Ljava/util/Map;)Lcom/slack/eithernet/ApiResult$Failure$UnknownFailure; 54 | } 55 | 56 | public final class com/slack/eithernet/ApiResult$Success : com/slack/eithernet/ApiResult { 57 | public final fun getValue ()Ljava/lang/Object; 58 | public final fun withTags (Ljava/util/Map;)Lcom/slack/eithernet/ApiResult$Success; 59 | } 60 | 61 | public abstract interface annotation class com/slack/eithernet/DecodeErrorBody : java/lang/annotation/Annotation { 62 | } 63 | 64 | public abstract interface annotation class com/slack/eithernet/ExperimentalEitherNetApi : java/lang/annotation/Annotation { 65 | } 66 | 67 | public final class com/slack/eithernet/ExtensionsKt { 68 | public static final fun exceptionOrNull (Lcom/slack/eithernet/ApiResult$Failure;)Ljava/lang/Throwable; 69 | public static final fun fold (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; 70 | public static final fun fold (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; 71 | public static final fun onApiFailure (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Lcom/slack/eithernet/ApiResult; 72 | public static final fun onFailure (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Lcom/slack/eithernet/ApiResult; 73 | public static final fun onHttpFailure (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Lcom/slack/eithernet/ApiResult; 74 | public static final fun onNetworkFailure (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Lcom/slack/eithernet/ApiResult; 75 | public static final fun onSuccess (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Lcom/slack/eithernet/ApiResult; 76 | public static final fun onUnknownFailure (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Lcom/slack/eithernet/ApiResult; 77 | public static final fun successOrElse (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; 78 | public static final fun successOrNothing (Lcom/slack/eithernet/ApiResult;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; 79 | public static final fun successOrNull (Lcom/slack/eithernet/ApiResult;)Ljava/lang/Object; 80 | } 81 | 82 | public abstract interface annotation class com/slack/eithernet/InternalEitherNetApi : java/lang/annotation/Annotation { 83 | } 84 | 85 | public abstract interface annotation class com/slack/eithernet/ResultType : java/lang/annotation/Annotation { 86 | public abstract fun isArray ()Z 87 | public abstract fun ownerType ()Ljava/lang/Class; 88 | public abstract fun rawType ()Ljava/lang/Class; 89 | public abstract fun typeArgs ()[Lcom/slack/eithernet/ResultType; 90 | } 91 | 92 | public final class com/slack/eithernet/RetriesKt { 93 | public static final fun retryWithExponentialBackoff-3c68mSE (IJDJDLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 94 | public static synthetic fun retryWithExponentialBackoff-3c68mSE$default (IJDJDLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 95 | } 96 | 97 | public abstract interface annotation class com/slack/eithernet/StatusCode : java/lang/annotation/Annotation { 98 | public abstract fun value ()I 99 | } 100 | 101 | public final class com/slack/eithernet/TagsKt { 102 | public static final fun tag (Lcom/slack/eithernet/ApiResult;Lkotlin/reflect/KClass;)Ljava/lang/Object; 103 | } 104 | 105 | public final class com/slack/eithernet/Util { 106 | public static final fun resolve (Ljava/lang/reflect/Type;Ljava/lang/reflect/Type;Ljava/lang/Class;)Ljava/lang/reflect/Type; 107 | } 108 | 109 | -------------------------------------------------------------------------------- /eithernet/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 17 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 18 | 19 | plugins { 20 | alias(libs.plugins.kotlin.multiplatform) 21 | `java-test-fixtures` 22 | alias(libs.plugins.dokka) 23 | alias(libs.plugins.ksp) 24 | alias(libs.plugins.mavenPublish) 25 | } 26 | 27 | kotlin { 28 | // region KMP Targets 29 | jvm() 30 | iosX64() 31 | iosArm64() 32 | iosSimulatorArm64() 33 | js(IR) { 34 | moduleName = property("POM_ARTIFACT_ID").toString() 35 | browser() 36 | } 37 | @OptIn(ExperimentalWasmDsl::class) 38 | wasmJs { 39 | moduleName = property("POM_ARTIFACT_ID").toString() 40 | browser() 41 | } 42 | // endregion 43 | 44 | applyDefaultHierarchyTemplate() 45 | 46 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 47 | compilerOptions { 48 | optIn.addAll( 49 | "kotlin.ExperimentalStdlibApi", 50 | "kotlinx.coroutines.ExperimentalCoroutinesApi", 51 | "com.slack.eithernet.InternalEitherNetApi", 52 | ) 53 | freeCompilerArgs.addAll("-Xexpect-actual-classes", "-Xrender-internal-diagnostic-names") 54 | } 55 | sourceSets { 56 | commonMain { 57 | dependencies { 58 | implementation(libs.coroutines.core) 59 | implementation(libs.okio) 60 | } 61 | } 62 | commonTest { 63 | dependencies { 64 | implementation(libs.coroutines.core) 65 | implementation(libs.coroutines.test) 66 | implementation(libs.kotlin.test) 67 | } 68 | } 69 | jvmTest { 70 | dependencies { 71 | implementation(libs.coroutines.core) 72 | implementation(libs.coroutines.test) 73 | implementation(libs.junit) 74 | implementation(libs.truth) 75 | implementation(libs.autoService.annotations) 76 | implementation(project(":eithernet:test-fixtures")) 77 | } 78 | } 79 | } 80 | } 81 | 82 | // KMP can never get configuration cache/task dependencies right 83 | tasks 84 | .named { it == "jsBrowserTest" || it == "wasmJsBrowserTest" } 85 | .configureEach { 86 | dependsOn("jsTestTestDevelopmentExecutableCompileSync") 87 | dependsOn("wasmJsTestTestDevelopmentExecutableCompileSync") 88 | } 89 | 90 | dependencies { 91 | "kspJvmTest"(libs.autoService.ksp) 92 | testFixturesApi(project(":eithernet:test-fixtures")) 93 | } 94 | -------------------------------------------------------------------------------- /eithernet/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=EitherNet 2 | POM_ARTIFACT_ID=eithernet 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/ApiException.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | /** 19 | * Represents a generic API error from a given endpoint. 20 | * 21 | * @see [ApiResult.Failure.ApiFailure] for full documentation for how this [error] property is used 22 | * and its caveats. 23 | */ 24 | public class ApiException(public val error: Any?) : Exception() 25 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/ApiResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import com.slack.eithernet.ApiResult.Failure 19 | import com.slack.eithernet.ApiResult.Failure.ApiFailure 20 | import com.slack.eithernet.ApiResult.Failure.HttpFailure 21 | import com.slack.eithernet.ApiResult.Failure.NetworkFailure 22 | import com.slack.eithernet.ApiResult.Failure.UnknownFailure 23 | import com.slack.eithernet.ApiResult.Success 24 | import kotlin.reflect.KClass 25 | import okio.IOException 26 | 27 | /** 28 | * Represents a result from a traditional HTTP API. [ApiResult] has two sealed subtypes: [Success] 29 | * and [Failure]. [Success] is typed to [T] with no error type and [Failure] is typed to [E] with no 30 | * success type. 31 | * 32 | * [Failure] in turn is represented by four sealed subtypes of its own: [Failure.NetworkFailure], 33 | * [Failure.ApiFailure], [Failure.HttpFailure], and [Failure.UnknownFailure]. This allows for simple 34 | * handling of results through a consistent, non-exceptional flow via sealed `when` branches. 35 | * 36 | * ``` 37 | * when (val result = myApi.someEndpoint()) { 38 | * is Success -> doSomethingWith(result.response) 39 | * is Failure -> when (result) { 40 | * is NetworkFailure -> showError(result.error) 41 | * is HttpFailure -> showError(result.code) 42 | * is ApiFailure -> showError(result.error) 43 | * is UnknownError -> showError(result.error) 44 | * } 45 | * } 46 | * ``` 47 | * 48 | * Usually, user code for this could just simply show a generic error message for a [Failure] case, 49 | * but a sealed class is exposed for more specific error messaging. 50 | */ 51 | public sealed interface ApiResult { 52 | 53 | /** A successful result with the data available in [value]. */ 54 | public class Success 55 | @InternalEitherNetApi 56 | public constructor(public val value: T, tags: Map, Any>) : ApiResult { 57 | 58 | /** Extra metadata associated with the result such as original requests, responses, etc. */ 59 | @InternalEitherNetApi public val tags: Map, Any> = tags.toUnmodifiableMap() 60 | 61 | /** Returns a new copy of this with the given [tags]. */ 62 | public fun withTags(tags: Map, Any>): Success { 63 | return Success(value, tags) 64 | } 65 | } 66 | 67 | /** Represents a failure of some sort. */ 68 | public sealed interface Failure : ApiResult { 69 | 70 | /** 71 | * A network failure caused by a given [error]. This error is opaque, as the actual type could 72 | * be from a number of sources (connectivity, etc). This event is generally considered to be a 73 | * non-recoverable and should be used as signal or logging before attempting to gracefully 74 | * degrade or retry. 75 | */ 76 | public class NetworkFailure 77 | @InternalEitherNetApi 78 | public constructor(public val error: IOException, tags: Map, Any>) : 79 | Failure { 80 | 81 | /** Extra metadata associated with the result such as original requests, responses, etc. */ 82 | internal val tags: Map, Any> = tags.toUnmodifiableMap() 83 | 84 | /** Returns a new copy of this with the given [tags]. */ 85 | public fun withTags(tags: Map, Any>): NetworkFailure { 86 | return NetworkFailure(error, tags.toUnmodifiableMap()) 87 | } 88 | } 89 | 90 | /** 91 | * An unknown failure caused by a given [error]. This error is opaque, as the actual type could 92 | * be from a number of sources (serialization issues, etc). This event is generally considered 93 | * to be a non-recoverable and should be used as signal or logging before attempting to 94 | * gracefully degrade or retry. 95 | */ 96 | public class UnknownFailure 97 | @InternalEitherNetApi 98 | public constructor(public val error: Throwable, tags: Map, Any>) : Failure { 99 | 100 | /** Extra metadata associated with the result such as original requests, responses, etc. */ 101 | internal val tags: Map, Any> = tags.toUnmodifiableMap() 102 | 103 | /** Returns a new copy of this with the given [tags]. */ 104 | public fun withTags(tags: Map, Any>): UnknownFailure { 105 | return UnknownFailure(error, tags.toUnmodifiableMap()) 106 | } 107 | } 108 | 109 | /** 110 | * An HTTP failure. This indicates a 4xx or 5xx response. The [code] is available for reference. 111 | * 112 | * @property code The HTTP status code. 113 | * @property error An optional [error][E]. This would be from the error body of the response. 114 | */ 115 | public class HttpFailure 116 | @InternalEitherNetApi 117 | public constructor(public val code: Int, public val error: E?, tags: Map, Any>) : 118 | Failure { 119 | 120 | /** Extra metadata associated with the result such as original requests, responses, etc. */ 121 | internal val tags: Map, Any> = tags.toUnmodifiableMap() 122 | 123 | /** Returns a new copy of this with the given [tags]. */ 124 | public fun withTags(tags: Map, Any>): HttpFailure { 125 | return HttpFailure(code, error, tags.toUnmodifiableMap()) 126 | } 127 | } 128 | 129 | /** 130 | * An API failure. This indicates a 2xx response where [ApiException] was thrown during response 131 | * body conversion. 132 | * 133 | * An [ApiException], the [error] property will be best-effort populated with the value of the 134 | * [ApiException.error] property. 135 | * 136 | * @property error An optional [error][E]. 137 | */ 138 | public class ApiFailure 139 | @InternalEitherNetApi 140 | public constructor(public val error: E?, tags: Map, Any>) : Failure { 141 | 142 | /** Extra metadata associated with the result such as original requests, responses, etc. */ 143 | internal val tags: Map, Any> = tags.toUnmodifiableMap() 144 | 145 | /** Returns a new copy of this with the given [tags]. */ 146 | public fun withTags(tags: Map, Any>): ApiFailure { 147 | return ApiFailure(error, tags.toUnmodifiableMap()) 148 | } 149 | } 150 | } 151 | 152 | public companion object { 153 | private const val OK = 200 154 | private val HTTP_SUCCESS_RANGE = OK..299 155 | private val HTTP_FAILURE_RANGE = 400..599 156 | 157 | /** Returns a new [Success] with given [value]. */ 158 | public fun success(value: T): Success = Success(value, emptyMap()) 159 | 160 | /** Returns a new [HttpFailure] with given [code] and optional [error]. */ 161 | public fun httpFailure(code: Int): HttpFailure { 162 | return httpFailure(code, null) 163 | } 164 | 165 | /** Returns a new [HttpFailure] with given [code] and optional [error]. */ 166 | public fun httpFailure(code: Int, error: E? = null): HttpFailure { 167 | checkHttpFailureCode(code) 168 | return HttpFailure(code, error, emptyMap()) 169 | } 170 | 171 | /** Returns a new [ApiFailure] with given [error]. */ 172 | public fun apiFailure(): ApiFailure = apiFailure(null) 173 | 174 | /** Returns a new [ApiFailure] with given [error]. */ 175 | public fun apiFailure(error: E? = null): ApiFailure = ApiFailure(error, emptyMap()) 176 | 177 | /** Returns a new [NetworkFailure] with given [error]. */ 178 | public fun networkFailure(error: IOException): NetworkFailure = 179 | NetworkFailure(error, emptyMap()) 180 | 181 | /** Returns a new [UnknownFailure] with given [error]. */ 182 | public fun unknownFailure(error: Throwable): UnknownFailure = UnknownFailure(error, emptyMap()) 183 | 184 | internal fun checkHttpFailureCode(code: Int) { 185 | require(code !in HTTP_SUCCESS_RANGE) { 186 | "Status code '$code' is a successful HTTP response. If you mean to use a $OK code + error " + 187 | "string to indicate an API error, use the ApiResult.apiFailure() factory." 188 | } 189 | require(code in HTTP_FAILURE_RANGE) { 190 | "Status code '$code' is not a HTTP failure response. Must be a 4xx or 5xx code." 191 | } 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/DecodeErrorBody.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.annotation.AnnotationRetention.RUNTIME 19 | import kotlin.annotation.AnnotationTarget.FUNCTION 20 | 21 | /** 22 | * Indicates that this endpoint should attempt to decode 4xx or 5xx response error bodies if 23 | * present. 24 | * 25 | * This API should be considered read-only. 26 | */ 27 | @Target(FUNCTION) @Retention(RUNTIME) public annotation class DecodeErrorBody 28 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/ExperimentalEitherNetApi.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.RequiresOptIn.Level.ERROR 19 | import kotlin.annotation.AnnotationRetention.BINARY 20 | import kotlin.annotation.AnnotationTarget.CLASS 21 | import kotlin.annotation.AnnotationTarget.FUNCTION 22 | import kotlin.annotation.AnnotationTarget.PROPERTY 23 | import kotlin.annotation.AnnotationTarget.TYPEALIAS 24 | 25 | /** Indicates that a given API is currently experimental and subject to change. */ 26 | @Retention(BINARY) 27 | @Target(CLASS, FUNCTION, TYPEALIAS, PROPERTY) 28 | @RequiresOptIn( 29 | level = ERROR, 30 | message = "Indicates that a given API is currently experimental and subject to change.", 31 | ) 32 | public annotation class ExperimentalEitherNetApi 33 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/Extensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | @file:OptIn(ExperimentalContracts::class) 17 | @file:Suppress("TooManyFunctions") 18 | 19 | package com.slack.eithernet 20 | 21 | import kotlin.contracts.ExperimentalContracts 22 | import kotlin.contracts.InvocationKind 23 | import kotlin.contracts.contract 24 | 25 | /** If [ApiResult.Success], returns the underlying [T] value. Otherwise, returns null. */ 26 | public fun ApiResult.successOrNull(): T? = 27 | when (this) { 28 | is ApiResult.Success -> value 29 | else -> null 30 | } 31 | 32 | /** 33 | * If [ApiResult.Success], returns the underlying [T] value. Otherwise, returns the result of the 34 | * [defaultValue] function. 35 | */ 36 | @OptIn(ExperimentalContracts::class) 37 | public inline fun ApiResult.successOrElse( 38 | defaultValue: (failure: ApiResult.Failure) -> T 39 | ): T { 40 | contract { callsInPlace(defaultValue, InvocationKind.AT_MOST_ONCE) } 41 | return when (this) { 42 | is ApiResult.Success -> value 43 | is ApiResult.Failure -> defaultValue(this) 44 | } 45 | } 46 | 47 | /** 48 | * If [ApiResult.Success], returns the underlying [T] value. Otherwise, calls [body] with the 49 | * failure, which can either throw an exception or return early (since this function is inline). 50 | */ 51 | @OptIn(ExperimentalContracts::class) 52 | public inline fun ApiResult.successOrNothing( 53 | body: (failure: ApiResult.Failure) -> Nothing 54 | ): T { 55 | contract { callsInPlace(body, InvocationKind.AT_MOST_ONCE) } 56 | return when (this) { 57 | is ApiResult.Success -> value 58 | is ApiResult.Failure -> body(this) 59 | } 60 | } 61 | 62 | /** 63 | * Returns the encapsulated [Throwable] exception if this failure type if one is available or null 64 | * if none are available. 65 | * 66 | * Note that if this is [ApiResult.Failure.HttpFailure] or [ApiResult.Failure.ApiFailure], the 67 | * `error` property will be returned IFF it's a [Throwable]. 68 | */ 69 | public fun ApiResult.Failure.exceptionOrNull(): Throwable? { 70 | return when (this) { 71 | is ApiResult.Failure.NetworkFailure -> error 72 | is ApiResult.Failure.UnknownFailure -> error 73 | is ApiResult.Failure.HttpFailure -> error as? Throwable? 74 | is ApiResult.Failure.ApiFailure -> error as? Throwable? 75 | } 76 | } 77 | 78 | /** Transforms an [ApiResult] into a [C] value. */ 79 | @OptIn(ExperimentalContracts::class) 80 | @Suppress( 81 | // Inline to allow contextual actions 82 | "NOTHING_TO_INLINE", 83 | // https://youtrack.jetbrains.com/issue/KT-71690 84 | "WRONG_INVOCATION_KIND", 85 | ) 86 | public inline fun ApiResult.fold( 87 | noinline onSuccess: (value: T) -> C, 88 | noinline onFailure: (failure: ApiResult.Failure) -> C, 89 | ): C { 90 | contract { 91 | callsInPlace(onSuccess, InvocationKind.AT_MOST_ONCE) 92 | callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE) 93 | } 94 | @Suppress("UNCHECKED_CAST") 95 | return fold( 96 | onSuccess, 97 | onFailure as (ApiResult.Failure.NetworkFailure) -> C, 98 | onFailure as (ApiResult.Failure.UnknownFailure) -> C, 99 | onFailure, 100 | onFailure, 101 | ) 102 | } 103 | 104 | /** Transforms an [ApiResult] into a [C] value. */ 105 | @OptIn(ExperimentalContracts::class) 106 | public inline fun ApiResult.fold( 107 | onSuccess: (value: T) -> C, 108 | onNetworkFailure: (failure: ApiResult.Failure.NetworkFailure) -> C, 109 | onUnknownFailure: (failure: ApiResult.Failure.UnknownFailure) -> C, 110 | onHttpFailure: (failure: ApiResult.Failure.HttpFailure) -> C, 111 | onApiFailure: (failure: ApiResult.Failure.ApiFailure) -> C, 112 | ): C { 113 | contract { 114 | callsInPlace(onSuccess, InvocationKind.AT_MOST_ONCE) 115 | callsInPlace(onNetworkFailure, InvocationKind.AT_MOST_ONCE) 116 | callsInPlace(onUnknownFailure, InvocationKind.AT_MOST_ONCE) 117 | callsInPlace(onHttpFailure, InvocationKind.AT_MOST_ONCE) 118 | callsInPlace(onApiFailure, InvocationKind.AT_MOST_ONCE) 119 | } 120 | return when (this) { 121 | is ApiResult.Success -> onSuccess(value) 122 | is ApiResult.Failure.ApiFailure -> onApiFailure(this) 123 | is ApiResult.Failure.HttpFailure -> onHttpFailure(this) 124 | is ApiResult.Failure.NetworkFailure -> onNetworkFailure(this) 125 | is ApiResult.Failure.UnknownFailure -> onUnknownFailure(this) 126 | } 127 | } 128 | 129 | /** 130 | * Performs the given [action] on the encapsulated [ApiResult.Failure] if this instance represents 131 | * [failure][ApiResult.Failure]. Returns the original `ApiResult` unchanged. 132 | */ 133 | @OptIn(ExperimentalContracts::class) 134 | public inline fun ApiResult.onFailure( 135 | action: (failure: ApiResult.Failure) -> Unit 136 | ): ApiResult { 137 | contract { callsInPlace(action, InvocationKind.AT_MOST_ONCE) } 138 | if (this is ApiResult.Failure) action(this) 139 | return this 140 | } 141 | 142 | /** 143 | * Performs the given [action] on the encapsulated [ApiResult.Failure.HttpFailure] if this instance 144 | * represents [failure][ApiResult.Failure.HttpFailure]. Returns the original `ApiResult` unchanged. 145 | */ 146 | @OptIn(ExperimentalContracts::class) 147 | public inline fun ApiResult.onHttpFailure( 148 | action: (failure: ApiResult.Failure.HttpFailure) -> Unit 149 | ): ApiResult { 150 | contract { callsInPlace(action, InvocationKind.AT_MOST_ONCE) } 151 | if (this is ApiResult.Failure.HttpFailure) action(this) 152 | return this 153 | } 154 | 155 | /** 156 | * Performs the given [action] on the encapsulated [ApiResult.Failure.ApiFailure] if this instance 157 | * represents [failure][ApiResult.Failure.ApiFailure]. Returns the original `ApiResult` unchanged. 158 | */ 159 | @OptIn(ExperimentalContracts::class) 160 | public inline fun ApiResult.onApiFailure( 161 | action: (failure: ApiResult.Failure.ApiFailure) -> Unit 162 | ): ApiResult { 163 | contract { callsInPlace(action, InvocationKind.AT_MOST_ONCE) } 164 | if (this is ApiResult.Failure.ApiFailure) action(this) 165 | return this 166 | } 167 | 168 | /** 169 | * Performs the given [action] on the encapsulated [ApiResult.Failure.NetworkFailure] if this 170 | * instance represents [failure][ApiResult.Failure.NetworkFailure]. Returns the original `ApiResult` 171 | * unchanged. 172 | */ 173 | @OptIn(ExperimentalContracts::class) 174 | public inline fun ApiResult.onNetworkFailure( 175 | action: (failure: ApiResult.Failure.NetworkFailure) -> Unit 176 | ): ApiResult { 177 | contract { callsInPlace(action, InvocationKind.AT_MOST_ONCE) } 178 | if (this is ApiResult.Failure.NetworkFailure) action(this) 179 | return this 180 | } 181 | 182 | /** 183 | * Performs the given [action] on the encapsulated [ApiResult.Failure.UnknownFailure] if this 184 | * instance represents [failure][ApiResult.Failure.UnknownFailure]. Returns the original `ApiResult` 185 | * unchanged. 186 | */ 187 | @OptIn(ExperimentalContracts::class) 188 | public inline fun ApiResult.onUnknownFailure( 189 | action: (failure: ApiResult.Failure.UnknownFailure) -> Unit 190 | ): ApiResult { 191 | contract { callsInPlace(action, InvocationKind.AT_MOST_ONCE) } 192 | if (this is ApiResult.Failure.UnknownFailure) action(this) 193 | return this 194 | } 195 | 196 | /** 197 | * Performs the given [action] on the encapsulated value if this instance represents 198 | * [success][ApiResult.Success]. Returns the original `ApiResult` unchanged. 199 | */ 200 | @OptIn(ExperimentalContracts::class) 201 | public inline fun ApiResult.onSuccess( 202 | action: (value: T) -> Unit 203 | ): ApiResult { 204 | contract { callsInPlace(action, InvocationKind.AT_MOST_ONCE) } 205 | if (this is ApiResult.Success) action(value) 206 | return this 207 | } 208 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/InternalEitherNetApi.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.RequiresOptIn.Level.ERROR 19 | import kotlin.annotation.AnnotationRetention.BINARY 20 | import kotlin.annotation.AnnotationTarget.CLASS 21 | import kotlin.annotation.AnnotationTarget.CONSTRUCTOR 22 | import kotlin.annotation.AnnotationTarget.FUNCTION 23 | import kotlin.annotation.AnnotationTarget.PROPERTY 24 | import kotlin.annotation.AnnotationTarget.TYPEALIAS 25 | 26 | /** 27 | * Marks declarations that are **internal** in EitherNet API, which means that they should not be 28 | * used outside of `com.slack.eithernet`, because their signatures and semantics will change between 29 | * future releases without any warnings and without providing any migration aids. 30 | */ 31 | @Retention(BINARY) 32 | @Target(CLASS, FUNCTION, TYPEALIAS, PROPERTY, CONSTRUCTOR) 33 | @RequiresOptIn( 34 | level = ERROR, 35 | message = 36 | "This is an internal EitherNet API that " + 37 | "should not be used from outside of EitherNet. No compatibility guarantees are provided.", 38 | ) 39 | public annotation class InternalEitherNetApi 40 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/KTypes.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.contracts.ExperimentalContracts 19 | import kotlin.contracts.contract 20 | import kotlin.reflect.KClass 21 | import kotlin.reflect.KClassifier 22 | import kotlin.reflect.KType 23 | import kotlin.reflect.KTypeParameter 24 | import kotlin.reflect.KTypeProjection 25 | import kotlin.reflect.KVariance 26 | 27 | private fun KTypeParameter.simpleToString(): String { 28 | return buildList { 29 | if (isReified) add("reified") 30 | when (variance) { 31 | KVariance.IN -> add("in") 32 | KVariance.OUT -> add("out") 33 | KVariance.INVARIANT -> {} 34 | } 35 | if (name.isNotEmpty()) add(name) 36 | if (upperBounds.isNotEmpty()) { 37 | add(":") 38 | addAll(upperBounds.map { it.toString() }) 39 | } 40 | } 41 | .joinToString(" ") 42 | } 43 | 44 | private fun KClassifier.simpleToString(): String { 45 | return when (this) { 46 | is KClass<*> -> qualifiedNameForComparison ?: "" 47 | is KTypeParameter -> simpleToString() 48 | else -> error("Unknown type classifier: $this") 49 | } 50 | } 51 | 52 | // Not every platform has KType.annotations, so we have to expect/actual this. 53 | // We use a "real" EitherNetKType impl below to actually share all the logic and implement 54 | // via class delegation on platforms. 55 | internal expect class KTypeImpl( 56 | classifier: KClassifier?, 57 | arguments: List, 58 | isMarkedNullable: Boolean, 59 | annotations: List, 60 | ) : KType, EitherNetKType { 61 | override val classifier: KClassifier? 62 | override val arguments: List 63 | override val isMarkedNullable: Boolean 64 | override val annotations: List 65 | } 66 | 67 | @InternalEitherNetApi 68 | public fun KType.canonicalize(): KType { 69 | return when (this) { 70 | is KTypeImpl -> this 71 | else -> 72 | KTypeImpl( 73 | classifier = classifier, 74 | arguments = arguments.map { it.copy(type = it.type?.canonicalize()) }, 75 | isMarkedNullable = isMarkedNullable, 76 | annotations = emptyList(), 77 | ) 78 | } 79 | } 80 | 81 | internal interface EitherNetKType { 82 | val classifier: KClassifier? 83 | val arguments: List 84 | val isMarkedNullable: Boolean 85 | val annotations: List 86 | } 87 | 88 | internal class EitherNetKTypeImpl( 89 | override val classifier: KClassifier?, 90 | override val arguments: List, 91 | override val isMarkedNullable: Boolean, 92 | override val annotations: List, 93 | ) : EitherNetKType { 94 | 95 | override fun toString(): String { 96 | return buildString { 97 | if (annotations.isNotEmpty()) { 98 | annotations.joinTo(this, " ") { "@$it" } 99 | append(' ') 100 | } 101 | append(classifier?.simpleToString() ?: "") 102 | if (arguments.isNotEmpty()) { 103 | append("<") 104 | arguments.joinTo(this, ", ") { it.toString() } 105 | append(">") 106 | } 107 | if (isMarkedNullable) { 108 | append("?") 109 | } 110 | } 111 | } 112 | 113 | override fun equals(other: Any?): Boolean { 114 | if (this === other) return true 115 | // On the JVM we'd ideally do this too 116 | // if (javaClass != other?.javaClass) return false 117 | if (other !is KType) return false 118 | 119 | if (classifier != other.classifier) return false 120 | if (arguments != other.arguments) return false 121 | if (isMarkedNullable != other.isMarkedNullable) return false 122 | if (other is KTypeImpl) { 123 | if (annotations != other.annotations) return false 124 | } 125 | 126 | return true 127 | } 128 | 129 | override fun hashCode(): Int { 130 | var result = classifier?.hashCode() ?: 0 131 | result = 31 * result + arguments.hashCode() 132 | result = 31 * result + isMarkedNullable.hashCode() 133 | result = 31 * result + annotations.hashCode() 134 | return result 135 | } 136 | } 137 | 138 | internal class KTypeParameterImpl( 139 | override val isReified: Boolean, 140 | override val name: String, 141 | override val upperBounds: List, 142 | override val variance: KVariance, 143 | ) : KTypeParameter { 144 | override fun equals(other: Any?): Boolean { 145 | if (this === other) return true 146 | 147 | other as KTypeParameterImpl 148 | 149 | if (isReified != other.isReified) return false 150 | if (name != other.name) return false 151 | if (upperBounds != other.upperBounds) return false 152 | if (variance != other.variance) return false 153 | 154 | return true 155 | } 156 | 157 | override fun hashCode(): Int { 158 | var result = isReified.hashCode() 159 | result = 31 * result + name.hashCode() 160 | result = 31 * result + upperBounds.hashCode() 161 | result = 31 * result + variance.hashCode() 162 | return result 163 | } 164 | 165 | override fun toString(): String { 166 | return simpleToString() 167 | } 168 | } 169 | 170 | // Sneaky backdoor way of marking a value as non-null to the compiler and skip the null-check 171 | // intrinsic. 172 | // Safe to use (unstable) contracts since they're gone in the final bytecode 173 | @OptIn(ExperimentalContracts::class) 174 | @Suppress("NOTHING_TO_INLINE") 175 | internal inline fun markNotNull(value: T?) { 176 | contract { returns() implies (value != null) } 177 | } 178 | 179 | /** Returns true if [this] and [other] are equal. */ 180 | @InternalEitherNetApi 181 | public fun KType?.isFunctionallyEqualTo(other: KType?): Boolean { 182 | if (this === other) { 183 | return true // Also handles (a == null && b == null). 184 | } 185 | 186 | markNotNull(this) 187 | markNotNull(other) 188 | 189 | if (isMarkedNullable != other.isMarkedNullable) return false 190 | if (!arguments.contentEquals(other.arguments) { a, b -> a.type.isFunctionallyEqualTo(b.type) }) 191 | return false 192 | 193 | // This isn't a supported type. 194 | when (val classifier = classifier) { 195 | is KClass<*> -> { 196 | if (classifier.qualifiedNameForComparison == "kotlin.Array") { 197 | // We can't programmatically create array types that implement equals fully, as the runtime 198 | // versions look at the underlying jClass that we can't get here. So we just do a simple 199 | // check for arrays. 200 | return (other.classifier as? KClass<*>?)?.qualifiedNameForComparison == "kotlin.Array" 201 | } 202 | return classifier == other.classifier 203 | } 204 | is KTypeParameter -> { 205 | val otherClassifier = other.classifier 206 | if (otherClassifier !is KTypeParameter) return false 207 | // TODO Use a plain KTypeParameter.equals again once 208 | // https://youtrack.jetbrains.com/issue/KT-39661 is fixed 209 | return (classifier.upperBounds.contentEquals( 210 | otherClassifier.upperBounds, 211 | KType::isFunctionallyEqualTo, 212 | ) && (classifier.name == otherClassifier.name)) 213 | } 214 | else -> return false // This isn't a supported type. 215 | } 216 | } 217 | 218 | private fun List.contentEquals( 219 | other: List, 220 | comparator: (a: T, b: T) -> Boolean, 221 | ): Boolean { 222 | if (size != other.size) return false 223 | for (i in indices) { 224 | val arg = get(i) 225 | val otherArg = other[i] 226 | // TODO do we care about variance? 227 | if (!comparator(arg, otherArg)) return false 228 | } 229 | return true 230 | } 231 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/ResultType.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.annotation.AnnotationRetention.RUNTIME 19 | import kotlin.reflect.KClass 20 | 21 | /** 22 | * Represents a [java.lang.reflect.Type] via its components. Retrieve it from Retrofit annotations 23 | * via [errorType] and piece this back into a real instance via `ResultType.toType()`. 24 | * 25 | * This API should be considered read-only. 26 | */ 27 | @Retention(RUNTIME) 28 | public annotation class ResultType( 29 | val rawType: KClass<*>, 30 | val typeArgs: Array = [], 31 | val ownerType: KClass<*> = Nothing::class, 32 | // If it's an array, the rawType is used as the component type 33 | val isArray: Boolean, 34 | ) 35 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/StatusCode.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.annotation.AnnotationRetention.RUNTIME 19 | 20 | /** 21 | * Represents a status code in a 4xx or 5xx response. Retrieve it from Retrofit annotations via 22 | * [statusCode]. 23 | * 24 | * This API should be considered read-only. 25 | */ 26 | @Retention(RUNTIME) public annotation class StatusCode(public val value: Int) 27 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/annotations.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KType 20 | import kotlin.reflect.KTypeProjection 21 | 22 | /** 23 | * Returns a [Pair] of a [StatusCode] and subset of these annotations without that [StatusCode] 24 | * instance. This should be used in a custom Retrofit [retrofit2.Converter.Factory] to know what the 25 | * given status code of a non-2xx response was when decoding the error body. This can be useful for 26 | * contextually decoding error bodies based on the status code. 27 | * 28 | * ``` 29 | * override fun responseBodyConverter( 30 | * type: Type, 31 | * annotations: Array, 32 | * retrofit: Retrofit 33 | * ): Converter? { 34 | * val (statusCode, nextAnnotations) = annotations.statusCode() 35 | * ?: return 36 | * val errorType = when (statusCode) { 37 | * 401 -> Unuuthorized::class.java 38 | * 404 -> NotFound::class.java 39 | * // ... 40 | * } 41 | * val errorDelegate = retrofit.nextResponseBodyConverter(this, errorType.toType(), nextAnnotations) 42 | * return MyCustomBodyConverter(errorDelegate) 43 | * } 44 | * ``` 45 | */ 46 | public fun Array.statusCode(): Pair>? { 47 | return nextAnnotations(StatusCode::class) 48 | } 49 | 50 | /** 51 | * Returns a [Pair] of a [ResultType] and subset of these annotations without that [ResultType] 52 | * instance. This should be used in a custom Retrofit [retrofit2.Converter.Factory] to determine the 53 | * error type of an [ApiResult] for the given endpoint. 54 | * 55 | * ``` 56 | * override fun responseBodyConverter( 57 | * type: Type, 58 | * annotations: Array, 59 | * retrofit: Retrofit 60 | * ): Converter? { 61 | * val (errorType, nextAnnotations) = annotations.errorType() 62 | * ?: error("No error type found!") 63 | * val errorDelegate = retrofit.nextResponseBodyConverter(this, errorType.toType(), nextAnnotations) 64 | * return MyCustomBodyConverter(errorDelegate) 65 | * } 66 | * ``` 67 | */ 68 | public fun Array.errorType(): Pair>? { 69 | return nextAnnotations(ResultType::class) 70 | } 71 | 72 | private fun Array.nextAnnotations( 73 | type: KClass 74 | ): Pair>? { 75 | var nextIndex = 0 76 | val theseAnnotations = this 77 | var resultType: A? = null 78 | val nextAnnotations = arrayOfNulls(size - 1) 79 | for (i in indices) { 80 | val next = theseAnnotations[i] 81 | if (type.isInstance(next)) { 82 | @Suppress("UNCHECKED_CAST") 83 | resultType = next as A 84 | } else if (nextIndex < nextAnnotations.size) { 85 | nextAnnotations[nextIndex] = next 86 | nextIndex++ 87 | } 88 | } 89 | 90 | return if (resultType != null) { 91 | @Suppress("UNCHECKED_CAST") 92 | resultType to (nextAnnotations as Array) 93 | } else { 94 | null 95 | } 96 | } 97 | 98 | /** Returns a new [KType] representation of this [ResultType]. */ 99 | @Suppress("SpreadOperator") // This is _the worst_ detekt check 100 | public fun ResultType.toKType(): KType { 101 | val initialValue = 102 | KTypeImpl( 103 | classifier = rawType, 104 | arguments = typeArgs.map { KTypeProjection.invariant(it.toKType()) }, 105 | isMarkedNullable = false, 106 | annotations = emptyList(), 107 | ) 108 | if (isArray) { 109 | return KTypeImpl( 110 | classifier = Array::class, 111 | arguments = listOf(KTypeProjection.invariant(initialValue)), 112 | isMarkedNullable = false, 113 | annotations = emptyList(), 114 | ) 115 | } 116 | return initialValue 117 | } 118 | 119 | @InternalEitherNetApi 120 | public fun createStatusCode(code: Int): StatusCode { 121 | ApiResult.checkHttpFailureCode(code) 122 | return StatusCode(code) 123 | } 124 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/platform.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.reflect.KClass 19 | 20 | internal expect val KClass<*>.qualifiedNameForComparison: String? 21 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/retries.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.math.nextUp 19 | import kotlin.random.Random 20 | import kotlin.time.Duration 21 | import kotlin.time.Duration.Companion.milliseconds 22 | import kotlin.time.Duration.Companion.seconds 23 | import kotlin.time.times 24 | import kotlinx.coroutines.delay 25 | 26 | /** 27 | * Retries a [block] of code with exponential backoff. 28 | * 29 | * This function will attempt the operation you give it up to [maxAttempts] times, multiplying the 30 | * delay between each attempt by [delayFactor], starting from [initialDelay] and not exceeding 31 | * [maxDelay]. If the operation continues to fail after [maxAttempts] times, it will return the last 32 | * failure result. If the operation succeeds at any point, it will immediately return the success 33 | * result. 34 | * 35 | * Note: This uses a default exponential backoff strategy with optional jitter. Depending on your 36 | * use case, you might want to customize the strategy, for example by handling certain kinds of 37 | * failures differently. 38 | * 39 | * @param maxAttempts The maximum number of times to retry the operation. 40 | * @param initialDelay The delay before the first retry. 41 | * @param delayFactor The factor by which the delay should increase after each failed attempt. 42 | * @param maxDelay The maximum delay between retries. 43 | * @param jitterFactor The maximum factor of jitter to introduce. For example, a value of 0.1 will 44 | * introduce up to 10% jitter (both positive and negative). 45 | * @param onFailure An optional callback for failures, useful for logging. 46 | * @param shouldRetry An optional callback for indicating whether to retry a failure. This can be 47 | * used to short-circuit attempts in the event of some non-retry-able condition. 48 | * @return The result of the operation if it's successful, or the last failure result if all 49 | * attempts fail. 50 | */ 51 | @Suppress("LongParameterList") 52 | public tailrec suspend fun retryWithExponentialBackoff( 53 | maxAttempts: Int = 3, 54 | initialDelay: Duration = 500.milliseconds, 55 | delayFactor: Double = 2.0, 56 | maxDelay: Duration = 10.seconds, 57 | jitterFactor: Double = 0.25, 58 | onFailure: ((failure: ApiResult.Failure) -> Unit)? = null, 59 | shouldRetry: suspend ((failure: ApiResult.Failure) -> Boolean) = { true }, 60 | block: suspend () -> ApiResult, 61 | ): ApiResult { 62 | require(maxAttempts > 0) { "maxAttempts must be greater than 0" } 63 | 64 | return when (val result = block()) { 65 | is ApiResult.Success -> result 66 | is ApiResult.Failure -> { 67 | val attemptsRemaining = maxAttempts - 1 68 | onFailure?.invoke(result) 69 | if (attemptsRemaining == 0 || !shouldRetry(result)) { 70 | result 71 | } else { 72 | val jitter = 1 + Random.nextDouble(-jitterFactor, jitterFactor.nextUp()) 73 | val nextDelay = (initialDelay + initialDelay * jitter).coerceAtMost(maxDelay) 74 | delay(nextDelay) 75 | retryWithExponentialBackoff( 76 | maxAttempts = attemptsRemaining, 77 | initialDelay = delayFactor * initialDelay, 78 | delayFactor = delayFactor, 79 | maxDelay = maxDelay, 80 | jitterFactor = jitterFactor, 81 | onFailure = onFailure, 82 | shouldRetry = shouldRetry, 83 | block = block, 84 | ) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/tags.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | @file:Suppress("UNCHECKED_CAST") 17 | 18 | package com.slack.eithernet 19 | 20 | import com.slack.eithernet.ApiResult.Failure.ApiFailure 21 | import com.slack.eithernet.ApiResult.Failure.HttpFailure 22 | import com.slack.eithernet.ApiResult.Failure.NetworkFailure 23 | import com.slack.eithernet.ApiResult.Failure.UnknownFailure 24 | import com.slack.eithernet.ApiResult.Success 25 | import kotlin.reflect.KClass 26 | 27 | /* 28 | * Common tags added automatically to different ApiResult types. 29 | */ 30 | 31 | /** Returns the tag attached with [T] as a key, or null if no tag is attached with that key. */ 32 | public inline fun ApiResult<*, *>.tag(): T? = tag(T::class) 33 | 34 | /** Returns the tag attached with [klass] as a key, or null if no tag is attached with that key. */ 35 | public fun ApiResult<*, *>.tag(klass: KClass): T? { 36 | val tags = 37 | when (this) { 38 | is ApiFailure -> tags 39 | is HttpFailure -> tags 40 | is NetworkFailure -> tags 41 | is UnknownFailure -> tags 42 | is Success -> tags 43 | } 44 | return tags[klass] as? T 45 | } 46 | -------------------------------------------------------------------------------- /eithernet/src/commonMain/kotlin/com/slack/eithernet/util.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | internal fun Map.toUnmodifiableMap() = buildMap { putAll(this@toUnmodifiableMap) } 19 | -------------------------------------------------------------------------------- /eithernet/src/commonTest/kotlin/com/slack/eithernet/ExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.test.Test 19 | import kotlin.test.assertEquals 20 | import kotlin.test.assertNull 21 | import kotlin.test.assertSame 22 | import kotlin.test.fail 23 | import okio.IOException 24 | 25 | @Suppress("ThrowsCount") 26 | class ExtensionsTest { 27 | 28 | @Test 29 | fun successOrNullWithSuccess() { 30 | val result = ApiResult.success("Hello") 31 | assertEquals("Hello", result.successOrNull()) 32 | } 33 | 34 | @Test 35 | fun successOrNullWithFailure() { 36 | val result: ApiResult<*, *> = ApiResult.unknownFailure(Throwable()) 37 | assertNull(result.successOrNull()) 38 | } 39 | 40 | @Test 41 | fun successOrElseWithSuccess() { 42 | val result = ApiResult.success("Hello") 43 | assertEquals("Hello", result.successOrElse { error("") }) 44 | } 45 | 46 | @Test 47 | fun successOrElseWithFailure() { 48 | val result = ApiResult.unknownFailure(Throwable()) 49 | assertEquals("Hello", result.successOrElse { "Hello" }) 50 | } 51 | 52 | @Test 53 | fun successOrElseWithCustomFailure() { 54 | val result = ApiResult.unknownFailure(Throwable()) 55 | assertEquals( 56 | "Hello", 57 | result.successOrElse { failure -> 58 | when (failure) { 59 | is ApiResult.Failure.UnknownFailure -> "Hello" 60 | else -> throw AssertionError() 61 | } 62 | }, 63 | ) 64 | } 65 | 66 | @Test 67 | fun foldSuccess() { 68 | val result = ApiResult.success("Hello") 69 | val folded = result.fold({ it }, { "Failure" }) 70 | assertEquals("Hello", folded) 71 | } 72 | 73 | @Test 74 | fun foldFailure() { 75 | val result = ApiResult.apiFailure("Hello") 76 | val folded = result.fold({ throw AssertionError() }, { "Failure" }) 77 | assertEquals("Failure", folded) 78 | } 79 | 80 | @Test 81 | fun foldApiFailure() { 82 | val result = ApiResult.apiFailure("Hello") 83 | val folded = 84 | result.fold( 85 | onApiFailure = { "Failure" }, 86 | onSuccess = { throw AssertionError() }, 87 | onHttpFailure = { throw AssertionError() }, 88 | onNetworkFailure = { throw AssertionError() }, 89 | onUnknownFailure = { throw AssertionError() }, 90 | ) 91 | assertEquals("Failure", folded) 92 | } 93 | 94 | @Test 95 | fun foldHttpFailure() { 96 | val result = ApiResult.httpFailure(404, "Hello") 97 | val folded = 98 | result.fold( 99 | onSuccess = { throw AssertionError() }, 100 | onHttpFailure = { "Failure" }, 101 | onApiFailure = { throw AssertionError() }, 102 | onNetworkFailure = { throw AssertionError() }, 103 | onUnknownFailure = { throw AssertionError() }, 104 | ) 105 | assertEquals("Failure", folded) 106 | } 107 | 108 | @Test 109 | fun foldUnknownFailure() { 110 | val result = ApiResult.unknownFailure(Throwable()) 111 | val folded = 112 | result.fold( 113 | onSuccess = { throw AssertionError() }, 114 | onUnknownFailure = { "Failure" }, 115 | onApiFailure = { throw AssertionError() }, 116 | onNetworkFailure = { throw AssertionError() }, 117 | onHttpFailure = { throw AssertionError() }, 118 | ) 119 | assertEquals("Failure", folded) 120 | } 121 | 122 | @Test 123 | fun foldNetworkFailure() { 124 | val result = ApiResult.networkFailure(IOException()) 125 | val folded = 126 | result.fold( 127 | onNetworkFailure = { "Failure" }, 128 | onSuccess = { throw AssertionError() }, 129 | onApiFailure = { throw AssertionError() }, 130 | onUnknownFailure = { throw AssertionError() }, 131 | onHttpFailure = { throw AssertionError() }, 132 | ) 133 | assertEquals("Failure", folded) 134 | } 135 | 136 | @Test 137 | fun successOrNothingShouldEscape() { 138 | val result: ApiResult<*, *> = ApiResult.networkFailure(IOException()) 139 | result.successOrNothing { 140 | return 141 | } 142 | fail("Should not reach here") 143 | } 144 | 145 | @Test 146 | fun exceptionOrNull_with_networkFailure() { 147 | val exception = IOException() 148 | val result = ApiResult.networkFailure(exception) 149 | assertSame(exception, result.exceptionOrNull()) 150 | } 151 | 152 | @Test 153 | fun exceptionOrNull_with_unknownFailure() { 154 | val exception = IOException() 155 | val result = ApiResult.unknownFailure(exception) 156 | assertSame(exception, result.exceptionOrNull()) 157 | } 158 | 159 | @Test 160 | fun exceptionOrNull_with_throwableApiFailure() { 161 | val exception = IOException() 162 | val result = ApiResult.apiFailure(exception) 163 | assertSame(exception, result.exceptionOrNull()) 164 | } 165 | 166 | @Test 167 | fun exceptionOrNull_with_throwableHttpFailure() { 168 | val exception = IOException() 169 | val result = ApiResult.httpFailure(404, exception) 170 | assertSame(exception, result.exceptionOrNull()) 171 | } 172 | 173 | @Test 174 | fun exceptionOrNull_with_nonThrowableApiFailure() { 175 | val result = ApiResult.apiFailure("nope") 176 | assertNull(result.exceptionOrNull()) 177 | } 178 | 179 | @Test 180 | fun exceptionOrNull_with_nonThrowableHttpFailure() { 181 | val result = ApiResult.httpFailure(404, "nope") 182 | assertNull(result.exceptionOrNull()) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /eithernet/src/commonTest/kotlin/com/slack/eithernet/ResultTypeTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.annotation.AnnotationRetention.RUNTIME 19 | import kotlin.reflect.KClass 20 | import kotlin.reflect.KType 21 | import kotlin.reflect.typeOf 22 | import kotlin.test.Test 23 | import kotlin.test.assertEquals 24 | import kotlin.test.assertNull 25 | import kotlin.test.assertSame 26 | import kotlin.test.assertTrue 27 | 28 | @Retention(RUNTIME) annotation class SampleAnnotation 29 | 30 | class ResultTypeTest { 31 | 32 | @Test fun classType() = testType() 33 | 34 | @Test fun parameterizedType() = testType>() 35 | 36 | @Test fun enumType() = testType() 37 | 38 | @Test fun array() = testType>() 39 | 40 | @Test 41 | fun errorType_present() { 42 | val annotations = Array(4) { SampleAnnotation() } 43 | val resultTypeAnnotation = typeOf().toResultType() 44 | annotations[0] = resultTypeAnnotation 45 | val (resultType, nextAnnotations) = annotations.errorType() ?: error("No annotation found") 46 | assertEquals(3, nextAnnotations.size) 47 | assertSame(resultTypeAnnotation, resultType) 48 | } 49 | 50 | @Test 51 | fun errorType_absent() { 52 | val annotations = Array(4) { SampleAnnotation() } 53 | assertNull(annotations.errorType()) 54 | } 55 | 56 | @Test 57 | fun statusCode_present() { 58 | val annotations = Array(4) { SampleAnnotation() } 59 | val statusCodeAnnotation = createStatusCode(404) 60 | annotations[0] = statusCodeAnnotation 61 | val (statusCode, nextAnnotations) = annotations.statusCode() ?: error("No annotation found") 62 | assertEquals(3, nextAnnotations.size) 63 | assertSame(statusCodeAnnotation, statusCode) 64 | } 65 | 66 | @Test 67 | fun statusCode_absent() { 68 | val annotations = Array(4) { SampleAnnotation() } 69 | assertNull(annotations.statusCode()) 70 | } 71 | 72 | enum class TestEnum 73 | 74 | private inline fun testType() { 75 | testType(typeOf()) 76 | } 77 | 78 | private fun testType(type: KType) { 79 | val annotation = type.toResultType() 80 | val created = annotation.toKType() 81 | assertTrue(type.isFunctionallyEqualTo(created)) 82 | } 83 | 84 | private fun KType.toResultType(): ResultType { 85 | val klass = classifier as KClass<*> 86 | if (klass.qualifiedNameForComparison?.endsWith("Array") == true) { 87 | return ResultType(rawType = arguments.first().type!!.classifier as KClass<*>, isArray = true) 88 | } 89 | return ResultType( 90 | rawType = klass, 91 | typeArgs = arguments.mapNotNull { it.type?.toResultType() }.toTypedArray(), 92 | isArray = false, 93 | ) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /eithernet/src/commonTest/kotlin/com/slack/eithernet/RetriesTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.test.Test 19 | import kotlin.test.assertEquals 20 | import kotlin.test.assertTrue 21 | import kotlin.time.Duration.Companion.milliseconds 22 | import kotlinx.coroutines.test.currentTime 23 | import kotlinx.coroutines.test.runTest 24 | import okio.IOException 25 | 26 | class RetriesTest { 27 | 28 | @Test 29 | fun retryUntilSuccess() = runTest { 30 | var attempts = 0 31 | val expectedResult = ApiResult.success("result") 32 | val result = retryWithExponentialBackoff { 33 | attempts++ 34 | if (attempts < 3) { 35 | ApiResult.unknownFailure(RuntimeException("error")) 36 | } else { 37 | expectedResult 38 | } 39 | } 40 | assertEquals(expectedResult, result) 41 | assertEquals(3, attempts) 42 | } 43 | 44 | @Test 45 | fun reachMaxAttempts() = runTest { 46 | var attempts = 0 47 | val expectedException = RuntimeException("error") 48 | val result = 49 | retryWithExponentialBackoff(maxAttempts = 3) { 50 | attempts++ 51 | ApiResult.unknownFailure(expectedException) 52 | } 53 | check(result is ApiResult.Failure.UnknownFailure) 54 | assertEquals(expectedException, result.error) 55 | assertEquals(3, attempts) 56 | } 57 | 58 | @Test 59 | fun logFailedAttempts() = runTest { 60 | var attempts = 0 61 | val recordedAttempts = mutableListOf>() 62 | val expectedException = RuntimeException("error") 63 | val result = 64 | retryWithExponentialBackoff( 65 | maxAttempts = 3, 66 | onFailure = { failure -> recordedAttempts.add(failure) }, 67 | ) { 68 | attempts++ 69 | ApiResult.unknownFailure(expectedException) 70 | } 71 | check(result is ApiResult.Failure.UnknownFailure) 72 | assertEquals(expectedException, result.error) 73 | assertEquals(3, attempts) 74 | assertTrue(recordedAttempts.size == 3) 75 | } 76 | 77 | @Test 78 | fun doesNotExceedMaxDelay() = runTest { 79 | var attempts = 0 80 | var lastAttemptTime = currentTime 81 | retryWithExponentialBackoff(initialDelay = 100.milliseconds, maxDelay = 500.milliseconds) { 82 | attempts++ 83 | if (attempts > 1) { 84 | val delay = currentTime - lastAttemptTime 85 | assertTrue(delay >= 100) 86 | assertTrue(delay <= 500) 87 | } 88 | lastAttemptTime = currentTime 89 | ApiResult.unknownFailure(RuntimeException("error")) 90 | } 91 | } 92 | 93 | @Test 94 | fun appliesJitterResultsInDifferentDelays() = runTest { 95 | var attempts = 0 96 | val delays = mutableListOf() 97 | var lastAttemptTime = currentTime 98 | retryWithExponentialBackoff(delayFactor = 1.0, jitterFactor = 0.5) { 99 | attempts++ 100 | if (attempts > 1) { 101 | val delay = currentTime - lastAttemptTime 102 | lastAttemptTime = currentTime 103 | delays.add(delay) 104 | } 105 | ApiResult.unknownFailure(RuntimeException("error")) 106 | } 107 | // Check that delays are not all the same, which would indicate that jitter was not applied 108 | assertEquals(delays.size, delays.distinct().size) 109 | } 110 | 111 | @Test 112 | fun doesNotApplyJitterWhenJitterFactorIs0() = runTest { 113 | var attempts = 0 114 | val delays = mutableListOf() 115 | var lastAttemptTime = currentTime 116 | retryWithExponentialBackoff(delayFactor = 1.0, jitterFactor = 0.0) { 117 | attempts++ 118 | if (attempts > 1) { 119 | val delay = currentTime - lastAttemptTime 120 | lastAttemptTime = currentTime 121 | delays.add(delay) 122 | } 123 | ApiResult.unknownFailure(RuntimeException("error")) 124 | } 125 | // Check that all delays are the same, which would indicate that jitter was not applied 126 | assertEquals(1, delays.distinct().size) 127 | } 128 | 129 | @Test 130 | fun doesNotRetryOnMatchingCondition() = runTest { 131 | var attempts = 0 132 | retryWithExponentialBackoff( 133 | maxAttempts = 5, 134 | shouldRetry = { it !is ApiResult.Failure.UnknownFailure }, 135 | ) { 136 | attempts++ 137 | if (attempts > 2) { 138 | ApiResult.unknownFailure(RuntimeException("error")) 139 | } else { 140 | ApiResult.networkFailure(IOException("error")) 141 | } 142 | } 143 | // Check that we only attempted until an unknown failure happened 144 | assertEquals(3, attempts) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /eithernet/src/jsMain/kotlin/com/slack/eithernet/platform.js.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KClassifier 20 | import kotlin.reflect.KType 21 | import kotlin.reflect.KTypeProjection 22 | 23 | internal actual val KClass<*>.qualifiedNameForComparison: String? 24 | get() { 25 | // Unfortunately qualifiedName isn't implemented in JS 26 | return simpleName 27 | } 28 | 29 | internal actual class KTypeImpl 30 | actual constructor( 31 | actual override val classifier: KClassifier?, 32 | actual override val arguments: List, 33 | actual override val isMarkedNullable: Boolean, 34 | actual override val annotations: List, 35 | ) : KType, EitherNetKType { 36 | private val impl = EitherNetKTypeImpl(classifier, arguments, isMarkedNullable, annotations) 37 | 38 | override fun equals(other: Any?) = impl == other 39 | 40 | override fun hashCode() = impl.hashCode() 41 | 42 | override fun toString() = impl.toString() 43 | } 44 | -------------------------------------------------------------------------------- /eithernet/src/jvmMain/kotlin/com/slack/eithernet/Types.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2008 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import java.lang.reflect.GenericArrayType 19 | import java.lang.reflect.ParameterizedType 20 | import java.lang.reflect.Type 21 | import java.lang.reflect.TypeVariable 22 | import java.lang.reflect.WildcardType 23 | import kotlin.reflect.KType 24 | import kotlin.reflect.KTypeProjection 25 | import kotlin.reflect.KVariance.INVARIANT 26 | 27 | /** Returns the raw [Class] type of this type. */ 28 | internal val Type.rawType: Class<*> 29 | get() = Types.getRawType(this) 30 | 31 | /** Returns a [GenericArrayType] with [this] as its [GenericArrayType.getGenericComponentType]. */ 32 | internal fun Type.asArrayType(): GenericArrayType = Types.arrayOf(this) 33 | 34 | /** 35 | * Creates a new [KType] representation of this [Type] for use with Moshi serialization. Note that 36 | * [wildcards][WildcardType] are stripped away in [type projections][KTypeProjection] as they are 37 | * not relevant for serialization and are also not standalone [KType] subtypes in Kotlin. 38 | */ 39 | @InternalEitherNetApi 40 | public fun Type.toKType( 41 | isMarkedNullable: Boolean = true, 42 | annotations: List = emptyList(), 43 | ): KType { 44 | return when (this) { 45 | is Class<*> -> KTypeImpl(kotlin, emptyList(), isMarkedNullable, annotations) 46 | is ParameterizedType -> 47 | KTypeImpl( 48 | classifier = (rawType as Class<*>).kotlin, 49 | arguments = actualTypeArguments.map { it.toKTypeProjection() }, 50 | isMarkedNullable = isMarkedNullable, 51 | annotations = annotations, 52 | ) 53 | is GenericArrayType -> { 54 | KTypeImpl( 55 | classifier = rawType.kotlin, 56 | arguments = listOf(genericComponentType.toKTypeProjection()), 57 | isMarkedNullable = isMarkedNullable, 58 | annotations = annotations, 59 | ) 60 | } 61 | is WildcardType -> removeSubtypeWildcard().toKType(isMarkedNullable, annotations) 62 | is TypeVariable<*> -> 63 | KTypeImpl( 64 | classifier = KTypeParameterImpl(false, name, bounds.map { it.toKType() }, INVARIANT), 65 | arguments = emptyList(), 66 | isMarkedNullable = isMarkedNullable, 67 | annotations = annotations, 68 | ) 69 | else -> throw IllegalArgumentException("Unsupported type: $this") 70 | } 71 | } 72 | 73 | /** Creates a new [KTypeProjection] representation of this [Type] for use in [KType.arguments]. */ 74 | @InternalEitherNetApi 75 | public fun Type.toKTypeProjection(): KTypeProjection { 76 | return when (this) { 77 | is Class<*>, 78 | is ParameterizedType, 79 | is TypeVariable<*> -> KTypeProjection.invariant(toKType()) 80 | is WildcardType -> { 81 | val lowerBounds = lowerBounds 82 | val upperBounds = upperBounds 83 | if (lowerBounds.isEmpty() && upperBounds.isEmpty()) { 84 | return KTypeProjection.STAR 85 | } 86 | return if (lowerBounds.isNotEmpty()) { 87 | KTypeProjection.contravariant(lowerBounds[0].toKType()) 88 | } else { 89 | KTypeProjection.invariant(upperBounds[0].toKType()) 90 | } 91 | } 92 | else -> { 93 | throw NotImplementedError("Unsupported type: $this") 94 | } 95 | } 96 | } 97 | 98 | /** Factory methods for types. */ 99 | @InternalEitherNetApi 100 | public object Types { 101 | /** 102 | * Returns a new parameterized type, applying `typeArguments` to `rawType`. Use this method if 103 | * `rawType` is not enclosed in another type. 104 | */ 105 | @JvmStatic 106 | public fun newParameterizedType(rawType: Type, vararg typeArguments: Type): ParameterizedType { 107 | require(typeArguments.isNotEmpty()) { "Missing type arguments for $rawType" } 108 | return ParameterizedTypeImpl(null, rawType, *typeArguments) 109 | } 110 | 111 | /** 112 | * Returns a new parameterized type, applying `typeArguments` to `rawType`. Use this method if 113 | * `rawType` is enclosed in `ownerType`. 114 | */ 115 | @JvmStatic 116 | public fun newParameterizedTypeWithOwner( 117 | ownerType: Type?, 118 | rawType: Type, 119 | vararg typeArguments: Type, 120 | ): ParameterizedType { 121 | require(typeArguments.isNotEmpty()) { "Missing type arguments for $rawType" } 122 | return ParameterizedTypeImpl(ownerType, rawType, *typeArguments) 123 | } 124 | 125 | /** Returns an array type whose elements are all instances of `componentType`. */ 126 | @JvmStatic 127 | public fun arrayOf(componentType: Type): GenericArrayType { 128 | return GenericArrayTypeImpl(componentType) 129 | } 130 | 131 | /** 132 | * Returns a type that represents an unknown type that extends `bound`. For example, if `bound` is 133 | * `CharSequence.class`, this returns `? extends CharSequence`. If `bound` is `Object.class`, this 134 | * returns `?`, which is shorthand for `? extends Object`. 135 | */ 136 | @JvmStatic 137 | public fun subtypeOf(bound: Type): WildcardType { 138 | val upperBounds = 139 | if (bound is WildcardType) { 140 | bound.upperBounds 141 | } else { 142 | arrayOf(bound) 143 | } 144 | return WildcardTypeImpl(upperBounds, EMPTY_TYPE_ARRAY) 145 | } 146 | 147 | /** 148 | * Returns a type that represents an unknown supertype of `bound`. For example, if `bound` is 149 | * `String.class`, this returns `? super String`. 150 | */ 151 | @JvmStatic 152 | public fun supertypeOf(bound: Type): WildcardType { 153 | val lowerBounds = 154 | if (bound is WildcardType) { 155 | bound.lowerBounds 156 | } else { 157 | arrayOf(bound) 158 | } 159 | return WildcardTypeImpl(arrayOf(Any::class.java), lowerBounds) 160 | } 161 | 162 | @JvmStatic 163 | public fun getRawType(type: Type?): Class<*> { 164 | return when (type) { 165 | is Class<*> -> { 166 | // type is a normal class. 167 | type 168 | } 169 | is ParameterizedType -> { 170 | // I'm not exactly sure why getRawType() returns Type instead of Class. Neal isn't either 171 | // but 172 | // suspects some pathological case related to nested classes exists. 173 | val rawType = type.rawType 174 | rawType as Class<*> 175 | } 176 | is GenericArrayType -> { 177 | val componentType = type.genericComponentType 178 | java.lang.reflect.Array.newInstance(getRawType(componentType), 0).javaClass 179 | } 180 | is TypeVariable<*> -> { 181 | // We could use the variable's bounds, but that won't work if there are multiple. having a 182 | // raw 183 | // type that's more general than necessary is okay. 184 | Any::class.java 185 | } 186 | is WildcardType -> getRawType(type.upperBounds[0]) 187 | else -> { 188 | val className = type?.javaClass?.name?.toString() 189 | throw IllegalArgumentException( 190 | "Expected a Class, ParameterizedType, or GenericArrayType, but <$type> is of type $className" 191 | ) 192 | } 193 | } 194 | } 195 | 196 | /** Returns true if `a` and `b` are equal. */ 197 | @JvmStatic 198 | public fun equals(a: Type?, b: Type?): Boolean { 199 | if (a === b) { 200 | return true // Also handles (a == null && b == null). 201 | } 202 | // This isn't a supported type. 203 | when (a) { 204 | is Class<*> -> { 205 | return if (b is GenericArrayType) { 206 | equals(a.componentType, b.genericComponentType) 207 | } else if (b is ParameterizedType && a.rawType == b.rawType) { 208 | // Class instance with generic info, from method return types 209 | return a.typeParameters.flatMap { it.bounds.toList() } == b.actualTypeArguments.toList() 210 | } else { 211 | a == b // Class already specifies equals(). 212 | } 213 | } 214 | is ParameterizedType -> { 215 | // Class instance with generic info, from method return types 216 | if (b is Class<*> && a.rawType == b.rawType) { 217 | return b.typeParameters.map { it.bounds }.toTypedArray().flatten() == 218 | a.actualTypeArguments.toList() 219 | } 220 | if (b !is ParameterizedType) return false 221 | val aTypeArguments = 222 | if (a is ParameterizedTypeImpl) a.typeArguments else a.actualTypeArguments 223 | val bTypeArguments = 224 | if (b is ParameterizedTypeImpl) b.typeArguments else b.actualTypeArguments 225 | return (equals(a.ownerType, b.ownerType) && 226 | (a.rawType == b.rawType) && 227 | aTypeArguments.contentEquals(bTypeArguments)) 228 | } 229 | is GenericArrayType -> { 230 | if (b is Class<*>) { 231 | return equals(b.componentType, a.genericComponentType) 232 | } 233 | if (b !is GenericArrayType) return false 234 | return equals(a.genericComponentType, b.genericComponentType) 235 | } 236 | is WildcardType -> { 237 | if (b !is WildcardType) return false 238 | return (a.upperBounds.contentEquals(b.upperBounds) && 239 | a.lowerBounds.contentEquals(b.lowerBounds)) 240 | } 241 | is TypeVariable<*> -> { 242 | if (b !is TypeVariable<*>) return false 243 | return (a.genericDeclaration === b.genericDeclaration && (a.name == b.name)) 244 | } 245 | else -> return false // This isn't a supported type. 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /eithernet/src/jvmMain/kotlin/com/slack/eithernet/annotations.jvm.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import java.lang.reflect.ParameterizedType 19 | import java.lang.reflect.Type 20 | import java.lang.reflect.WildcardType 21 | 22 | /** Returns a new [Type] representation of this [ResultType]. */ 23 | @Suppress("SpreadOperator") // This is _the worst_ detekt check 24 | public fun ResultType.toType(): Type { 25 | return when { 26 | typeArgs.isEmpty() -> { 27 | val clazz = rawType.java 28 | if (isArray) { 29 | Types.arrayOf(clazz) 30 | } else { 31 | clazz 32 | } 33 | } 34 | else -> { 35 | val hasOwnerType = ownerType != Nothing::class 36 | if (hasOwnerType) { 37 | Types.newParameterizedTypeWithOwner( 38 | ownerType.javaObjectType, 39 | rawType.javaObjectType, 40 | *typeArgs.map(ResultType::toType).toTypedArray(), 41 | ) 42 | } else { 43 | Types.newParameterizedType( 44 | rawType.javaObjectType, 45 | *typeArgs.map(ResultType::toType).toTypedArray(), 46 | ) 47 | } 48 | } 49 | } 50 | } 51 | 52 | @InternalEitherNetApi 53 | public fun createResultType(type: Type): ResultType { 54 | var ownerType: Type = Nothing::class.java 55 | val rawType: Class<*> 56 | val typeArgs: Array 57 | var isArray = false 58 | when (type) { 59 | is Class<*> -> { 60 | typeArgs = emptyArray() 61 | if (type.isArray) { 62 | rawType = type.componentType 63 | isArray = true 64 | } else { 65 | rawType = type 66 | } 67 | } 68 | is ParameterizedType -> { 69 | ownerType = type.ownerType ?: Nothing::class.java 70 | rawType = Types.getRawType(type) 71 | typeArgs = 72 | Array(type.actualTypeArguments.size) { i -> createResultType(type.actualTypeArguments[i]) } 73 | } 74 | is WildcardType -> return createResultType(type.removeSubtypeWildcard()) 75 | else -> error("Unrecognized type: $type") 76 | } 77 | 78 | return ResultType( 79 | ownerType = (ownerType.canonicalize() as Class<*>).kotlin, 80 | rawType = (rawType.canonicalize() as Class<*>).kotlin, 81 | typeArgs = typeArgs, 82 | isArray = isArray, 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /eithernet/src/jvmMain/kotlin/com/slack/eithernet/platform.jvm.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KClassifier 20 | import kotlin.reflect.KType 21 | import kotlin.reflect.KTypeProjection 22 | 23 | internal actual val KClass<*>.qualifiedNameForComparison: String? 24 | get() = qualifiedName 25 | 26 | internal actual class KTypeImpl 27 | actual constructor( 28 | actual override val classifier: KClassifier?, 29 | actual override val arguments: List, 30 | actual override val isMarkedNullable: Boolean, 31 | actual override val annotations: List, 32 | ) : KType, EitherNetKType { 33 | private val impl = EitherNetKTypeImpl(classifier, arguments, isMarkedNullable, annotations) 34 | 35 | override fun equals(other: Any?) = impl == other 36 | 37 | override fun hashCode() = impl.hashCode() 38 | 39 | override fun toString() = impl.toString() 40 | } 41 | -------------------------------------------------------------------------------- /eithernet/src/jvmMain/resources/META-INF/proguard/eithernet.pro: -------------------------------------------------------------------------------- 1 | # Keep ApiResult's generics or else R8 could strip them. We introspect these at runtime 2 | -keep,allowobfuscation,allowshrinking class com.slack.eithernet.ApiResult 3 | -------------------------------------------------------------------------------- /eithernet/src/jvmTest/kotlin/com/slack/eithernet/EitherNetControllersTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import com.google.auto.service.AutoService 19 | import com.google.common.truth.Truth.assertThat 20 | import com.slack.eithernet.ApiResult.Failure.HttpFailure 21 | import com.slack.eithernet.ApiResult.Success 22 | import com.slack.eithernet.test.ApiValidator 23 | import com.slack.eithernet.test.enqueue 24 | import com.slack.eithernet.test.newEitherNetController 25 | import kotlin.reflect.KClass 26 | import kotlin.reflect.KFunction 27 | import kotlin.reflect.full.hasAnnotation 28 | import kotlin.test.assertFailsWith 29 | import kotlin.test.fail 30 | import kotlinx.coroutines.TimeoutCancellationException 31 | import kotlinx.coroutines.awaitCancellation 32 | import kotlinx.coroutines.runBlocking 33 | import kotlinx.coroutines.test.runTest 34 | import kotlinx.coroutines.withTimeout 35 | import org.junit.Test 36 | 37 | class EitherNetControllersTest { 38 | 39 | @Test 40 | fun happyPath() = runTest { 41 | val testApi = newEitherNetController() 42 | val api = testApi.api 43 | 44 | testApi.enqueue(PandaApi::getPandas) { ApiResult.success("Po") } 45 | 46 | val result = api.getPandas() 47 | check(result is Success) 48 | assertThat(result.value).isEqualTo("Po") 49 | } 50 | 51 | @Test 52 | fun happyPath_scalar() = runTest { 53 | val testApi = newEitherNetController() 54 | val api = testApi.api 55 | 56 | testApi.enqueue(PandaApi::getPandas, ApiResult.success("Po")) 57 | 58 | val result = api.getPandas() 59 | check(result is Success) 60 | assertThat(result.value).isEqualTo("Po") 61 | } 62 | 63 | @Test 64 | fun functionWithParams() = runTest { 65 | val testApi = newEitherNetController() 66 | val api = testApi.api 67 | 68 | testApi.enqueue(PandaApi::getPandasWithParams, ApiResult.success("Po")) 69 | 70 | val result = api.getPandasWithParams(1) 71 | check(result is Success) 72 | assertThat(result.value).isEqualTo("Po") 73 | } 74 | 75 | @Test 76 | fun failure() = runTest { 77 | val testApi = newEitherNetController() 78 | val api = testApi.api 79 | 80 | testApi.enqueue(PandaApi::getPandas, ApiResult.httpFailure(404)) 81 | 82 | val result = api.getPandas() 83 | check(result is HttpFailure) 84 | assertThat(result.code).isEqualTo(404) 85 | } 86 | 87 | @Test 88 | fun mismatchedResultType() { 89 | val testApi = newEitherNetController() 90 | 91 | try { 92 | // This will be an error in future kotlin versions fortunately. This test just covers that 93 | // case until then 94 | @Suppress("TYPE_INTERSECTION_AS_REIFIED_WARNING") 95 | testApi.enqueue(PandaApi::getPandas, ApiResult.success(3)) 96 | fail() 97 | } catch (e: IllegalStateException) { 98 | assertThat(e).hasMessageThat().contains("Type check failed") 99 | } 100 | } 101 | 102 | @Test 103 | fun mismatchedApiFunctions() { 104 | val testApi = newEitherNetController() 105 | 106 | try { 107 | testApi.enqueue(AnotherApi::getPandas, ApiResult.success("Po")) 108 | fail() 109 | } catch (e: IllegalStateException) { 110 | assertThat(e).hasMessageThat().contains("is not a member of target API") 111 | } 112 | } 113 | 114 | @Test 115 | fun invalidApi() { 116 | try { 117 | newEitherNetController() 118 | fail() 119 | } catch (e: IllegalStateException) { 120 | assertThat(e) 121 | .hasMessageThat() 122 | .contains( 123 | """ 124 | Service errors found for BadApi 125 | - Function missingApiResult must return ApiResult for EitherNet to work. 126 | - Function missingSlackEndpoint is missing @SlackEndpoint annotation. 127 | - Function missingSuspend must be a suspend function for EitherNet to work. 128 | """ 129 | .trimIndent() 130 | ) 131 | } 132 | } 133 | 134 | @Test 135 | fun unstubbed_failure() = runTest { 136 | val testApi = newEitherNetController() 137 | val api = testApi.api 138 | 139 | testApi.enqueue(PandaApi::getPandasWithParams, ApiResult.success("Po")) 140 | 141 | try { 142 | api.getPandas() 143 | fail() 144 | } catch (e: IllegalStateException) { 145 | assertThat(e).hasMessageThat().contains("No result enqueued for getPandas.") 146 | } 147 | } 148 | 149 | @Test 150 | fun custom_behavior_example() { 151 | val testApi = newEitherNetController() 152 | val api = testApi.api 153 | 154 | testApi.enqueue(PandaApi::getPandas) { 155 | // Never "returns"! 156 | awaitCancellation() 157 | } 158 | 159 | assertFailsWith { 160 | runBlocking { withTimeout(1000) { api.getPandas() } } 161 | } 162 | } 163 | 164 | @Test 165 | fun custom_behavior_example2() { 166 | val testApi = newEitherNetController() 167 | val api = testApi.api 168 | val expected = Exception() 169 | 170 | testApi.enqueue(PandaApi::getPandas) { throw expected } 171 | 172 | try { 173 | runBlocking { api.getPandas() } 174 | fail() 175 | } catch (e: Exception) { 176 | assertThat(e).isSameInstanceAs(expected) 177 | } 178 | } 179 | 180 | @Test 181 | fun assertNoMoreQueuedResults() { 182 | val testApi = newEitherNetController() 183 | val api = testApi.api 184 | 185 | // Enqueue a result 186 | testApi.enqueue(PandaApi::getPandas, ApiResult.success("Po")) 187 | 188 | // Asserting before taking results fails correctly 189 | try { 190 | testApi.assertNoMoreQueuedResults() 191 | fail() 192 | } catch (e: AssertionError) { 193 | assertThat(e) 194 | .hasMessageThat() 195 | .isEqualTo( 196 | """ 197 | Found unprocessed ApiResults: 198 | -- getPandas() has 1 unprocessed result 199 | """ 200 | .trimIndent() 201 | ) 202 | } 203 | 204 | // Now process the result 205 | runBlocking { api.getPandas() } 206 | 207 | // Now it successfully asserts 208 | testApi.assertNoMoreQueuedResults() 209 | } 210 | 211 | // Cover for inherited APIs 212 | interface BaseApi { 213 | @SlackEndpoint suspend fun getPandas(): ApiResult 214 | } 215 | 216 | interface PandaApi : BaseApi { 217 | @SlackEndpoint suspend fun getPandasWithParams(count: Int): ApiResult 218 | } 219 | 220 | interface AnotherApi { 221 | @SlackEndpoint suspend fun getPandas(): ApiResult 222 | } 223 | 224 | interface BadApi { 225 | suspend fun missingSlackEndpoint(): ApiResult 226 | 227 | @SlackEndpoint fun missingSuspend(): ApiResult 228 | 229 | @SlackEndpoint suspend fun missingApiResult(): String 230 | 231 | fun defaultMethodIsSkipped() {} 232 | 233 | @JvmSynthetic fun syntheticMethodIsSkipped() {} 234 | 235 | companion object { 236 | @JvmStatic fun staticMethodsIsSkipped() {} 237 | } 238 | } 239 | } 240 | 241 | /** Example of a marker annotation for a validator to require */ 242 | annotation class SlackEndpoint 243 | 244 | @AutoService(ApiValidator::class) 245 | class SlackEndpointValidator : ApiValidator { 246 | override fun validate(apiClass: KClass<*>, function: KFunction<*>, errors: MutableList) { 247 | if (!function.hasAnnotation()) { 248 | errors += "- Function ${function.name} is missing @SlackEndpoint annotation." 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /eithernet/src/jvmTest/kotlin/com/slack/eithernet/ResultTypeTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import com.google.common.truth.Truth.assertThat 19 | import java.lang.reflect.Type 20 | import kotlin.reflect.KType 21 | import kotlin.reflect.javaType 22 | import kotlin.reflect.typeOf 23 | import org.junit.Test 24 | 25 | @SampleAnnotation 26 | class ResultTypeTestJvm { 27 | 28 | @Test fun classType() = testType() 29 | 30 | @Test fun parameterizedType() = testType>() 31 | 32 | @Test 33 | fun parameterizedTypeWithOwner() { 34 | val typeWithOwner = 35 | Types.newParameterizedTypeWithOwner( 36 | ResultTypeTestJvm::class.java, 37 | A::class.java, 38 | B::class.java, 39 | ) 40 | val annotation = createResultType(typeWithOwner) 41 | val created = annotation.toType() 42 | created.assertEqualTo(typeWithOwner) 43 | } 44 | 45 | @Test fun enumType() = testType() 46 | 47 | @Test fun array() = testType>() 48 | 49 | @Test 50 | fun wildcard() { 51 | val wildcard = Types.subtypeOf(String::class.java) 52 | val annotation = createResultType(wildcard) 53 | val created = annotation.toType() 54 | wildcard.removeSubtypeWildcard().assertEqualTo(created) 55 | } 56 | 57 | @Test 58 | fun errorType_present() { 59 | val annotations = 60 | Array(4) { 61 | ResultTypeTestJvm::class.java.getAnnotation(SampleAnnotation::class.java) 62 | } 63 | val resultTypeAnnotation = createResultType(String::class.java) 64 | annotations[0] = resultTypeAnnotation 65 | val (resultType, nextAnnotations) = annotations.errorType() ?: error("No annotation found") 66 | assertThat(nextAnnotations.size).isEqualTo(3) 67 | assertThat(resultType).isSameInstanceAs(resultTypeAnnotation) 68 | } 69 | 70 | @Test 71 | fun errorType_absent() { 72 | val annotations = 73 | Array(4) { 74 | ResultTypeTestJvm::class.java.getAnnotation(SampleAnnotation::class.java) 75 | } 76 | assertThat(annotations.errorType()).isNull() 77 | } 78 | 79 | @Test 80 | fun statusCode_present() { 81 | val annotations = 82 | Array(4) { 83 | ResultTypeTestJvm::class.java.getAnnotation(SampleAnnotation::class.java) 84 | } 85 | val statusCodeAnnotation = createStatusCode(404) 86 | annotations[0] = statusCodeAnnotation 87 | val (statusCode, nextAnnotations) = annotations.statusCode() ?: error("No annotation found") 88 | assertThat(nextAnnotations.size).isEqualTo(3) 89 | assertThat(statusCode).isSameInstanceAs(statusCodeAnnotation) 90 | } 91 | 92 | @Test 93 | fun statusCode_absent() { 94 | val annotations = 95 | Array(4) { 96 | ResultTypeTestJvm::class.java.getAnnotation(SampleAnnotation::class.java) 97 | } 98 | assertThat(annotations.statusCode()).isNull() 99 | } 100 | 101 | private class A 102 | 103 | private class B 104 | 105 | enum class TestEnum 106 | 107 | private fun Type.assertEqualTo(other: Type) { 108 | assertThat(Types.equals(this, other)).isTrue() 109 | } 110 | 111 | private inline fun testType() { 112 | testType(typeOf()) 113 | } 114 | 115 | private fun testType(type: KType) { 116 | val annotation = createResultType(type.javaType) 117 | val created = annotation.toType() 118 | type.javaType.assertEqualTo(created) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /eithernet/src/nativeMain/kotlin/com/slack/eithernet/platform.native.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KClassifier 20 | import kotlin.reflect.KType 21 | import kotlin.reflect.KTypeProjection 22 | 23 | internal actual val KClass<*>.qualifiedNameForComparison: String? 24 | get() = qualifiedName 25 | 26 | internal actual class KTypeImpl 27 | actual constructor( 28 | actual override val classifier: KClassifier?, 29 | actual override val arguments: List, 30 | actual override val isMarkedNullable: Boolean, 31 | actual override val annotations: List, 32 | ) : KType, EitherNetKType { 33 | private val impl = EitherNetKTypeImpl(classifier, arguments, isMarkedNullable, annotations) 34 | 35 | override fun equals(other: Any?) = impl == other 36 | 37 | override fun hashCode() = impl.hashCode() 38 | 39 | override fun toString() = impl.toString() 40 | } 41 | -------------------------------------------------------------------------------- /eithernet/src/wasmJsMain/kotlin/com/slack/eithernet/platform.wasmJs.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KClassifier 20 | import kotlin.reflect.KType 21 | import kotlin.reflect.KTypeProjection 22 | 23 | internal actual val KClass<*>.qualifiedNameForComparison: String? 24 | get() { 25 | // Unfortunately qualifiedName isn't implemented in JS 26 | return simpleName 27 | } 28 | 29 | internal actual class KTypeImpl 30 | actual constructor( 31 | actual override val classifier: KClassifier?, 32 | actual override val arguments: List, 33 | actual override val isMarkedNullable: Boolean, 34 | actual override val annotations: List, 35 | ) : KType, EitherNetKType { 36 | private val impl = EitherNetKTypeImpl(classifier, arguments, isMarkedNullable, annotations) 37 | 38 | override fun equals(other: Any?) = impl == other 39 | 40 | override fun hashCode() = impl.hashCode() 41 | 42 | override fun toString() = impl.toString() 43 | } 44 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/api/test-fixtures.api: -------------------------------------------------------------------------------- 1 | public abstract interface class com/slack/eithernet/test/ApiValidator { 2 | public abstract fun validate (Lkotlin/reflect/KClass;Lkotlin/reflect/KFunction;Ljava/util/List;)V 3 | } 4 | 5 | public final class com/slack/eithernet/test/CreateEndpointKeyKt { 6 | public static final fun create (Lcom/slack/eithernet/test/EndpointKey$Companion;Ljava/lang/reflect/Method;)Lcom/slack/eithernet/test/EndpointKey; 7 | } 8 | 9 | public abstract interface class com/slack/eithernet/test/EitherNetController { 10 | public abstract fun assertNoMoreQueuedResults ()V 11 | public abstract fun getApi ()Ljava/lang/Object; 12 | } 13 | 14 | public final class com/slack/eithernet/test/EitherNetControllersKt { 15 | public static final fun newEitherNetController (Lkotlin/reflect/KClass;)Lcom/slack/eithernet/test/EitherNetController; 16 | } 17 | 18 | public final class com/slack/eithernet/test/EitherNetControllers_jvmKt { 19 | public static final fun newEitherNetController (Ljava/lang/Class;)Lcom/slack/eithernet/test/EitherNetController; 20 | public static final fun newProxy (Ljava/lang/Class;Lcom/slack/eithernet/test/EitherNetTestOrchestrator;)Ljava/lang/Object; 21 | } 22 | 23 | public final class com/slack/eithernet/test/EndpointKey { 24 | public final fun component1 ()Ljava/lang/String; 25 | public final fun component2 ()Ljava/util/List; 26 | public fun equals (Ljava/lang/Object;)Z 27 | public final fun getName ()Ljava/lang/String; 28 | public final fun getParameters ()Ljava/util/List; 29 | public fun hashCode ()I 30 | public fun toString ()Ljava/lang/String; 31 | } 32 | 33 | public final class com/slack/eithernet/test/JavaEitherNetControllers { 34 | public static final fun enqueueFromJava (Lcom/slack/eithernet/test/EitherNetController;Ljava/lang/Class;Ljava/lang/String;Lcom/slack/eithernet/ApiResult;)V 35 | } 36 | 37 | public final class com/slack/eithernet/test/JvmUtilKt { 38 | public static final fun isEqualTo (Lkotlin/reflect/KType;Lkotlin/reflect/KType;)Z 39 | } 40 | 41 | public final class com/slack/eithernet/test/ParameterKey { 42 | public fun equals (Ljava/lang/Object;)Z 43 | public fun hashCode ()I 44 | public fun toString ()Ljava/lang/String; 45 | } 46 | 47 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/api/test-fixtures.klib.api: -------------------------------------------------------------------------------- 1 | // Klib ABI Dump 2 | // Targets: [iosArm64, iosSimulatorArm64, iosX64, js, wasmJs] 3 | // Rendering settings: 4 | // - Signature version: 2 5 | // - Show manifest properties: true 6 | // - Show declarations: true 7 | 8 | // Library unique name: 9 | abstract fun interface com.slack.eithernet.test/ApiValidator { // com.slack.eithernet.test/ApiValidator|null[0] 10 | abstract fun validate(kotlin.reflect/KClass<*>, kotlin.reflect/KFunction<*>, kotlin.collections/MutableList) // com.slack.eithernet.test/ApiValidator.validate|validate(kotlin.reflect.KClass<*>;kotlin.reflect.KFunction<*>;kotlin.collections.MutableList){}[0] 11 | } 12 | 13 | abstract interface <#A: kotlin/Any> com.slack.eithernet.test/EitherNetController { // com.slack.eithernet.test/EitherNetController|null[0] 14 | abstract val api // com.slack.eithernet.test/EitherNetController.api|{}api[0] 15 | abstract fun (): #A // com.slack.eithernet.test/EitherNetController.api.|(){}[0] 16 | 17 | abstract fun assertNoMoreQueuedResults() // com.slack.eithernet.test/EitherNetController.assertNoMoreQueuedResults|assertNoMoreQueuedResults(){}[0] 18 | } 19 | 20 | final class com.slack.eithernet.test/EndpointKey { // com.slack.eithernet.test/EndpointKey|null[0] 21 | final val name // com.slack.eithernet.test/EndpointKey.name|{}name[0] 22 | final fun (): kotlin/String // com.slack.eithernet.test/EndpointKey.name.|(){}[0] 23 | final val parameters // com.slack.eithernet.test/EndpointKey.parameters|{}parameters[0] 24 | final fun (): kotlin.collections/List // com.slack.eithernet.test/EndpointKey.parameters.|(){}[0] 25 | 26 | final fun component1(): kotlin/String // com.slack.eithernet.test/EndpointKey.component1|component1(){}[0] 27 | final fun component2(): kotlin.collections/List // com.slack.eithernet.test/EndpointKey.component2|component2(){}[0] 28 | final fun equals(kotlin/Any?): kotlin/Boolean // com.slack.eithernet.test/EndpointKey.equals|equals(kotlin.Any?){}[0] 29 | final fun hashCode(): kotlin/Int // com.slack.eithernet.test/EndpointKey.hashCode|hashCode(){}[0] 30 | final fun toString(): kotlin/String // com.slack.eithernet.test/EndpointKey.toString|toString(){}[0] 31 | } 32 | 33 | final class com.slack.eithernet.test/ParameterKey { // com.slack.eithernet.test/ParameterKey|null[0] 34 | final fun equals(kotlin/Any?): kotlin/Boolean // com.slack.eithernet.test/ParameterKey.equals|equals(kotlin.Any?){}[0] 35 | final fun hashCode(): kotlin/Int // com.slack.eithernet.test/ParameterKey.hashCode|hashCode(){}[0] 36 | final fun toString(): kotlin/String // com.slack.eithernet.test/ParameterKey.toString|toString(){}[0] 37 | } 38 | 39 | final fun <#A: kotlin/Any> com.slack.eithernet.test/newEitherNetController(kotlin.reflect/KClass<#A>): com.slack.eithernet.test/EitherNetController<#A> // com.slack.eithernet.test/newEitherNetController|newEitherNetController(kotlin.reflect.KClass<0:0>){0§}[0] 40 | final inline fun <#A: reified kotlin/Any> com.slack.eithernet.test/newEitherNetController(): com.slack.eithernet.test/EitherNetController<#A> // com.slack.eithernet.test/newEitherNetController|newEitherNetController(){0§}[0] 41 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 17 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 18 | 19 | plugins { 20 | alias(libs.plugins.kotlin.multiplatform) 21 | alias(libs.plugins.dokka) 22 | alias(libs.plugins.mavenPublish) 23 | } 24 | 25 | kotlin { 26 | // region KMP Targets 27 | jvm { withJava() } 28 | iosX64() 29 | iosArm64() 30 | iosSimulatorArm64() 31 | js(IR) { 32 | moduleName = property("POM_ARTIFACT_ID").toString() 33 | browser() 34 | } 35 | @OptIn(ExperimentalWasmDsl::class) 36 | wasmJs { 37 | moduleName = property("POM_ARTIFACT_ID").toString() 38 | browser() 39 | } 40 | // endregion 41 | 42 | applyDefaultHierarchyTemplate() 43 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 44 | compilerOptions { 45 | optIn.addAll("kotlin.ExperimentalStdlibApi", "kotlinx.coroutines.ExperimentalCoroutinesApi") 46 | } 47 | sourceSets { 48 | commonMain { 49 | dependencies { 50 | api(project(":eithernet")) 51 | api(libs.kotlin.reflect) 52 | implementation(libs.coroutines.core) 53 | implementation(libs.statelyCollections) 54 | } 55 | } 56 | jvmMain { 57 | dependencies { 58 | // Android APIs access, gated at runtime 59 | compileOnly(libs.androidProcessingApi) 60 | implementation(libs.retrofit) 61 | 62 | // For access to Types 63 | implementation(libs.moshi) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=EitherNet Test Fixtures (JVM) 2 | POM_ARTIFACT_ID=eithernet-test-fixtures 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/ApiValidator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KFunction 20 | 21 | /** 22 | * A simple callback API for validating APIs in `EitherNetController`. Implementations of this can 23 | * be supplied via `ServiceLoader` on the JVM. 24 | */ 25 | public fun interface ApiValidator { 26 | /** 27 | * Callback to validate a given [function] from the given [apiClass]. 28 | * 29 | * If any errors are found, add a detailed description to [errors]. 30 | */ 31 | public fun validate(apiClass: KClass<*>, function: KFunction<*>, errors: MutableList) 32 | } 33 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/EitherNetController.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.ApiResult 19 | import com.slack.eithernet.InternalEitherNetApi 20 | 21 | /** 22 | * This is a helper API returned by [newEitherNetController] to help with testing EitherNet 23 | * endpoints. 24 | * 25 | * Simple usage looks something like this: 26 | * ``` 27 | * val controller = newEitherNetController() 28 | * 29 | * // Take the api instance from the controller! 30 | * val provider = PandaDataProvider(controller.api) 31 | * 32 | * // Later in a test 33 | * controller.enqueue(PandaApi::getPandas, ApiResult.success("Po")) 34 | * ``` 35 | * 36 | * Enqueued results are keyed to their corresponding endpoint. There is also an overload of 37 | * [enqueue] that accepts a `suspend` function body to allow for custom/dynamic behavior. 38 | * 39 | * For Java interop, there is a limited API available at [enqueueFromJava]. 40 | */ 41 | public interface EitherNetController { 42 | /** The underlying API implementation that should be passed into the thing being tested. */ 43 | public val api: T 44 | 45 | /** Asserts that there are no more remaining results in the queue. */ 46 | public fun assertNoMoreQueuedResults() 47 | 48 | @InternalEitherNetApi 49 | public fun unsafeEnqueue( 50 | key: EndpointKey, 51 | resultBody: suspend (args: Array) -> ApiResult, 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/EitherNetControllers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import kotlin.reflect.KClass 19 | 20 | /** Returns a new [EitherNetController] for API service type [T]. */ 21 | public inline fun newEitherNetController(): EitherNetController { 22 | return newEitherNetController(T::class) 23 | } 24 | 25 | /** Returns a new [EitherNetController] for API service type [T]. */ 26 | public fun newEitherNetController(service: KClass): EitherNetController { 27 | service.validateApi() 28 | // Get functions with retrofit annotations 29 | val endpoints = 30 | service 31 | .getFunctions() 32 | .filter { it.isApplicable } 33 | .map { it.toEndpointKey() } 34 | .associateWithTo(newConcurrentMap()) { ArrayDeque() } 35 | val orchestrator = EitherNetTestOrchestrator(endpoints) 36 | val proxy = newServiceInstance(service, orchestrator) 37 | return RealEitherNetController(orchestrator, proxy) 38 | } 39 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/EitherNetTestOrchestrator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | /** 19 | * Simple orchestration holder class to allow [endpoints] access between proxied APIs and 20 | * [EitherNetController] instances. 21 | */ 22 | internal class EitherNetTestOrchestrator( 23 | val endpoints: Map> 24 | ) 25 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/EndpointKey.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | /** A simple key for a given endpoint. */ 19 | public data class EndpointKey 20 | internal constructor(val name: String, val parameters: List) { 21 | // Here for extension points 22 | internal companion object 23 | } 24 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/ParameterKey.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.InternalEitherNetApi 19 | import com.slack.eithernet.canonicalize 20 | import kotlin.coroutines.Continuation 21 | import kotlin.reflect.KType 22 | 23 | /** A simple parameter endpoint key for use with [EndpointKey]. */ 24 | public class ParameterKey internal constructor(type: KType) { 25 | 26 | @OptIn(InternalEitherNetApi::class) private val type = type.canonicalize() 27 | 28 | override fun equals(other: Any?): Boolean { 29 | if (this === other) return true 30 | if (other == null || this::class != other::class) return false 31 | 32 | other as ParameterKey 33 | 34 | return type == other.type 35 | } 36 | 37 | override fun hashCode(): Int { 38 | return type.hashCode() 39 | } 40 | 41 | override fun toString(): String { 42 | return "ParameterKey(type=$type)" 43 | } 44 | } 45 | 46 | internal fun createParameterKey(parameterType: KType): ParameterKey? { 47 | if (parameterType.classifier == Continuation::class) return null 48 | return ParameterKey(parameterType) 49 | } 50 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/Platform.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import kotlin.reflect.KClass 19 | import kotlin.reflect.KFunction 20 | 21 | internal expect fun KClass<*>.getFunctions(): Collection> 22 | 23 | internal expect fun newConcurrentMap(): MutableMap 24 | 25 | internal expect fun KFunction<*>.toEndpointKey(): EndpointKey 26 | 27 | internal expect val KFunction<*>.isApplicable: Boolean 28 | 29 | internal expect fun KClass<*>.validateApi() 30 | 31 | internal expect fun newServiceInstance( 32 | klass: KClass, 33 | orchestrator: EitherNetTestOrchestrator, 34 | ): T 35 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/RealEitherNetController.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.ApiResult 19 | import com.slack.eithernet.InternalEitherNetApi 20 | 21 | internal class RealEitherNetController( 22 | private val orchestrator: EitherNetTestOrchestrator, 23 | override val api: T, 24 | ) : EitherNetController { 25 | @InternalEitherNetApi 26 | override fun unsafeEnqueue( 27 | key: EndpointKey, 28 | resultBody: suspend (args: Array) -> ApiResult, 29 | ) { 30 | val result = orchestrator.endpoints[key] 31 | checkNotNull(result) { 32 | "EndpointKey $key (hashCode ${key.hashCode()} not found in orchestrator.endpoints: ${orchestrator.endpoints.keys} (${orchestrator.endpoints.keys.map { it.hashCode() }})" 33 | } 34 | orchestrator.endpoints.getValue(key).add(resultBody) 35 | } 36 | 37 | override fun assertNoMoreQueuedResults() { 38 | val errors = mutableListOf() 39 | orchestrator.endpoints.forEach { (endpoint, resultsQueue) -> 40 | if (resultsQueue.isNotEmpty()) { 41 | val directObject = 42 | if (resultsQueue.size == 1) { 43 | "result" 44 | } else { 45 | "results" 46 | } 47 | errors += "-- ${endpoint.name}() has ${resultsQueue.size} unprocessed $directObject" 48 | } 49 | } 50 | if (errors.isNotEmpty()) { 51 | throw AssertionError("Found unprocessed ApiResults:\n${errors.joinToString("\n")}") 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/commonMain/kotlin/com/slack/eithernet/test/Util.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.ApiResult 19 | 20 | internal typealias SuspendedResult = suspend (args: Array) -> ApiResult<*, *> 21 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jsMain/kotlin/com/slack/eithernet/test/Platform.js.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import co.touchlab.stately.collections.ConcurrentMutableMap 19 | import kotlin.reflect.KClass 20 | import kotlin.reflect.KFunction 21 | 22 | internal actual fun KClass<*>.getFunctions(): Collection> { 23 | TODO("Kotlin Reflect is not supported on JS yet.") 24 | } 25 | 26 | internal actual fun newConcurrentMap(): MutableMap { 27 | // JS is single-threaded anyway 28 | return ConcurrentMutableMap() 29 | } 30 | 31 | internal actual fun KFunction<*>.toEndpointKey(): EndpointKey { 32 | TODO("Kotlin Reflect is not supported on JS yet.") 33 | } 34 | 35 | internal actual val KFunction<*>.isApplicable: Boolean 36 | get() = false 37 | 38 | internal actual fun KClass<*>.validateApi() { 39 | // Not implemented 40 | } 41 | 42 | internal actual fun newServiceInstance( 43 | klass: KClass, 44 | orchestrator: EitherNetTestOrchestrator, 45 | ): T { 46 | TODO("Kotlin Reflect is not supported on JS yet.") 47 | } 48 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/CoroutineTransformer.java: -------------------------------------------------------------------------------- 1 | package com.slack.eithernet.test; 2 | 3 | import com.slack.eithernet.ApiResult; 4 | import kotlin.coroutines.Continuation; 5 | import kotlin.jvm.functions.Function2; 6 | 7 | enum CoroutineTransformer { 8 | ; 9 | 10 | /** 11 | * A weird but effective way to redirect a {@link Continuation} acquired via reflection back into 12 | * a true {@code suspend} function. 13 | */ 14 | public static Object transform( 15 | Object[] args, Object body, Continuation> continuation) { 16 | try { 17 | //noinspection unchecked 18 | return JvmUtilKt.awaitResponse( 19 | (Function2< 20 | ? super Object[], 21 | ? super Continuation>, 22 | ?>) 23 | body, 24 | args, 25 | continuation); 26 | } catch (Exception e) { 27 | return JvmUtilKt.suspendAndThrow(e, continuation); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/EitherNetController.jvm.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.ApiResult 19 | import com.slack.eithernet.InternalEitherNetApi 20 | import kotlin.reflect.KFunction 21 | import kotlin.reflect.jvm.javaMethod 22 | 23 | /** Enqueues a suspended [resultBody]. */ 24 | public inline fun EitherNetController 25 | .enqueue( 26 | ref: KFunction>, 27 | noinline resultBody: suspend (args: Array) -> ApiResult, 28 | ) { 29 | ref.validateTypes() 30 | val apiClass = T::class.java 31 | check(ref.javaMethod?.declaringClass?.isAssignableFrom(apiClass) == true) { 32 | "Given function ${ref.javaMethod?.declaringClass}.${ref.name} is not a member of target API $apiClass." 33 | } 34 | val key = EndpointKey.create(ref.javaMethod!!) 35 | @OptIn(InternalEitherNetApi::class) unsafeEnqueue(key, resultBody) 36 | } 37 | 38 | /** Enqueues a scalar [result] instance. */ 39 | public inline fun EitherNetController 40 | .enqueue(ref: KFunction>, result: ApiResult): Unit = enqueue(ref) { result } 41 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/EitherNetControllers.jvm.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.ApiResult 19 | import com.slack.eithernet.test.Platform.Companion 20 | import java.lang.reflect.InvocationHandler 21 | import java.lang.reflect.Method 22 | import java.lang.reflect.Proxy 23 | import kotlin.coroutines.Continuation 24 | 25 | /** Returns a new [EitherNetController] for API service type [T]. */ 26 | public fun newEitherNetController(service: Class): EitherNetController { 27 | return newEitherNetController(service.kotlin) 28 | } 29 | 30 | /** 31 | * Creates a new [Proxy] instance of the given [T] API service that delegates to the underlying 32 | * [orchestrator] for enqueued responses. 33 | * 34 | * This heavily mirrors Retrofit's own internal implementation of creating Proxies. 35 | */ 36 | @PublishedApi 37 | @Suppress("UNCHECKED_CAST") 38 | internal fun newProxy(service: Class, orchestrator: EitherNetTestOrchestrator): T { 39 | return Proxy.newProxyInstance( 40 | service.classLoader, 41 | arrayOf(service), 42 | object : InvocationHandler { 43 | override fun invoke(proxy: Any, method: Method, args: Array?): Any? { 44 | // If the method is a method from Object then defer to normal invocation. 45 | val finalArgs = args.orEmpty() 46 | if (method.declaringClass == Object::class.java) { 47 | return method.invoke(this, *finalArgs) 48 | } 49 | 50 | return if (Companion.INSTANCE.isDefaultMethod(method)) { 51 | Companion.INSTANCE.invokeDefaultMethod(method, service, proxy, finalArgs) 52 | } else { 53 | val key = EndpointKey.create(method) 54 | check(finalArgs.isNotEmpty()) { 55 | "No arguments found on method ${key.name}, did you forget to add the 'suspend' modifier?" 56 | } 57 | val continuation = finalArgs.last() 58 | check(continuation is Continuation<*>) { 59 | "Last arg is not a Continuation, did you forget to add the 'suspend' modifier?" 60 | } 61 | val argsArray = Array(finalArgs.size - 1) { i -> finalArgs[i] } 62 | val body = 63 | orchestrator.endpoints.getValue(key).removeFirstOrNull() 64 | ?: error("No result enqueued for ${key.name}.") 65 | CoroutineTransformer.transform( 66 | argsArray, 67 | body, 68 | continuation as Continuation>, 69 | ) 70 | } 71 | } 72 | }, 73 | ) as T 74 | } 75 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/JavaEitherNetControllers.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | @file:JvmName("JavaEitherNetControllers") 17 | 18 | package com.slack.eithernet.test 19 | 20 | import com.slack.eithernet.ApiResult 21 | import com.slack.eithernet.InternalEitherNetApi 22 | 23 | /** Enqueues a suspended [resultBody]. Note that this is not safe and only available for Java. */ 24 | public fun EitherNetController.enqueueFromJava( 25 | clazz: Class, 26 | methodName: String, 27 | resultBody: ApiResult, 28 | ) { 29 | val method = clazz.declaredMethods.first { it.name == methodName } 30 | val key = EndpointKey.create(method) 31 | @OptIn(InternalEitherNetApi::class) unsafeEnqueue(key) { resultBody } 32 | } 33 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/JvmUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.ApiResult 19 | import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED 20 | import kotlin.coroutines.intrinsics.intercepted 21 | import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn 22 | import kotlin.coroutines.resumeWithException 23 | import kotlin.reflect.KFunction 24 | import kotlin.reflect.KType 25 | import kotlin.reflect.typeOf 26 | import kotlinx.coroutines.Dispatchers 27 | 28 | /** 29 | * Force the calling coroutine to suspend before throwing [this]. 30 | * 31 | * This is needed when a checked exception is synchronously caught in a [java.lang.reflect.Proxy] 32 | * invocation to avoid being wrapped in [java.lang.reflect.UndeclaredThrowableException]. 33 | * 34 | * The implementation is derived from: 35 | * https://github.com/Kotlin/kotlinx.coroutines/pull/1667#issuecomment-556106349 36 | */ 37 | internal suspend fun Exception.suspendAndThrow(): Nothing { 38 | suspendCoroutineUninterceptedOrReturn { continuation -> 39 | Dispatchers.Default.dispatch(continuation.context) { 40 | continuation.intercepted().resumeWithException(this@suspendAndThrow) 41 | } 42 | COROUTINE_SUSPENDED 43 | } 44 | } 45 | 46 | /** 47 | * Validates that the type declarations on [this] endpoint function's return type match the reified 48 | * [S] and [E] types that [newEitherNetController] specifies. We do this in lieu of stronger type 49 | * checking from the IDE, which appears to throw type checking out the window due to [ApiResult]'s 50 | * use of covariant (i.e. `out`) types. 51 | */ 52 | @PublishedApi 53 | internal inline fun KFunction>.validateTypes() { 54 | // Note that we don't use Moshi's nicer canonicalize APIs because it would lose the Kotlin type 55 | // information like nullability and intrinsic types. 56 | val type = returnType 57 | val (success, error) = type.arguments.map { it.type!! } 58 | 59 | check(success isEqualTo typeOf()) { 60 | "Type check failed! Expected success type of '$success' but found '${typeOf()}'. Ensure that your result type matches the target endpoint as the IDE won't correctly infer this!" 61 | } 62 | check(error isEqualTo typeOf()) { 63 | "Type check failed! Expected error type of '$error' but found '${typeOf()}'. Ensure that your result type matches the target endpoint as the IDE won't correctly infer this!" 64 | } 65 | } 66 | 67 | /** 68 | * Kotlin 1.6 introduces a new `platformTypeUpperBound` value to `KType.equals()` (under 69 | * [kotlin.jvm.internal.TypeReference]) that we can't control or access, so we compare these 70 | * directly. 71 | * 72 | * https://youtrack.jetbrains.com/issue/KT-49318 73 | */ 74 | @PublishedApi 75 | internal infix fun KType.isEqualTo(other: KType): Boolean { 76 | return classifier == other.classifier && 77 | arguments == other.arguments && 78 | isMarkedNullable == other.isMarkedNullable 79 | } 80 | 81 | internal suspend fun SuspendedResult.awaitResponse(args: Array): ApiResult<*, *> = invoke(args) 82 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/Platform.jvm.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2013 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import android.annotation.SuppressLint 19 | import android.os.Build.VERSION 20 | import com.slack.eithernet.ApiResult 21 | import java.lang.invoke.MethodHandles 22 | import java.lang.invoke.MethodHandles.Lookup 23 | import java.lang.reflect.Constructor 24 | import java.lang.reflect.Method 25 | import java.lang.reflect.Modifier 26 | import java.util.ServiceLoader 27 | import java.util.concurrent.ConcurrentHashMap 28 | import kotlin.reflect.KClass 29 | import kotlin.reflect.KFunction 30 | import kotlin.reflect.full.functions 31 | import kotlin.reflect.jvm.javaMethod 32 | 33 | private fun loadValidators(): Set = 34 | ServiceLoader.load(ApiValidator::class.java).toSet() 35 | 36 | internal actual fun KClass<*>.validateApi() { 37 | val validators = loadValidators() 38 | val errors = mutableListOf() 39 | for (function in getFunctions()) { 40 | if (!function.isApplicable) { 41 | continue 42 | } 43 | if (!function.isSuspend) { 44 | errors += "- Function ${function.name} must be a suspend function for EitherNet to work." 45 | } 46 | if (function.returnType.classifier != ApiResult::class) { 47 | errors += "- Function ${function.name} must return ApiResult for EitherNet to work." 48 | } 49 | for (validator in validators) { 50 | validator.validate(this, function, errors) 51 | } 52 | } 53 | 54 | if (errors.isNotEmpty()) { 55 | error("Service errors found for $simpleName\n${errors.joinToString("\n")}") 56 | } 57 | } 58 | 59 | internal actual val KFunction<*>.isApplicable: Boolean 60 | get() { 61 | // Default, static, synthetic, and bridge methods are not applicable 62 | return javaMethod?.let { method -> 63 | method.declaringClass != Object::class.java && 64 | !method.isDefault && 65 | !Modifier.isStatic(method.modifiers) && 66 | !method.isSynthetic && 67 | !method.isBridge 68 | } ?: false 69 | } 70 | 71 | internal actual fun KClass<*>.getFunctions(): Collection> { 72 | return functions 73 | } 74 | 75 | internal actual fun newConcurrentMap(): MutableMap = ConcurrentHashMap() 76 | 77 | internal actual fun KFunction<*>.toEndpointKey(): EndpointKey = EndpointKey.create(javaMethod!!) 78 | 79 | internal actual fun newServiceInstance( 80 | klass: KClass, 81 | orchestrator: EitherNetTestOrchestrator, 82 | ) = newProxy(klass.java, orchestrator) 83 | 84 | /** 85 | * Simple indirection for platform-specific implementations for Android and JVM. Adapted from 86 | * Retrofit's internal version. 87 | */ 88 | internal open class Platform(private val hasJava8Types: Boolean) { 89 | private val lookupConstructor: Constructor? 90 | 91 | init { 92 | var lookupConstructor: Constructor? = null 93 | if (hasJava8Types) { 94 | try { 95 | // Because the service interface might not be public, we need to use a MethodHandle lookup 96 | // that ignores the visibility of the declaringClass. 97 | lookupConstructor = 98 | Lookup::class.java.getDeclaredConstructor(Class::class.java, Int::class.javaPrimitiveType) 99 | lookupConstructor.setAccessible(true) 100 | } catch (ignored: NoClassDefFoundError) { 101 | // Android API 24 or 25 where Lookup doesn't exist. Calling default methods on non-public 102 | // interfaces will fail, but there's nothing we can do about it. 103 | } catch (ignored: NoSuchMethodException) { 104 | // Assume JDK 14+ which contains a fix that allows a regular lookup to succeed. 105 | // See https://bugs.openjdk.java.net/browse/JDK-8209005. 106 | } 107 | } 108 | this.lookupConstructor = lookupConstructor 109 | } 110 | 111 | @SuppressLint("NewApi") 112 | fun isDefaultMethod(method: Method): Boolean { 113 | return hasJava8Types && method.isDefault 114 | } 115 | 116 | @SuppressLint("NewApi") 117 | open fun invokeDefaultMethod( 118 | method: Method, 119 | declaringClass: Class<*>, 120 | obj: Any, 121 | vararg args: Any, 122 | ): Any? { 123 | val lookup = 124 | if (lookupConstructor != null) { 125 | lookupConstructor.newInstance(declaringClass, -1 /* trusted */) 126 | } else { 127 | MethodHandles.lookup() 128 | } 129 | return lookup.unreflectSpecial(method, declaringClass).bindTo(obj).invokeWithArguments(*args) 130 | } 131 | 132 | internal class Android : Platform(VERSION.SDK_INT >= 24) { 133 | override fun invokeDefaultMethod( 134 | method: Method, 135 | declaringClass: Class<*>, 136 | obj: Any, 137 | vararg args: Any, 138 | ): Any? { 139 | if (VERSION.SDK_INT < 26) { 140 | throw UnsupportedOperationException( 141 | "Calling default methods on API 24 and 25 is not supported" 142 | ) 143 | } 144 | return super.invokeDefaultMethod(method, declaringClass, obj, *args) 145 | } 146 | } 147 | 148 | companion object { 149 | val INSTANCE by lazy { findPlatform() } 150 | 151 | private fun findPlatform(): Platform { 152 | return if ("Dalvik" == System.getProperty("java.vm.name")) { 153 | Android() 154 | } else { 155 | Platform(true) 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/jvmMain/java/com/slack/eithernet/test/createEndpointKey.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import com.slack.eithernet.InternalEitherNetApi 19 | import com.slack.eithernet.toKType 20 | import java.lang.reflect.Method 21 | 22 | @OptIn(InternalEitherNetApi::class) 23 | @PublishedApi 24 | internal fun EndpointKey.Companion.create(method: Method): EndpointKey { 25 | return EndpointKey( 26 | method.name, 27 | method.parameterTypes.mapNotNull { createParameterKey(it.toKType()) }, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/nativeMain/kotlin/com/slack/eithernet/test/Platform.native.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import co.touchlab.stately.collections.ConcurrentMutableMap 19 | import kotlin.reflect.KClass 20 | import kotlin.reflect.KFunction 21 | 22 | internal actual fun KClass<*>.getFunctions(): Collection> { 23 | TODO("Kotlin Reflect is not supported on Native yet.") 24 | } 25 | 26 | internal actual fun newConcurrentMap(): MutableMap { 27 | return ConcurrentMutableMap() 28 | } 29 | 30 | internal actual fun KFunction<*>.toEndpointKey(): EndpointKey { 31 | TODO("Kotlin Reflect is not supported on Native yet.") 32 | } 33 | 34 | internal actual val KFunction<*>.isApplicable: Boolean 35 | get() = false 36 | 37 | internal actual fun KClass<*>.validateApi() { 38 | // Not implemented 39 | } 40 | 41 | internal actual fun newServiceInstance( 42 | klass: KClass, 43 | orchestrator: EitherNetTestOrchestrator, 44 | ): T { 45 | TODO("Kotlin Reflect is not supported on Native yet.") 46 | } 47 | -------------------------------------------------------------------------------- /eithernet/test-fixtures/src/wasmJsMain/kotlin/com/slack/eithernet/test/Platform.wasmJs.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.test 17 | 18 | import co.touchlab.stately.collections.ConcurrentMutableMap 19 | import kotlin.reflect.KClass 20 | import kotlin.reflect.KFunction 21 | 22 | internal actual fun KClass<*>.getFunctions(): Collection> { 23 | TODO("Kotlin Reflect is not supported on JS yet.") 24 | } 25 | 26 | internal actual fun newConcurrentMap(): MutableMap { 27 | // JS is single-threaded anyway 28 | return ConcurrentMutableMap() 29 | } 30 | 31 | internal actual fun KFunction<*>.toEndpointKey(): EndpointKey { 32 | TODO("Kotlin Reflect is not supported on JS yet.") 33 | } 34 | 35 | internal actual val KFunction<*>.isApplicable: Boolean 36 | get() = false 37 | 38 | internal actual fun KClass<*>.validateApi() { 39 | // Not implemented 40 | } 41 | 42 | internal actual fun newServiceInstance( 43 | klass: KClass, 44 | orchestrator: EitherNetTestOrchestrator, 45 | ): T { 46 | TODO("Kotlin Reflect is not supported on JS yet.") 47 | } 48 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # suppress inspection "AlphaUnsortedPropertiesFile" for whole file 2 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 3 | 4 | org.gradle.parallel=true 5 | org.gradle.configureondemand=true 6 | org.gradle.caching=true 7 | org.gradle.configuration-cache=true 8 | 9 | # New Kotlin IC flags 10 | kotlin.compiler.suppressExperimentalICOptimizationsWarning=true 11 | kotlin.compiler.keepIncrementalCompilationCachesInMemory=true 12 | kotlin.compiler.preciseCompilationResultsBackup=true 13 | 14 | # Enable for Compose Web and wasm 15 | org.jetbrains.compose.experimental.jscanvas.enabled=true 16 | org.jetbrains.compose.experimental.wasm.enabled=true 17 | 18 | # Disable noisy stability warning 19 | kotlin.mpp.stability.nowarn=true 20 | kotlin.mpp.androidSourceSetLayoutVersion=2 21 | kotlin.apple.xcodeCompatibility.nowarn=true 22 | # Ignore disabled targets (i.e iOS on Linux) 23 | kotlin.native.ignoreDisabledTargets=true 24 | 25 | # Use KSP2 26 | ksp.useKSP2=true 27 | 28 | GROUP=com.slack.eithernet 29 | VERSION_NAME=2.1.0-SNAPSHOT 30 | POM_DESCRIPTION=A pluggable sealed API result type for modeling Retrofit responses. 31 | POM_URL=https://github.com/slackhq/eithernet/ 32 | POM_SCM_URL=https://github.com/slackhq/eithernet/ 33 | POM_SCM_CONNECTION=scm:git:git://github.com/slackhq/eithernet.git 34 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/slackhq/eithernet.git 35 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 36 | POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt 37 | POM_LICENCE_DIST=repo 38 | POM_DEVELOPER_ID=slackhq 39 | POM_DEVELOPER_NAME=Slack Technologies, LLC 40 | POM_DEVELOPER_URL=https://github.com/slackhq 41 | SONATYPE_STAGING_PROFILE=com.slack 42 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | coroutines = "1.9.0" 3 | kotlin = "2.1.0" 4 | jdk = "22" 5 | jvmTarget = "11" 6 | ksp = "2.1.0-1.0.29" 7 | ktfmt = "0.53" 8 | moshi = "1.15.1" 9 | okhttp = "4.9.0" 10 | retrofit = "2.9.0" 11 | 12 | [plugins] 13 | binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } 14 | detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.5" } 15 | dokka = { id = "org.jetbrains.dokka", version = "1.9.20" } 16 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 17 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 18 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 19 | mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.29.0" } 20 | spotless = { id = "com.diffplug.spotless", version = "7.0.0.BETA4" } 21 | 22 | [libraries] 23 | androidProcessingApi = "com.google.android:android:4.1.1.4" 24 | autoService-annotations = "com.google.auto.service:auto-service-annotations:1.1.1" 25 | autoService-ksp = "dev.zacsweers.autoservice:auto-service-ksp:1.1.0" 26 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 27 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 28 | junit = "junit:junit:4.13.2" 29 | kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } 30 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 31 | ktfmt = { module = "com.facebook:ktfmt", version.ref = "ktfmt" } 32 | moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } 33 | moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } 34 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 35 | okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } 36 | okio = "com.squareup.okio:okio:3.9.0" 37 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } 38 | retrofit-converterScalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } 39 | statelyCollections = "co.touchlab:stately-concurrent-collections:2.0.7" 40 | truth = "com.google.truth:truth:1.4.2" -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slackhq/EitherNet/1078b4fceaba9f24f57efaf861f16c9b47cd4076/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /integrations/retrofit/api/retrofit.api: -------------------------------------------------------------------------------- 1 | public final class com/slack/eithernet/integration/retrofit/ApiResultCallAdapterFactory : retrofit2/CallAdapter$Factory { 2 | public static final field INSTANCE Lcom/slack/eithernet/integration/retrofit/ApiResultCallAdapterFactory; 3 | public fun get (Ljava/lang/reflect/Type;[Ljava/lang/annotation/Annotation;Lretrofit2/Retrofit;)Lretrofit2/CallAdapter; 4 | } 5 | 6 | public final class com/slack/eithernet/integration/retrofit/ApiResultConverterFactory : retrofit2/Converter$Factory { 7 | public static final field INSTANCE Lcom/slack/eithernet/integration/retrofit/ApiResultConverterFactory; 8 | public fun responseBodyConverter (Ljava/lang/reflect/Type;[Ljava/lang/annotation/Annotation;Lretrofit2/Retrofit;)Lretrofit2/Converter; 9 | } 10 | 11 | public final class com/slack/eithernet/integration/retrofit/Tags_jvmKt { 12 | public static final fun request (Lcom/slack/eithernet/ApiResult;)Lokhttp3/Request; 13 | public static final fun response (Lcom/slack/eithernet/ApiResult;)Lokhttp3/Response; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /integrations/retrofit/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | plugins { 17 | alias(libs.plugins.kotlin.jvm) 18 | alias(libs.plugins.dokka) 19 | alias(libs.plugins.mavenPublish) 20 | } 21 | 22 | kotlin { 23 | compilerOptions { 24 | optIn.addAll("kotlin.ExperimentalStdlibApi", "com.slack.eithernet.InternalEitherNetApi") 25 | } 26 | } 27 | 28 | dependencies { 29 | api(libs.retrofit) 30 | api(project(":eithernet")) 31 | 32 | testImplementation(libs.coroutines.core) 33 | testImplementation(libs.coroutines.test) 34 | testImplementation(libs.retrofit.converterScalars) 35 | testImplementation(libs.okhttp) 36 | testImplementation(libs.okhttp.mockwebserver) 37 | testImplementation(libs.moshi) 38 | testImplementation(libs.moshi.kotlin) 39 | testImplementation(libs.junit) 40 | testImplementation(libs.truth) 41 | testImplementation(libs.autoService.annotations) 42 | testImplementation(project(":eithernet:test-fixtures")) 43 | } 44 | -------------------------------------------------------------------------------- /integrations/retrofit/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=EitherNet (Retrofit) 2 | POM_ARTIFACT_ID=eithernet-integration-retrofit 3 | POM_PACKAGING=jar -------------------------------------------------------------------------------- /integrations/retrofit/src/main/kotlin/com/slack/eithernet/integration/retrofit/ApiResultCallAdapterFactory.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.integration.retrofit 17 | 18 | import com.slack.eithernet.ApiException 19 | import com.slack.eithernet.ApiResult 20 | import com.slack.eithernet.DecodeErrorBody 21 | import com.slack.eithernet.createStatusCode 22 | import java.io.IOException 23 | import java.lang.reflect.ParameterizedType 24 | import java.lang.reflect.Type 25 | import kotlin.reflect.KClass 26 | import okhttp3.Request 27 | import retrofit2.Call 28 | import retrofit2.CallAdapter 29 | import retrofit2.Callback 30 | import retrofit2.Response 31 | import retrofit2.Retrofit 32 | 33 | /** 34 | * A custom [CallAdapter.Factory] for [ApiResult] calls. This creates a delegating adapter for 35 | * suspend function calls that return [ApiResult]. This facilitates returning all error types 36 | * through the possible [ApiResult] subtypes. 37 | */ 38 | public object ApiResultCallAdapterFactory : CallAdapter.Factory() { 39 | @Suppress("ReturnCount") 40 | override fun get( 41 | returnType: Type, 42 | annotations: Array, 43 | retrofit: Retrofit, 44 | ): CallAdapter<*, *>? { 45 | if (getRawType(returnType) != Call::class.java) { 46 | return null 47 | } 48 | val apiResultType = getParameterUpperBound(0, returnType as ParameterizedType) 49 | if (apiResultType !is ParameterizedType || apiResultType.rawType != ApiResult::class.java) { 50 | return null 51 | } 52 | 53 | val decodeErrorBody = annotations.any { it is DecodeErrorBody } 54 | return ApiResultCallAdapter(retrofit, apiResultType, decodeErrorBody, annotations) 55 | } 56 | 57 | private class ApiResultCallAdapter( 58 | private val retrofit: Retrofit, 59 | private val apiResultType: ParameterizedType, 60 | private val decodeErrorBody: Boolean, 61 | private val annotations: Array, 62 | ) : CallAdapter, Call>> { 63 | 64 | private companion object { 65 | private const val HTTP_NO_CONTENT = 204 66 | private const val HTTP_RESET_CONTENT = 205 67 | } 68 | 69 | override fun adapt(call: Call>): Call> { 70 | return object : Call> by call { 71 | @Suppress("LongMethod") 72 | override fun enqueue(callback: Callback>) { 73 | call.enqueue( 74 | object : Callback> { 75 | override fun onFailure(call: Call>, t: Throwable) { 76 | when (t) { 77 | is ApiException -> { 78 | callback.onResponse( 79 | call, 80 | Response.success( 81 | ApiResult.Failure.ApiFailure( 82 | error = t.error, 83 | tags = mapOf(Request::class to call.request()), 84 | ) 85 | ), 86 | ) 87 | } 88 | is IOException -> { 89 | callback.onResponse( 90 | call, 91 | Response.success( 92 | ApiResult.Failure.NetworkFailure( 93 | error = t, 94 | tags = mapOf(Request::class to call.request()), 95 | ) 96 | ), 97 | ) 98 | } 99 | else -> { 100 | callback.onResponse( 101 | call, 102 | Response.success( 103 | ApiResult.Failure.UnknownFailure( 104 | error = t, 105 | tags = mapOf(Request::class to call.request()), 106 | ) 107 | ), 108 | ) 109 | } 110 | } 111 | } 112 | 113 | override fun onResponse( 114 | call: Call>, 115 | response: Response>, 116 | ) { 117 | if (response.isSuccessful) { 118 | // Repackage the initial result with new tags with this call's request + 119 | // response 120 | val tags = mapOf(okhttp3.Response::class to response.raw()) 121 | val withTag = 122 | when (val result = response.body()) { 123 | is ApiResult.Success -> result.withTags(result.tags + tags) 124 | null -> { 125 | val responseCode = response.code() 126 | if ( 127 | (responseCode == HTTP_NO_CONTENT || responseCode == HTTP_RESET_CONTENT) && 128 | apiResultType.actualTypeArguments[0] == Unit::class.java 129 | ) { 130 | @Suppress("UNCHECKED_CAST") 131 | ApiResult.success(Unit).withTags(tags as Map, Any>) 132 | } else { 133 | null 134 | } 135 | } 136 | else -> null 137 | } 138 | callback.onResponse(call, Response.success(withTag)) 139 | } else { 140 | var errorBody: Any? = null 141 | if (decodeErrorBody) { 142 | response.errorBody()?.let { responseBody -> 143 | // Don't try to decode empty bodies 144 | // Unknown length bodies (i.e. -1L) are fine 145 | if (responseBody.contentLength() == 0L) return@let 146 | val errorType = apiResultType.actualTypeArguments[1] 147 | val statusCode = createStatusCode(response.code()) 148 | val nextAnnotations = annotations + statusCode 149 | @Suppress("TooGenericExceptionCaught") 150 | errorBody = 151 | try { 152 | retrofit 153 | .responseBodyConverter(errorType, nextAnnotations) 154 | .convert(responseBody) 155 | } catch (e: Throwable) { 156 | @Suppress("UNCHECKED_CAST") 157 | callback.onResponse( 158 | call, 159 | Response.success( 160 | ApiResult.Failure.UnknownFailure( 161 | error = e, 162 | tags = mapOf(okhttp3.Response::class to response.raw()), 163 | ) 164 | ), 165 | ) 166 | return 167 | } 168 | } 169 | } 170 | @Suppress("UNCHECKED_CAST") 171 | callback.onResponse( 172 | call, 173 | Response.success( 174 | ApiResult.Failure.HttpFailure( 175 | code = response.code(), 176 | error = errorBody, 177 | tags = mapOf(okhttp3.Response::class to response.raw()), 178 | ) 179 | ), 180 | ) 181 | } 182 | } 183 | } 184 | ) 185 | } 186 | } 187 | } 188 | 189 | override fun responseType(): Type = apiResultType 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /integrations/retrofit/src/main/kotlin/com/slack/eithernet/integration/retrofit/ApiResultConverterFactory.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.integration.retrofit 17 | 18 | import com.slack.eithernet.ApiException 19 | import com.slack.eithernet.ApiResult 20 | import com.slack.eithernet.ResultType 21 | import com.slack.eithernet.createResultType 22 | import java.lang.reflect.ParameterizedType 23 | import java.lang.reflect.Type 24 | import okhttp3.ResponseBody 25 | import retrofit2.Converter 26 | import retrofit2.Retrofit 27 | 28 | /** 29 | * A custom [Converter.Factory] for [ApiResult] responses. This creates a delegating adapter for the 30 | * underlying type of the result, and wraps successful results in a new [ApiResult]. 31 | * 32 | * When delegating to a converter for the `Success` type, a [ResultType] annotation is added to the 33 | * forwarded annotations to allow for a downstream adapter to potentially contextually decode the 34 | * result and throw an [ApiException] with a decoded error type. 35 | */ 36 | public object ApiResultConverterFactory : Converter.Factory() { 37 | override fun responseBodyConverter( 38 | type: Type, 39 | annotations: Array, 40 | retrofit: Retrofit, 41 | ): Converter? { 42 | if (getRawType(type) != ApiResult::class.java) return null 43 | 44 | val successType = (type as ParameterizedType).actualTypeArguments[0] 45 | val errorType = type.actualTypeArguments[1] 46 | val errorResultType: Annotation = createResultType(errorType) 47 | val nextAnnotations = annotations + errorResultType 48 | val delegateConverter = 49 | retrofit.nextResponseBodyConverter(this, successType, nextAnnotations) 50 | return ApiResultConverter(delegateConverter) 51 | } 52 | 53 | private class ApiResultConverter(private val delegate: Converter) : 54 | Converter> { 55 | override fun convert(value: ResponseBody): ApiResult<*, *>? { 56 | return delegate.convert(value)?.let(ApiResult.Companion::success) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /integrations/retrofit/src/main/kotlin/com/slack/eithernet/integration/retrofit/tags.jvm.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2021 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.slack.eithernet.integration.retrofit 17 | 18 | import com.slack.eithernet.ApiResult 19 | import com.slack.eithernet.tag 20 | import okhttp3.Request 21 | import okhttp3.Response 22 | 23 | /* 24 | * Common tags added automatically to different ApiResult types. 25 | */ 26 | 27 | /** Returns the original [Response] used for this call. */ 28 | public fun ApiResult<*, *>.response(): Response? = tag() 29 | 30 | /** Returns the original [Request] used for this call. */ 31 | public fun ApiResult<*, *>.request(): Request? = response()?.request() ?: tag() 32 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -exo pipefail 4 | 5 | # Gets a property out of a .properties file 6 | # usage: getProperty $key $filename 7 | function getProperty() { 8 | grep "${1}" "$2" | cut -d'=' -f2 9 | } 10 | 11 | # Increments an input version string given a version type 12 | # usage: increment_version $version $version_type 13 | increment_version() { 14 | local delimiter=. 15 | local array=() 16 | while IFS='' read -r line; do array+=("$line"); done < <(echo "$1" | tr $delimiter '\n') 17 | local version_type=$2 18 | local major=${array[0]} 19 | local minor=${array[1]} 20 | local patch=${array[2]} 21 | 22 | if [ "$version_type" = "--major" ]; then 23 | major=$((major + 1)) 24 | minor=0 25 | patch=0 26 | elif [ "$version_type" = "--minor" ]; then 27 | minor=$((minor + 1)) 28 | patch=0 29 | elif [ "$version_type" = "--patch" ]; then 30 | patch=$((patch + 1)) 31 | else 32 | echo "Invalid version type. Must be one of: '--major', '--minor', '--patch'" 33 | exit 1 34 | fi 35 | 36 | incremented_version="$major.$minor.$patch" 37 | 38 | echo "${incremented_version}" 39 | } 40 | 41 | # Gets the latest version from the CHANGELOG.md file. Note this assumes the changelog is updated with the 42 | # new version as the latest, so it gets the *2nd* match. 43 | # usage: get_latest_version $changelog_file 44 | get_latest_version() { 45 | local changelog_file=$1 46 | grep -m 2 -o '^[0-9]\+\.[0-9]\+\.[0-9]\+' "$changelog_file" | tail -n 1 47 | } 48 | 49 | # Updates the VERSION_NAME prop in all gradle.properties files to a new value 50 | # usage: update_gradle_properties $new_version 51 | update_gradle_properties() { 52 | local new_version=$1 53 | 54 | find . -type f -name 'gradle.properties' | while read -r file; do 55 | if grep -q "VERSION_NAME=" "$file"; then 56 | local prev_version 57 | prev_version=$(getProperty 'VERSION_NAME' "${file}") 58 | sed -i '' "s/${prev_version}/${new_version}/g" "${file}" 59 | fi 60 | done 61 | } 62 | 63 | # default to patch if no second argument is given 64 | version_type=${1:---patch} 65 | LATEST_VERSION=$(get_latest_version CHANGELOG.md) 66 | NEW_VERSION=$(increment_version "$LATEST_VERSION" "$version_type") 67 | NEXT_SNAPSHOT_VERSION="$(increment_version "$NEW_VERSION" --minor)-SNAPSHOT" 68 | 69 | echo "Publishing $NEW_VERSION" 70 | 71 | # Prepare release 72 | update_gradle_properties "$NEW_VERSION" 73 | git commit -am "Prepare for release $NEW_VERSION." 74 | git tag -a "$NEW_VERSION" -m "Version $NEW_VERSION" 75 | 76 | # Publish 77 | ./gradlew publish -x dokkaHtml --no-configuration-cache 78 | 79 | # Prepare next snapshot 80 | echo "Setting next snapshot version $NEXT_SNAPSHOT_VERSION" 81 | update_gradle_properties "$NEXT_SNAPSHOT_VERSION" 82 | git commit -am "Prepare next development version." 83 | 84 | # Push it all up 85 | git push && git push --tags 86 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | rootProject.name = "eithernet" 17 | 18 | // ^^ This is important because Gradle module metadata uses this in the pom artifact id of test 19 | // fixtures! 20 | 21 | dependencyResolutionManagement { repositories { mavenCentral() } } 22 | 23 | pluginManagement { 24 | repositories { 25 | mavenCentral() 26 | gradlePluginPortal() 27 | } 28 | } 29 | 30 | include(":eithernet") 31 | 32 | include(":eithernet:test-fixtures") 33 | 34 | include(":integrations:retrofit") 35 | -------------------------------------------------------------------------------- /spotless/spotless.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) $YEAR Slack Technologies, LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ --------------------------------------------------------------------------------