Brewing Better APIs: OpenAPI and Quarkus for Java Developers
Learn how to build, document, and test a Coffee API with contracts that scale across teams and clients.
APIs are promises. They connect frontends, backends, and third-party consumers across an enterprise. But a promise written only in code is fragile: documentation drifts, expectations misalign, and integrations break.
OpenAPI fixes this by turning your REST API into a living contract. Humans explore it with Swagger UI. Machines consume it as JSON or YAML for code generation, testing, and validation. A good OpenAPI spec is as important as your source code.
Quarkus makes this easy with the smallrye-openapi extension. In this tutorial, we’ll build a Coffee API that goes beyond “Hello World”:
Expose REST endpoints with contracts.
Enrich the spec with annotations.
Add security metadata.
Annotate models with examples, validation, and Jackson mappings.
Generate a Java client from the spec.
Write Quarkus tests to enforce the contract.
Prerequisites
You’ll need:
Java 17+
Maven 3.9+
Quarkus CLI (
sdk install quarkus
orbrew install quarkusio/tap/quarkus
)HTTPie or
curl
for quick testing
Bootstrapping the Coffee API
Let’s create the project with REST and OpenAPI support:
quarkus create app org.acme:coffee-api \
-x rest-jackson,smallrye-openapi,hibernate-validator
cd coffee-api
This sets up a Maven project with REST endpoints (rest-jackson
) and OpenAPI/Swagger integration (smallrye-openapi
).
Follow along and build yourself or grab the ready made example from my Github repository!
Defining the Coffee Resource
Our API starts simple: list all coffees, and fetch one by ID.
src/main/java/org/acme/coffee/CoffeeResource.java
:
package org.acme.coffee;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
@Path("/coffee")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoffeeResource {
@GET
public List<Coffee> listCoffees() {
return List.of(
new Coffee(1L, "Espresso", "Strong and bold"),
new Coffee(2L, "Latte", "Smooth with milk"),
new Coffee(3L, "Cappuccino", "Foamy delight")
);
}
@GET
@Path("/{id}")
public Coffee getCoffee(@PathParam("id") Long id) {
return new Coffee(id, "Filter Coffee", "Simple classic");
}
}
And the model class:
package org.acme.coffee;
public class Coffee {
public Long id;
public String name;
public String description;
public Coffee() {
}
public Coffee(Long id, String name, String description) {
this.id = id;
this.name = name;
this.description = description;
}
}
Run the app:
quarkus dev
Visit http://localhost:8080/coffee to see the JSON list.
Exploring the Auto-Generated Contract
Quarkus now generates an OpenAPI spec automatically:
Raw contract: http://localhost:8080/q/openapi (file download)
Swagger UI: http://localhost:8080/q/swagger-ui
This is already useful. But real-world APIs need richer contracts.
Enriching Endpoints with Annotations
Add descriptions, status codes, and examples with MicroProfile OpenAPI annotations:
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
@GET
@Operation(
summary = "List all coffees",
description = "Returns the full coffee menu including Espresso, Latte, etc."
)
@APIResponses({
@APIResponse(
responseCode = "200",
description = "Coffee list",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = Coffee.class))
)
})
public List<Coffee> listCoffees() { ... }
@GET
@Path("/{id}")
@Operation(summary = "Find coffee by ID", description = "Returns a coffee if it exists")
@APIResponses({
@APIResponse(responseCode = "200", description = "Coffee found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Coffee.class))),
@APIResponse(responseCode = "404", description = "Coffee not found")
})
public Coffee getCoffee(@PathParam("id") Long id) { ... }
Why it matters:
@Operation
explains what the endpoint does.@APIResponse
makes status codes explicit.@Schema
ties responses to model definitions.
Swagger UI now looks like a professional API, not just a skeleton.
Documenting Security
A contract without security details is incomplete. You can declare schemes even before implementation.
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme;
@SecurityScheme(securitySchemeName = "coffee-oauth", type = org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType.OAUTH2, description = "OAuth2 flow for Coffee API")
@Path("/coffee")
@SecurityRequirement(name = "coffee-oauth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoffeeResource {
// endpoints...
}
Swagger UI will only show the Authorize button if you configure it correctly though. But clients already know about it now.
Enriching the Model
Endpoints are only half the story. Models can also be documented.
package org.acme.coffee;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Schema(name = "Coffee", description = "A coffee drink with details")
public class Coffee {
@JsonProperty("coffeeId")
@Schema(required = true, example = "1", description = "Unique coffee identifier")
public Long id;
@NotBlank
@Size(max = 50)
@Schema(required = true, example = "Espresso")
public String name;
@Schema(example = "Strong and bold taste")
public String description;
@JsonIgnore
public String internalNote;
public Coffee() {
}
public Coffee(Long id, String name, String description) {
this.id = id;
this.name = name;
this.description = description;
}
}
Here’s how things fit together:
OpenAPI annotations (
@Schema
) add examples and descriptions.Hibernate Validation (
@NotBlank
,@Size
) show up as constraints in the spec.Jackson annotations (
@JsonProperty
,@JsonIgnore
) control JSON output, and the spec respects them.
Swagger UI now shows realistic examples and constraints that consumers can rely on.
Adding a POST Endpoint
Let’s accept new coffees. Validation rules apply both at runtime and in the OpenAPI contract.
import jakarta.validation.Valid;
import jakarta.ws.rs.core.Response;
@POST
@Operation(summary = "Add a new coffee", description = "Creates a new coffee entry")
@APIResponses({
@APIResponse(responseCode = "201", description = "Coffee created"),
@APIResponse(responseCode = "400", description = "Invalid input")
})
public Response addCoffee(@Valid Coffee coffee) {
// In a real app, persist the coffee
return Response.status(Response.Status.CREATED).entity(coffee).build();
}
Try it in Swagger UI: fill out a JSON request body and hit “Execute.”
If you submit an empty name, the validation fails automatically.
Generating a Java Client
A contract enables reuse. The OpenAPI Generator CLI creates client code in many languages.
Install the CLI
With Homebrew:
brew install openapi-generator
Or manually:
wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.7.0/openapi-generator-cli-7.7.0.jar -O openapi-generator-cli.jar
alias openapi-generator-cli="java -jar $(pwd)/openapi-generator-cli.jar"
Generate a client
curl http://localhost:8080/q/openapi > coffee-api.yaml
openapi-generator-cli generate \
-i coffee-api.yaml \
-g java \
-o coffee-client
In the generated project:
CoffeeApi api = new CoffeeApi();
List<Coffee> coffees = api.listCoffees();
Consumers don’t need to hand-code clients. The contract does the heavy lifting.
Testing the Contract
Swagger UI is great for manual checks, but automation matters. Quarkus integrates with REST Assured:
src/test/java/org/acme/coffee/CoffeeResourceTest.java
:
package org.acme.coffee;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.notNullValue;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class CoffeeResourceTest {
@Test
void shouldListCoffees() {
given()
.when().get("/coffee")
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("size()", greaterThan(0))
.body("[0].name", notNullValue());
}
@Test
void shouldRejectInvalidCoffee() {
given()
.contentType(ContentType.JSON)
.body("{\"name\": \"\"}")
.when().post("/coffee")
.then()
.statusCode(400);
}
}
Run them with:
quarkus test
You now validate both the API implementation and the contract rules.
Advanced OpenAPI Features for Enterprise APIs
So far, we’ve created a functional contract with documentation, models, and validation. In larger projects, you’ll need to go further. OpenAPI provides additional annotations to organize, externalize, and standardize your APIs.
Let’s look at the most useful ones.
Tags for Grouping Endpoints
When you have dozens of endpoints, Swagger UI can look overwhelming. Tags let you group related endpoints under a single label.
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/coffee")
@Tag(name = "Coffee", description = "Endpoints related to coffee drinks")
public class CoffeeResource {
// GET /coffee, POST /coffee...
}
In Swagger UI, all endpoints from this resource now appear under the Coffee section.
You can apply multiple tags to a single endpoint if it belongs in more than one category.
This helps consumers quickly find what they need without scrolling through a flat list.
Declaring Servers
Most APIs live in multiple environments: development, staging, production. OpenAPI lets you declare them, so consumers know where to connect.
import org.eclipse.microprofile.openapi.annotations.servers.Server;
@Server(url = "{baseUrl}", description = "Dynamic base URL for all environments")
@Path("/coffee")
public class CoffeeResource { ... }
Notice the {baseUrl}
placeholder instead of hardcoding. In production, CI/CD or deployment tooling should set this property dynamically. For example:
Local dev → http://localhost:8080
Staging → https://staging.api.mycompany.com
Production → https://api.mycompany.com
Rule of thumb: Never hardcode server URLs in code. Treat them as configuration, not contracts.
Linking to External Documentation
Sometimes your API needs to reference business logic, domain knowledge, or integration guides. Use @ExternalDocumentation
to make Swagger UI more useful.
import org.eclipse.microprofile.openapi.annotations.ExternalDocumentation;
@ExternalDocumentation(
description = "Coffee brewing guide",
url = "https://example.com/docs/brewing"
)
@Path("/coffee")
public class CoffeeResource { ... }
Swagger UI now shows a clickable link next to your Coffee API section. This is a lightweight way to bridge internal contracts with external business rules.
Supporting Multiple Content Types
Not all APIs return JSON. Sometimes you need CSV for reporting, or PDF for invoices. OpenAPI lets you describe them.
@GET
@Path("/export")
@Produces("text/csv")
@Operation(summary = "Export coffee list", description = "Download coffee menu in CSV format")
@APIResponse(responseCode = "200", description = "CSV file with all coffees", content = @Content(mediaType = "text/csv"))
public Response exportCoffeesAsCsv() {
List<Coffee> coffees = listCoffees();
StringBuilder csv = new StringBuilder();
csv.append("id,name,description\n");
for (Coffee coffee : coffees) {
csv.append(coffee.id).append(",")
.append("\"").append(coffee.name).append("\",")
.append("\"").append(coffee.description).append("\"\n");
}
return Response.ok(csv.toString())
.type("text/csv")
.header("Content-Disposition", "attachment; filename=coffees.csv")
.build();
}
Swagger UI shows the endpoint as returning text/csv
, and consumers can download the file.
This makes your contract explicit: clients won’t mistakenly expect JSON when you’re sending a binary format.
Versioning Strategies
In enterprises, APIs evolve. OpenAPI lets you communicate versioning clearly:
Path-based versioning:
@Path("/api/v1/coffee")
@Tag(name = "Coffee v1")
public class CoffeeResourceV1 { ... }
Tag-based versioning:
@Tag(name = "v2", description = "Second version of the Coffee API")
public class CoffeeResourceV2 { ... }
Which strategy you choose depends on your governance rules. The important part: your OpenAPI contract should always make versioning explicit.
Putting It Together
Imagine our Coffee API now has:
A Coffee tag for grouping.
A dynamic server URL set by configuration.
A CSV export endpoint alongside JSON.
An external documentation link to the brewing guide.
A version label for clear lifecycle management.
Swagger UI becomes not just a toy, but a living, navigable map of your API. Developers, testers, and architects can all rely on it.
Closing Thoughts
Your API is more than endpoints. It is a promise to every consumer, frontends, mobile apps, partner systems, that your service will behave as documented. Quarkus and OpenAPI help you keep that promise.
With just a few extensions and annotations, you can:
Generate contracts automatically and expose them through Swagger UI.
Enrich endpoints with human-friendly descriptions, response codes, and examples.
Document security requirements before they’re implemented.
Reuse models with Jackson, Bean Validation, and
@Schema
annotations.Generate clients in many languages from a single contract.
Enforce behavior with automated tests.
And when your APIs grow beyond a handful of endpoints, OpenAPI gives you advanced tools:
Tags to group endpoints for clarity.
Dynamic servers set through configuration, so your spec adapts from dev to prod without code changes.
External documentation links to connect contracts with domain knowledge.
Multiple content types so consumers know exactly what to expect.
Versioning strategies that make lifecycle management explicit.
Together, these capabilities turn OpenAPI from “just documentation” into a living contract that scales with your enterprise. Quarkus makes the integration seamless, so your team can focus on delivering features while the framework keeps the promises honest.
Just like a perfectly brewed cup of coffee, a well-documented API should be clear, consistent, and satisfying. Every time. ☕