add devices endpoint

- fix missing coroutine in
  - TypeRepository
  - TypesController
  - TypeService
  - TypesControllerTest
  - TypeServiceTest
- add DevicesEndpoint
- add DevicesControllerTest
- add devices.http
- add DevicesController
- add DeviceEntity.toDevicesResponse() to Mapping.kt
- add PostgresTestContainer to DeviceService
- update DeviceRepository with find all
- update version catalog
  - update container annotation in DeviceEndpoint
  - update container annotation in IdentityEndpoint
  - update container annotation in NodeEndpoint
  - update container annotation in TypeEndpoint
  - update container annotation in TypesEndpoint
  - update version in gradle.properties
This commit is contained in:
2025-08-16 16:13:55 +02:00
parent fc95f5d4b8
commit 9d4ecf7aa0
19 changed files with 253 additions and 40 deletions

View File

@@ -1,4 +1,4 @@
kotlin.code.style=official
version=0.7.0-SNAPSHOT
catalog=0.11.0
catalog=0.12.0-SNAPSHOT
container.port.host=9010

8
http/devices.http Normal file
View File

@@ -0,0 +1,8 @@
### get all types
GET {{hostname}}/devices
### get all types
GET {{hostname}}/devices/page-1
### get all types
GET {{hostname}}/devices/page-1/show-2

View File

@@ -0,0 +1,33 @@
package ltd.hlaeja.controller
import jakarta.validation.constraints.Min
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ltd.hlaeja.library.deviceRegistry.Devices
import ltd.hlaeja.service.DeviceService
import ltd.hlaeja.util.toDevicesResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
@RestController
class DevicesController(
private val deviceService: DeviceService,
) {
companion object {
const val DEFAULT_PAGE: Int = 1
const val DEFAULT_SIZE: Int = 25
}
@GetMapping(
"/devices",
"/devices/page-{page}",
"/devices/page-{page}/show-{show}",
)
suspend fun getDevices(
@PathVariable(required = false) @Min(1) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(1) show: Int = DEFAULT_SIZE,
): Flow<Devices.Response> = deviceService.getDevices((page - 1) * show, show)
.map { it.toDevicesResponse() }
}

View File

@@ -27,7 +27,7 @@ class TypesController(
"/types/filter-{filter}/page-{page}",
"/types/filter-{filter}/page-{page}/show-{show}",
)
fun getTypes(
suspend fun getTypes(
@PathVariable(required = false) @Min(1) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(1) show: Int = DEFAULT_SIZE,
@PathVariable(required = false) filter: String? = null,

View File

@@ -1,9 +1,19 @@
package ltd.hlaeja.repository
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.entity.DeviceEntity
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface DeviceRepository : CoroutineCrudRepository<DeviceEntity, UUID>
interface DeviceRepository : CoroutineCrudRepository<DeviceEntity, UUID> {
@Query("SELECT * FROM devices LIMIT :limit OFFSET :offset")
fun findAll(
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): Flow<DeviceEntity>
}

View File

@@ -13,13 +13,13 @@ import org.springframework.stereotype.Repository
interface TypeRepository : CoroutineCrudRepository<TypeEntity, UUID> {
@Query("SELECT * FROM types ORDER BY name LIMIT :limit OFFSET :offset")
fun findAll(
suspend fun findAll(
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): Flow<TypeEntity>
@Query("SELECT * FROM types WHERE name ILIKE :filter ORDER BY name LIMIT :limit OFFSET :offset")
fun findAllContaining(
suspend fun findAllContaining(
@Param("filter") filter: String,
@Param("offset") offset: Int,
@Param("limit") limit: Int,

View File

@@ -3,6 +3,7 @@ package ltd.hlaeja.service
import io.github.oshai.kotlinlogging.KotlinLogging
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.entity.DeviceEntity
import ltd.hlaeja.repository.DeviceRepository
import org.springframework.dao.DataIntegrityViolationException
@@ -31,4 +32,9 @@ class DeviceService(
suspend fun getDevice(device: UUID): DeviceEntity = deviceRepository.findById(device)
?.also { log.debug { "Get device ${it.id}" } }
?: throw ResponseStatusException(NOT_FOUND)
suspend fun getDevices(
page: Int,
show: Int,
): Flow<DeviceEntity> = deviceRepository.findAll(page, show)
}

View File

@@ -26,7 +26,7 @@ class TypeService(
private val typeDescriptionRepository: TypeDescriptionRepository,
) {
fun getTypes(
suspend fun getTypes(
page: Int,
show: Int,
filter: String?,

View File

@@ -2,13 +2,14 @@ package ltd.hlaeja.util
import java.time.ZonedDateTime
import java.util.UUID
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.DeviceEntity
import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeDescriptionEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.jwt.service.PrivateJwtService
import ltd.hlaeja.library.deviceRegistry.Device
import ltd.hlaeja.library.deviceRegistry.Devices
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.library.deviceRegistry.Type
@@ -68,3 +69,9 @@ fun DeviceEntity.toDeviceResponse(
type,
jwtService.sign("device" to id),
)
fun DeviceEntity.toDevicesResponse(): Devices.Response = Devices.Response(
id ?: throw ResponseStatusException(EXPECTATION_FAILED),
type,
timestamp,
)

View File

@@ -3,7 +3,7 @@ package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Device
import ltd.hlaeja.test.compareToFile
import ltd.hlaeja.test.container.PostgresContainer
import ltd.hlaeja.test.container.PostgresTestContainer
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
@@ -18,7 +18,7 @@ import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class DeviceEndpoint {

View File

@@ -0,0 +1,105 @@
package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Devices
import ltd.hlaeja.test.container.PostgresTestContainer
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
class DevicesEndpoint {
@LocalServerPort
var port: Int = 0
lateinit var webClient: WebTestClient
@BeforeEach
fun setup() {
webClient = WebTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
@Test
fun `get devices default - success`() {
// when
val result = webClient.get().uri("/devices").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Devices.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(4)
}
}
@ParameterizedTest
@CsvSource(
value = [
"1,4",
"2,0",
],
)
fun `get devices by page - success`(page: Int, expected: Int) {
// when
val result = webClient.get().uri("/devices/page-$page").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Devices.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@Test
fun `get devices by pages - fail`() {
// when
val result = webClient.get().uri("/devices/page-0").exchange()
// then
result.expectStatus().isBadRequest
}
@ParameterizedTest
@CsvSource(
value = [
"1,2,2",
"2,2,2",
"3,2,0",
],
)
fun `get devices by page and show - success`(page: Int, show: Int, expected: Int) {
// when
val result = webClient.get().uri("/devices/page-$page/show-$show").exchange()
// then
result.expectStatus().isOk()
.expectBody<List<Devices.Response>>()
.consumeWith {
assertThat(it.responseBody?.size).isEqualTo(expected)
}
}
@ParameterizedTest
@CsvSource(
value = [
"0,1",
"1,0",
],
)
fun `get devices by page and show - fail`(page: Int, show: Int) {
// when
val result = webClient.get().uri("/devices/page-$page/show-$show").exchange()
// then
result.expectStatus().isBadRequest
}
}

View File

@@ -2,7 +2,7 @@ package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.test.container.PostgresContainer
import ltd.hlaeja.test.container.PostgresTestContainer
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
@@ -16,7 +16,7 @@ import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class IdentityEndpoint {

View File

@@ -2,7 +2,7 @@ package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.test.container.PostgresContainer
import ltd.hlaeja.test.container.PostgresTestContainer
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
@@ -17,7 +17,7 @@ import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class NodeEndpoint {

View File

@@ -1,7 +1,7 @@
package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.test.container.PostgresContainer
import ltd.hlaeja.test.container.PostgresTestContainer
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions
@@ -21,7 +21,7 @@ import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class)
class TypeEndpoint {

View File

@@ -1,11 +1,10 @@
package ltd.hlaeja.controller
import ltd.hlaeja.library.deviceRegistry.Types
import ltd.hlaeja.test.container.PostgresContainer
import ltd.hlaeja.test.container.PostgresTestContainer
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.boot.test.context.SpringBootTest
@@ -14,9 +13,8 @@ import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer
@PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TypesEndpoint {
@LocalServerPort

View File

@@ -6,10 +6,3 @@ spring:
url: r2dbc:postgresql://placeholder
username: placeholder
password: placeholder
container:
postgres:
version: postgres:17
init: postgres/schema.sql
before: postgres/data.sql
after: postgres/reset.sql

View File

@@ -0,0 +1,54 @@
package ltd.hlaeja.controller
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.test.runTest
import ltd.hlaeja.entity.DeviceEntity
import ltd.hlaeja.service.DeviceService
import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class DevicesControllerTest {
companion object {
const val NIL_UUID: String = "00000000-0000-0000-0000-000000000000"
val id: UUID = UUID.fromString(NIL_UUID)
val type: UUID = UUID.fromString(NIL_UUID)
val timestamp: ZonedDateTime = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0, 1), ZoneId.of("UTC"))
}
val service: DeviceService = mockk()
lateinit var controller: DevicesController
@BeforeEach
fun setUp() {
controller = DevicesController(service)
}
@Test
fun `get all devices`() = runTest {
// given
coEvery {
service.getDevices(any(), any())
} returns flowOf(DeviceEntity(id, timestamp, type))
// when
val response = controller.getDevices().single()
// then
coVerify(exactly = 1) { service.getDevices(0, 25) }
assertThat(response.id).isEqualToUuid(NIL_UUID)
assertThat(response.type).isEqualToUuid(NIL_UUID)
assertThat(response.timestamp).isEqualTo(timestamp)
}
}

View File

@@ -1,8 +1,8 @@
package ltd.hlaeja.controller
import io.mockk.every
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
@@ -37,7 +37,7 @@ class TypesControllerTest {
@Test
fun `get all types`() = runTest {
// given
every {
coEvery {
service.getTypes(any(), any(), any())
} returns flowOf(TypeEntity(id, timestamp, NAME))
@@ -45,7 +45,7 @@ class TypesControllerTest {
val response = controller.getTypes().single()
// then
verify(exactly = 1) { service.getTypes(0, 25, null) }
coVerify(exactly = 1) { service.getTypes(0, 25, null) }
assertThat(response.id).isEqualToUuid(NIL_UUID)
assertThat(response.name).isEqualTo(NAME)

View File

@@ -6,7 +6,6 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
@@ -57,29 +56,29 @@ class TypeServiceTest {
}
@Test
fun `get all types`() {
fun `get all types`() = runTest {
// given
every { typeRepository.findAll(any(), any()) } returns flowOf(mockk<TypeEntity>())
coEvery { typeRepository.findAll(any(), any()) } returns flowOf(mockk<TypeEntity>())
// when
service.getTypes(1, 10, null)
// then
verify(exactly = 1) { typeRepository.findAll(1, 10) }
verify(exactly = 0) { typeRepository.findAllContaining(any(), any(), any()) }
coVerify(exactly = 1) { typeRepository.findAll(1, 10) }
coVerify(exactly = 0) { typeRepository.findAllContaining(any(), any(), any()) }
}
@Test
fun `get all types with filter`() {
fun `get all types with filter`() = runTest {
// given
every { typeRepository.findAllContaining(any(), any(), any()) } returns flowOf(mockk<TypeEntity>())
coEvery { typeRepository.findAllContaining(any(), any(), any()) } returns flowOf(mockk<TypeEntity>())
// when
service.getTypes(1, 10, "abc")
// then
verify(exactly = 1) { typeRepository.findAllContaining("%abc%", 1, 10) }
verify(exactly = 0) { typeRepository.findAll(any(), any()) }
coVerify(exactly = 1) { typeRepository.findAllContaining("%abc%", 1, 10) }
coVerify(exactly = 0) { typeRepository.findAll(any(), any()) }
}
@Test