Implementing Zalando RESTful API Guidelines with Quarkus
Consistency and predictability are cornerstones of well-designed APIs. They enhance developer experience, reduce integration friction, and promote maintainability. The Zalando RESTful API Guidelines provide a comprehensive and pragmatic set of rules for building robust and usable web APIs.
Quarkus, with its focus on developer productivity, standards-based approach (JAX-RS, MicroProfile), and performance, is an excellent framework for implementing these guidelines effectively. This article provides a practical guide on how to apply key aspects of the Zalando guidelines within a Quarkus application.
Prerequisites
JDK 11+ installed
Apache Maven 3.8.1+ or Gradle 7+
Your favorite IDE
(Optional) Quarkus CLI
Setting Up a Basic Quarkus Project
Let's start with a standard Quarkus REST application using RESTEasy Reactive with Jackson for JSON handling.
Bash
# Using Quarkus CLI
quarkus create app com.example:zalando-guidelines-app \
--extension='resteasy-reactive-jackson'
cd zalando-guidelines-app
# Or using Maven directly
mvn io.quarkus.platform:quarkus-maven-plugin:3.21.0:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=zalando-guidelines-app \
-Dextensions="resteasy-reactive-jackson"
cd zalando-guidelines-app
This creates a basic project with a sample GreetingResource
. We'll modify and add to this.
Guideline Implementation in Quarkus
Let's look at some specific guidelines and see how to implement them using Quarkus and JAX-RS / RESTEasy Reactive features.
1. Use API Definition Language (OpenAPI)
Guideline: Provide an OpenAPI (Swagger) definition for your API.
Quarkus Implementation: Quarkus has excellent built-in support via the quarkus-smallrye-openapi
extension (often included by default with REST extensions).
Ensure Dependency: If not already present, add it:
Bash
# Using Quarkus CLI
quarkus ext add quarkus-smallrye-openapi
# Or add manually to pom.xml
# <dependency>
# <groupId>io.quarkus</groupId>
# <artifactId>quarkus-smallrye-openapi</artifactId>
# </dependency>
Automatic Generation: Quarkus automatically scans your JAX-RS annotations (
@Path
,@GET
,@POST
,@Produces
,@Consumes
, POJOs) to generate theopenapi.json
oropenapi.yaml
specification.Access: Run your application (
quarkus dev
) and access:/q/openapi
- The raw OpenAPI definition./q/swagger-ui
- The interactive Swagger UI.
Enhance Definition (Optional but recommended): Use MicroProfile OpenAPI annotations to add more detail:
package com.example.resource;
import com.example.model.Product;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
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.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import java.util.Collections;
import java.util.List;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Product Operations", description = "APIs for managing products")
public class ProductResource {
@GET
@Path("/{id}")
@Operation(summary = "Get Product by ID", description = "Retrieves a single product based on its unique identifier.")
@APIResponse(responseCode = "200", description = "Product found",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = Product.class)))
@APIResponse(responseCode = "404", description = "Product not found")
public Response getProductById(
@Parameter(description = "ID of the product to retrieve", required = true)
@PathParam("id") String id) {
// ... implementation (fetch product) ...
Product product = findProduct(id); // Placeholder
if (product != null) {
return Response.ok(product).build();
} else {
// We'll improve error handling later
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@GET
@Operation(summary = "List Products", description = "Retrieves a list of products, potentially filtered or paginated.")
@APIResponse(responseCode = "200", description = "List of products",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = ProductList.class))) // Use a wrapper for lists
public Response listProducts() {
// ... implementation (fetch products, pagination etc.) ...
List<Product> products = Collections.emptyList(); // Placeholder
// Always return a wrapper object, even for lists, for future flexibility
return Response.ok(new ProductList(products)).build();
}
// ... other methods (POST, PUT, DELETE) with annotations ...
// --- Helper Methods/Classes ---
private Product findProduct(String id) {
// Simulate finding a product
if ("prod123".equals(id)) {
return new Product("prod123", "Awesome Gadget", 99.99);
}
return null;
}
// Example POJO
public static class Product {
public String id;
public String name;
public double price;
// Constructors, getters, setters (or public fields for simplicity here)
public Product(String id, String name, double price) {
this.id = id; this.name = name; this.price = price;
}
public Product() {} // Needed for JSON-B/Jackson
}
// Wrapper for list responses
public static class ProductList {
public List<Product> items;
public ProductList(List<Product> items) { this.items = items; }
public ProductList() {} // Needed for JSON-B/Jackson
}
}
2. Use Common Vocabulary (JSON, camelCase)
Guideline: Use application/json
. Use camelCase
for property keys.
Quarkus Implementation:
quarkus-resteasy-reactive-jackson
handlesapplication/json
by default.Use standard Java Bean naming conventions (private fields, public getters/setters, or public fields if preferred for simple DTOs). Jackson automatically maps Java
camelCase
fields/properties to JSONcamelCase
keys.
// In Product.java (Example POJO)
public class Product {
// Jackson maps this Java camelCase field to JSON "productId"
private String productId;
private String productName;
private double listPrice;
// Constructors...
// Standard Getters/Setters
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
// ... other getters/setters ...
}
Ensure your JAX-RS methods declare
@Produces(MediaType.APPLICATION_JSON)
and@Consumes(MediaType.APPLICATION_JSON)
(though often default).
3. Use Problem JSON (RFC 7807) for Errors
Guideline: Use application/problem+json
for reporting errors. Provide standard fields (type
, title
, status
, detail
, instance
).
Quarkus Implementation: Implement a JAX-RS ExceptionMapper
.
Define
ProblemDetail
POJO: Create a class representing the RFC 7807 structure.
package com.example.error;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.net.URI;
@JsonInclude(JsonInclude.Include.NON_NULL) // Don't include null fields in JSON
public class ProblemDetail {
// Standard fields from RFC 7807
private URI type = URI.create("about:blank"); // Default, change if you have specific error type URIs
private String title;
private int status;
private String detail;
private URI instance; // URI identifying the specific occurrence of the problem
// Optional extension fields (add as needed)
@JsonProperty("invalid_params") // Example extension field
private Object invalidParams;
// Private constructor - use builder pattern or static factory methods
private ProblemDetail(Builder builder) {
this.type = builder.type;
this.title = builder.title;
this.status = builder.status;
this.detail = builder.detail;
this.instance = builder.instance;
this.invalidParams = builder.invalidParams;
}
// Getters... (Needed for Jackson serialization)
public URI getType() { return type; }
public String getTitle() { return title; }
public int getStatus() { return status; }
public String getDetail() { return detail; }
public URI getInstance() { return instance; }
public Object getInvalidParams() { return invalidParams; }
// --- Builder Pattern ---
public static Builder builder(int status) {
return new Builder(status);
}
public static class Builder {
private URI type = URI.create("about:blank");
private String title;
private final int status;
private String detail;
private URI instance;
private Object invalidParams;
Builder(int status) {
this.status = status;
// Auto-set title based on common statuses (optional)
this.title = getDefaultTitle(status);
}
private String getDefaultTitle(int status) {
switch (status) {
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 409: return "Conflict";
case 500: return "Internal Server Error";
// Add more as needed
default: return "Error";
}
}
public Builder type(URI type) { this.type = type; return this; }
public Builder type(String typeUri) { this.type = URI.create(typeUri); return this; }
public Builder title(String title) { this.title = title; return this; }
public Builder detail(String detail) { this.detail = detail; return this; }
public Builder instance(URI instance) { this.instance = instance; return this; }
public Builder instance(String instanceUri) { this.instance = URI.create(instanceUri); return this; }
public Builder invalidParams(Object invalidParams) { this.invalidParams = invalidParams; return this; }
public ProblemDetail build() {
return new ProblemDetail(this);
}
}
}
Create
ExceptionMapper
(s): Map specific exceptions (or a generalException
) to aProblemDetail
response.
package com.example.error;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Provider // Make Quarkus discover this mapper
public class NotFoundExceptionMapper implements ExceptionMapper<NotFoundException> {
private static final Logger log = LoggerFactory.getLogger(NotFoundExceptionMapper.class);
public static final String PROBLEM_JSON_TYPE = "application/problem+json";
@Context // Inject UriInfo to get the request path
UriInfo uriInfo;
@Override
public Response toResponse(NotFoundException exception) {
log.debug("Mapping NotFoundException: {}", exception.getMessage());
ProblemDetail problem = ProblemDetail.builder(Response.Status.NOT_FOUND.getStatusCode())
.title("Resource Not Found") // Overrides default if needed
.detail(exception.getMessage())
.instance(uriInfo.getAbsolutePath()) // Use request path as instance URI
// Optionally add a custom type URI if you have one defined
// .type("https://example.com/errors/not-found")
.build();
return Response.status(Response.Status.NOT_FOUND)
.type(PROBLEM_JSON_TYPE) // Set Content-Type
.entity(problem)
.build();
}
}
// You might want a general purpose mapper too:
@Provider
public class GeneralExceptionMapper implements ExceptionMapper<Exception> {
private static final Logger log = LoggerFactory.getLogger(GeneralExceptionMapper.class);
public static final String PROBLEM_JSON_TYPE = "application/problem+json";
@Context
UriInfo uriInfo;
@Override
public Response toResponse(Exception exception) {
// Handle specific web application exceptions if needed
int status = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode();
String title = "Internal Server Error";
String detail = "An unexpected error occurred.";
if (exception instanceof jakarta.ws.rs.WebApplicationException) {
Response originalResponse = ((jakarta.ws.rs.WebApplicationException) exception).getResponse();
status = originalResponse.getStatus();
// Try to reuse the title/detail if appropriate, otherwise use defaults
// Be careful not to leak sensitive internal details!
title = Response.Status.fromStatusCode(status) != null ?
Response.Status.fromStatusCode(status).getReasonPhrase() : "Error";
detail = exception.getMessage() != null ? exception.getMessage() : "An error occurred processing the request.";
} else {
// Log non-web exceptions with more detail for debugging
log.error("Unhandled internal exception processing request {}: {}", uriInfo.getAbsolutePath(), exception.getMessage(), exception);
}
ProblemDetail problem = ProblemDetail.builder(status)
.title(title)
.detail(detail) // Be cautious about exposing internal messages
.instance(uriInfo.getAbsolutePath())
.build();
return Response.status(status)
.type(PROBLEM_JSON_TYPE)
.entity(problem)
.build();
}
}
Quarkus automatically detects classes annotated with
@Provider
.Use
@Context UriInfo
to get the path of the request (instance
field).Set the
Content-Type
toapplication/problem+json
.Create specific mappers for custom application exceptions (e.g.,
ValidationException
,DuplicateResourceException
) for more granular error reporting.
4. Resource Naming Conventions
Guideline: Use plural nouns for collections (/products
). Use IDs in the path for specific resources (/products/{productId}
). Use standard HTTP methods (GET, POST, PUT, DELETE, PATCH).
Quarkus Implementation: Use JAX-RS annotations directly.
@Path("/products") // Plural noun for the collection resource
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
@GET // GET on collection -> List resources
public Response listProducts(/*... pagination params ...*/) { /* ... */ }
@POST // POST on collection -> Create new resource
public Response createProduct(Product newProduct, @Context UriInfo uriInfo) {
// ... save product, generate ID ...
String newId = "prod" + System.currentTimeMillis(); // Placeholder ID generation
URI location = uriInfo.getAbsolutePathBuilder().path(newId).build();
return Response.created(location)./* entity(createdProduct) .*/build(); // Return 201 Created
}
@GET
@Path("/{productId}") // Path parameter for specific resource ID
public Response getProductById(@PathParam("productId") String productId) { /* ... */ }
@PUT
@Path("/{productId}") // PUT on specific resource -> Replace/update entire resource
public Response updateProduct(@PathParam("productId") String productId, Product productData) {
// ... find existing, update all fields ...
// Return 200 OK or 204 No Content
return Response.ok(/* updatedProduct or */).build();
}
@DELETE
@Path("/{productId}") // DELETE on specific resource -> Delete resource
public Response deleteProduct(@PathParam("productId") String productId) {
// ... delete product ...
return Response.noContent().build(); // Return 204 No Content
}
// PATCH (requires custom handling or JSON-Patch library)
@PATCH
@Path("/{productId}")
@Consumes("application/merge-patch+json") // Or application/json-patch+json
public Response patchProduct(@PathParam("productId") String productId, /* JsonMergePatch or JsonPatch object */ Object patchData) {
// ... apply partial update ...
// Be careful with PATCH implementation complexity. JSON Merge Patch is simpler.
return Response.ok(/* patchedProduct */).build();
}
}
5. Pagination
Guideline: Use query parameters (limit
, offset
or page-based) for pagination. Provide Link
headers or embed pagination info in the response body.
While this is a super simple example, make sure to check how to do “Pagination” with Hibernate ORM with Panache for a database backed example.
Quarkus Implementation:
Read Query Parameters: Use
@QueryParam
with default values.
@GET
@Operation(summary = "List Products (Paginated)")
@APIResponse(responseCode = "200", description = "Paginated list of products")
public Response listProductsPaginated(
@Parameter(description = "Number of items per page") @QueryParam("limit") @DefaultValue("20") int limit,
@Parameter(description = "Offset for pagination") @QueryParam("offset") @DefaultValue("0") int offset,
@Context UriInfo uriInfo) {
// Validate limit/offset (e.g., max limit)
if (limit < 1 || limit > 100) limit = 20; // Example validation
if (offset < 0) offset = 0;
// --- Fetch Data (Simulated) ---
long totalCount = getTotalProductCount(); // Get total count for pagination links
List<Product> products = findProductsPaginated(limit, offset); // Fetch subset
// --- Build Response ---
PaginatedResponse<Product> responsePayload = new PaginatedResponse<>(
products,
offset,
limit,
totalCount,
uriInfo // Pass UriInfo to generate links
);
// --- Add Link Headers (Optional but good practice) ---
Response.ResponseBuilder responseBuilder = Response.ok(responsePayload);
responsePayload.getLinks().forEach((rel, link) -> {
responseBuilder.link(link.getUri(), link.getRel());
});
return responseBuilder.build();
}
// --- Helper methods (Simulated data access) ---
private long getTotalProductCount() { return 150; }
private List<Product> findProductsPaginated(int limit, int offset) {
// Simulate fetching from DB based on limit/offset
List<Product> all = /* get all products */;
int start = Math.min(offset, all.size());
int end = Math.min(offset + limit, all.size());
if (start >= end) return Collections.emptyList();
return all.subList(start, end);
}
Create a Paginated Response Wrapper: Encapsulate data and pagination metadata.
package com.example.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.ws.rs.core.Link;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class PaginatedResponse<T> {
private List<T> items;
private PaginationMetadata pagination;
// Links can be in headers OR embedded here (Zalando prefers headers)
// @JsonIgnore // If you ONLY want Link headers
private Map<String, PaginationLink> links;
public PaginatedResponse(List<T> items, int offset, int limit, long totalCount, UriInfo uriInfo) {
this.items = items;
this.pagination = new PaginationMetadata(offset, limit, items.size(), totalCount);
this.links = buildLinks(uriInfo, offset, limit, totalCount);
}
// Needed for JSON-B/Jackson
public PaginatedResponse() {}
// --- Getters ---
public List<T> getItems() { return items; }
public PaginationMetadata getPagination() { return pagination; }
public Map<String, PaginationLink> getLinks() { return links; }
private Map<String, PaginationLink> buildLinks(UriInfo uriInfo, int offset, int limit, long totalCount) {
Map<String, PaginationLink> linkMap = new HashMap<>();
UriBuilder baseBuilder = uriInfo.getAbsolutePathBuilder(); // Base URI for the current request
// Self
linkMap.put("self", new PaginationLink(baseBuilder.clone()
.queryParam("offset", offset)
.queryParam("limit", limit)
.build().toString()));
// First
linkMap.put("first", new PaginationLink(baseBuilder.clone()
.queryParam("offset", 0)
.queryParam("limit", limit)
.build().toString()));
// Last
if (totalCount > 0) {
long lastOffset = ((totalCount - 1) / limit) * limit;
linkMap.put("last", new PaginationLink(baseBuilder.clone()
.queryParam("offset", lastOffset)
.queryParam("limit", limit)
.build().toString()));
}
// Next
if (offset + limit < totalCount) {
linkMap.put("next", new PaginationLink(baseBuilder.clone()
.queryParam("offset", offset + limit)
.queryParam("limit", limit)
.build().toString()));
}
// Previous
if (offset > 0) {
int prevOffset = Math.max(0, offset - limit);
linkMap.put("prev", new PaginationLink(baseBuilder.clone()
.queryParam("offset", prevOffset)
.queryParam("limit", limit)
.build().toString()));
}
return linkMap;
}
// --- Inner classes for Metadata and Links ---
public static class PaginationMetadata {
public int offset;
public int limit;
@JsonProperty("returned_items") // Example: Use snake_case if needed via Jackson
public int returnedItems;
@JsonProperty("total_items")
public long totalItems;
public PaginationMetadata(int offset, int limit, int returnedItems, long totalItems) {
this.offset = offset; this.limit = limit; this.returnedItems = returnedItems; this.totalItems = totalItems;
}
public PaginationMetadata() {} // Jackson
}
public static class PaginationLink {
public String href;
// Add other Link attributes if needed (e.g., type, title)
public PaginationLink(String href) { this.href = href; }
public PaginationLink() {} // Jackson
// Helper for building JAX-RS Link objects for headers
public Link toJaxRsLink(String rel) {
return Link.fromUri(href).rel(rel).build();
}
}
}
6. Filtering and Sorting
Guideline: Use query parameters for filtering (e.g., ?status=active
) and sorting (e.g., ?sort=name,-createdAt
).
Quarkus Implementation:
Use
@QueryParam
to capture filter criteria and sort parameters.Implementing the actual filtering/sorting logic depends heavily on your persistence layer (JPA, Panache, SQL). You'll need to parse these parameters and translate them into database queries. This can range from simple
if
statements to complex query builders or specification patterns.
@GET
@Operation(summary = "List Products (Filtered/Sorted)")
public Response listProductsFiltered(
@Parameter(description = "Filter by status") @QueryParam("status") String status,
@Parameter(description = "Filter by minimum price") @QueryParam("minPrice") Double minPrice,
@Parameter(description = "Sort order (e.g., 'name', '-createdAt')") @QueryParam("sort") String sort,
@Context UriInfo uriInfo) {
log.info("Filtering by status={}, minPrice={}, sort={}", status, minPrice, sort);
// --- Query Building Logic (Placeholder) ---
// 1. Parse 'sort' parameter (e.g., split by ',', handle '-')
// 2. Build query based on 'status', 'minPrice', etc. using your ORM/DB layer
// Example using Panache (if you were using quarkus-hibernate-orm-panache):
// PanacheQuery<Product> query;
// Parameters params = new Parameters();
// String queryString = "FROM Product p WHERE 1=1";
// if (status != null) { queryString += " AND p.status = :status"; params.and("status", status); }
// if (minPrice != null) { queryString += " AND p.price >= :minPrice"; params.and("minPrice", minPrice); }
// Sort panacheSort = parseSortParameter(sort); // Implement this helper
// query = Product.find(queryString, panacheSort, params);
// List<Product> products = query.list();
List<Product> products = findFilteredProducts(status, minPrice, sort); // Placeholder
return Response.ok(new ProductList(products)).build(); // Assume non-paginated for simplicity here
}
// --- Placeholder Filter/Sort Logic ---
private List<Product> findFilteredProducts(String status, Double minPrice, String sort) {
// In a real app, this interacts with the database based on criteria
log.warn("Filtering/Sorting logic is simulated!");
return List.of(
new Product("prod1", "Gadget A", 50.0),
new Product("prod2", "Gadget B", 150.0)
);
}
// Dummy ProductList wrapper from earlier example
public static class ProductList {
public List<Product> items;
public ProductList(List<Product> items) { this.items = items; }
public ProductList() {}
}
// Dummy Product POJO from earlier example
public static class Product {
public String id;
public String name;
public double price;
public Product(String id, String name, double price) {
this.id = id; this.name = name; this.price = price;
}
public Product() {}
}
// Add logger
private static final Logger log = LoggerFactory.getLogger(ProductResource.class);
Other Considerations
Versioning: The guidelines recommend URI versioning (e.g.,
/v1/products
) or header versioning. URI versioning is straightforward in Quarkus using@Path("/v1/products")
. Header versioning requires checking@HeaderParam
within your methods.Security: Zalando recommends OAuth 2.0. Quarkus provides robust security extensions (
quarkus-oidc
,quarkus-smallrye-jwt
). Implementing security is a significant topic beyond the scope of this article but is well-supported.Deprecation: Use
Deprecation
andSunset
headers for retiring API versions or endpoints. You can add these headers to the JAX-RSResponse
object.Idempotency: Understand and implement idempotency correctly for PUT, DELETE, and potentially POST operations (using
Idempotency-Key
header if needed). Quarkus itself doesn't enforce this; it's an application logic concern.Testing: Use
quarkus-junit5
and REST Assured (included with Quarkus test dependencies) to write integration tests verifying your API adheres to the expected contracts, including error responses and status codes.
Summary
The Zalando RESTful API Guidelines offer valuable direction for creating high-quality APIs. Quarkus, with its adherence to standards like JAX-RS, MicroProfile OpenAPI, and its flexible extension mechanism, provides an excellent platform for implementing these guidelines efficiently.
By using features like automatic OpenAPI generation, straightforward JSON mapping with Jackson, custom ExceptionMapper
s for Problem+JSON, and standard JAX-RS annotations for resource structure and request handling, you can build consistent, predictable, and developer-friendly REST APIs in Quarkus that align closely with industry best practices like those defined by Zalando. Remember that consistency is key – apply the chosen guidelines uniformly across your API landscape.