Skip to main content

Controllers and CRUDs

Controllers and CRUD operations are important concepts in software development that we utilize in all of our projects. So, let's talk about what they are and how they work.

What is a Controller?

Controllers are classes that handle incoming requests from a client and manage the processing of these requests. They are a crucial part of our software development toolkits.
Let's create a simple controller that greets a name specified in an incoming request. We'll use this controller to handle requests to the "/greet" endpoint.

Our first Controller

Before we make our first controller, we need first to specify an endpoint for our controller to handle. So what are we waiting? Let's define it:

object GreetEndPoint :
    EndPoint<GreetEndPoint.Request, GreetEndPoint.Response>() {
    class Request(
        val name: String ,
    )
    class Response(
        val greeted: String,
    )
}


Here, we make an object that extends EndPoint. an EndPoint is an abstract class that defines the properties of an endpoint (see Routing). An Endpoint has two generic type parameters: one for the request (for our controller to handle) and the other for the response (for our controller to send back to the client). In our example, we want the request to include a single field for the name to be greeted, and the response to also include a single field for the greeted name (e.g "Hello name").
After we have defined our endpoint, we need to specify that it should be accessible at the "/greet/" path.

object Routing {
  fun init() = api {
        "greet" {
               route(GreetEndPoint)
           }
  }
}


Now we're all set to create our first controller. so let's dive right into that:

class GreetController :
    EndpointHandler<GreetEndPoint.Request, GreetEndPoint.Response>(
        GreetEndPoint.Request::class,
        GreetEndPoint
    ) {
    override fun process(
        requestDto: GreetEndPoint.Request,
        clientData: AuthorizedClientData?,
    ): DataResponse<GreetEndPoint.Response> {
        return DataResponse(
            GreetEndPoint.Response(greeted = "Hello ${requestDto.name}")
        )
    }
}


There are a lot of things happening in this code, so let's break it down and explain each part:
In this code, we create our controller class and have it inherit from the EndpointHandler class. EndpointHandler is an abstract class that is responsible of processing requests and sending back responses. EndpointHandler, just like EndPoint, takes two generic type parameters: the request, and the response. In order to use the EndpointHandler class, we need to pass in the class of the request and the endpoint to the constructor of the EndpointHandler class. in this case, our request class is GreetEndPoint.Request::class and our defined endpoint is GreetEndPointEndpointHandler has one method that we have to override, which is the process method. The process method takes in a request of type GreetEndPoint.Request and an authorized client (if one exists) as input, and returns a DataResponse object as output. Inside the method, we create our response object and use the name field that exists in our request to greet the incoming name. We then wrap our response object in a DataResponse object and return it. 

Now, let's make a request from the client

fun main() {
   GlobalScope.launch {
        val response = GreetEndPoint.remoteProcess(GreetEndPoint.Request(name = "Mohammed"))
        println(response.data.greeted) // output: Hello Mohammed

    }
 }

We use the GreetEndPoint.remoteProcess function to make the request. remoteProcess is an extension function on EndPoint that deals with sending the request to the server.
Everything's working great!
Now let's take a look at CRUD operations

What are CRUD operations?

CRUD (stands for Create, Read, Update, and Delete) operations are four basic operations for manipulating a database. These functions are essential for building functional programs that can create, read, update, and delete data. For example, consider a todo app, in a Todo app, users would need the ability to create new todos, read their existing todos, update them, and delete them.

Combining ControllerControllers with CRUD operations

Controllers work well with CRUD operations. Typically, we define the CRUD operations in a repository and have our controller delegate these operations to a repository.
Ok, let's look at an example.

let's make a simple Todo class

data class Todo(val id: String?, val task: String, val isDone: Boolean)

Consider we have this todos repository

object TodosRepository {
      fun create(todo: Todo): Todo {
          ...
      }
      // rest of operations
}


Now, we need an endpoint for our todos

object TodosEndPoint: CrudEndPoint<TodosEndPoint.Request, TododsEndPoint.Response> {

  class Request(
       val todo: Todo? = null
   )
  
  class Response
 
 }


Instead of inheriting from EndPoint like last time, we're inheriting from CrudEndPoint to support CRUD operations.
Let's map our endpoint to "/todos"

object Routing {
   // ..
   fun init() = api {
               "todos" {
                  crud(TodosEndPoint)  
          }
   }
}


Here we use crud instead of route indicating it is a CRUD endpoint.
Let's now make our controller:

class TodosCrudController: EndpointCrudController<TodosEndPoint.Request, TodosEndPoint.Response>(
  listof(TodosEndPoint), 
  TodosEndPoint.Request::class, 
  TodosEndPoint.Response::class) {

    override fun getItemsList(
      // some other params,
        clientData: AuthorizedClientData?
    ): ListAndTotal<TodosEndPoint.Request> {
            val list: List<Todo> = TodosRepository.getTodos()
            return ListAndTotal(list.map { TodosEndPoint.Request(todo = it) }.toList(), list.size.toLong())

        }
    }

    override fun createItem(item: TodosEndPoint.Request, clientData: AuthorizedClientData?): TodosEndPoint.Request {
        return TodosEndPoint.Request(todo = TodosRepository.create(todo = item.todo!!))
    }
    
    override fun updateItem(item: TodosEndPoint.Request, clientData: AuthorizedClientData?): TodosEndPoint.Request {
        return TodosEndPoint.Request(todo = TodosRepository.update(todo = item.todo!!))
    }
    
    override fun deleteItem(id: UUID?, clientData: AuthorizedClientData?) {
        TodosRepository.delete(id)
    }

}


We start by extending the EndpointCrudController class, passing in the endpoints that we have (in our case, it's only the TodosEndPoint), and then the class of our request and response. EndpointCrudController provides us with the basic CRUD operations that we need to implement. We delegate these operations to our TodosRepository, which will handle the actual storage and manipulation of the data.

Now finally let's make a request to our todos endpoint:

 fun main() {
    GlobalScope.launch {
        val todo = Todo(id = null, task = "Learning controllers and CRUDs", isDone = true);
        TodosEndPoint.remoteAdd(TodosEndPoint.Request(todo))
        val todosResponse = TodosEndPoint.remoteList(CrudDto.GetList.Request())
        console.log(todosResponse.data.list.map {it.todo.name}) // output: ["Learning controllers and CRUDs"]

    }
 }


There are a number of useful extension methods on CrudEndPoint that makes it easier for the client to make all sort of crud operations. In this example, we used TodosEndPoint.remoteAdd to add a todo item and TodosEndPoint.remoteList to get our list of todos. There's also remoteUpdate and remoteDelete for updating and deleting that you can try.

Conclusion

In summary, we learned about controllers, which are classes that handle incoming requests, process them, and return a response. We created our first controller that greets the name specified in the request.". We also learned about CRUD operations, which are the basic functions for manipulating data in a database. We combined these concepts to build a simple Todo API.