1. Introduction
Hello ! ๐ Continuing the DSL topic, I would like to show you how to expose a REST API using Spring Boot 3, Kotlin, bean definition, and router DSL.
In my previous article, I showed you how the router DSL works and how to expose endpoints manually without needing any web-related annotations, like @RequestMapping, @RestController, @RequestBody, etc…
But is it possible to eliminate the annotations when defining beans? Yes, and in this article, I will prove to you that it’s possible to expose a REST API without any annotations, like @Component or any specialization.
Video Tutorial
If you prefer video content, then check out my video:
If you find this content useful, please leave a subscription ๐
2. Project Setup
Firstly, let’s navigate to https://start.spring.io/ and generate a new project:
As we can see, in order to expose a REST API with Spring Boot 3 and Kotlin DSL, we don’t have to add any additional dependencies. The only one is the Spring Web, which lets us build web applications.
Following, please import the project to your favorite IDE (like IntelliJ).
3. Add Models
Nextly, let’s create the model
package and add 3 data classes to our application:
User
, which will transfer the data between the app and the data source,UserDTO
, which will be serialized to JSON responses,- and
ErrorResponse
, shipping information about backend errors.
// User.kt data class User( val id: Long? = null, val email: String, val name: String, val age: Int ) // UserDTO.kt data class UserDTO( val email: String, val name: String, val age: Int ) // ErrorResponse.kt data class ErrorResponse( val message: String )
4. Implement a Repository
As the next step, let’s create a repository
package.
Make a real progress thanks to practical examples, exercises, and quizzes.
- 64 written lessons
- 62 quizzes with a total of 269 questions
- Dedicated Facebook Support Group
- 30-DAYS Refund Guarantee
4.1 Add UserRepository Interface
Following, let’s introduce a UserRepository
:
interface UserRepository { fun create(user: User): User fun findAll(): List<User> fun findById(id: Long): User? fun updateById(id: Long, user: User): User? fun deleteById(id: Long) }
And although oftentimes I skip this part for the simplicity of my tutorials, you will later see that working with interfaces, rather than implementations gives us much more flexibility. And in real-life scenarios, we should consider using them.
4.2 Add Implementation
So nextly, let’s add the UserCrudRepository
to the codebase:
class UserCrudRepository( private val dataSource: MutableMap<Long, User> ) : UserRepository { override fun create(user: User): User { val lastId = this.dataSource.keys.max() val incrementedId = lastId + 1 val updatedUser = user.copy(id = incrementedId) this.dataSource[incrementedId] = updatedUser return updatedUser } override fun findAll(): List<User> = this.dataSource.values .toList() override fun findById(id: Long): User? = this.dataSource[id] override fun updateById(id: Long, user: User): User? = this.dataSource[id] ?.let { foundUser -> val updatedUser = user.copy(id = foundUser.id) this.dataSource[id] = updatedUser updatedUser } override fun deleteById(id: Long) { this.dataSource.remove(id) } }
As we can see, this class is just a dummy in-memory database, which will be operating on the dataSource
map provided as a constructor argument.
4.3 Implement DataSource
With that being done, let’s create a new object called DataSource
(can go to the config
package):
object DataSource { val devDataSource: MutableMap<Long, User> = mutableMapOf( 1L to User(1L, "email-1@gmail.com", "Name 1", 22), 2L to User(2L, "email-2@gmail.com", "Name 2", 43), 3L to User(3L, "email-3@gmail.com", "Name 3", 26), 4L to User(4L, "email-4@gmail.com", "Name 4", 50) ) val prodDataSource: MutableMap<Long, User> = mutableMapOf( 1L to User(1L, "prod-email-1@gmail.com", "Name 1", 22), 2L to User(2L, "prod-email-2@gmail.com", "Name 2", 43), 3L to User(3L, "prod-email-3@gmail.com", "Name 3", 26), 4L to User(4L, "prod-email-4@gmail.com", "Name 4", 50) ) }
Well, in our application we will be dealing with dummy data.
However, in real life instead of two maps implementations, we would rather have a few implementations of UserRepository, for example:
- one, in-memory, which could be used for local development and testing,
- the second, “real”, one, responsible for database connection.
Either way, the purpose of this tutorial is to learn a bit more about Kotlin with bean definition DSL, and this setup will be useful in our considerations.
5. Configure Routing
In order to expose REST endpoints with Kotlin route DSL, we need to add two things:
UserHandler
– which normally would be a UserController when working with annotations,- custom
RouteFunctionDSL
– responsible for URL mappings.
5.1 Add UserHandler
Firstly, let’s add a handler
package and implement the UserHandler
:
class UserHandler( private val userRepository: UserRepository ) { fun createUser( request: ServerRequest ): ServerResponse { val userRequest = request.body(UserDTO::class.java) val createdUserResponse = userRepository.create( user = userRequest.toModel() ) .toDTO() return ServerResponse.ok() .body(createdUserResponse) } fun findAllUsers( request: ServerRequest ): ServerResponse { val usersResponses = userRepository.findAll() .map(User::toDTO) return ServerResponse.ok() .body(usersResponses) } fun findUserById( request: ServerRequest ): ServerResponse { val id = request.pathVariable("id") .toLongOrNull() ?: return badRequestResponse("Invalid id") val userResponse = userRepository.findById(id) ?.toDTO() return userResponse ?.let { response -> ServerResponse.ok() .body(response) } ?: notFoundResponse(id) } fun updateUserById( request: ServerRequest ): ServerResponse { val id = request.pathVariable("id") .toLongOrNull() ?: return badRequestResponse("Invalid id") val userRequest = request.body(UserDTO::class.java) val updatedUser = userRepository.updateById( id = id, user = userRequest.toModel() ) return updatedUser ?.let { response -> ServerResponse.ok() .body(response) } ?: notFoundResponse(id) } fun deleteUserById( request: ServerRequest ): ServerResponse { val id = request.pathVariable("id") .toLongOrNull() ?: return badRequestResponse("Invalid id") userRepository.deleteById(id) return ServerResponse.noContent() .build() } private fun badRequestResponse(reason: String): ServerResponse = ServerResponse.badRequest() .body( ErrorResponse(reason) ) private fun notFoundResponse(id: Long): ServerResponse = ServerResponse.badRequest() .body( ErrorResponse("User with id: $id was not found.") ) } private fun UserDTO.toModel(): User = User( email = this.email, name = this.name, age = this.age ) private fun User.toDTO(): UserDTO = UserDTO( email = this.email, name = this.name, age = this.age )
As we can see, the handler has only one parameter of the UserRepository
type. Thanks do that, we are not coupled with any implementation. Moreover, we gained flexibility and we can provide the implementation even on the fly when configuring beans.
Another interesting thing is the usage of ServerRequests and ServerResponses. When working with Spring Boot and Kotlin routes DSL, that’s how we deal with incoming requests and responses. And although for more details I redirect you to my previous article about this topic, I wanted to mention this because of one thing. As you can see- instead of throwing exceptions, which then Spring would translate to HTTP responses, we have the possibility to return our custom payload (and status codes, as well).
5.2 Implement Routing
Nevertheless, handler implementation is not sufficient. We have to instruct Spring about how HTTP requests should be handled.
To do so, let’s add the Routes.kt
file inside the config
package:
fun appRouter(userHandler: UserHandler) = router { "/api".nest { "/users".nest { POST(userHandler::createUser) GET(userHandler::findAllUsers) GET("/{id}", userHandler::findUserById) PUT("/{id}", userHandler::updateUserById) DELETE("/{id}", userHandler::deleteUserById) } } }
The appRouter
function has one parameter of the UserHandler
type, which we then use to provide references to functions inside it.
It’s worth mentioning that function references in Kotlin are an interesting syntactic sugar. However, if you prefer not to use them, you can always simply make use of brackets:
POST { request -> userHandler.createUser(request) }
6. Kotlin Bean Definition DSL
With all of that being done, we can finally learn how the Kotlin bean definition DSL work with Spring Boot 3.
6.1 Implement BeansConfig
Firstly, let’s add a new file called BeansConfig.kt
inside the config
package:
val beans = beans { // beans definitions }
The beans {}
is nothing else than a function, which leverages the concept of type-safe builders.
Secondly, let’s add the BeansConfig class:
class BeansConfig : ApplicationContextInitializer<GenericApplicationContext> { override fun initialize(context: GenericApplicationContext) = beans.initialize(context) }
In order to register the beans in our application context, we have to invoke the initialize()
.
Lastly, we need to make Spring aware of our initializer in application.yaml
:
context: initializer: classes: com.codersee.kotlindsl.config.BeansConfig
6.2 Autowiring By Type
As the next step, let’s see how we can define UserHandler and router beans and instruct Spring to autowire by type:
bean<UserHandler>() bean(::appRouter)
As we can see, we don’t even have to specify the types of particular parameters and Spring will take care of that automatically.
Moreover, with callable reference, we can define a bean with the appRouter
top-level function.
6.3 Explicitly Specify Bean Type
Of course, if we would like to specify the type of the bean manually or wire by name, then we can do it, as well:
bean("myHandlerBean") { UserHandler(ref()) } bean { appRouter( ref("myHandlerBean") ) }
And this time we simply make use of the ref function, which is responsible for getting a reference to beans by type (line 2), or by type and name (line 6).
6.4 Conditional Beans Based On Profile
Following, let’s see how we can differentiate the data source based on the profile property:
profile("dev") { bean { UserCrudRepository(devDataSource) } } profile("prod") { bean { UserCrudRepository(prodDataSource) } }
With this approach, beans defined inside the profile()
won’t be created unless the specified profile is active.
Of course, we can activate profiles, for example inside the application.yaml
:
spring: profiles: active: dev
6.5 Define Additional Beans Properties
Following, let’s see how we can customize bean definitions:
bean( name = "userHandler", scope = BeanDefinitionDsl.Scope.SINGLETON, isLazyInit = true, isPrimary = true, isAutowireCandidate = true, initMethodName = "", destroyMethodName = "", description = "description", role = BeanDefinitionDsl.Role.APPLICATION )
As we can see, all parameters of the bean
function have default values assigned.
Nevertheless, if we would like to set any values explicitly, then we can do it with ease.
6.6 Reading Environment Variables
As the last thing, let’s see how we can read environment variables:
val someVariable = env.systemEnvironment["SOME_VARIABLE"]
The systemEnvironment is nothing else than a Map<String, Object>
instance, which is a result of System.getenv()
invocation.
7. Spring Boot 3 Kotlin DSL Summary
And that’s all for this tutorial about how to implement a REST API without annotations using Spring Boot 3, Kotlin bean definition, and router DSL.
I’m happy to hear your feedback– trust me, such a simple comment can motivate a lot and help me to work on my weaknesses ๐
Of course, if you would like to see the source code for this article, then you can find it in this GitHub repository.