OpenAPI, Code Generation, Bazel, and Spring Boot

Tom Liu
7 min readNov 5, 2021

--

Once upon a time, people used Spring Boot and Bazel to build web application REST API that uses HTTP, JSON, and you know ….

OpenAPI

There was an OpenAPI as a standard to define the REST API spec. People edit the OpenAPI spec by using a swagger editor here.

One day, I put the following content to the editor,

openapi: 3.0.3
info:
title: Hello World API
description: Say hello API
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
version: 1.0.0
servers:
- url: /hello-service/v1
tags:
- name: Hello Service
description: Hello Service to say hello
# - name: Other Service
# description: Other Service with different tag & API interface
paths:
/hello:
put:
tags:
- Hello Service
summary: update the way to say hello.
operationId: updateHello
responses:
200:
description: Updated the way to say hello
content:
application/json:
schema:
$ref: '#/components/schemas/Hello' # this can be in another file 'path/to/file#/components/schemas/Hello'
500:
description: Error to update
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
get:
tags:
- Hello Service
summary: Get hello
operationId: getHello
responses:
200:
description: Got hello object
content:
application/json:
schema:
$ref: '#/components/schemas/Hello'
500:
description: Error getting hello
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/hello/{language}:
get:
tags:
- Hello Service
summary: Get hello by language
operationId: getHelloByLang
parameters:
- name: language
in: path
required: true
schema:
type: string
responses:
200:
description: Got hello object
content:
application/json:
schema:
$ref: '#/components/schemas/Hello'
500:
description: Error getting hello
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Hello:
type: object
properties:
language:
type: string
enum:
- English
- French
- Chinese
default: English
Content:
type: string
Error:
type: object
properties:
code:
type: integer
description: Error code
message:
type: string
description: Error message

Mysteriously, I got the following content displayed quite nicely on the page.

Quite nice content

Code Generation

There are many ways you can generate the Spring Boot code, say, by using openapi-generator gradle/Bazel plugin, by using openapi-generator CLI, etc. I simply used the CLI as this:

$ java -jar openapi-generator-cli-5.1.0.jar  generate -i hello.yaml -g spring --package-name com.awesome.tom --model-package com.awesome.tom.model --api-package com.awesome.tom.api --invoker-package com.awesome.tom -o generated

This command awesomely generated the files look like this.

The awesome files

Bazel

Bazel build is like a complicated manual car which requires lots of manual operations. Fortunately, this JVM rules helps a lot.

First, I created a WORKSPACE file located at the root of the Bazel project. Don’t ask me how I figured out the artifacts/dependencies. I just knew it.

workspace(name = "hello")
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
RULES_JVM_EXTERNAL_TAG = "4.1"
RULES_JVM_EXTERNAL_SHA = "f36441aa876c4f6427bfb2d1f2d723b48e9d930b62662bf723ddfb8fc80f0140"
http_archive(
name = "rules_jvm_external",
strip_prefix = "rules_jvm_external-%s" % RULES_JVM_EXTERNAL_TAG,
sha256 = RULES_JVM_EXTERNAL_SHA,
url = "https://github.com/bazelbuild/rules_jvm_external/archive/%s.zip" % RULES_JVM_EXTERNAL_TAG,
)
load("@rules_jvm_external//:defs.bzl", "maven_install")
maven_install(
artifacts = [
"org.slf4j:slf4j-api:2.0.0",
"org.slf4j:slf4j-simple:2.0.0",
"org.springframework.boot:spring-boot-autoconfigure:2.3.9.RELEASE",
"org.springframework.boot:spring-boot-test-autoconfigure:2.3.9.RELEASE",
"org.springframework.boot:spring-boot-test:2.3.9.RELEASE",
"org.springframework.boot:spring-boot:2.3.9.RELEASE",
"org.springframework.boot:spring-boot-starter-web:2.3.9.RELEASE",
"org.springframework.boot:spring-boot-starter-tomcat:2.3.9.RELEASE",
"org.springframework.boot:spring-boot-loader:2.3.9.RELEASE",
"org.springframework:spring-beans:5.2.13.RELEASE",
"org.springframework:spring-context:5.2.13.RELEASE",
"org.springframework:spring-test:5.2.13.RELEASE",
"org.springframework:spring-web:5.2.13.RELEASE",
"org.springframework:spring-webmvc:5.2.13.RELEASE",
"io.springfox:springfox-core:2.9.2",
"io.springfox:springfox-spi:2.9.2",
"io.springfox:springfox-spring-web:2.9.2",
"io.springfox:springfox-swagger2:2.9.2",
"io.springfox:springfox-swagger-ui:2.9.2",
"org.openapitools:jackson-databind-nullable:0.2.1",
"com.fasterxml.jackson.core:jackson-annotations:2.11.4",
"com.fasterxml.jackson.core:jackson-databind:2.11.4",
"io.swagger:swagger-annotations:1.5.20",
"javax.servlet:javax.servlet-api:4.0.0",
"javax.validation:validation-api:2.0.1.Final",
],
fetch_sources = True,
repositories = [
"https://repo1.maven.org/maven2",
],
)

Bonus Note: guess what, if there are 2 libraries whose versions do not match, you will get an error like this at runtime:

......
The method's class, ..., is available from the following locations:
......
Action:
Correct the classpath of your application so that it contains a single, compatible version of ...

OK, let’s continue. After that, I created an empty file at the root of the project. Don’t ask me why. I got trouble without it.

$ touch BUILD

Then, I run this command in the root of the project:

$ bazel run @maven//:pin

After the command completed, it sweetly showed this instruction on the screen:

Next, please update your WORKSPACE file by adding the maven_install_json attribute and loading pinned_maven_install from @maven//:defs.bzl.
For example:
=============================================================
maven_install(
artifacts = # ...,
repositories = # ...,
maven_install_json = "@//:maven_install.json",
)
load("@maven//:defs.bzl", "pinned_maven_install")
pinned_maven_install()
=============================================================
To update maven_install.json, run this command to re-pin the unpinned repository:
bazel run @unpinned_maven//:pin

So I did:

......
maven_install(
......
maven_install_json = "//:maven_install.json",
repositories = [
"https://repo1.maven.org/maven2",
],
)
load("@maven//:defs.bzl", "pinned_maven_install")
pinned_maven_install()

From then on, every time when I added a dependency in the WORKSPACE file, I run this command to update maven_install.json:

$ bazel run @unpinned_maven//:pin

After WORKSPACE file is done, I created a BUILD.bazel file in the root of the project. Note: when there are multiple sub-projects, the BUILD.bazel shouldn’t be in the root folder. I put it here simply as a demo.

load("@rules_jvm_external//:defs.bzl", "artifact")
package(default_visibility = ["//visibility:public"])
java_library(
name = "hello",
srcs = glob(["generated/src/main/java/**/*.java"]),
deps = [
artifact("org.slf4j:slf4j-api"),
artifact("org.slf4j:slf4j-simple"),
artifact("org.springframework:spring-context"),
artifact("org.springframework:spring-beans"),
artifact("io.springfox:springfox-spi"),
artifact("io.springfox:springfox-swagger2"),
artifact("io.springfox:springfox-swagger-ui"),
artifact("io.springfox:springfox-core"),
artifact("io.springfox:springfox-spring-web"),
artifact("io.swagger:swagger-annotations"),
artifact("org.springframework.boot:spring-boot"),
artifact("org.springframework.boot:spring-boot-autoconfigure"),
artifact("org.springframework.boot:spring-boot-starter-web"),
artifact("org.springframework:spring-web"),
artifact("org.springframework:spring-webmvc"),
artifact("com.fasterxml.jackson.core:jackson-annotations"),
artifact("org.openapitools:jackson-databind-nullable"),
artifact("com.fasterxml.jackson.core:jackson-databind"),
artifact("javax.servlet:javax.servlet-api"),
artifact("javax.validation:validation-api"),
],
runtime_deps = [
artifact("org.springframework.boot:spring-boot-starter-tomcat")
]
)
java_binary(
name = "app",
main_class = "com.awesome.tom.OpenAPI2SpringBoot",
resources = [
"generated/src/main/resources/application.properties"
],
runtime_deps = [
":hello",
],
)

Guess what, if there are 2 libraries whose versions do not match, you will get an error like this at runtime:

......
The method's class, ..., is available from the following locations:
......
Action:
Correct the classpath of your application so that it contains a single, compatible version of ...

Spring Boot

First, let’s add a Hello service:

package com.awesome.tom.api;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class HelloService {
@Value("${my.defaul.hello:'Say Hello word'}")
private String hi;
public String getHello() {
return hi;
}
}

In HelloAPIController.java, use the Hello service to get Hello:

package com.awesome.tom.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.NativeWebRequest;
import java.util.Optional;
import com.awesome.tom.model.Hello;
import com.awesome.tom.model.Hello.LanguageEnum;

@RestController
@RequestMapping("${openapi.helloWorld.base-path:/hello-service/v1}")
public class HelloApiController implements HelloApi {
@Autowired
private HelloService svc;
private final NativeWebRequest request;
@org.springframework.beans.factory.annotation.Autowired
public HelloApiController(NativeWebRequest request) {
this.request = request;
}
@Override
public Optional<NativeWebRequest> getRequest() {
return Optional.ofNullable(request);
}
public ResponseEntity<Hello> getHello() {
try {
Hello hello = new Hello().language(LanguageEnum.ENGLISH).content(this.svc.getHello());
return new ResponseEntity<>(hello, HttpStatus.OK);
} catch (Exception e) {
com.awesome.tom.model.Error error =
new com.awesome.tom.model.Error().code(HttpStatus.INTERNAL_SERVER_ERROR.value()).message("something went wrong");
return new ResponseEntity(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}

Note that although the method signature has ResponseEntity<Hello>, you can still conveniently return a Hello object on success or an Error object on failure. The trick is to remove the generic from ResponseEntity when returning an Error object.

I started the server by Bazel successfully:

$ bazel run //:app

Voila… on localhost:

Note: In case you have put the controller in another location. If there is no Hello Service endpoints displayed in the page, please check the OpenAPIDocumentationConfig.java file to make sure the RequestHandlerSelectors.basePackage is pointing to the location where you put your controllers. If you have multiple controllers in different locations, instead of using thebasePackage method, use RequestHandlerSelectors.withClassAnnotation(RestController.class)

That is not the end of the story. We still need executable jar for Spring Boot app, right?

In WORKSPACE file, I added this rules_spring at the end:

......
load("@maven//:defs.bzl", "pinned_maven_install")
pinned_maven_install()
http_archive(
name = "rules_spring",
sha256 = "9385652bb92d365675d1ca7c963672a8091dc5940a9e307104d3c92e7a789c8e",
urls = [
"https://github.com/salesforce/rules_spring/releases/download/2.1.4/rules-spring-2.1.4.zip",
],
)

Then, in BUILD.bazel, I did:

load("@rules_jvm_external//:defs.bzl", "artifact")
load("@rules_spring//springboot:springboot.bzl", "springboot")
......
runtime_deps = [
artifact("org.springframework.boot:spring-boot-loader"),
artifact("org.springframework.boot:spring-boot-starter-tomcat")
]
......
springboot(
name = "app-springboot",
boot_app_class = "com.awesome.tom.OpenAPI2SpringBoot",
java_library = ":hello",
)

Then, I run this command to start the server:

$ bazel run //:app-springboot

Voila… on localhost , you will see the same page as above.

Wait a minute… where is the executable jar?

Voila… under your project root, here it stays: bazel-bin/app-springboot.jar. In fact, it is also shown in the log when you run the aforementioned command:

$ bazel run //:app-springboot
......
Target //:app-springboot up-to-date:
bazel-bin/libhello.jar
bazel-bin/MANIFEST.MF
bazel-bin/bazelrun_env.sh
bazel-bin/git.properties
bazel-bin/app-springboot.jar

Then, I run this command to start the server:

$ java -jar bazel-bin/app-springboot.jar

Voila… on localhost , you will see the same page as above.. Deja vu?

Then, I run this curl to try my REST API:

$ curl -X GET "http://localhost:8080/hello-service/v1/hello" -H "accept: application/json"

I got this response:

{"language":"English","Content":"Say Hello word"}

Say Hello word” ? Doesn’t that sound wierd?

This is the end of the story.

Actually, it doesn’t completely end yet. If you are upgrading your project to use the latest Spring Boot 2.6.7, Springfox 3.0.0 etc. Surprise! you will have compiling failure due to that EnableSwagger2 along with some other classes are missing. What can I do? Springdoc to the rescue. Spring team also provided example here. Isn’t that sweet?

For request JSON payload validation, the OpenAPI has validations annotations such as string max length etc. These annotations will be interpreted as javax.validation annotations in the generated code. Spring Boot requires the following dependency to be added to WORKSPACE and deps section of your BUILD.bazel. I believe you know where and how to add dependency now :-)

org.springframework.boot:spring-boot-starter-validation:2.3.9.RELEASE

To show a detailed validation error message in the REST response, please add this to application.properties:

server.error.include-binding-errors=always

Yes, that’s it, only tiny change in WORKSPACE, BUILD.bazel, and application.properties. Then you will have detailed error messages for all the HTTP requests that violate the validation rules. Isn’t that sweet once more?

This is the end of the story, probably…

Ref

  1. https://github.com/bazelbuild/rules_jvm_external/tree/master/examples/spring_boot
  2. https://docs.bazel.build/versions/4.2.1/be/java.html#java_binary.deploy_manifest_lines
  3. https://github.com/salesforce/rules_spring
  4. https://openapi-generator.tech/docs/usage/

--

--

Tom Liu

Life is beautiful…