Building and testing a REST HAL API using Kotlin, Spring Data REST and JPA

Building and testing a REST HAL API using Kotlin, Spring Data REST and JPA

Β·

18 min read

Overview

This article will walk through the process of implementing a simple yet powerful hypermedia-driven REST API application, using Kotlin, Spring Boot, and Spring Data REST.

The goal is to demonstrate how we can build a robust REST API, following HATEOAS constraints with very few lines of code, barely only representing our domain core model.

Our API will be themed upon a Movie Universe, designed to manage movies, directors, actors and characters within a cinematic universe. These elements will form the core of our domain model.

The tech stack chosen for this demonstration is the following:

  • JDK 17 (GraalVM distribution)

  • Kotlin 1.8

  • Gradle 8

  • Spring Boot 3

  • JPA

  • Flyway

  • PostgreSQL

  • Docker* & Testcontainers

\ Docker installation process is not part of the article, we assume is already installed.*

TLDR; show me the code!

The full source code of the article is published on GitHub πŸ˜‰

https://github.com/ArnauAregall/kotlin-spring-data-rest-movies

Getting started

πŸƒ Generating our project with Spring Initalizr

We will delegate the bootiful Spring Initalizr our project bootstrapping.

Head to https://start.spring.io and build a new project with the following configuration:

Screenshot of Spring Initializr project configuration

Here is a link you can follow to automatically generate the project with the previous configuration:

https://start.spring.io/#!type=gradle-project-kotlin&language=kotlin&platformVersion=3.1.0-SNAPSHOT&packaging=jar&jvmVersion=17&groupId=tech.aaregall.lab&artifactId=kotlin-spring-data-rest-movies&name=kotlin-spring-data-rest-movies&description=Demo%20project%20for%20Spring%20Boot&packageName=tech.aaregall.lab.kotlin-spring-data-rest-movies&dependencies=native,data-rest,data-jpa,validation,flyway,postgresql,testcontainers

Once you download the project .zip file, extract it to your favorite workspace folder, and we are ready to continue.

β˜•οΈ Installing our JDK distribution with GraalVM

We will use SDKMAN! to install our JDK 17 distribution with GraalVM.

$ sdk install java 22.3.r17-grl
$ sdk use java 22.3.r17-grl
$ java -version
openjdk version "17.0.5" 2022-10-18
OpenJDK Runtime Environment GraalVM CE 22.3.0 (build 17.0.5+8-jvmci-22.3-b08)
OpenJDK 64-Bit Server VM GraalVM CE 22.3.0 (build 17.0.5+8-jvmci-22.3-b08, mixed mode, sharing)

⬆️ Upgrading Gradle Wrapper to v8

By the time this article was written, the project generated by Spring Initializr comes with Gradle 7.6.1. We will use Gradle 8 as it supports Kotlin DSL out of the box.

For that, we can just simply pinpoint the version 8.0.2 in the distributionUrl property under gradle/wrapper/gradle-wrapper.properties file.

distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip

Once done, we can run ./gradlew -version to force the download of the new Gradle wrapper JAR file (gradle/wrapper/gradle-wrapper.jar should be automatically replaced).

Adjustments on our Gradle setup for generating native images

To generate a native image of our application, a few adjustments are needed with the default configuration provided by Spring Intializr.

On our build.gradle.kts file:

  1. We will add the org.graalvm.buildtools.native plugin: once included, running bootBuildImage task will automatically detect the plugin presence in the classpath and will generate a native image for us by default.

  2. Also, we will need the org.jetbrains.kotlin.plugin.allopen plugin to configure that our classes annotated with @Entity are open by default upon compilation (Kotlin classes are final by default and thus cannot be proxied unless we explicitly open them). The goal is that Hibernate can apply effective proxies for lazy loading on our entities.

  3. Then, we will configure the BootBuildImage task to define:

    1. A custom image name, we will later use it to run the application using Docker.

    2. A base Paketo builder tiny version, and since I am an ARM64 arch user, one that we can use if we are on a Mac M1+ host (thanks to DaShaun 🫢).

  4. Finally, we will configure the allOpen extension to open all classes annotated with @Entity


plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
    id("org.graalvm.buildtools.native")
    id("org.jetbrains.kotlin.plugin.allopen")
    kotlin("jvm")
    kotlin("plugin.spring")
    kotlin("plugin.jpa")
}

// repositories {...}, dependencies {...}

tasks.withType<BootBuildImage> {
    imageName.set("${project.group}/${rootProject.name}")
    builder.set("dashaun/builder:tiny")
}

allOpen {
    annotation("jakarta.persistence.Entity")
}

Note: full build.gradle.kts file is available on GitHub.

πŸ“ The API Domain Model

Our Movie Universe Management API core domain model is as follows:

  • Actors can be cast in multiple movies, playing one character per movie.

  • Characters can be played by multiple actors and can appear in multiple movies.

  • Directors can direct multiple movies.

  • Movies can be directed by one director and cast multiple actors, each playing a playing different character.

The SQL schema

To represent our domain model, we will create a relational SQL schema on PostgreSQL. We want our domain schema to be versioned, hence we will create it using a Flyway migration script.

Spring Boot, by default, looks for Flyway migration scripts under src/main/resources/db/migration folder, so we will add there our V0.0.1__create_schema.sql script to create the required tables to build the mentioned domain model.

SET SEARCH_PATH TO "kotlin-spring-data-rest-movies";

CREATE TABLE IF NOT EXISTS "actor" (
    id BIGSERIAL UNIQUE NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    birth_date DATE NOT NULL,
    death_date DATE,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS "character" (
    id BIGSERIAL UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS "director" (
    id BIGSERIAL UNIQUE NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS "movie" (
    id BIGSERIAL UNIQUE NOT NULL,
    title VARCHAR(255) NOT NULL,
    release_date DATE NOT NULL,
    director_id BIGSERIAL NOT NULL,
    PRIMARY KEY (id),
    FOREIGN KEY (director_id) REFERENCES director(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS "movie_character" (
    movie_id BIGSERIAL NOT NULL,
    character_id BIGSERIAL NOT NULL,
    PRIMARY KEY (movie_id, character_id),
    UNIQUE (movie_id, character_id),
    FOREIGN KEY (movie_id) REFERENCES movie(id) ON DELETE CASCADE,
    FOREIGN KEY (character_id) REFERENCES character(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS "actor_character" (
    actor_id BIGSERIAL NOT NULL,
    character_id BIGSERIAL NOT NULL,
    PRIMARY KEY (actor_id, character_id),
    UNIQUE (actor_id, character_id),
    FOREIGN KEY (actor_id) REFERENCES actor(id) ON DELETE CASCADE,
    FOREIGN KEY (character_id) REFERENCES character(id) ON DELETE CASCADE
);

Creating our domain classes

Our domain classes (or entities) will represent the core model of our application, each object of those representing a table record on our database, that we will later expose as HTTP REST resources.

Actor

Our Actor entity will have the following properties:

  • An auto-generated ID.

  • A first name, a last name, and a birth date - all of them are required.

  • An optional death date.

  • A collection of Characters that the Actor has interpreted, using a combination of @ManyToMany and @JoinTable.

  • And also, a private function that will inform if the Actor is alive or not (that will be automatically mapped to our API response).

@Entity
@Table(name = "actor")
class Actor (

    @NotNull @Column(name = "first_name")
    var firstName: String,

    @NotNull @Column(name = "last_name")
    var lastName: String,

    @NotNull@Column(name = "birth_date")
    var birthDate: LocalDate,

    @Column(name = "death_date")
    var deathDate: LocalDate?

) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @ManyToMany
    @JoinTable(
        name = "actor_character",
        joinColumns = [JoinColumn(name = "actor_id")],
        inverseJoinColumns = [JoinColumn(name = "character_id")]
    )
    var characters: MutableSet<Character>? = mutableSetOf()

    @JsonProperty("is_alive")
    private fun isAlive(): Boolean = isNull(deathDate)
}

Character

Our Character entity will have the following properties:

  • An auto-generated ID.

  • A name, required.

  • A collection of Actors that have interpreted that Character (the reverse relationship that we saw in Actor#characters).

  • A collection of Movies where the Character appears, again with a @ManyToMany and @JoinTable combo.

@Entity
@Table(name = "character")
class Character (

    @NotNull
    @Column(name = "name")
    var name: String?

) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @ManyToMany
    @JoinTable(
        name = "actor_character",
        joinColumns = [JoinColumn(name = "character_id")],
        inverseJoinColumns = [JoinColumn(name = "actor_id")]
    )
    var actors: MutableSet<Actor>? = mutableSetOf()

    @ManyToMany
    @JoinTable(
        name = "movie_character",
        joinColumns = [JoinColumn(name = "character_id")],
        inverseJoinColumns = [JoinColumn(name = "movie_id")]
    )
    var movies: MutableSet<Movie>? = mutableSetOf()
}

Director

Our Director entity will have the following properties:

  • An auto-generated ID.

  • A first name and a last name - both required.

  • A collection of Movies that the Director has directed, mapped by a @OneToMany relationship.

@Entity
@Table(name = "director")
class Director (

    @NotNull
    @Column(name = "first_name")
    var firstName: String,

    @NotNull
    @Column(name = "last_name")
    var lastName: String

) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @OneToMany(mappedBy = "director")
    var movies: MutableSet<Movie>? = mutableSetOf()
}

Movie

Finally, our Movie entity has the following properties:

  • An auto-generated ID.

  • A title, a release date, and a Director (mapped by a @ManyToOne) - all of them required.

  • A collection of Characters that appear in that movie, once again using @ManyToMany and @JoinTable.

@Entity
@Table(name = "movie")
class Movie (

    @NotNull
    @Column(name = "title")
    var title: String,

    @NotNull
    @Column(name = "release_date")
    var releaseDate: LocalDate,

    @NotNull
    @ManyToOne
    @JoinColumn(name = "director_id")
    var director: Director

) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @ManyToMany
    @JoinTable(
        name = "movie_character",
        joinColumns = [JoinColumn(name = "movie_id")],
        inverseJoinColumns = [JoinColumn(name = "character_id")]
    )
    var characters: MutableSet<Character>? = mutableSetOf()
}

Exposing a HAL REST API for our domain with Spring Data REST

Once our domain classes are defined, we are ready to expose them as REST HATEOAS resources using Spring Data REST.

Spring Data REST builds on top of the Spring Data repositories and automatically exports those as REST resources. It leverages hypermedia to let clients automatically find functionality exposed by the repositories and integrate these resources into related hypermedia-based functionality.

πŸ”— Spring Data REST reference documentation.

Spring Boot configuration

Let's have a look at how our Spring Configuration would look to achieve the following:

  1. Configure our DataSource:

    1. Our application will be backed by a PostgreSQL database, so we will need to configure our DataSource URL, as well as the database username/password, plus the default schema for Hibernate.
  2. Configure Spring Data REST itself:

    1. We will define that our API base path (/api) and configure Jackson for a snake_case response serialization flavor.

    2. We will tell Spring to expose as REST APIs our Spring Data JPA repositories annotated with @RepositoryRestResource.

spring:
  application:
    name: kotlin-spring-data-rest-movies

  data:
    rest:
      base-path: /api/
      detection-strategy: annotated

  datasource:
    url: jdbc:postgresql://localhost:5432/lab
    username: postgres
    password: postgrespw

  jpa:
    properties:
      hibernate:
        default_schema: ${spring.application.name}

  flyway:
    default-schema: ${spring.application.name}

  jackson:
    property-naming-strategy: SNAKE_CASE

  sql:
    init:
      mode: always

Declaring and exposing our repositories as REST endpoints

To expose our domain entities as REST resources, we just need to declare regular interfaces that extend the well-known Spring Data JpaRepository<DomainClass, IdClass> interface, and annotate them with @RepositoryRestResource (by annotating them Beans will automatically be declared).

@RepositoryRestResource(path = "actors", collectionResourceRel = "actors", itemResourceRel = "actor")
interface ActorRestRepository : JpaRepository<Actor, Long>

@RepositoryRestResource(path = "characters", collectionResourceRel = "characters", itemResourceRel = "character")
interface CharacterRestRepository : JpaRepository<Character, Long>

@RepositoryRestResource(path = "directors", collectionResourceRel = "directors", itemResourceRel = "director")
interface DirectorRestRepository : JpaRepository<Director, Long>

@RepositoryRestResource(path = "movies", collectionResourceRel = "movies", itemResourceRel = "movie")
interface MovieRestRepository : JpaRepository<Movie, Long>

We configured the following attributes on each @RepositoryRestResource:

  • path: The path segment under which the resource will be exported, of course, under our base path (/api/). Usually is the plural noun of our domain entity name. The above configuration will translate to expose the following endpoints on runtime:

    • /api/actors/**, /api/characters/**, /api/directors/**, /api/movies/**
  • collectionResourceRel: The relationship name to use when generating links to the collection resource. Usually is the plural noun of our domain entity name. Example: Director -> directors.

  • itemResourceRel: The relationship name to use when generating links to a single element resource. Usually is the singular noun of our domain entity name. Example: Movie -> movie.

Additional Spring Data REST customizations

As the reference documentation states, Spring Data REST configuration can be customized if the out-of-the-box one does not fully suit our needs.

In our case, just for demonstration purposes, we will customize our configuration so the API responses include the IDs of our entities (by default it does not).

To do so, we will implement a @Configuration class declaring a RepositoryRestConfigurer bean.

@Configuration
class RepositoryRestConfig {

    @Bean
    fun repositoryRestConfigurer(): RepositoryRestConfigurer {
        return object : RepositoryRestConfigurer {

            override fun configureRepositoryRestConfiguration(config: RepositoryRestConfiguration?, cors: CorsRegistry?) {
                config!!.exposeIdsFor(
                    Actor::class.java,
                    Character::class.java, 
                    Director::class.java, 
                    Movie::class.java
                )
            }

        }
    }

}

Here we override configureRepositoryRestConfiguration, and further configuration customizations could be achieved with the config argument we receive. The interface provides quite a few more methods to configure more aspects of the framework, like the Jackson ObjectMapper definition and validation event listeners, among others.

πŸ§ͺ Implementing integration tests

Our application integration tests will consist of performing real HTTP requests to our API to test the full CRUD of each entity, although to keep the article shortened we will just demonstrate a basic movie setup use case.

Abstract class with PostgreSQL Testcontainer

We want our integration tests to persist/query real data against a real database, so we will implement a base abstract class that our tests will extend, which will be responsible for:

  • Spin up the Spring Boot application context with a test profile auto-configuring.

  • Configure and start a PostgreSQL testcontainer.

@ActiveProfiles("test")
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AbstractIT {

    companion object {

        @Container
        private val container = PostgreSQLContainer(DockerImageName.parse("postgres:15-alpine"))
            .apply {
                withDatabaseName("test")
                withUsername("test")
                withPassword("test")
                start()
            }

        @JvmStatic
        @DynamicPropertySource
        fun properties(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", container::getJdbcUrl)
            registry.add("spring.datasource.username", container::getUsername)
            registry.add("spring.datasource.password", container::getPassword)
        }
    }
}

With the usage of @DynamicPropertySource from Spring Test libraries, we are able to override the Spring Boot DataSource configuration properties in order to connect to the testcontainer instance. We also need the @JvmStatic Kotlin annotation to tell the compiler to generate our method as static, as @DynamicPropertySource requires static methods.

Disclaimer: base class for integration test can end up with a serious mess in large codebase projects (I've been there), although we will not discuss this here - maybe in another post? πŸ˜‰ πŸ”œ

Starting each test with a clean database

A common pitfall of integration testing using real databases is that data generated by tests might interfere with the execution of others.

For example, think about a test where we want to get all available Actors by performing a GET /api/actors?page=0&size=50 asserting the data returned by the API is the one we generate within that test execution, let's say 50 generated Actor records.

What would happen if another test that creates an actor runs first, without cleaning the database? πŸ€” You guessed right: 51 actors will be returned and our test would fail.

To solve this issue and start with a clean database when running each test, we will implement a custom annotation @CleanDatabase that could be placed at class level of each JUnit test class. Behind the scenes, before each test execution of each @Test in the class, it will TRUNCATE all our database tables (inspired by @maciejwalkowiak's blog post).

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ExtendWith(CleanDatabaseCallback::class)
annotation class CleanDatabase

private class CleanDatabaseCallback : BeforeEachCallback {

    override fun beforeEach(context: ExtensionContext) {
        val dataSource = SpringExtension.getApplicationContext(context).getBean(DataSource::class.java)
        ResourceDatabasePopulator(ClassPathResource("db/scripts/clean_db.sql")).execute(dataSource)
    }
}

The clean-up SQL script would be placed under src/test/resources/db/scripts folder:

SET SEARCH_PATH TO "kotlin-spring-data-rest-movies";

TRUNCATE TABLE "actor" RESTART IDENTITY CASCADE;
TRUNCATE TABLE "director" RESTART IDENTITY CASCADE;
TRUNCATE TABLE "character" RESTART IDENTITY CASCADE;
TRUNCATE TABLE "movie" RESTART IDENTITY CASCADE;

Integration tests for CRUD operations

Now we have everything prepared to start writing actual integration tests that would validate the behavior of our REST API.

We will take the Actor entity as an example to demonstrate how we would test at least 3 endpoints:

  • Find available Actors using pagination

  • Get an Actor by ID

  • Create an Actor

To do so, we will create a new test class that would extend AbstractIT, annotated with @CleanDatabase, and it's constr the MockMvc and ActorRestRepository beans (the later only to perform post-request assertions).

private const val BASE_PATH = "/api/actors"

@CleanDatabase
class ActorRestRepositoryIT (
    @Autowired val mockMvc: MockMvc,
    @Autowired val actorRestRepository: ActorRestRepository): AbstractIT() {

    @Nested
    @DisplayName("GET $BASE_PATH")
    inner class Get {

        @Test
        fun `Should find available Actors using pagination` () {
            val actors = IntStream.rangeClosed(1, 50)
                .mapToObj {index -> actorRestRepository.save(
                    Actor( "FirstName $index", "LastName $index", LocalDate.now().minusYears(index.toLong()), null)
                )}
                .toList()

            mockMvc.perform(get(BASE_PATH)
                .accept(HAL_JSON)
                .param("page", "0")
                .param("size", actors.size.toString()))
                .andExpect(status().isOk)
                .andExpect(content().contentType(HAL_JSON))
                .andExpectAll(
                    jsonPath("$._embedded.actors.length()").value(actors.size),
                    jsonPath("$._embedded.actors[*].id",
                        containsInAnyOrder(streamToIsMatcher(actors.stream().map { it.id!!.toInt() }))
                    ),
                    jsonPath("$._embedded.actors[*].first_name",
                        containsInAnyOrder(streamToIsMatcher(actors.stream().map(Actor::firstName)))
                    ),
                    jsonPath("$._embedded.actors[*].birth_date",
                        containsInAnyOrder(streamToIsMatcher(actors.stream().map(Actor::birthDate).map(LocalDate::toString)))
                    ),
                    jsonPath("$.page").isNotEmpty,
                    jsonPath("$.page.size").value(actors.size),
                    jsonPath("$.page.total_elements").value(actors.size),
                    jsonPath("$.page.total_pages").value(1),
                    jsonPath("$.page.number").value(0)
                )
        }
    }

    @Nested
    @DisplayName("POST $BASE_PATH")
    inner class Post {

        @Test
        fun `Should Create an Actor` (@Autowired objectMapper: ObjectMapper) {
            val actor = Actor("Daniel", "Craig", LocalDate.of(1968, 3, 2), null)

            val result = mockMvc.perform(post(BASE_PATH)
                .accept(HAL_JSON)
                .contentType(HAL_JSON)
                .content(objectMapper.writeValueAsString(actor)))
                .andExpect(status().isCreated)
                .andExpect(content().contentType(HAL_JSON))
                .andExpectAll(
                    jsonPath("$.id").value(notNullValue()),
                    jsonPath("$.first_name").value(actor.firstName),
                    jsonPath("$.last_name").value(actor.lastName),
                    jsonPath("$.birth_date").value(actor.birthDate.toString()),
                    jsonPath("$.death_date").value(nullValue()),
                    jsonPath("$.is_alive").value(true)
                ).andReturn()

            val actorId = JsonPath.read<Int>(result.response.contentAsString, "$.id")

            assertThat(actorRestRepository.getReferenceById(actorId.toLong()))
                .isNotNull
                .extracting(Actor::firstName, Actor::lastName, Actor::birthDate, Actor::deathDate)
                .containsExactly(actor.firstName, actor.lastName, actor.birthDate, null)
        }

    }

    // PATCH, PUT, and DELETE tests follow on...

}

You can find the rest of the integration tests on GitHub.

Integration tests for entities' relationships

Now that we have seen how to implement integration tests with CRUD operating with a single domain entity, we can move on to how to write integration tests for relationships between entities using Spring Data REST and HAL links.

We will continue with the James Bond theme, which suits us perfectly to illustrate the following entity relationship use case:

  • We want to create a couple of James Bond saga movies, each one directed by the same director.

  • In each movie, James Bond appears as a character but is interpreted by a different actor.

  • Also, each movie has its main villain, each one interpreted by a different actor.

To build relationships between entities, we can make use of the HATEOAS links. When creating or updating an entity, the request body JSON payload would contain the relationship property with a link/array of links pointing to the existing REST resource.

For example, if we want to create a new Character interpreted by two existing Actors, the request would look like the following:

curl --location 'http://localhost:8080/api/characters' \
--header 'Content-Type: application/json' \
--data '{
    "name": "James Bond",
    "actors": ["http://localhost:8080/api/actors/1", "http://localhost:8080/api/actors/2"]
}'

An integration test to cover the previous use case would have different steps following the AAA principle "Arrange-Act-Assert".

Arrange

First, we will implement a couple of utility functions to help us perform a POST request with a given payload on a given path, verify the resource was created by checking the response status code, and retuning the link of the created resource.

fun performPost(path: String, body: String) : ResultActions {
    return mockMvc.perform(post(path)
        .accept(HAL_JSON)
        .content(body))
        .andExpect(status().isCreated)
        .andExpect(content().contentType(HAL_JSON))
}

fun createAndReturnSelfHref(path: String, body: String): String {
    return JsonPath.read(
        performPost(path, body)
            .andReturn().response.contentAsString, "_links.self.href")
}

Now we can start arranging our data, the Actors first - we will create 4 different resources:

val pierceBrosnanLink = createAndReturnSelfHref("/api/actors",
    """
        {"first_name": "Pierce", "last_name": "Brosnan", "birth_date": "1953-05-16"}
    """.trimIndent())

val danielCraigLink = createAndReturnSelfHref("/api/actors",
    """
        {"first_name": "Daniel", "last_name": "Craig", "birth_date": "1968-03-02"}
    """.trimIndent())

val madsMikkelsenLink = createAndReturnSelfHref("/api/actors",
    """
        {"first_name": "Mads", "last_name": "Mikkelsen", "birth_date": "1965-11-22"}
    """.trimIndent())

val seanBeanLink = createAndReturnSelfHref("/api/actors",
    """
        {"first_name": "Sean", "last_name": "Bean", "birth_date": "1959-04-17"}
    """.trimIndent())

Once we have the Actors, we can start creating the Characters, by specifying in the request body the Actors that interpret each Character:

val jamesBondLink = createAndReturnSelfHref("/api/characters",
    """
        {"name": "James Bond", "actors": ["$pierceBrosnanLink", "$danielCraigLink"]}
    """.trimIndent())

val leChiffreLink = createAndReturnSelfHref("/api/characters",
    """
        {"name": "Le Chiffre", "actors": ["$madsMikkelsenLink"]}
    """.trimIndent())

val alecTrevelyanLink = createAndReturnSelfHref("/api/characters",
    """
        {"name": "Alec Trevelyan", "actors": ["$seanBeanLink"]}
    """.trimIndent())

We also need to create the Director:

val directorLink = createAndReturnSelfHref("/api/directors",
    """
        {"first_name": "Martin", "last_name": "Campbell"}
    """.trimIndent())

And finally, we can create the two mentioned Movies, directed by the same Director, with at least a shared Character interpreted by two different Actors:

val goldeneyeLink = createAndReturnSelfHref("/api/movies",
    """
        {"title": "Goldeneye", 
        "release_date": "1995-12-20", 
        "director": "$directorLink", 
        "characters": ["$jamesBondLink", "$alecTrevelyanLink"]}
    """.trimIndent())

val casinoRoyaleLink = createAndReturnSelfHref("/api/movies",
    """
        {"title": "Casino Royale", 
        "release_date": "2006-11-14", 
        "director": "$directorLink", 
        "characters": ["$jamesBondLink", "$leChiffreLink"]}
    """.trimIndent())

Act and Assert

The "Act" step of our integration test consists in performing GET requests to the different Movie resources.

The "Assert" step will consist in asserting the next statements using again GET request on the HAL links:

  • Each Movie is directed by the same Director.

  • Each Movie has the expected Characters.

  • Each Character is interpreted by the expected Actor in each Movie.

We will again implement a utility function to perform the GET requests:

fun performGet(path: String): ResultActions {
    return mockMvc.perform(get(path)
        .accept(HAL_JSON))
        .andExpect(status().isOk)
        .andExpect(content().contentType(HAL_JSON))
}

The Movie assertions would consist in:

  • GET the Movie.

  • Follow the Director link.

  • Follow the Characters link.

  • For each Character, follow the Actors link.

The following would be the assertion for Goldeneye:

performGet(goldeneyeLink)
    .andExpectAll(
        jsonPath("$.id").isNotEmpty,
        jsonPath("$.title").value("Goldeneye"),
        jsonPath("$.release_date").value("1995-12-20")
    )
    .andDo {
        val movieResponse = it.response.contentAsString

        performGet(JsonPath.read(movieResponse, "$._links.director.href"))
            .andExpectAll(
                jsonPath("$.id").isNotEmpty,
                jsonPath("$.first_name").value("Martin"),
                jsonPath("$.last_name").value("Campbell")
            )

        performGet(JsonPath.read(movieResponse, "$._links.characters.href"))
            .andExpectAll(
                jsonPath("$._embedded.characters.length()").value(2),
                jsonPath("$._embedded.characters[*].name", containsInAnyOrder("James Bond", "Alec Trevelyan"))
            )
            .andDo { charactersResult ->

                val charactersActorsLinks = JsonPath.read<JSONArray?>(charactersResult.response.contentAsString, "$._embedded.characters[*]._links.actors.href")
                    .filterIsInstance<String>()
                    .sorted()

                performGet(charactersActorsLinks[0])
                    .andExpectAll(
                        jsonPath("$._embedded.actors.length()").value(2),
                        jsonPath("$._embedded.actors[*].first_name", containsInAnyOrder("Pierce", "Daniel")),
                        jsonPath("$._embedded.actors[*].last_name", containsInAnyOrder("Brosnan", "Craig"))
                    )

                performGet(charactersActorsLinks[1])
                    .andExpectAll(
                        jsonPath("$._embedded.actors.length()").value(1),
                        jsonPath("$._embedded.actors[0].first_name").value("Sean"),
                        jsonPath("$._embedded.actors[0].last_name").value("Bean")
                    )

            }
    }

And this one would correspond to Casino Royale:

performGet(casinoRoyaleLink)
    .andExpectAll(
        jsonPath("$.id").isNotEmpty,
        jsonPath("$.title").value("Casino Royale"),
        jsonPath("$.release_date").value("2006-11-14")
    )
    .andDo {
        val movieResponse = it.response.contentAsString

        performGet(JsonPath.read(movieResponse, "$._links.director.href"))
            .andExpectAll(
                jsonPath("$.id").isNotEmpty,
                jsonPath("$.first_name").value("Martin"),
                jsonPath("$.last_name").value("Campbell")
            )

        performGet(JsonPath.read(movieResponse, "$._links.characters.href"))
            .andExpectAll(
                jsonPath("$._embedded.characters.length()").value(2),
                jsonPath("$._embedded.characters[*].name", containsInAnyOrder("James Bond", "Le Chiffre")),
            )
            .andDo { charactersResult ->

                val charactersActorsLinks = JsonPath.read<JSONArray?>(charactersResult.response.contentAsString, "$._embedded.characters[*]._links.actors.href")
                    .filterIsInstance<String>()
                    .sorted()

                performGet(charactersActorsLinks[0])
                    .andExpectAll(
                        jsonPath("$._embedded.actors.length()").value(2),
                        jsonPath("$._embedded.actors[*].first_name", containsInAnyOrder("Pierce", "Daniel")),
                        jsonPath("$._embedded.actors[*].last_name", containsInAnyOrder("Brosnan", "Craig"))
                    )

                performGet(charactersActorsLinks[1])
                    .andExpectAll(
                        jsonPath("$._embedded.actors.length()").value(1),
                        jsonPath("$._embedded.actors[0].first_name").value("Mads"),
                        jsonPath("$._embedded.actors[0].last_name").value("Mikkelsen")
                    )

            }
    }

The full "James Bond use case" integration test is on GitHub.

🐳 Running the application natively using Docker

To run the application using Docker, we will create a docker-compose.yml file that will:

  • Create a service that will deploy our REST API application on a Docker container using the latest Docker image version (native! πŸš€).

  • Create a service that will start a Postgres container with our Database "lab" created. It will mount a init_db.sql as entry point that will create our database when Postgres server starts with a plain CREATE DATABASE lab; command.

  • We will provide the database connection settings for both containers using environment variables.

version: '1'
services:
  rest-api:
    image: tech.aaregall.lab/kotlin-spring-data-rest-movies:latest
    restart: always
    depends_on:
      - postgres
    ports:
      - 8080:8080
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/lab
  postgres:
    image: postgres:latest
    container_name: postgres
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgrespw
    ports:
      - '5432:5432'
    volumes:
      - ./init_db.sql:/docker-entrypoint-initdb.d/init_db.sql

To keep everything well organized we will save the previous files under src/main/docker directory.

Once we are ready we can just build our native image and run docker-compose up.

$ ./gradlew bootBuildImage
$ docker-compose -f src/main/docker/docker-compose.yml up

The application will be running localhost, port 8080.

curl -i --location 'http://localhost:8080/api/actors' \
--header 'Content-Type: application/json' \
--data '{
    "first_name": "Daniel",
    "last_name": "Craig",
    "birth_date": "1968-03-02"
}'

HTTP/1.1 201
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Location: http://localhost:8080/api/actors/1
Content-Type: application/hal+json
Transfer-Encoding: chunked
Date: Sat, 22 Apr 2023 13:15:22 GMT

{
  "first_name" : "Daniel",
  "last_name" : "Craig",
  "birth_date" : "1968-03-02",
  "death_date" : null,
  "id" : 1,
  "is_alive" : true,
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/actors/1"
    },
    "actor" : {
      "href" : "http://localhost:8080/api/actors/1"
    },
    "characters" : {
      "href" : "http://localhost:8080/api/actors/1/characters"
    }
  }
}

Conclusion

This article demonstrated:

  • How to configure our Gradle 8 build files using Kotlin DSL.

  • How to configure Gradle to generate a native Docker image ARM64 compatible.

  • How to implement our domain model entities using Kotlin and JPA.

  • How to expose a REST HAL API for each entity using Spring Data REST.

  • How to implement integration test for our application.

  • How to run our application natively using Docker.

The examples used on this article are available in the linked GitHub repository.

Β