JVM Advent

The JVM Programming Advent Calendar

New Year’s Resolution: Be a Good API Citizen

2021 is getting closer and it’s time to draft your new year’s resolution 🎆. How about becoming a “better API citizen”?

In my free time, I maintain and contribute to several open-source Kotlin libraries and projects on Github.

With great tools comes great responsibility. And as a library maintainer, I have the duty of protecting the library public API. This means making sure that the library evolves sustainably, without pushing breaking changes to our clients. This also means proactively discover breaking changes introduced by external Pull Requests.

In this blog-post, I will walk you through several techniques that will help you to become a better API citizen. Please note that this blogpost will mostly focus on the perspective of a Kotlin developer. However, those suggestions are valid for all the APIs consumed by languages on the JVM.

@Deprecated

When evolving your library, you will necessarily end up in a situation where you want to remove an API. Perhaps you exposed something that was not supposed to. Or you have a broken method and an alternative implementation that you prefer your users to use.

In Kotlin you can use the @Deprecated annotation to mark APIs that should not be used anymore:

@Deprecated(
    message = "This method is local-specific, replace with lowercase() that is locale-agnostic",
    replaceWith = ReplaceWith("lowercase()"),
    level = DeprecationLevel.ERROR
)
fun toLowerCase(with: String)

Kotlin allows you to specify also:

  • A message to clarify why you’re deprecating an API.
  • An alternative API to use with ReplaceWith. IDEs can consume this to offer quick-fixes to your users while typing.
  • The deprecation level to define if usages of this API should raise a Warning or an Error. You can also use DeprecationLevel.HIDDEN to mark the API as hidden. This will make them inaccessible from the code while still keeping the binary compatibility.

As a rule of thumb, consider having a deprecation cycle for your most used APIs. This means communicating upfront when you plan to remove an API (e.g. either in the next major release or in a fixed amount of time). Don’t forget to mention in your release notes if you’re deprecating or are planning to remove some APIs.

@OptIn

As you’re removing old API, you might want to add new ones. If you have some experience with library development, you know that a poorly-designed API feels like a cage. It’s expensive to maintain and hard to evolve.

That’s why Kotlin offers you the possibility to specify Experimental APIs with the @OptIn and @RequiresOptIn annotations.

To do so, you first define a custom annotation that you can use to mark the experimental APIs:

@RequiresOptIn(message = "This API is experimental")
annotation class MyExperimentalApi

You can then use your annotation to specify the surface of your API that is experimental:

@MyExperimentalApi
fun getAnswer() = 42

Once a user tries to use this API, they will get notified with a warning and will have two alternatives.

They can annotate their usages with the same @MyExperimentalApi annotation. This will cause their usage to propagate the experimental API surface. Callers of the new method will also get warned of the experimental API usages:

@MyExperimentalApi
fun main() {
    println(getAnswer())
}

Or they can annotate their usages with the @OptIn annotation. This will not propagate the experimental API surface and will work as an acknowledgment of the user on the API contract:

@OptIn(MyExperimentalApi::class)
fun main() {
    println(getAnswer())
}

Using @OptIn allows you to be intentional when evolving your API. Your users will have to commit to your experimental API so they should expect breakages on those specific parts of the API.

Explicit API Mode

With Kotlin 1.4, library and SDK developers have another tool that can simplify their lives as maintainers: Explicit API Mode, a compiler mode that enforces the API of your Kotlin code to be “explicit”.

You can enable Explicit API Mode using the -Xexplicit-api={strict|warning} compiler flag or by adding this snippet in your build.gradle:

kotlin {
    explicitApi()
    // OR
    explicitApiWarning()
}

One check performed with Explicit API Mode is the explicit visibility. The compiler will warn you for all the classes/methods/interfaces that don’t have specified visibility. This is useful as the default visibility for Kotlin is public (rather than Java’s package-private default visibility). The result is Kotlin libraries accidentally leaking implementation classes that should not be exposed.

Explicit API Mode will also force you to use explicit types. This helps to prevent breaking changes caused by inferred return types. For instance in the function from the previous example:

fun getAnswer() = 42.0

the return type is not specified. The exposed API will have Int as the return type, given that the type is inferred from the literal 42. If you accidentally change the literal to 42.0, you will change the return type from Int to Double resulting in a breaking change pushed to your clients.

With Explicit API Mode enabled, the compiler forces you to adapt the code to:

public fun getAnswer() : Int = 42

Semantic Versioning

How often do you bump your major version?

Do you feel that your releases are never perfect and you find it hard to release a new major version of your library? Have you ever felt a connection between human emotions and version bumps?

If this is the case, you should consider switching to Semantic Versioning. If you haven’t done it yet, I invite you to take a look at the full specification on https://semver.org/ (you can find also a nice Backus–Naur-form grammar for it).

In short words, Semantic Versioning relies on having a version number that follows the MAJOR.MINOR.PATCH schema. You’re supposed to increment:

  • MAJOR version when you make incompatible API changes,
  • MINOR version when you add functionality in a backwards-compatible manner, and
  • PATCH version when you make backwards-compatible bug fixes.

Following this versioning schema allows you to have a clear contract with your users. They will know what to expect from your library just by looking at the version number.

Automatic API verification

Using @Deprecated@OptIn, Explicit API mode, and Semantic versioning would be great steps towards becoming a better API citizen. Yet, we’re humans and we do mistakes, a lot of them.

I invite you to take a look at the Evolving Java-based APIs Part 1 and Part 2 from the Eclipse documentation to get an overview of all the possible scenarios that lead to breaking or non-breaking API changes.

If you feel overwhelmed by it, you’re not alone.

Luckily, there are tools to warn you if you’re accidentally introducing a breaking change in your API.

I prefer to use those two open-source alternatives:

  • binary-compatibility-validator, a Gradle plugin to compute a file representation of your public Kotlin/Java API.
  • japicmp, a Java tool to compute the API diff between two JARs.

binary-compatibility-validator

Kotlin/binary-compatibility-validator is a Gradle plugin that helps you manage your public API.

The setup is straightforward as all the other Gradle plugins:

buildscript {
    dependencies {
        classpath("org.jetbrains.kotlinx:binary-compatibility-validator:0.2.4")
    }
}
apply plugin: "binary-compatibility-validator"

Once applied, it offers two Gradle tasks that you can run on your project:

  • apiDump to compute a dump of the public API and save it in a .api file.
  • apiCheck to compute again the dump of the public API and compare it with the one already on disk.

Once you setup this tool, you should run apiDump once for your projects and commit the .api files to your repository.

During your normal workflow, you can run the apiCheck task to verify if your code change is introducing a change in the public API. If so, that task will fail. You are then supposed to rerun apiDump and add the modified .api file to your repository. This makes sure all the code changes that are impacting the public API are tracked with a related change to the .api file.

Moreover, having a .api file is a convenient way to have an always up-to-date snapshot of your public API.

Beware that the apiCheck task will warn you for every change to your public API, both the breaking and the non-breaking ones. To have more fine-grained control over the type of changes you introduce, you can use japicmp.

japicmp

japicmp is a Java tool to compute the diff between the public API of two JARs.

To use it, you can download a fat JAR with all the dependencies from Maven Central and run it from the command line. You need to pass both the old and the new JAR that you’re interested in comparing.

$ java -jar japicmp-jar-with-dependencies.jar \
    --old library-1.0.0.jar \
    --new library-1.1.0.jar

Japicmp supports several flags that allow you to fail the execution if there is either a binary or a source incompatible change:

$ java -jar japicmp-jar-with-dependencies.jar -h

SYNOPSIS
        java -jar japicmp.jar [-a <accessModifier>] [(-b | --only-incompatible)]
                [(-e <excludes> | --exclude <excludes>)] [--exclude-exclusively]
                [(-h | --help)] [--html-file <pathToHtmlOutputFile>]
                [--html-stylesheet <pathToHtmlStylesheet>]
                [(-i <includes> | --include <includes>)] [--ignore-missing-classes]
                [--ignore-missing-classes-by-regex <ignoreMissingClassesByRegEx>...]
                [--include-exclusively] [--include-synthetic] [(-m | --only-modified)]
                [(-n <pathToNewVersionJar> | --new <pathToNewVersionJar>)]
                [--new-classpath <newClassPath>] [--no-annotations]
                [(-o <pathToOldVersionJar> | --old <pathToOldVersionJar>)]
                [--old-classpath <oldClassPath>] [--report-only-filename]
                [(-s | --semantic-versioning)]
                [(-x <pathToXml> | --xml-file <pathToXml>)]
                [--error-on-binary-incompatibility]
                [--error-on-source-incompatibility]
                [--error-on-modifications]
                [--no-error-on-exclusion-incompatibility]
                [--error-on-semantic-incompatibility]
                [--ignore-missing-old-version] [--ignore-missing-new-version]

You can also use the --semantic-versioning flag to let the tool compute your correct Semantic Versioning bump.

By default, japicmp will print out the API diff to the console. If you’re looking into a more convenient way to navigate your API diff, you can use the --html-file flag to get an HTML report that would look like this one:

Sample Japicmp HTML report

Sample Japicmp HTML report

If you’re building your project with either Maven or Gradle, you can use the respective Maven plugin or Gradle plugin.

Conclusion

Evolving Java/Kotlin APIs is hard.

Using automated tools such as binary-compatibility-validator or japicmp can help you catch unintended breaking changes before you release them to your users.

Ultimately, consider using @Deprecated@OptIn, Explicit API mode, and Semantic Versioning to make your API more user-friendly and become a better API citizen.

Author: Nicola Corti

Kotlin GDE – Android Infra @ Spotify

Nicola Corti is a Google Developer Expert for Kotlin. He has been working with the language since before version 1.0 and he is the maintainer of several open-source libraries and tools.

He’s currently working as Android Infrastructure Engineer at Spotify in Stockholm, Sweden.

Next Post

Previous Post

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

© 2024 JVM Advent | Powered by steinhauer.software Logosteinhauer.software

Theme by Anders Norén