Having up-to-date API documentation is important as it enables the users of your application to know what routes your application serves the required payload and expected responses. Updating it manually takes time and ensuring everything accurately reflects the way the application is currently working is hard. Automating this process can help you detect accidental changes on the API, and it reflects the application's behaviour.

Luckily, there are ways to automatically generate the documentation and as a bonus, it follows the OpenAPI Specification. This way, you can ensure that our APIs are following the same documentation pattern and are easily updated.

In this article, we will explore how to configure the generation of your API specification in your Kotlin application using the springdoc library.

Configuring SpringDoc

The SpringDoc is a library available for Spring Boot projects, that helps with the generation of the documentation.

First, you will need to add that library to the dependencies of our project in the gradle.build

dependencies {
  ...
  // Spring Doc
  implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0") {
     exclude("org.springframework.boot")
  }
}

You may need to exclude the org.springframework.boot to avoid conflict between packages.

With the package installed, you will be able to configure an endpoint in our application to access the documentation by adding this to the gradle.build:

openApi {
    apiDocsUrl.set("<http://localhost:8080/v3/api-docs.yaml>")
}

Now if you run your application and access the endpointhttp://localhost:8080/v3/api-docs.yaml you will get as a response the YAML of the OpenAPI Specification for the current application.

If you are getting an error, firstly, ensure that this route is not missing an authentication token and also check if it is enabled with this environment variable:

springdoc.api-docs.enabled=true
In my team we have chosen to keep this api-docs endpoint disabled, because we didn’t want the documentation to be generated with the running application. We generate it in the CI pipeline.

So far we have explored adding an endpoint which enables a way to generate the docs, however, this still entails additional manual work. You can add a plugin for Gradle, so the specification is generated automatically and saved to a file.

For this we need to add this plugin to the gradle.build:

plugins {
    ...
    id("org.springdoc.openapi-gradle-plugin") version "1.6.0"
    ...
}

You also need to make some changes to the open API specification in the gradle.build

openApi {
    apiDocsUrl.set("<http://localhost:8080/v3/api-docs.yaml>")
    outputFileName.set("openapi.yaml")
    customBootRun {
        jvmArgs.set(mutableListOf("-Dspringdoc.api-docs.enabled=true"))
    }
}

The apiDocsUrl is the address that the command will access to retrieve the documentation.

The outputFileName will determine the name of the file that will be generated, the default path that it will be created under the build directory.

The customBootRun configuration enables the api-docs endpoint, this is only necessary if you disabled the endpoint.

Also, you will need to update how some of the tasks run, to avoid conflict. You can do this by adding the following to the build.gradle

tasks.named("jar") {
    mustRunAfter("forkedSpringBootRun", "generateOpenApiDocs")
}

tasks.named("inspectClassesForKotlinIC") {
    mustRunAfter("forkedSpringBootRun", "generateOpenApiDocs")
}

Now you can execute the following:

gradle generateOpenApiDocs

It will start your application and generate a file with the name you defined in the name you specified in the build repository, now you just need to replace the openapi.yaml in your repository with this one, and it's updated.

If the specified endpoint for the API documentation requires any kind of authentication, it will fail to execute.

CI configuration

To improve this process of the documentation and ensure that it is always updated in the repository, you can add it to the CI Pipeline to always check that the documentation is updated.

First, we need to create a new stage for the CI process, we decided to put it just before the Test stage.

..
stages:
  ...
  - Docs
  - Test
  ...

For the configuration, of this step, you can do the following:

Generate Docs:
  stage: Docs
  services:
    # Services that are needed to start the application, 
    # in our case it was only the database.
    - name: database:latest
      alias: database

  extends: .gradle-base
  script:
    - gradle generateOpenApiDocs; exit_code=$?
    - if [ $exit_code -ne 0 ]; then echo "\\033[0;31mKotlin generateOpenApiDocs failed"; fi;
    - diff openapi.yaml build/openapi.yaml; diff_result=$?
    - if [ $diff_result -ne 0 ]; then echo "\\033[0;31mThe openapi doc in the repository is different from the generated one. Run \\033[0m./gradlew generateOpenApiDocs to generate a new one"; fi;
  rules:
    - if: '$CI_MERGE_REQUEST_ID || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  variables:
    ...
The configuration should include the database configuration, because the generateOpenApiDocs command need the application running to be able to generate the documentation

Taking a close look at the script part:

script:
    - gradle generateOpenApiDocs; exit_code=$?
    - if [ $exit_code -ne 0 ]; then echo "\\033[0;31mKotlin generateOpenApiDocs failed"; fi;
    - diff openapi.yaml build/openapi.yaml; diff_result=$?
    - if [ $diff_result -ne 0 ]; then echo "\\033[0;31mThe openapi doc in the repository is different from the generated one. Run \\033[0m./gradlew generateOpenApiDocs to generate a new one"; fi;

The CI stage will do the following:

  • execute gradle generateOpenApiDocs - which will start your application. If this fails it will raise an error
  • It will then compare the generated documentation file with the one already present in the repository.
    • If there are differences, it will print them and the job will fail.
    • If no differences are found the job will successfully complete.

Example of the Pipeline running

Consider that you added a new field to a request body and didn’t update the openapi.yaml in the repository, which was done on this commit.

data class UserRequestBody(
  ...
	val newField: String?,
)

When the pipeline executes the stage of the Docs you will be able to see the following:

$ diff openapi.yaml build/openapi.yaml; diff_result=$?
--- openapi.yaml
+++ build/openapi.yaml
@@ -551,6 +551,8 @@
           type: string
         connection:
           type: string
+        newField:
+          type: string
     ClientRequestBody:
       required:
       - active
Cleaning up project directory and file based variables
00:00
ERROR: Job failed: command terminated with exit code 1

Looking at the output of the job, it failed in the diff and shows us and is different because the newly generated documentation has a new field added to it, which is the desired effect. That job can be verified here.

Now, when you update the documentation and make a new commit, you can see that it now succeeds in the running of the job. The job was completed successfully, and it continued the execution of the pipeline.

$ if [ $exit_code -ne 0 ]; then echo "\\033[0;31mKotlin generateOpenApiDocs failed"; fi;
$ diff openapi.yaml build/openapi.yaml; diff_result=$?
$ if [ $diff_result -ne 0 ]; then echo "\\033[0;31mThe openapi doc in the repository is different from the generated one. Run \\033[0m./gradlew generateOpenApiDocs to generate a new one"; fi;
Saving cache for successful job
Creating cache default-non_protected...
WARNING: .gradle/wrapper: no matching files. Ensure that the artifact path is relative to the working directory (/builds/identity/identity-service) 
.gradle/caches: found 11795 matching artifact files and directories 
Uploading cache.zip to <https://ca-amt-shared-production-1-gitlab-runner-cache.s3.dualstack.eu-west-1.amazonaws.com/runner/project/3925/default-non_protected> 
Created cache
Cleaning up project directory and file based variables
00:01
Job succeeded

Why not auto-commit the generated file?

Instead of comparing the documentation files and giving a failing pipeline when they are different, you could auto-commit the file and avoid the manual work of updating the file.

In those cases, you may end up accidentally pushing changes that result in a different documentation when it shouldn’t, and you won’t be able to notice at first. With a pipeline failing, you can look into why it failed and see if the changes in the code should affect the documentation or not.

Reference

  1. https://github.com/springdoc/springdoc-openapi-gradle-plugin
  2. https://springdoc.org/#getting-started

Author: João Cabral