One of the main issues with code and API design across an enterprise is consistency. Due to the lack of specification in the OpenAPI specification regarding error messages, the resulting JSON schemas could vary significantly between APIs. These variances are rarely attributed to incorrect versus correct designs, but rather subjective viewpoints as it relates to the data structures. Most of the time, it actually varies simply based on naming of structure and attributes but can sometimes differ based on the degree of details included in the error responses.
The first step is to create and agree to a single set of JSON schemas making up the error response from any API. For this structure, we must handle two different error modes: expected and unexpected. Expected errors from an API are those related to 4XX type errors. These are business logic or data validation errors which should be expected by the API consumer. Unexpected errors are those which are outside the normal operations. These include infrastructure errors such as database availability issues or communication errors related to unexpected payload parsing exceptions. These two cases have a few differing requirements.
Expected | Unexpected | |
Cardinality | A single error response may contain one of more errors related to data validation or business logic | An unexpected error response will only contain a single error message |
Details | Detailed explanations of why the request was not processed is required | Generic, abstracted explanations should be used as to not provide any detailed system information to the user |
Alerting | No alerts are required for expected errors | System administrators should be notified immediately for unexpected errors as they could indicate poor application health |
To accomplish this in a single data structure, we can use the following JSON schema.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"errorId": {
"type": ["string", "null"]
},
"errorMessages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": ["string", "null"]
},
"message": {
"type": "string"
}
},
"required": ["message"]
}
}
},
"required": ["errorMessages"]
}
This schema defines the ErrorResponse
as an object with an optional errorId
and a required errorMessages
array. Each item in the errorMessages
array is an object defined by ErrorMessage
, which has an optional path
and a required message
. The schema allows for errorId
and path
to be null. The errorId
is used for unexpected errors where the end-user could provide the id as part of a ticket or other support request. This id should be printed directly into the logs along with the details of the error so support can easily search and find the details of the error. Implementation could simply be a GUID or some other unique identifier.
As part of an OpenAPI specification, it would look something like this.
openapi: 3.0.0
info:
title: My API
version: 1.0.0
paths:
...
components:
schemas:
ErrorMessage:
type: object
properties:
path:
type: string
nullable: true
message:
type: string
description: Description of the error
required:
- message
ErrorResponse:
type: object
properties:
errorId:
type: string
nullable: true
description: Unique identifier for the unexpected error
errorMessages:
type: array
items:
$ref: '#/components/schemas/ErrorMessage'
required:
- errorMessages
Creating a Common API Jar
From an enterprise standpoint, there are a couple of choices you could make. One of them is to simply define the API and then allow the teams to generate the model and handling themselves without interference. This works well in polyglot environments with a wide variety. However, if you are building a bunch of Java-based Jakarta components, then there is an opportunity to consolidate all the common code into a single enterprise jar.
Below are the code examples of what to include. Remember to include the jandex index generator in the jar build so that Quarkus will pick up the components.
Here’s an example POM for the Common jar.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ossrep.api</groupId>
<artifactId>ossrep-api-common</artifactId>
<version>0.0.1</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.6</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.smallrye</groupId>
<artifactId>jandex-maven-plugin</artifactId>
<version>3.2.3</version>
<executions>
<execution>
<id>make-index</id>
<goals>
<goal>jandex</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
public record ErrorMessage(String path, String message) {
public ErrorMessage(String message) {
this(null, message);
}
}
import java.util.List;
public record ErrorResponse(String errorId, List<ErrorMessage> errorMessages) {
public ErrorResponse(List<ErrorMessage> errorMessages) {
this(null, errorMessages);
}
public ErrorResponse(String errorId, ErrorMessage errorMessage) {
this(errorId, List.of(errorMessage));
}
public ErrorResponse(ErrorMessage errorMessage) {
this(null, List.of(errorMessage));
}
}
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.UUID;
@Provider
public class ThrowableMapper implements ExceptionMapper<Throwable> {
private final static Logger LOGGER = LoggerFactory.getLogger(ThrowableMapper.class);
@Override
public Response toResponse(Throwable e) {
String errorId = UUID.randomUUID().toString();
String defaultErrorMessage = this.getDefaultErrorMessage();
ErrorMessage errorMessage = new ErrorMessage(null, defaultErrorMessage);
ErrorResponse errorResponse = new ErrorResponse(errorId, errorMessage);
LOGGER.error(errorResponse.toString());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errorResponse).build();
}
private String getDefaultErrorMessage() {
return "An unexpected system error has occurred";
}
}
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.List;
@Provider
public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException e) {
List<ErrorMessage> errorMessages = e.getConstraintViolations().stream()
.map(constraintViolation -> new ErrorMessage(constraintViolation.getPropertyPath().toString(), constraintViolation.getMessage()))
.toList();
return Response.status(Response.Status.BAD_REQUEST).entity(new ErrorResponse(errorMessages)).build();
}
}
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Provider
public class NotFoundExceptionMapper implements ExceptionMapper<NotFoundException> {
private final static Logger LOGGER = LoggerFactory.getLogger(NotFoundExceptionMapper.class);
@Override
public Response toResponse(NotFoundException e) {
LOGGER.debug("{}", e.getMessage());
return e.getResponse();
}
}