Contract-first development is a methodology where we first create a design document (or interface definition) that formally describes the REST API we are going to implement, before writing any actual prototype. This methodology is useful for clearly communicating the structure and capabilities of the upcoming API before we implement it. Given a formal description of the API using a definition language, consumers of our API can start building clients, or even test it against generated mock servers, if they want to. Therefore contract-first development increases the independence of coworking teams. In the design phase, the interface definition can serve as the shared design artifact, so that it becomes a mutually agreed contract between the implementors and the consumers of the endpoints.
OpenAPI (formerly Swagger) is not the only, but the most commonly used REST API markup language existing. It is a language-independent YAML-based format to describe the endpoints, content types, request and response payloads of an API. Those of us on the Management Center team of Hazelcast use OpenAPI documents for designing and reviewing our REST endpoints, and communicating changes between the frontend and backend developers. From a SOAP perspective, you can think about the OpenAPI file as an equivalent of a WSDL document.
In this post, we will demonstrate how can OpenAPI be used in a RestAssured-based integration test to ensure that the API conforms to its specification.
Defining and implementing the API
To get started, let’s define an API that we are going to implement and test throughout this article:
person-api.yaml
:
openapi: '3.0.2'
info:
title: 'Person API for testing with RestAssured and OpenAPI4J'
version: '1.0'
paths:
/person:
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Person'
responses:
200:
description: success
components:
schemas:
Person:
type: object
properties:
name:
type: string
age:
type: integer
minimum: 0
emails:
type: array
items:
type: string
minItems: 1
required:
- name
- age
- emails
additionalProperties: false
This markup defines a POST /person
endpoint, with a request body as a JSON object, and its schema is described using JSON schema. We are not going to go into the details of the JSON Schema language in this article. In a nutshell, as you can see, the request body should be an object with 3 properties, name
, age
and emails
.
So far so good, once this OpenAPI definition is complete, you can hand it over to other teams consuming the service, and you can use this definition as a common source of truth.
Now let’s proceed with an (intentionally skeletal) Spring Boot based implementation of the API:
PersonController.java
:
@RestController
public class PersonController {
private Map<String, PersonModel> storage = new ConcurrentHashMap<>();
@PostMapping("/persons")
public void create(@RequestBody PersonModel person) {
storage.put(person.getUserName(), person);
}
}
PersonModel.java
:
@Data
public class PersonModel {
private String userName;
private int age;
private String emails;
}
Also, let’s write an integration test using RestAssured for verifying the endpoint:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PersonControllerIntegrationTest {
@LocalServerPort
int port;
@Test
public void testCreatePerson() {
given().port(port)
.contentType("application/json")
.when().body("{\"userName\":\"John Doe\", \"age\":20, \"emails\":\"johndoe@example.com\"}")
.post("/persons")
.then().statusCode(200);
}
}
So far so good. The API looks nice, and we added a test for the happy-path scenario to make sure everything will work as expected in production. But… wait, what?
If you compare the implemented API with the previous OpenAPI specification, then you will find a number of mismatches, then you can find a number of mismatches between the two:
- the endpoint path is
/person
in the OpenAPI specification, while it is/persons
in the implementation - the person’s name is represented by the
name
property in the specification, while it isuserName
in the implementation - the
emails
is defined as an array in the specification, while it is a string in the implementation
These are the kind of mistakes that are not easily discoverable when testing the service in isolation, and they only show up when we exercise the endpoint in integration with the consuming services (worse case: only in production). So how can we prevent these problems in an early testing phase? How can we make sure that the API that we actually implement conforms to the specification we shared with our consumers?
Enhancing the test with OpenAPI-based validation
We can embed our OpenAPI definition into the integration test suite. Let’s implement a RestAssured filter that validates every request sent by our test, and also the responses received from the service, against the person-api.yaml
containing our definition. Given that OpenAPI has a Java-based implementation called OpenAPI4J, and it has an adapter that helps to validate the Request and Response objects of RestAssured, it is fairly simple to put together the RestAssured filter that validates the actual request-response pairs against the specification (disclaimer: the RestAssured adapter of OpenAPI4J was developed by me).
OpenApiValidationFilter.java
:
public class OpenApiValidationFilter
implements Filter {
private static final Map<String, Function<Path, Operation>> METHOD_TO_OPERATION = Map.of(
"GET", Path::getGet, "PUT", Path::getPut,
"POST", Path::getPost, "DELETE", Path::getDelete,
"OPTIONS", Path::getOptions, "HEAD", Path::getHead,
"PATCH", Path::getPatch, "TRACE", Path::getTrace
);
private static OpenApi3 parse(String yamlResourcePath) {
try {
return new OpenApi3Parser().parse(OpenApiValidationFilter.class.getResource(yamlResourcePath), true);
} catch (ResolutionException | ValidationException e) {
throw new RuntimeException(e);
}
}
private final OpenApi3 api;
public OpenApiValidationFilter(String yamlResourcePath) {
this(parse(yamlResourcePath));
}
public OpenApiValidationFilter(OpenApi3 api) {
this.api = api;
}
@SneakyThrows
@Override
public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec,
FilterContext ctx) {
try {
ValidationResults results = OpenApi3Validator.instance().validate(api);
if (results.size() > 0) {
throw new RuntimeException("invalid OpenAPI definition: " + results);
}
RestAssuredRequest request = new RestAssuredRequest(requestSpec);
RequestValidator validator = new RequestValidator(api);
validator.validate(request);
Response response = ctx.next(requestSpec, responseSpec);
Path path = getOpenApiPathByRequest(request);
validator.validate(new RestAssuredResponse(response), path,
METHOD_TO_OPERATION.get(requestSpec.getMethod()).apply(path));
return response;
} catch (ValidationException e) {
throw new AssertionError(e);
}
}
public Path getOpenApiPathByRequest(RestAssuredRequest request) {
Map<Pattern, Path> patterns = new HashMap<>();
for (Map.Entry<String, Path> pathEntry : api.getPaths().entrySet()) {
List builtPathPatterns = PathResolver.instance().buildPathPatterns(
api.getContext(),
api.getServers(),
pathEntry.getKey());
for (Pattern pathPattern : builtPathPatterns) {
patterns.put(pathPattern, pathEntry.getValue());
}
}
Pattern pathPattern = PathResolver.instance().findPathPattern(patterns.keySet(), request.getPath());
Path p = patterns.get(pathPattern);
return p;
}
}
Finally, let’s amend the previously written integration test to use the filter to intercept all requests and responses:
@Test
public void testCreatePerson(){
given().port(port)
// setting up the filter
.filter(new OpenApiValidationFilter("/person-api.yaml"))
.contentType("application/json")
.when().body("{\\"userName\\":\\"John Doe\\", \\"age\\":20, \\"emails\\":[]}")
.post("/persons")
.then().statusCode(200);
}
Given this filter, now if we run the test, we get the following error: java.lang.AssertionError: Operation path not found from URL 'http://localhost:43979/persons'
.
Once we change the request path both in the controller and in the test to /person, we get more errors about the request body schema mismatches:
java.lang.AssertionError: Invalid request.
Validation error(s) :
body: Additional property 'userName' is not allowed. (code: 1000)
From: body.<additionalProperties>
body.emails: Type expected 'array', found 'string'. (code: 1027)
From: body.emails.<type>
body: Field 'name' is required. (code: 1026)
From: body.<required>.
After fixing these validation failures, the test will pass.
Summary
- Contract-first development is about using OpenAPI or similar interface definition language for formally describing the API to be implemented
- the OpenAPI definition can be used as a shared source of truth between teams, specifying and documenting the API
- without extra effort, the implemented API may not conform to the specified interface, leading to inter-service communication errors, which may be tedious to debug
- RestAssured and openapi4j together can help to uncover such problems during integration testing, by verifying that each request and response conforms to the OpenAPI definition
Resources
All code samples presented in this post are available in this repository.
Top comments (0)