21
Keeping public API in check with the Kotlin binary validator plugin
Within the Stream Chat Android SDK project, we use the Kotlin binary compatibility validator plugin to keep track of all the changes we make to our public API. This is a first-party plugin by JetBrains, though it's still experimental (it's an incubator project by JetBrains on GitHub).
In this article, you'll learn what the plugin does, how to set up and configure it, and how you can use it to make your library project better.
Let's start with a TL;DR of what the plugin does:
It generates
.api
files that describe the public API for each module, and if you make changes to public API, you have to explicitly update the content of the.api
files (otherwise, checks will fail, alerting you of the accidental API change).
By doing this, it guarantees that developers on the project are always aware of the exact changes they're making to public API. Since the plugin works on binary API, it will catch incompatible changes that might not be obvious when looking at source code. For example, when writing Kotlin code, using data classes, default implementations, or companion objects might create binary API that you're usually unaware of.
The binary validator is available as a simple Gradle plugin, which makes setup really quick. Just add this code to your top level build.gradle
file:
buildscript {
dependencies {
classpath 'org.jetbrains.kotlinx:binary-compatibility-validator:0.6.0'
}
}
apply plugin: 'binary-compatibility-validator'
With the plugin added, it's now time to configure it. You can do this inside the apiValidation
block. Here's the configuration we use in our SDK as an example:
apiValidation {
ignoredPackages += [ // 1
'com/getstream/sdk/chat/databinding',
'io/getstream/chat/android/ui/databinding',
]
ignoredProjects += [ // 2
'stream-chat-android-docs',
'stream-chat-android-sample',
'stream-chat-android-ui-components-sample',
'stream-chat-android-test',
]
nonPublicMarkers += [ // 3
'io.getstream.chat.android.core.internal.InternalStreamChatApi',
]
}
Let's look at each of the options we're using:
-
ignoredPackages
allows you to exclude some packages from validation. In our case, we don't want to keep track of the files generated by View Binding. You could also useignoredClasses
to exclude individual classes by name. - By default, the plugin will be enabled for all modules of a project. We use
ignoredProjects
to exclude non-published modules, like documentation and sample apps. - The
nonPublicMarkers
entry allows you to specify any Kotlin Opt-in annotations that you're using on API that's not considered publicly available. To learn more about these, watch Mastering API Visibility in Kotlin.
To (re)generate the .api
files, you'll have to run the apiDump
Gradle task. When you're doing this for the first time, you should review all the generated .api
files to make sure that everything in there is really supposed to be public API, and then commit them to your repository.
Here's a small sample of what you'll see in these files:
public abstract interface class io/getstream/chat/android/ui/StyleTransformer {
public abstract fun transform (Ljava/lang/Object;)Ljava/lang/Object;
}
public final class io/getstream/chat/android/ui/common/Debouncer {
public fun <init> (J)V
public final fun shutdown ()V
public final fun submit (Lkotlin/jvm/functions/Function0;)V
public final fun submitSuspendable (Lkotlin/jvm/functions/Function1;)V
}
public abstract interface class io/getstream/chat/android/ui/common/UrlSigner {
public abstract fun signFileUrl (Ljava/lang/String;)Ljava/lang/String;
public abstract fun signImageUrl (Ljava/lang/String;)Ljava/lang/String;
}
You can then use the apiCheck
Gradle task to verify that your current source code still has the same API as your committed .api
files. This task will create up-to-date .api
dumps in temporary build folders, and compare them to the .api
files you have in your repository. If they don't match, it will fail, and report the differences detected.
For example, see this error the task generates after moving a property from a data class' constructor to its body:
Execution failed for task ':stream-chat-android-client:apiCheck'.
> API check failed for project stream-chat-android-client.
@@ -218,9 +218,8 @@
public final class io/getstream/chat/android/client/api/models/AutocompleteFilterObject : io/getstream/chat/android/client/api/models/FilterObject {
public final fun component1 ()Ljava/lang/String;
- public final fun component2 ()Ljava/lang/String;
- public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/client/api/models/AutocompleteFilterObject;
+ public final fun copy (Ljava/lang/String;)Lio/getstream/chat/android/client/api/models/AutocompleteFilterObject;
public fun equals (Ljava/lang/Object;)Z
public final fun getFieldName ()Ljava/lang/String;
public final fun getValue ()Ljava/lang/String;
You can run :stream-chat-android-client:apiDump task to overwrite API declarations
If those differences are intentional, you should run the apiDump
task again, which updates the stored .api
files, now including any changes in the API you've created by changing the source code. Running the apiCheck
task at this point will complete successfully, as the sources and the .api
files are in sync again.
This is the goal of the plugin: making developers explicitly run a task to update the API description files when they touch public API.
You should then commit the changes you've introduced to the .api
files. These will show up in commits and on pull requests, allowing reviewers to easily tell when you've made changes to the binary API, and catch any unintentional changes.
The power of the plugin really shows if you have automated checks in place to make sure that whenever you change your public API, you've actually updated the .api
files to match.
At Stream, we run the checks in two ways:
- Using a pre-commit git hook in our repository that will run the
apiCheck
task (and automatically runapiDump
for you if it fails) when you try to create a new commit in the repository. - With a GitHub Action step in our PR checks workflow that makes sure that the
api
files are up-to-date before we merge changes.
This plugin is a binary validator. This means that it doesn't track public API changes that don't affect binary compatibility. For example, reified functions are part of source-level API but are not present in the binary API. Thankfully, changes in these should be obvious from the source file changes anyway (and they're not very frequent).
There are also some known issues in the library, as you can see on GitHub. We've reported some of these ourselves, mostly for problems in respecting non-public markers, which we use extensively in our project (see #36 and #58).
Even with those limitations in mind, the binary compatibility validator plugin is a great tool for making sure that you don't make any accidental changes in your public binary API when building a library. Check it out on GitHub.
You'll also find our Android Chat SDK on GitHub, and you can try our In-App Messaging Tutorial to get started with it.
More library development topics you might be interested in:
21