Avatar

Salut, I'm Julia.

Spring Boot With Kotlin

#kotlin #spring boot

11 min read

This post is a continuation of my Kotlin + Spring Boot learning journey. Check out my first post on Kotlin here. These are notes I wrote down when going through this YouTube series (which I'd really recommend for those new to Spring Boot).

Getting up and running with a REST endpoint

  • Use @RestController to initialise a controller class for handling REST requests.
  • @RequestMapping defines a path for the endpoint.
  • Use @GetMapping to state it's a GET endpoint.
    • It can also be used to add extra path params to the parent endpoint path.
@RestController
@RequestMapping("api/hello")
class HelloWorldController {
  @GetMapping
  fun helloWorld(): String {
    return "Hey world"
  }
}

Note, we can simplify the helloWorld function by writing it as an expression.

@RestController
@RequestMapping("api/hello")
class HelloWorldController {
  @GetMapping
  fun helloWorld(): String = "Hey world"
}
  • Run your main application and check it all works!

Spring Boot project structure

What is Gradle?

Gradle is a build automation tool for multi-language software development. It controls the development process in the tasks of compilation and packaging to testing, deployment, and publishing. Supported languages include Java, C/C++, and JavaScript.

  • The gradle/wrapper directory stores information on the Gradle distribution version that will be downloaded for anyone who uses your application. This means that your user does not need to have Gradle already downloaded on their local machine to use your application.
    • To use the wrapper, run gradlew build.
  • settings.gradle.kts sets up the repositories where Gradle can find the Spring plugins. This is also where the name of your root project is set, when you first initialised the creation of the Sprint Boot project.
  • build.gradle.kts lists the plugins and dependencies applied to your project (e.g. Spring and Kotlin). It also details the version of Java compatibility and the repository locations for fetching Gradle dependencies.
    • Jackson is for serialising and deserialising to JSON.
    • Spring Boot Starter Test is a testing library that gives us assertions and mocking capabilities.
  • The main application files sit within src/main/kotlin and src/test/kotlin.
    • There will be an Application file in the main directory with a main function that runs the application.
      • The @SpringBootApplication annotation tells Spring Boot that this is the main application class, and that it should build the context and config starting at this class.
    • There will be an ApplicationTest file automatically created in the test directory, with a test to check that your app doesn't crash on load.
  • src/main/resources contains an application.properties file. This is the main entry file for configuring the whole application. e.g. the port the application should run, what Tomcat version to use, default logging levels and other custom configurations.

Setting up the data layer

  • Create a folder (aka package) called model in src/main/kotlin/myapplicationmodule. This will be where we store the data representations in the application.
  • Create a new "Kotlin class" file for Game to store the data transfer object (DTO - the data to be sent over the network).
    • Remember to use val to denote properties that should only have a getter method, and var to denote properties that have a setter method as well.
    • Kotlin defaults class properties to be public (i.e. it exposes the properties getters and setters publicly).
    • Think about whether you need secondary constructors. If you just need primary constructors, you can use the Kotlin shorthand.
      • class Game(val first_prop: String, val second_prop: String)
    • Use a data class to auto-create standard implementations of equals, hashCode and toString (perfect for DTOs).
  • There's no real need to write a test for checking data class properties.
data class Game(
  private val name: String
  private val console: String
)

Setting up the data source layer

  • Create a new package called datasource in src/main/kotlin/myapplicationmodule. This will be where we store the various data sources we might want to be able to access from a service layer, and store logic for data retrieval and storage.
  • Create a new file called GameDataSource and create an interface with a function to get all the games.
interface GameDataSource {
  fun retrieveGames(): Collection<Game>
}
  • Create another file called MockGameDataSource with a class that implements the interface above.
    • Use the annotation @Repository to mark the class as a Spring Boot bean. This adds it to the application context and tells Spring to initialise this bean / object at runtime. It also allows it to be injected via dependency injection.
    • Note: @Repository also has other special Spring associations that come with it e.g. during testing etc.
@Repository
class MockGameDataSource : GameDataSource {
  val games = listOf(Game("Call of Duty", "XBox"))
  override fun retrieveGames(): Collection<Game> = games
}
  • Create a test file in JUnit called MockGameDataSourceTest.
internal class MockGameDataSourceTest {

  private val mockDataSource = MockGameDataSource()

  @Test
  fun `should list a collection of games`() {
    // when
    val games = mockDataSource.retrieveGames()

    // then
    assertThat(games).isNotEmpty
  }
}

Setting up the service layer

  • The service layer is the main entry point into your business logic.
  • Create a new package called service in src/main/kotlin/myapplicationmodule.
  • Create a new Kotlin file called GameService. Use the @Service annotation to mark the class as a bean.
    • Note that if you want to add a generic bean that isn't a Service or Repository, you can use the @Component annotation.
@Service
class GameService(private val dataSource: GameDataSource) {
  fun getGames(): Collection<Game> = dataSource.retrieveGames()
}
  • Create a test file called GameServiceTest.
  • Add new library to Gradle dependencies: testImplementation("io.mockk:mockk:someversionhere"). Ensure you load the Gradle changes and wait for it to be downloaded (check it appears in your "External Libraries" folder). This allows us to use mockk for mocking in tests.
internal class GameServiceTest {
  private val dataSource: GameDataSource = mockk()

  private val gameService = GameService(dataSource)

  @Test
  fun `should call datasource to retrieve games`() {
    // given
    every { dataSource.retrieveGames() } returns emptyList()

    // when
    val games = gameService.getGames()

    // then
    verify(exactly = 1) { dataSource.retrieveGames() }
  }
}

Alternatively, if we don't really care about what the retrieveGames() dataSource method returns, we can use the relaxed = true feature in mockk.

internal class GameServiceTest {
  private val dataSource: GameDataSource = mockk(relaxed = true)

  private val gameService = GameService(dataSource)

  @Test
  fun `should call datasource to retrieve games`() {
    // when
    gameService.getGames()

    // then
    verify(exactly = 1) { dataSource.retrieveGames() }
  }
}

Setting up the web layer

  • The web (or API) layer contains the logic for your controllers and REST mappings.
  • Create a new package called controller in src/main/kotlin/myapplicationmodule.
  • Create a new Kotlin file called GameController. Use the @RestController annotation and set the API path with the @RequestMapping annotation.
    • We can set the GameService parameter to be a private val since it is only called in this GameController.
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {

  @GetMapping
  fun getGames(): Collection<Game> = service.getGames()
}
  • Create a test file called GameControllerTest. Instead of initialising individual classes and testing using an instance, we'll write an integration test for this using the @SpringBootTest annotation.
    • Note that this annotation initialises the entire application context, so does take longer to run than a regular unit test. It only initialises our beans, so you'll need to add the @AutoConfigureMockMvc annotation as well to ensure MockMvc beans are also initialised.
    • MockMvc comes with Spring and allows us to call our endpoints without actually sending a HTTP request.
    • To tell Spring Boot to give us a bean of this mockMvc object, use the @Autowired annotation. This is Spring Boot's way of injecting the dependency once it's initialised. This is also why we need Kotlin's lateinit var, so that it knows some kind of framework method is needed to initialise the object, before it can be injected later on.
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest {

  @Autowired
  lateinit var mockMvc: MockMvc

  @Test
  fun `should return all games`() {
    // when-then
    mockMvc.get("/api/games")
      .andDo { print() }
      .andExpect {
        status { isOk() }
        content { contentType(MediaType.APPLICATION_JSON) }
        jsonPath("$[0].name") { value("Call of Duty") }
      }
  }
}
  • Note .andDo { print() } just adds debugging output. The Jackson mapper package is what automatically returns the result as application/json (it does the collection mapping for you).

Adding a GET by path param endpoint

  • Add a new @GetMapping with a path parameter of name. Also add an exception handler to catch the NoSuchElementException (which is raised when the repository cannot find a game).
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {

  @ExceptionHandler(NoSuchElementException::class)
  fun notFound(e: NoSuchElementException): ResponseEntity<String> =
    ResponseEntity(e.message, HttpStatus.NOT_FOUND)

  @GetMapping("/{name}")
  fun getGame(@PathVariable name: String): Game = service.getGame()
}
  • Amend the repository file and the service file.
interface GameDataSource {
  fun retrieveGame(): Game
}
@Repository
class MockGameDataSource : GameDataSource {
  val games = listOf(Game("Call of Duty", "XBox"))

  override fun retrieveGame(name: String): Game =
    games.firstOrNull() { it.name == name}
      ?: throw NoSuchElementException("Could not find game with name $name")
}
@Service
class GameService(private val dataSource: GameDataSource) {
  fun getGame(name: String): Game = dataSource.retrieveGame(name)
}
  • Note: We can use the @Nested annotation to add scope to our tests. e.g.
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest {

  @Autowired
  lateinit var mockMvc: MockMvc

  @Nested
  @DisplayName("getGames()")
  @TestInstance(Lifecycle.PER_CLASS)
  inner class GetGames {

    @Test
    fun `should return all games`() {
      // when-then
      mockMvc.get("/api/games")
        .andDo { print() }
        .andExpect {
          status { isOk() }
          content { contentType(MediaType.APPLICATION_JSON) }
          jsonPath("$[0].name") { value("Call of Duty") }
        }
    }
  }

  @Nested
  @DisplayName("getGame()")
  @TestInstance(Lifecycle.PER_CLASS)
  inner class GetGame {

    @Test
    fun `should return the right game`() {
      // test...
    }
  }
}

Adding a POST endpoint

  • Add a test in GameControllerTest file.
    • Note: instead of using @Autowired lateinit variables, we can move them to the constructor (and amend them to be val).
    • ObjectMapper comes from the Jackson package and is used for (de)serialising JSON.
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest @Autowired constructor(
	val mockMvc: MockMvc,
	val objectMapper: ObjectMapper
) {

	// ... other tests

	@Nested
	@DisplayName("POST /api/games")
	@TestInstance(Lifecycle.PER_CLASS)
	inner class PostNewGame {
	
		@Test
		fun `should add new game`() {
			// given
			val newGame = Game("God of War", "PS5")
			
			// when
			val postResponse = mockMvc.post(baseUrl) {
				contentType = MediaType.APPLICATION_JSON
				content = objectMapper.writeValueAsString(newGame)
			}

			// then
			postResponse
				.andDo { print() }
				.andExpect { 
					status { isCreated() }
					content { contentType(MediaType.APPLICATION_JSON) }
					jsonPath("$.name") { value("God of War") }
					jsonPath("$.console") { value("PS5") }
				}
		}
	}
}
  • Amend the GameController. Use the @RequestBody annotation to get the game from the payload.
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {

	@ExceptionHandler(NoSuchElementException::class)
	fun notFound(e: NoSuchElementException): ResponseEntity<String> =
		ResponseEntity(e.message, HttpStatus.NOT_FOUND)

	// ... other endpoints

	@PostMapping
	@ResponseStatus(HttpStatus.CREATED)
	fun addGame(@RequestBody game: Game): Game = service.addGame(game)
}
  • Add the addGame method to the GameService and createGame function in the GameDataSource interface.
@Service
class GameService(private val dataSource: GameDataSource) {
	// other methods
	fun addGame(game: Game): Game { dataSource.createGame(game) }
}
interface GameDataSource {
	fun createGame(game: Game): Game
}
  • In our mock, we need to amend the listof to become mutableListOf to allow us to add the new game to the list.
@Repository
class MockGameDataSource : GameDataSource {
	val games = mutableListOf(Game("Call of Duty", "XBox"))

	override fun createGame(game: Game): Game =
		games.add(game)
		return game
}

Adding a PATCH endpoint

  • Start by amending the test file again. Note the amendment in content json assertion (a slightly cleaner version rather than asserting each individual value separately).
@SpringBootTest
@AutoConfigureMockMvc
internal class GameControllerTest @Autowired constructor(
	val mockMvc: MockMvc,
	val objectMapper: ObjectMapper
) {

	// ... other tests

	@Nested
	@DisplayName("PATCH /api/games")
	@TestInstance(Lifecycle.PER_CLASS)
	inner class PatchExistingGame {
	
		@Test
		fun `should update existing game`() {
			// given
			val updatedGame = Game("God of War", "XBox")
			
			// when
			val patchResponse = mockMvc.patch(baseUrl) {
				contentType = MediaType.APPLICATION_JSON
				content = objectMapper.writeValueAsString(updatedGame)
			}

			// then
			patchResponse
				.andDo { print() }
				.andExpect { 
					status { isOk() }
					content { 
						contentType(MediaType.APPLICATION_JSON)
						json(objectMapper.writeValueAsString(updatedGame))
					}
				}

			mockMvc.get("$baseUrl/${updatedGame.name}")
				.andExpect { 
					content { 
						json(objectMapper.writeValueAsString(updatedGame))
					}
				}
		}
	}
}
  • Amend the GameController.
@RestController
@RequestMapping("/api/games")
class GameController(private val service: GameService) {

	@ExceptionHandler(NoSuchElementException::class)
	fun notFound(e: NoSuchElementException): ResponseEntity<String> =
		ResponseEntity(e.message, HttpStatus.NOT_FOUND)

	// ... other endpoints

	@PatchMapping
	fun updateGame(@RequestBody game: Game): Game = service.updateGame(game)
}
  • Add the updateGame method to the GameService and GameDataSource interface.
@Service
class GameService(private val dataSource: GameDataSource) {
	// other methods
	fun updateGame(game: Game): Game { dataSource.updateGame(game) }
}
interface GameDataSource {
	fun updateGame(game: Game): Game
}
  • Finally, amend the MockGameDataSource for the updateGame method.
@Repository
class MockGameDataSource : GameDataSource {
	val games = mutableListOf(Game("Call of Duty", "XBox"))

	override fun updateGame(game: Game): Game =
		val currentGame = games.firstOrNull { it.name == game.name }
			?: throw NoSuchElementException ("Count not find a game with name ${game.name}")

		games.remove(currentGame)
		games.add(game)
		
		return game
}

© 2016-2024 Julia Tan · Powered by Next JS.