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.

Core concept

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.

Setup and configuration

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:

  1. 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 use ignoredClasses to exclude individual classes by name.
  2. 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.
  3. 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.

Usage

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.

Git hooks and CI checks

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 run apiDump 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.

Limitations

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

Conclusion

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:

19