mn create-app \
--features=yaml,discovery-kubernetes,management,security,kubernetes,serialization-jackson,validation,graalvm \
--build=maven \
--lang=kotlin \
--jdk=21 \
example.micronaut.users
Kubernetes service discovery and distributed configuration
How to use Kubernetes service discovery and distributed configuration in a Micronaut application
Authors: Nemanja Mikic
Micronaut Version: 4.6.3
1. Getting Started
In this guide, we will create three microservices, build containerized versions and deploy them with Kubernetes. We will use Kubernetes Service discovery and Distributed configuration to wire up our microservices.
Kubernetes is a portable, extensible, open source platform for managing containerized workloads and services, that facilitates both declarative configuration and automation. It has a large, rapidly growing ecosystem. Kubernetes services, support, and tools are widely available.
You will discover how the Micronaut framework eases Kubernetes integration.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 17 or greater installed with
JAVA_HOME
configured appropriately -
Local Kubernetes cluster. We will use Minikube in this guide.
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Writing the Apps
Let’s describe the microservices you will build through the guide.
-
users
- This microservice contains customers data that can place orders on items, also a new customer can be created. Microservice requires Basic authentication to access it. -
orders
- This microservice contains all orders that customers have created as well as available items that customers can order. Also this microservice enables the creation of new orders. Microservice requires Basic authentication to access it. -
api
- This microservice acts as a gateway to theorders
andusers
services. It combines results from both services and checks data when customers create a new order.
Initially we will hard-code the URLs of the orders
and users
services in the api
service. Additionally, we will hard-code credentials (username and password) into every microservice configuration that are required for Basic authentication.
In the second part of this guide we will use a Kubernetes discovery service and Kubernetes configuration maps to dynamically resolve the URLs of the orders
and users
microservices and get authentication credentials. The microservices call the Kubernetes API to register when they start up and then resolve placeholders inside the microservices' configurations.
4.1. Users Microservice
Create the users
microservice using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
If you use Micronaut Launch, select Micronaut Application as application type and add the yaml
, discovery-kubernetes
, management
, security
, serialization-jackson
, kubernetes
and graalvm
features.
The previous command creates a directory named users containing Micronaut application with a package named example.micronaut
.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
Create a package named controllers
and create a UsersController
class to handle incoming HTTP requests for the users
microservice:
package example.micronaut.controllers
import example.micronaut.models.User
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.exceptions.HttpStatusException
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import jakarta.validation.Valid
import jakarta.validation.constraints.NotNull
import kotlin.collections.ArrayList
@Controller("/users") (1)
@Secured(SecurityRule.IS_AUTHENTICATED) (2)
open class UsersController {
var persons: MutableList<User?> = ArrayList()
@Post (3)
open fun add(@Body user: @Valid User?): User {
val foundUser = findByUsername(user!!.username)
if (foundUser != null) {
throw HttpStatusException(HttpStatus.CONFLICT, "User with provided username already exists")
}
val newUser = User(persons.size + 1, user.firstName, user.lastName, user.username)
persons.add(newUser)
return newUser
}
@Get("/{id}") (4)
open fun findById(id: @NotNull Int?): User? {
return persons
.firstOrNull { it: User? ->
it!!.id == id
}
}
@Get (5)
open fun getUsers(): List<User?>? {
return persons
}
open fun findByUsername(username: @NotNull String?): User? {
return persons.firstOrNull { it: User? ->
it!!.username == username
}
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /users . |
2 | Annotate with io.micronaut.security.Secured to configure secured access. The isAuthenticated() expression will allow access only to authenticated users. |
3 | The @Post annotation maps the add method to an HTTP POST request on /users . |
4 | The @Get annotation maps the findById method to an HTTP GET request on /users/{id} . |
5 | The @Get annotation maps the getUsers method to an HTTP GET request on /users . |
Create package named models
where we will put our data beans.
The UsersController
class uses a User
object to represent customer. Create the User
class
package example.micronaut.models
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.core.annotation.Nullable
import io.micronaut.serde.annotation.Serdeable
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.NotBlank
@Serdeable (1)
data class User(@Nullable @Max(10000) val id: Int, (2)
@NotBlank @JsonProperty("first_name") val firstName:String,
@NotBlank @JsonProperty("last_name") val lastName:String,
val username:String)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | ID will be generated by application. |
Create package named auth
where you will check basic authentication credentials.
The Credentials
class will load and store credentials (username and password) from a configuration file.
package example.micronaut.auth
import io.micronaut.context.annotation.ConfigurationProperties
@ConfigurationProperties("authentication-credentials") (1)
class Credentials{
lateinit var username: String
lateinit var password: String
}
1 | The @ConfigurationProperties annotation takes the configuration prefix. |
The CredentialsChecker
class, as the name suggests, will check if the provided credentials inside the HTTP request’s Authorization
header are the same as those that are stored inside the Credentials
class that we created above.
package example.micronaut.auth
import io.micronaut.http.HttpRequest
import io.micronaut.security.authentication.AuthenticationFailureReason
import io.micronaut.security.authentication.AuthenticationRequest
import io.micronaut.security.authentication.AuthenticationResponse
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider
import jakarta.inject.Singleton
@Singleton (1)
class AuthenticationProviderUserPassword<B>(private val credentials: Credentials) : HttpRequestAuthenticationProvider<B> { (2)
override fun authenticate(
httpRequest: HttpRequest<B>?,
authenticationRequest: AuthenticationRequest<String, String>
): AuthenticationResponse {
return if (authenticationRequest.identity == credentials.username && authenticationRequest.secret == credentials.password)
AuthenticationResponse.success(authenticationRequest.identity) else
AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH)
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
4.1.1. Write tests to verify application logic
Create the UsersClient
, a declarative Micronaut HTTP Client for testing:
package example.micronaut
import example.micronaut.models.User
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Header
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.annotation.Client
@Client("/") (1)
interface UsersClient {
@Get("/users/{id}")
fun getById(@Header authorization: String?, id: Int?): User?
@Post("/users")
fun createUser(@Header authorization: String?, @Body user: User?): User
@Get("/users")
fun getUsers(@Header authorization: String?): List<User?>
}
1 | Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value. |
HealthTest
checks that there is /health
endpoint that is required for service discovery.
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@MicronautTest (1)
class HealthTest {
@Inject
@field:Client("/")
lateinit var client: HttpClient (2)
@Test
fun healthEndpointExposed() {
val status = client.toBlocking().retrieve(
HttpRequest.GET<Any>("/health"),
HttpStatus::class.java
)
assertEquals(HttpStatus.OK, status)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Inject the HttpClient bean and point it to the embedded server. |
UsersControllerTest
tests endpoints inside the UserController
.
package example.micronaut
import example.micronaut.auth.Credentials
import example.micronaut.models.User
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.exceptions.HttpClientException
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Base64
@MicronautTest (1)
class UsersControllerTest {
@Inject
var usersClient: UsersClient? = null
@Inject
var credentials: Credentials? = null
@Test
fun testUnauthorized() {
val exception = assertThrows(
HttpClientException::class.java
) { usersClient!!.getUsers("") }
assertTrue(exception.message!!.contains("Unauthorized"))
}
@Test
fun userThatDoesntExists() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val retriedUser = usersClient!!.getById(authHeader, 100)
assertNull(retriedUser)
}
@Test
fun multipleUserInteraction() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val firstName = "firstName"
val lastName = "lastName"
val username = "username"
val user = User(0, firstName, lastName, username)
val createdUser = usersClient!!.createUser(authHeader, user)
assertEquals(firstName, createdUser.firstName)
assertEquals(lastName, createdUser.lastName)
assertEquals(username, createdUser.username)
assertNotNull(createdUser.id)
val retriedUser = usersClient!!.getById(authHeader, createdUser.id)
assertEquals(firstName, retriedUser!!.firstName)
assertEquals(lastName, retriedUser.lastName)
assertEquals(username, retriedUser.username)
val users = usersClient!!.getUsers(authHeader)
assertNotNull(users)
assertNotNull(users.any {
it!!.username == username
})
}
@Test
fun createSameUserTwice() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val firstName = "SameUserFirstName"
val lastName = "SameUserLastName"
val username = "SameUserUsername"
val user = User(0, firstName, lastName, username)
val createdUser = usersClient!!.createUser(authHeader, user)
assertEquals(firstName, createdUser.firstName)
assertEquals(lastName, createdUser.lastName)
assertEquals(username, createdUser.username)
assertNotNull(createdUser.id)
assertNotEquals(createdUser.id, 0)
val exception = assertThrows(
HttpClientResponseException::class.java
) { usersClient!!.createUser(authHeader, user) }
assertEquals(HttpStatus.CONFLICT, exception.status)
assertTrue(
exception.response.getBody(String::class.java).orElse("")
.contains("User with provided username already exists")
)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
Edit application.yml
micronaut:
application:
name: users
authentication-credentials:
username: ${username} (1)
password: ${password} (2)
1 | Placeholder for username that will be populated by Kubernetes. |
2 | Placeholder for password that will be populated by Kubernetes. |
Edit the bootstrap.yml file in the resources directory to enable distributed configuration. Change the default contents to the following:
micronaut:
application:
name: users
config-client:
enabled: true (1)
kubernetes:
client:
secrets:
enabled: true (2)
use-api: true (3)
1 | Set microanut.config-client.enabled: true to read and resolve configuration from distributed sources. |
2 | Set kubernetes.client.secrets.enabled: true to enable Kubernetes secrets as distributed source. |
3 | Set kubernetes.client.secrets.use-api: true to use the Kubernetes API to fetch the configuration. |
Create src/main/resources/application-dev.yml
. The Micronaut framework applies this configuration file only for the dev
environment.
micronaut:
server:
port: 8081 (1)
authentication-credentials:
username: "test_username" (2)
password: "test_password" (3)
1 | Configure the application to listen on port 8081. |
2 | Hardcoded username for the development environment. |
3 | Hardcoded password for the development environment. |
Create a file named bootstrap-dev.yml to disable distributed configuration in the dev environment:
kubernetes:
client:
secrets:
enabled: false (1)
1 | Disable the Kubernetes secrets client. |
Create a file named application-test.yml for use in the test environment:
authentication-credentials:
username: "test_username" (1)
password: "test_password" (2)
1 | Hardcoded username for the test environment. |
2 | Hardcoded password for the test environment. |
Run the unit test:
./mvnw test
4.1.2. Running the application
Run the users
microservice:
MICRONAUT_ENVIRONMENTS=dev ./mvnw mn:run
14:28:34.034 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8081
4.2. Orders Microservice
Create the orders
microservice using the Micronaut Command Line Interface or with Micronaut Launch.
mn create-app \
--features=yaml,discovery-kubernetes,management,security,kubernetes,serialization-jackson,validation,graalvm \
--build=maven \
--lang=kotlin \
--jdk=21 \
example.micronaut.orders
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
If you use Micronaut Launch, select Micronaut Application as application type and add the yaml
, discovery-kubernetes
, management
, security
, serialization-jackson
, kubernetes
and graalvm
features.
The previous command creates a directory named orders containing a Micronaut application with a package named example.micronaut
.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
Create package named controllers
and create the OrdersController
and ItemsController
classes to handle incoming HTTP requests to the orders
microservice:
package example.micronaut.controllers
import example.micronaut.models.Item
import example.micronaut.models.Item.Companion.items
import example.micronaut.models.Order
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.exceptions.HttpStatusException
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import java.math.BigDecimal
import jakarta.validation.Valid
import jakarta.validation.constraints.NotNull
@Controller("/orders") (1)
@Secured(SecurityRule.IS_AUTHENTICATED) (2)
open class OrdersController {
private val orders: MutableList<Order> = ArrayList()
@Get("/{id}") (3)
open fun findById(id: @NotNull Int?): Order? {
return orders.firstOrNull{ it: Order ->
it.id == id
}
}
@Get (4)
fun getOrders(): List<Order?> {
return orders
}
@Post (5)
open fun createOrder(@Body order: @Valid Order): Order {
if (order.itemIds.isNullOrEmpty()) {
throw HttpStatusException(HttpStatus.BAD_REQUEST, "Items must be supplied")
}
val items: List<Item> = order.itemIds.map { x ->
items.firstOrNull { y: Item ->
y.id == x
}?: throw HttpStatusException(HttpStatus.BAD_REQUEST, String.format("Item with id %s doesn't exist", x))
}
val total: BigDecimal = items.map(Item::price).reduce(BigDecimal::add)
val newOrder = Order(orders.size + 1, order.userId, items, null, total)
orders.add(newOrder)
return newOrder
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /orders . |
2 | Annotate with io.micronaut.security.Secured to configure secured access. The isAuthenticated() expression will allow access only to authenticated users. |
3 | The @Get annotation maps the findById method to an HTTP GET request on /orders/{id} . |
4 | The @Get annotation maps the getOrders method to an HTTP GET request on /orders . |
5 | The @Post annotation maps the createOrder method to an HTTP POST request on /orders . |
package example.micronaut.controllers
import example.micronaut.models.Item
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.security.annotation.Secured
import io.micronaut.security.rules.SecurityRule
import jakarta.validation.constraints.NotNull
@Controller("/items") (1)
@Secured(SecurityRule.IS_AUTHENTICATED) (2)
open class ItemsController {
@Get("/{id}") (3)
open fun findById(id: @NotNull Int?): Item? {
return Item.items
.firstOrNull { it.id == id }
}
@Get (4)
fun getItems(): List<Item?> {
return Item.items
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /items . |
2 | Annotate with io.micronaut.security.Secured to configure secured access. The isAuthenticated() expression will allow access only to authenticated users. |
3 | The @Get annotation maps the findById method to an HTTP GET request on /items/{id} . |
4 | The @Get annotation maps the getItems method to an HTTP GET request on /items . |
Create package named models
where you will put your data beans.
The previous OrdersController
and ItemsController
controller uses Order
and Item
objects to represent customer orders. Create the Order
class:
package example.micronaut.models
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.core.annotation.Nullable
import io.micronaut.serde.annotation.Serdeable
import java.math.BigDecimal
import jakarta.validation.constraints.Max
@Serdeable (1)
data class Order (
@Nullable @Max(10000) val id:Int, (2)
@JsonProperty("user_id") val userId:Int,
@Nullable val items: List<Item>?, (3)
@JsonProperty("item_ids") val itemIds:List<Int>?, (4)
@Nullable val total: BigDecimal?)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | ID will be generated by application. |
3 | The List of Item class will be populated by the server and will be only visible in sever responses. |
4 | List of item_ids will be provided by client requests. |
Create the Item
class:
package example.micronaut.models
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.serde.annotation.Serdeable
import java.math.BigDecimal
@Serdeable (1)
data class Item (
val id:Int,
val name:String,
val price: BigDecimal
) {
companion object {
var items: List<Item> = listOf(
Item(1, "Banana", BigDecimal("1.5")),
Item(2, "Kiwi", BigDecimal("2.5")),
Item(3, "Grape", BigDecimal("1.25"))
)
}
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
Create package named auth
where you will check basic authentication credentials.
The Credentials
class will load and store credentials (username and password) from configuration files.
package example.micronaut.auth
import io.micronaut.context.annotation.ConfigurationProperties
@ConfigurationProperties("authentication-credentials") (1)
class Credentials{
lateinit var username: String
lateinit var password: String
}
1 | The @ConfigurationProperties annotation takes the configuration prefix. |
The CredentialsChecker
class, as name suggests, will check if provided credentials inside an HTTP request’s Authorization
header are the same as those that are stored inside Credentials
class that we created above.
package example.micronaut.auth
import io.micronaut.http.HttpRequest
import io.micronaut.security.authentication.AuthenticationFailureReason
import io.micronaut.security.authentication.AuthenticationRequest
import io.micronaut.security.authentication.AuthenticationResponse
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider
import jakarta.inject.Singleton
@Singleton (1)
class AuthenticationProviderUserPassword<B>(private val credentials: Credentials) : HttpRequestAuthenticationProvider<B> { (2)
override fun authenticate(
httpRequest: HttpRequest<B>?,
authenticationRequest: AuthenticationRequest<String, String>
): AuthenticationResponse {
return if (authenticationRequest.identity == credentials.username && authenticationRequest.secret == credentials.password)
AuthenticationResponse.success(authenticationRequest.identity) else
AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH)
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
4.2.1. Write tests to verify application logic
Create the OrderItemClient
, a declarative Micronaut HTTP Client for testing:
package example.micronaut
import example.micronaut.models.Item
import example.micronaut.models.Order
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Header
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.annotation.Client
@Client("/") (1)
interface OrderItemClient {
@Get("/orders/{id}")
fun getOrderById(@Header authorization: String?, id: Int?): Order?
@Post("/orders")
fun createOrder(@Header authorization: String?, @Body order: Order?): Order
@Get("/orders")
fun getOrders(@Header authorization: String?): List<Order>
@Get("/items")
fun getItems(@Header authorization: String?): List<Item>
@Get("/items/{id}")
fun getItemsById(@Header authorization: String?, id: Int?): Item?
}
1 | Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value. |
HealthTest
checks that there is /health
endpoint that is required for service discovery.
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@MicronautTest (1)
class HealthTest {
@Inject
@field:Client("/")
lateinit var client: HttpClient (2)
@Test
fun healthEndpointExposed() {
val status = client.toBlocking().retrieve(
HttpRequest.GET<Any>("/health"),
HttpStatus::class.java
)
assertEquals(HttpStatus.OK, status)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Inject the HttpClient bean and point it to the embedded server. |
ItemsControllerTest
tests endpoints inside the ItemController
.
package example.micronaut
import example.micronaut.auth.Credentials
import example.micronaut.models.Item
import io.micronaut.http.client.exceptions.HttpClientException
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.Base64
@MicronautTest (1)
class ItemsControllerTest {
@Inject
var orderItemClient: OrderItemClient? = null
@Inject
var credentials: Credentials? = null
@Test
fun testUnauthorized() {
val exception = assertThrows(
HttpClientException::class.java
) { orderItemClient?.getItems("") }
assertTrue(exception.message!!.contains("Unauthorized"))
}
@Test
fun getItem() {
val itemId = 1
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val item: Item? = orderItemClient!!.getItemsById(authHeader, itemId)
assertEquals(itemId, item!!.id)
assertEquals("Banana", item.name)
assertEquals(BigDecimal("1.5"), item.price)
}
@Test
fun getItems() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val items: List<Item> = orderItemClient!!.getItems(authHeader)
assertNotNull(items)
val existingItemNames = listOf("Kiwi", "Banana", "Grape")
assertEquals(3, items.size)
assertTrue(items.stream()
.map<Any>(Item::name)
.allMatch { name: Any ->
existingItemNames.stream().anyMatch { x: String -> x == name }
})
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
OrdersControllerTest
tests endpoints inside the OrdersController
.
package example.micronaut
import example.micronaut.auth.Credentials
import example.micronaut.models.Order
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.exceptions.HttpClientException
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.Base64
@MicronautTest (1)
class OrdersControllerTest {
@Inject
var orderItemClient: OrderItemClient? = null
@Inject
var credentials: Credentials? = null
@Test
fun testUnauthorized() {
val exception = Assertions.assertThrows(
HttpClientException::class.java
) { orderItemClient!!.getOrders("") }
assertTrue(exception.message!!.contains("Unauthorized"))
}
@Test
fun multipleOrderInteraction() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val userId = 1
val itemIds = listOf(1, 1, 2, 3)
val order = Order(0, userId, null, itemIds, null)
val createdOrder = orderItemClient!!.createOrder(authHeader, order)
assertNotNull(createdOrder.items)
assertEquals(4, createdOrder.items!!.size)
assertEquals(BigDecimal("6.75"), createdOrder.total)
assertEquals(userId, createdOrder.userId)
val retrievedOrder = orderItemClient!!.getOrderById(authHeader, createdOrder.id)
assertNotNull(retrievedOrder!!.items)
assertEquals(4, retrievedOrder.items!!.size)
assertEquals(BigDecimal("6.75"), retrievedOrder.total)
assertEquals(userId, retrievedOrder.userId)
val orders = orderItemClient!!.getOrders(authHeader)
assertNotNull(orders)
assertTrue(orders.stream()
.map<Any>(Order::userId)
.anyMatch { id: Any -> id == userId })
}
@Test
fun itemDoesntExists() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val userId = 1
val itemIds = listOf(5)
val order = Order(0, userId, null, itemIds, null)
val exception = Assertions.assertThrows(
HttpClientResponseException::class.java
) { orderItemClient!!.createOrder(authHeader, order) }
assertEquals(exception.status, HttpStatus.BAD_REQUEST)
assertTrue(
exception.response.getBody(String::class.java).orElse("").contains("Item with id 5 doesn't exist")
)
}
@Test
fun orderEmptyItems() {
val authHeader = "Basic " + Base64.getEncoder()
.encodeToString((credentials!!.username + ":" + credentials!!.password).toByteArray())
val userId = 1
val order = Order(0, userId, null, null, null)
val exception = Assertions.assertThrows(
HttpClientResponseException::class.java
) { orderItemClient!!.createOrder(authHeader, order) }
assertEquals(exception.status, HttpStatus.BAD_REQUEST)
assertTrue(
exception.response.getBody(String::class.java).orElse("").contains("Items must be supplied")
)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
Edit application.yml so it contains:
micronaut:
application:
name: orders
authentication-credentials:
username: ${username} (1)
password: ${password} (2)
1 | Placeholder for username that will be populated by Kubernetes. |
2 | Placeholder for password that will be populated by Kubernetes. |
Edit bootstrap.yml file in the resources directory to enable distributed configuration. Change it to the following:
micronaut:
application:
name: orders
config-client:
enabled: true (1)
kubernetes:
client:
secrets:
enabled: true (2)
use-api: true (3)
1 | Set microanut.config-client.enabled: true to read and resolve configuration from distributed sources. |
2 | Set kubernetes.client.secrets.enabled: true to enable Kubernetes secrets as distributed source. |
3 | Set kubernetes.client.secrets.use-api: true to use Kubernetes API to fetch configuration. |
Create src/main/resources/application-dev.yml
. The Micronaut framework applies this configuration file only for the dev
environment.
micronaut:
server:
port: 8082 (1)
authentication-credentials:
username: "test_username" (2)
password: "test_password" (3)
1 | Configure the application to listen on port 8082. |
2 | Hardcoded username for development environment. |
3 | Hardcoded password for development environment. |
Create a file named bootstrap-dev.yml to disable distributed configuration in the dev environment:
kubernetes:
client:
secrets:
enabled: false (1)
1 | Disable Kubernetes secrets client. |
Create a file named application-test.yml to be used in the test environment:
micronaut:
application:
name: orders
authentication-credentials:
username: "test_username" (1)
password: "test_password" (2)
1 | Hardcoded username for development environment. |
2 | Hardcoded password for development environment. |
Run the unit test:
./mvnw test
4.2.2. Running the application
Run the orders
microservice:
MICRONAUT_ENVIRONMENTS=dev ./mvnw mn:run
14:28:34.034 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8082
4.3. API (Gateway) Microservice
Create the api
microservice using the Micronaut Command Line Interface or with Micronaut Launch.
mn create-app \
--features=yaml,discovery-kubernetes,management,kubernetes,serialization-jackson,http-client,mockito,kapt,graalvm \
--build=maven \
--lang=kotlin \
--jdk=21 \
example.micronaut.api
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
If you use Micronaut Launch, select Micronaut Application as application type and add the yaml
, discovery-kubernetes
, management
, kubernetes
, serialization-jackson
, mockito
, graalvm
and http-client
features.
The previous command creates a directory named api containing a Micronaut application with a package named example.micronaut
.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
Create a package named controllers
and create a GatewayController
class to handle incoming HTTP requests to the api
microservice:
package example.micronaut.controllers
import example.micronaut.clients.OrdersClient
import example.micronaut.clients.UsersClient
import example.micronaut.models.Item
import example.micronaut.models.Order
import example.micronaut.models.User
import io.micronaut.core.annotation.NonNull
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.exceptions.HttpStatusException
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import jakarta.validation.Valid
@Controller("/api") (1)
@ExecuteOn(TaskExecutors.BLOCKING) (2)
class GatewayController(ordersClient: OrdersClient, usersClient: UsersClient) {
private val ordersClient: OrdersClient
private val userClient: UsersClient
init {
this.ordersClient = ordersClient
this.userClient = usersClient
}
@Get("/users/{id}") (3)
fun getUserById(@NonNull id: Int): User? {
return userClient.getById(id)
}
@Get("/orders/{id}") (4)
fun getOrdersById(@NonNull id: Int): Order {
val order = ordersClient.getOrderById(id)
return Order(
order.id,
null,
getUserById(order.userId!!),
order.items,
order.itemIds,
order.total
)
}
@Get("/items/{id}") (5)
fun getItemsById(@NonNull id: Int): Item {
return ordersClient.getItemsById(id)
}
@Get("/users") (6)
fun getUsers(): List<User> {
return userClient.users
}
@Get("/items") (7)
fun getItems(): List<Item> {
return ordersClient.items
}
@Get("/orders") (8)
fun getOrders(): List<Order>? {
val orders = mutableListOf<Order>()
ordersClient.orders.forEach{orders.add(Order(
it.id,
null,
getUserById(it.userId!!),
it.items,
it.itemIds,
it.total))
}
return orders
}
@Post("/orders") (9)
fun createOrder(@Body order: @Valid Order): Order? {
val user = getUserById(order!!.userId!!)
?: throw HttpStatusException(
HttpStatus.BAD_REQUEST,
String.format("User with id %s doesn't exist", order.userId)
)
val createdOrder = ordersClient.createOrder(order)
return Order(
createdOrder!!.id,
null,
user,
createdOrder.items,
createdOrder.itemIds,
createdOrder.total
)
}
@Post("/users") (10)
fun createUser(@Body @NonNull user: User): User {
return userClient.createUser(user)
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /api . |
2 | It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop. |
3 | The @Get annotation maps the getUserById method to an HTTP GET request on /users/{id} . |
4 | The @Get annotation maps the getOrdersById method to an HTTP GET request on /orders/{id} . |
5 | The @Get annotation maps the getItemsById method to an HTTP GET request on /items/{id} . |
6 | The @Get annotation maps the getUsers method to an HTTP GET request on /users . |
7 | The @Get annotation maps the getItems method to an HTTP GET request on /items . |
8 | The @Get annotation maps the getOrders method to an HTTP GET request on /orders . |
9 | The @Post annotation maps the createUser method to an HTTP POST request on /users . |
10 | The @Post annotation maps the createOrder method to an HTTP POST request on /orders . |
Create package named models
where you will put your data beans.
The previous GatewayController
and ItemsController
controller uses User
, Order
and Item
to represent customer orders. Create the User
class:
package example.micronaut.models
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.core.annotation.Nullable
import io.micronaut.serde.annotation.Serdeable
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.NotBlank
@Serdeable (1)
data class User(@Nullable @Max(10000) val id: Int, (1)
@NotBlank @JsonProperty("first_name") val firstName:String,
@NotBlank @JsonProperty("last_name") val lastName:String,
val username:String)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
Create the Order
class:
package example.micronaut.models
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.core.annotation.Nullable
import io.micronaut.serde.annotation.Serdeable
import java.math.BigDecimal
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.NotBlank
@Serdeable (1)
data class Order (
@Nullable @Max(10000) val id:Int, (2)
@NotBlank @Nullable @JsonProperty("user_id") val userId:Int?,
@Nullable val user: User?,
val items: List<Item>?, (3)
@NotBlank @JsonProperty("item_ids") val itemIds:List<Int>?, (4)
val total: BigDecimal?)
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
Create the Item
class:
package example.micronaut.models
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.serde.annotation.Serdeable
import java.math.BigDecimal
@Serdeable (1)
data class Item (
val id:Int,
val name:String,
val price: BigDecimal
) {
companion object {
var items: List<Item> = listOf(
Item(1, "Banana", BigDecimal("1.5")),
Item(2, "Kiwi", BigDecimal("2.5")),
Item(3, "Grape", BigDecimal("1.25"))
)
}
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
Create a package named clients
where you will put the HTTP Clients to call the users
and orders
microservices.
Create a UsersClient
for the users
microservice.
package example.micronaut.clients
import example.micronaut.models.User
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.annotation.Client
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Client("users") (1)
interface UsersClient {
@Get("/users/{id}")
fun getById(id: Int): User
@Post("/users")
fun createUser(@Body user: User?): User
@get:Get("/users")
val users: List<User>
}
1 | Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value. |
Create an OrdersClient
for the orders
microservice.
package example.micronaut.clients
import example.micronaut.models.Item
import example.micronaut.models.Order
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.annotation.Client
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Client("orders") (1)
interface OrdersClient {
@Get("/orders/{id}")
fun getOrderById(id: Int): Order
@Post("/orders")
fun createOrder(@Body order: Order?): Order
@get:Get("/orders")
val orders: List<Order>
@get:Get("/items")
val items: List<Item>
@Get("/items/{id}")
fun getItemsById(id: Int): Item
}
1 | Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value. |
Create a package named auth
where we will check basic authentication credentials.
Create a Credentials
class that will load the username and password from configuration that will be needed for comparison.
package example.micronaut.auth
import io.micronaut.context.annotation.ConfigurationProperties
@ConfigurationProperties("authentication-credentials") (1)
class Credentials{
lateinit var username: String
lateinit var password: String
}
1 | The @ConfigurationProperties annotation takes the configuration prefix. |
Create an AuthClientFilter
class that is a client filter applied to every client. It adds basic authentication header with credentials that are stored in the Credentials
class.
package example.micronaut.auth
import io.micronaut.http.HttpResponse
import io.micronaut.http.MutableHttpRequest
import io.micronaut.http.annotation.Filter
import io.micronaut.http.filter.ClientFilterChain
import io.micronaut.http.filter.HttpClientFilter
import org.reactivestreams.Publisher
@Filter(Filter.MATCH_ALL_PATTERN)
class AuthClientFilter(credentials: Credentials) : HttpClientFilter {
private val credentials: Credentials
init {
this.credentials = credentials
}
override fun doFilter(request: MutableHttpRequest<*>, chain: ClientFilterChain): Publisher<out HttpResponse<*>?> {
return chain.proceed(request.basicAuth(credentials.username, credentials.password))
}
}
Create a class named ErrorExceptionHandler
in the example.micronaut
package. ErrorExceptionHandler
will propagate errors from the orders
and users
microservices.
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.http.server.exceptions.ExceptionHandler
import jakarta.inject.Singleton
@Singleton (1)
class ErrorExceptionHandler :
ExceptionHandler<HttpClientResponseException, HttpResponse<*>> {
override fun handle(request: HttpRequest<*>?, exception: HttpClientResponseException): HttpResponse<*> {
return HttpResponse.status<Any>(exception.response.status()).body(
exception.response.getBody(
String::class.java
).orElse(null)
)
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
4.3.1. Write tests to verify application logic
Create a GatewayClient
, a declarative Micronaut HTTP Client for testing:
package example.micronaut
import example.micronaut.models.Item
import example.micronaut.models.Order
import example.micronaut.models.User
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.annotation.Client
@Client("/") (1)
interface GatewayClient {
@Get("/api/items/{id}")
fun getItemById(id: Int?): Item?
@Get("/api/orders/{id}")
fun getOrderById(id: Int?): Order?
@Get("/api/users/{id}")
fun getUsersById(id: Int?): User?
@get:Get("/api/users")
val users: List<User>
@get:Get("/api/items")
val items: List<Item>
@get:Get("/api/orders")
val orders: List<Order>
@Post("/api/orders")
fun createOrder(@Body order: Order): Order
@Post("/api/users")
fun createUser(@Body user: User): User
}
1 | Use @Client to use declarative HTTP Clients. You can annotate interfaces or abstract classes. You can use the id member to provide a service identifier or specify the URL directly as the annotation’s value. |
HealthTest
checks that there is /health
endpoint that is required for service discovery.
package example.micronaut
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@MicronautTest (1)
class HealthTest {
@Inject
@field:Client("/")
lateinit var client: HttpClient (2)
@Test
fun healthEndpointExposed() {
val status = client.toBlocking().retrieve(
HttpRequest.GET<Any>("/health"),
HttpStatus::class.java
)
assertEquals(HttpStatus.OK, status)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Inject the HttpClient bean and point it to the embedded server. |
GatewayControllerTest
tests endpoints inside the GatewayController
.
package example.micronaut
import example.micronaut.clients.OrdersClient
import example.micronaut.clients.UsersClient
import example.micronaut.models.Item
import example.micronaut.models.Order
import example.micronaut.models.User
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.annotation.MockBean
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import jakarta.inject.Inject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import java.math.BigDecimal
@MicronautTest (1)
class GatewayControllerTest {
@Inject
var ordersClient: OrdersClient? = null
@Inject
var usersClient: UsersClient? = null
@Inject
var gatewayClient: GatewayClient? = null
@MockBean(OrdersClient::class)
fun ordersClient(): OrdersClient {
return mock(OrdersClient::class.java)
}
@MockBean(UsersClient::class)
fun usersClient(): UsersClient {
return mock(UsersClient::class.java)
}
@Test
fun itemById() {
val itemId = 1
val item = Item(itemId, "test", BigDecimal.ONE)
`when`(ordersClient!!.getItemsById(1)).thenReturn(item)
val retrievedItem = gatewayClient!!.getItemById(item.id)
assertEquals(item.id, retrievedItem!!.id)
assertEquals(item.name, retrievedItem.name)
assertEquals(item.price, retrievedItem.price)
}
@Test
fun orderById() {
val order = Order(1, 2, null, null, ArrayList(), null)
val user = User(order.userId!!, "firstName", "lastName", "test")
`when`(ordersClient!!.getOrderById(1)).thenReturn(order)
`when`(usersClient!!.getById(user.id)).thenReturn(user)
val retrievedOrder = gatewayClient!!.getOrderById(order.id)
assertEquals(order.id, retrievedOrder!!.id)
assertEquals(order.userId, retrievedOrder.user!!.id)
assertNull(retrievedOrder.userId)
assertEquals(user.username, retrievedOrder.user!!.username)
}
@Test
fun userById() {
val user = User(1, "firstName", "lastName", "test")
`when`(usersClient!!.getById(1)).thenReturn(user)
val retrievedUser = gatewayClient!!.getUsersById(user.id)
assertEquals(user.id, retrievedUser!!.id)
assertEquals(user.username, retrievedUser.username)
}
@Test
fun users() {
val user = User(1, "firstName", "lastName", "test")
`when`(usersClient!!.users).thenReturn(listOf(user))
val users = gatewayClient!!.users
assertNotNull(users)
assertEquals(1, users.size)
assertEquals(user.id, users[0].id)
assertEquals(user.username, users[0].username)
}
@Test
fun items() {
val item = Item(1, "test", BigDecimal.ONE)
`when`(ordersClient!!.items).thenReturn(listOf(item))
val items = gatewayClient!!.items
assertNotNull(items)
assertEquals(1, items.size)
assertEquals(item.name, items[0].name)
assertEquals(item.price, items[0].price)
}
@Test
fun orders() {
val order = Order(1, 2, null, null, ArrayList(), null)
val user = User(order.userId!!, "firstName", "lastName", "test")
`when`(ordersClient!!.orders).thenReturn(listOf(order))
`when`(usersClient!!.getById(order.userId!!)).thenReturn(user)
val orders = gatewayClient!!.orders
assertNotNull(orders)
assertEquals(1, orders.size)
assertNull(orders[0].userId)
assertEquals(user.id, orders[0].user!!.id)
assertEquals(order.id, orders[0].id)
assertEquals(user.username, orders[0].user!!.username)
}
@Test
fun createUser() {
val firstName = "firstName"
val lastName = "lastName"
val username = "username"
val user = User(0, firstName, lastName, username)
`when`(usersClient!!.createUser(user)).thenReturn(user)
val createdUser = gatewayClient!!.createUser(user)
assertEquals(firstName, createdUser.firstName)
assertEquals(lastName, createdUser.lastName)
assertEquals(username, createdUser.username)
}
@Test
fun createOrder() {
val order = Order(1, 2, null, null, null, null)
val user = User(order.userId!!, "firstName", "lastName", "test")
`when`(usersClient!!.getById(user.id)).thenReturn(user)
`when`(ordersClient!!.createOrder(order)).thenReturn(order)
val createdOrder = gatewayClient!!.createOrder(order)
assertEquals(order.id, createdOrder.id)
assertNull(createdOrder.userId)
assertEquals(order.userId, createdOrder.user!!.id)
assertEquals(user.username, createdOrder.user!!.username)
}
@Test
fun createOrderUserDoesntExists() {
val order = Order(1, 2, null, null, null, BigDecimal(0))
`when`(ordersClient!!.createOrder(order)).thenReturn(order)
`when`(usersClient!!.getById(order.userId!!)).thenReturn(null)
val exception = assertThrows(
HttpClientResponseException::class.java
) { gatewayClient!!.createOrder(order) }
assertEquals(exception.status, HttpStatus.BAD_REQUEST)
assertTrue(
exception.response.getBody(String::class.java).orElse("").contains("User with id 2 doesn't exist")
)
}
@Test
fun exceptionHandler() {
val user = User(1, "firstname", "lastname", "username")
val message = "Test error message"
`when`(usersClient!!.createUser(user)).thenThrow(
HttpClientResponseException(
"Test",
HttpResponse.badRequest(message)
)
)
val exception = assertThrows(
HttpClientResponseException::class.java
) { gatewayClient!!.createUser(user) }
assertEquals(exception.status, HttpStatus.BAD_REQUEST)
assertTrue(exception.response.getBody(String::class.java).orElse("").contains("Test error message"))
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
Edit application.yml
micronaut:
application:
name: api
authentication-credentials:
username: ${username} (1)
password: ${password} (2)
1 | Placeholder for username that will be populated by Kubernetes. |
2 | Placeholder for password that will be populated by Kubernetes. |
Edit the bootstrap.yml file in the resources directory to enable distributed configuration so it looks like the following:
micronaut:
application:
name: api
config-client:
enabled: true (1)
kubernetes:
client:
secrets:
enabled: true (2)
use-api: true (3)
1 | Set microanut.config-client.enabled: true to read and resolve configuration from distributed sources. |
2 | Set kubernetes.client.secrets.enabled: true to enable Kubernetes secrets as a distributed source. |
3 | Set kubernetes.client.secrets.use-api: true to use the Kubernetes API to fetch configuration. |
Create src/main/resources/application-dev.yml
. The Micronaut framework applies this configuration file only for the dev
environment.
authentication-credentials:
username: "test_username" (1)
password: "test_password" (2)
1 | Hardcoded username for development environment. |
2 | Hardcoded password for development environment. |
Create a file named bootstrap-dev.yml to disable distributed configuration in the dev environment:
micronaut:
http:
services:
users:
urls:
- http://localhost:8081 (1)
orders:
urls:
- http://localhost:8082 (2)
kubernetes:
client:
secrets:
enabled: false (3)
1 | URL of the users microservice |
2 | URL of the orders microservice |
3 | Disable Kubernetes secrets client. |
Create a file named application-test.yml to be used in the test environment:
authentication-credentials:
username: "test_username" (1)
password: "test_password" (2)
1 | Hardcoded username for development environment. |
2 | Hardcoded password for development environment. |
Run the unit test:
./mvnw test
4.3.2. Running the application
Run api
microservice:
MICRONAUT_ENVIRONMENTS=dev ./mvnw mn:run
14:28:34.034 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8080
4.4. Test integration between applications
Store the URL of the api
microservice in the API_URL
environment variable.
export API_URL=http://localhost:8080
Run a cURL command to create a new user via the api
microservice:
curl -X "POST" "$API_URL/api/users" -H 'Content-Type: application/json; charset=utf-8' -d '{ "first_name": "Nemanja", "last_name": "Mikic", "username": "nmikic" }'
{"id":1,"username":"nmikic","first_name":"Nemanja","last_name":"Mikic"}
Run a cURL command to a new order via the api
microservice:
curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 1, "item_ids": [1,2] }'
{"id":1,"user":{"first_name":"Nemanja","last_name":"Mikic","id":1,"username":"nmikic"},"items":[{"id":1,"name":"Banana","price":1.5},{"id":2,"name":"Kiwi","price":2.5}],"total":4.0}
Run a cURL command to list created orders:
curl "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8'
[{"id":1,"user":{"first_name":"Nemanja","last_name":"Mikic","id":1,"username":"nmikic"},"items":[{"id":1,"name":"Banana","price":1.5},{"id":2,"name":"Kiwi","price":2.5}],"total":4.0}]
We can try to place an order for a user who doesn’t exist (with id 100). Run a cURL command:
curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 100, "item_ids": [1,2] }'
{"message":"Bad Request","_links":{"self":[{"href":"/api/orders","templated":false}]},"_embedded":{"errors":[{"message":"User with id 100 doesn't exist"}]}}
5. Kubernetes and the Micronaut framework
In this chapter we will first create the necessary Kubernetes resources for our microservices that will make them work properly then we will configure build container images and deploy each of the microservices that we created on the local Kubernetes cluster.
Create a filed named auth.yml that will service role for microservices that have secret configurations.
apiVersion: v1
kind: Namespace (1)
metadata:
name: micronaut-k8s
---
apiVersion: v1
kind: ServiceAccount (2)
metadata:
namespace: micronaut-k8s
name: micronaut-service
---
kind: Role (3)
apiVersion: rbac.authorization.k8s.io/v1
metadata:
namespace: micronaut-k8s
name: micronaut_service_role
rules:
- apiGroups: [""]
resources: ["services", "endpoints", "configmaps", "secrets", "pods"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding (4)
metadata:
namespace: micronaut-k8s
name: micronaut_service_role_bind
subjects:
- kind: ServiceAccount
name: micronaut-service
roleRef:
kind: Role
name: micronaut_service_role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret (5)
metadata:
namespace: micronaut-k8s
name: mysecret
type: Opaque
data:
username: YWRtaW4= (6)
password: bWljcm9uYXV0aXNhd2Vzb21l (7)
1 | We create a namespace named micronaut-k8s . |
2 | We create a service account named micronaut-service . |
3 | We create a role named micronaut_service_role . |
4 | We bind the micronaut_service_role role to the micronaut-service service account. |
5 | We create a secret named mysecret . |
6 | Base64 value of the username secret that will be used by the microservices. |
7 | Base64 value of the password secret that will be used by the microservices. |
Run the next command to create the resources described above:
kubectl apply -f auth.yml
Before we start deploying each service, ensure that Docker daemon is configured to use Kubernetes. If you are using Minikube run the next command to switch the docker daemon to use Minikube.
eval $(minikube docker-env)
5.1. Users Microservice
Build a docker image of the users
service with the name users
.
Edit the file named k8s.yml inside the users
microservice.
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: micronaut-k8s
name: "users"
spec:
selector:
matchLabels:
app: "users"
template:
metadata:
labels:
app: "users"
spec:
serviceAccountName: micronaut-service (1)
containers:
- name: "users"
image: users (2)
imagePullPolicy: Never (3)
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
namespace: micronaut-k8s
name: "users" (4)
spec:
selector:
app: "users"
type: NodePort
ports:
- protocol: "TCP"
port: 8080 (5)
1 | The service name that we created in the auth.yaml file. |
2 | The name of the container image for deployment. |
3 | The imagePullPolicy is set to Never. We will always use local one that we built in previous step. |
4 | Name of a service, required for service discovery. |
5 | Micronaut default port on which application is running. |
Run the next command to create the resources described above:
kubectl apply -f users/k8s.yml
deployment.apps/users created
service/users created
5.2. Orders Microservice
Build a docker image of the orders
service with the name orders
.
Edit the file named k8s.yml inside the orders
microservice.
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: micronaut-k8s
name: "orders"
spec:
selector:
matchLabels:
app: "orders"
template:
metadata:
labels:
app: "orders"
spec:
serviceAccountName: micronaut-service (1)
containers:
- name: "orders"
image: orders (2)
imagePullPolicy: Never (3)
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
namespace: micronaut-k8s
name: "orders" (4)
spec:
selector:
app: "orders"
type: NodePort
ports:
- protocol: "TCP"
port: 8080 (5)
1 | The service name that we created in the auth.yaml file. |
2 | The name of the container image for deployment. |
3 | The imagePullPolicy is set to Never. We will always use local one that we built in previous step. |
4 | Name of a service, required for service discovery. |
5 | Micronaut default port on which application is running. |
Run the next command to create the resources described above:
kubectl apply -f orders/k8s.yml
5.3. API (Gateway) Microservice
Build a docker image of the api
service with the name api
.
Edit the file named k8s.yml inside the api
microservice.
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: micronaut-k8s
name: "api"
spec:
selector:
matchLabels:
app: "api"
template:
metadata:
labels:
app: "api"
spec:
serviceAccountName: micronaut-service (1)
containers:
- name: "api"
image: api (2)
imagePullPolicy: Never (3)
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 3
failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
namespace: micronaut-k8s
name: "api" (4)
spec:
selector:
app: "api"
type: LoadBalancer
ports:
- protocol: "TCP"
port: 8080 (5)
1 | The service name that we created in the auth.yaml file. |
2 | The name of the container image for deployment. |
3 | The imagePullPolicy is set to Never. We will always use local one that we built in previous step. |
4 | Name of a service, required for service discovery. |
5 | Micronaut default port on which application is running. |
Run the next command to create the resources described above:
kubectl apply -f api/k8s.yml
5.4. Test integration between applications deployed on Kubernetes
Run the next command to check status of the pods and make sure that all of them have the status "Running":
kubectl get pods -n=micronaut-k8s
NAME READY STATUS RESTARTS AGE
api-774fd667b9-dmws4 1/1 Running 0 24s
orders-74ff4fcbc4-dnfbw 1/1 Running 0 19s
users-9f46dd7c6-vs8z7 1/1 Running 0 13s
Run the next command to check the status of the microservices:
kubectl get services -n=micronaut-k8s
5.4.1. Minikube
For Minikube the output should be similar to the following:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
api LoadBalancer 10.110.42.201 <pending> 8080:32601/TCP 18s
orders NodePort 10.105.43.19 <none> 8080:31033/TCP 21s
users NodePort 10.104.130.114 <none> 8080:31482/TCP 26s
By default, the EXTERNAL-IP address of the LoadBalancer service inside Minikube will be in the <pending> state. If you want to assign an external ip you have to run the minikube tunnel command.
|
Run the next command to retrieve the URL of the api
microservice:
export API_URL=$(minikube service api -n=micronaut-k8s --url)
5.4.2. Docker Desktop
For Docker Desktop’s Kubernetes integration the output should be similar to the following. Notice the external-ip is localhost
:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
api LoadBalancer 10.108.205.248 localhost 8080:31516/TCP 9m23s
orders NodePort 10.98.120.224 <none> 8080:31566/TCP 9m39s
users NodePort 10.109.155.86 <none> 8080:30545/TCP 10m
So for Docker Desktop the API_URL
should be set to http://localhost:8080
.
Run a cURL command to create a new user via the api
microservice:
curl -X "POST" "$API_URL/api/users" -H 'Content-Type: application/json; charset=utf-8' -d '{ "first_name": "Nemanja", "last_name": "Mikic", "username": "nmikic" }'
{"id":1,"username":"nmikic","first_name":"Nemanja","last_name":"Mikic"}
Run a cURL command to a new order via the api
microservice:
curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 1, "item_ids": [1,2] }'
{"id":1,"user":{"first_name":"Nemanja","last_name":"Mikic","id":1,"username":"nmikic"},"items":[{"id":1,"name":"Banana","price":1.5},{"id":2,"name":"Kiwi","price":2.5}],"total":4.0}
Run a cURL command to list created orders:
curl "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8'
[{"id":1,"user":{"first_name":"Nemanja","last_name":"Mikic","id":1,"username":"nmikic"},"items":[{"id":1,"name":"Banana","price":1.5},{"id":2,"name":"Kiwi","price":2.5}],"total":4.0}]
We can try to place an order for a user who doesn’t exist (with id 100). Run a cURL command:
curl -X "POST" "$API_URL/api/orders" -H 'Content-Type: application/json; charset=utf-8' -d '{ "user_id": 100, "item_ids": [1,2] }'
{"message":"Bad Request","_links":{"self":[{"href":"/api/orders","templated":false}]},"_embedded":{"errors":[{"message":"User with id 100 doesn't exist"}]}}
6. Cleaning Up
To delete all resources that were created in this guide run next command.
kubectl delete namespaces micronaut-k8s
7. Next Steps
Read more about Kubernetes.
Read more about Micronaut Kubernetes module.
8. License
All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…). |