1. Introduction

In this tutorial, we’re going to create a simple CRUD (Create, Read, Update, Delete) API using Spring Boot and the simplicity of Kotlin. In particular, we’ll create an API that manages a task list.

2. Setup the Project

This project requires a project setup with Spring Boot and Kotlin. Check out how to integrate Kotlin with Spring Boot. Additionally, we’ll use the Spring Data JPA to connect with an in-memory database to persist our data. For this project, we’ll use the H2 database.

3. Defining the Repository

Let’s start by defining a TaskEntity data class that we’ll use to define a JPA repository:

@Entity(name = "task")
data class TaskEntity(
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  var id: Long?,
  var name: String,
  var description: String,
  var done: Boolean)

In short, the id variable is defined as Long? to make it nullable. As a result, the JPA repository will assign the ID when creating a new entity. We annotated it with the @GeneratedValue to let it generate the id for us.

Now, we define the JPA repository that will allow us the common operations of saving, finding, and deleting entities on our in-memory database:

@Repository
interface TaskRepository : JpaRepository<TaskEntity, Long>

We now have everything in place to create our controllers for the CRUD operations.

4. CRUD Operations

Next, we need to create a controller to perform the CRUD operations that will work on the task. First, we’ll create a Kotlin class annotated with @RestController and @RequestMapping(“/tasks”) to map all the operations through the /tasks path that describes our RESTful web service:

@RestController
@RequestMapping("/tasks")
class TaskController(var taskService: TaskService) {
    // More code...
}

Now, we’ll create a service class that will act as a persistence adapter for our controller class. In short, the service class works with the DTOs as exposed to the client and operates on the database using the repository interface. In this case, there is only one TaskRepository. As a consequence, we must inject the repository inside the service class through the constructor to interact with the database:

@Service
class TaskService(var repository: TaskRepository) {
    // More code...
}

We’ll now look at each operation separately.

4.1. Create a New Task

First, we need to create a new object that represents the body of our tasks when creating it through the REST endpoint. You may be tempted to reuse the TaskEntity inside the controller. But, coupling database structure with client-exposed DTO models inhibits the independent evolution of the database. Therefore, we strongly discourage exposing entity classes with JPA or Hibernate annotations through public REST endpoints.

The model must be similar to the entity but without the ID, which our application will assign upon saving into the database. Therefore, we define the TaskDTORequest:

data class TaskDTORequest(
  @JsonProperty("id")
  var name: String,
  @JsonProperty("name")
  var description: String,
  @JsonProperty("done")
  var done: Boolean)

Similarly, we want to represent the response of our HTTP call with a data class different from TaskEntity. For this purpose, we create a TaskDTORequest:

data class TaskDTOResponse(
  @JsonProperty("id")
  var id: Long,
  @JsonProperty("name")
  var name: String,
  @JsonProperty("description")
  var description: String,
  @JsonProperty("done")
  var done: Boolean)

Note here that the id is not nullable because we always want to create the TaskDTOResponse with all attributes.

Now, we’ll create an endpoint that allows us to create a new task.

@PostMapping("/create")
fun createTask(@RequestBody newTask: TaskDTORequest): TaskDTOResponse {
    return taskService.createTask(newTask)
}

Here, we’re returning the complete TaskDTOResponse object so the consumer will get the generated ID of our task. Furthermore, the TaskService will have the .createTask() method:

fun createTask(newTask: TaskDTORequest): TaskDTOResponse {
    val save = repository.save(TaskEntity(id = null, name = newTask.name, description = newTask.description, done = newTask.done))
    return Task(id = save.id!!, name = save.name, description = save.description, done = save.done)
}

In the snippet above, we want to point out the !! operator, which is called the double-bang operator. Simply, it says to the compiler to suppress the null point when save.id is null. In case it is null, we’ll get a NullPointerException. Here, we are getting that data from the database, which will return an ID. As we create a new task, the database will create a new ID and return it.

4.2. Read a Task by ID

Then, we need an endpoint to read our task for a given ID from the backend:

@GetMapping("/{id}")
fun getTask(@PathVariable id: Long): TaskDTOResponse {
    return taskService.getTask(id) 
      ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task not found")
}

The endpoint will return the TaskDTOResponse object, or it will return a 404 HTTP error if there isn’t any task for the given ID. Similarly, the service class will have the .getTask() method to support this operation:

fun getTask(id: Long): TaskDTOResponse? {
    return repository.findById(id).map { Task(id = it.id!!, name = it.name, description = it.description, done = it.done) }.getOrNull()
}

Now, we can search our tasks by ID. Ideally, we should have an endpoint to list all tasks, but for simplicity, we are omitting it in this tutorial.

4.3. Update a Task by ID

The CRUD APIs define that to update the data, we must use PUT or PATCH requests. This will come in handy when marking the tasks as completed.

We’ll only define the PUT request, which can be used to update the complete resource as per the definition of the HTTP standard:

@PutMapping("/{id}")
fun updateTask(@PathVariable id: Long, @RequestBody updatedTask: TaskDTORequest): TaskDTOResponse {
    return taskService.updateTask(id, updatedTask)
      ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task not found")
}

We decided to pass the id of the task as a path variable and the TaskDTORequest as the body of the request. If there is no task with the given ID, we’ll return a 404 HTTP error. Likewise, the TaskService contains the .updateTask() method:

fun updateTask(id: Long, updatedTask: TaskDTORequest): TaskDTOResponse? {
    return repository.findById(id).map {
        val save = repository.save(TaskEntity(id = it.id, name = updatedTask.name, description = updatedTask.description, done = updatedTask.done))
        Task(id = save.id!!, name = save.name, description = save.description, done = save.done)
    }.orElseGet(null)
}

We can edit tasks now. Let’s move on to the final operation.

4.4. Delete a Task by ID

Finally, we need to define a way to delete tasks for a given ID:

@DeleteMapping("/{id}")
fun deleteTask(@PathVariable id: Long) {
    taskService.deleteTask(id)
}

This endpoint won’t return any data. As before, the TaskService contains the .deleteTask() method:

fun deleteTask(id: Long) {
    repository.deleteById(id)
}

Now, we have all the operations required by a CRUD API. Let’s now test our application.

5. Testing Our CRUD API

For testing, we are going to use the @SpringBootTest annotation and inject the TestRestTemplate variable into our testing class:

@SpringBootTest(
  classes = [SpringBootCrudApplication::class],
  webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
class TaskDTOResponseCRUDIntegrationTest(@Autowired var restTemplate: TestRestTemplate) {
    // ...
}

In short, @SpringBootTest annotation is used for integration testing and starts up the Spring application context for the tests. Meanwhile, the webEnvironment parameter specifies that the tests should be run with a real servlet environment, and the server should listen on a random port. Which is useful to avoid port conflicts in test environments.

Let’s first create a new task:

val taskDTORequest = TaskDTORequest("Task", "description", false)
val result = this.restTemplate.postForEntity("/tasks/create", taskDTORequest, TaskDTOResponse::class.java)
taskId = result.body?.id!!
assertTrue { result.body?.name.equals("Task") }
assertTrue { result.body?.description.equals("description") }

Above, we are simply saving the id inside the test class attribute taskId to use it later. Again, using the double-bang operator assures us the id is created; otherwise, it will throw a NullPointerException and fail the test. Finally, we are asserting that the TaskDTOResponse is the same one we created.

We can quickly check if the task is created by getting the complete TaskDTOResponse using the taskId:

createTask()
val result = this.restTemplate.getForEntity("/tasks/{id}", TaskDTOResponse::class.java, taskId)
assertTrue { result.body?.name.equals("Task") }

Now, we might want to update the task using the PUT request:

createTask()
val taskDTORequest = TaskDTORequest("Task", "description", true)
this.restTemplate.put("/tasks/{id}", taskDTORequest, taskId)
val result = this.restTemplate.getForEntity("/tasks/{id}", TaskDTOResponse::class.java, taskId)
assertTrue { result.statusCode.is2xxSuccessful }
assertTrue { result.body?.done!! }

In short, we create a task, mark it complete with a PUT request, then retrieve the task by taskId and verify the done attribute is true.

Finally, let’s delete the task:

createTask()
this.restTemplate.delete("/tasks/{id}", taskId)
val result = this.restTemplate.getForEntity("/tasks/{id}", String::class.java, taskId)
assertTrue { result.statusCode.equals(HttpStatus.NOT_FOUND) }

In the test above, after deleting the task with taskId, we tried to retrieve it. But as expected, this resulted in an HttpStatus.NOT_FOUND or HTTP 404 error.

6. Conclusion

In this article, we implemented a CRUD API using Spring Boot and Kotlin. First, we described our data object, and then we implemented the rest of the components, including a service class, to support all the operations.

As always, the source code for this article is available over on GitHub.