DEV Community

loading...

Contract-First Development Using RestAssured and OpenAPI

erosb profile image Bence Eros Originally published at hazelcast.com ・5 min read

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

PersonModel.java:

@Data
public class PersonModel {
    private String userName;
    private int age;
    private String emails;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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 is userName 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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>.
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

pic
Editor guide