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 GreetEndPoint
. EndpointHandler
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 up 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 Controller 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.