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 kotlin.code.style=official
version=0.7.0-SNAPSHOT version=0.7.0-SNAPSHOT
catalog=0.11.0 catalog=0.12.0-SNAPSHOT
container.port.host=9010 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}",
"/types/filter-{filter}/page-{page}/show-{show}", "/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) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(1) show: Int = DEFAULT_SIZE, @PathVariable(required = false) @Min(1) show: Int = DEFAULT_SIZE,
@PathVariable(required = false) filter: String? = null, @PathVariable(required = false) filter: String? = null,

View File

@@ -1,9 +1,19 @@
package ltd.hlaeja.repository package ltd.hlaeja.repository
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.entity.DeviceEntity import ltd.hlaeja.entity.DeviceEntity
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@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> { interface TypeRepository : CoroutineCrudRepository<TypeEntity, UUID> {
@Query("SELECT * FROM types ORDER BY name LIMIT :limit OFFSET :offset") @Query("SELECT * FROM types ORDER BY name LIMIT :limit OFFSET :offset")
fun findAll( suspend fun findAll(
@Param("offset") offset: Int, @Param("offset") offset: Int,
@Param("limit") limit: Int, @Param("limit") limit: Int,
): Flow<TypeEntity> ): Flow<TypeEntity>
@Query("SELECT * FROM types WHERE name ILIKE :filter ORDER BY name LIMIT :limit OFFSET :offset") @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("filter") filter: String,
@Param("offset") offset: Int, @Param("offset") offset: Int,
@Param("limit") limit: Int, @Param("limit") limit: Int,

View File

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

View File

@@ -2,13 +2,14 @@ package ltd.hlaeja.util
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.UUID import java.util.UUID
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.DeviceEntity import ltd.hlaeja.entity.DeviceEntity
import ltd.hlaeja.entity.NodeEntity import ltd.hlaeja.entity.NodeEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeDescriptionEntity import ltd.hlaeja.entity.TypeDescriptionEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.jwt.service.PrivateJwtService import ltd.hlaeja.jwt.service.PrivateJwtService
import ltd.hlaeja.library.deviceRegistry.Device import ltd.hlaeja.library.deviceRegistry.Device
import ltd.hlaeja.library.deviceRegistry.Devices
import ltd.hlaeja.library.deviceRegistry.Identity import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.library.deviceRegistry.Node import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.library.deviceRegistry.Type import ltd.hlaeja.library.deviceRegistry.Type
@@ -68,3 +69,9 @@ fun DeviceEntity.toDeviceResponse(
type, type,
jwtService.sign("device" to id), 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 java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Device import ltd.hlaeja.library.deviceRegistry.Device
import ltd.hlaeja.test.compareToFile import ltd.hlaeja.test.compareToFile
import ltd.hlaeja.test.container.PostgresContainer import ltd.hlaeja.test.container.PostgresTestContainer
import ltd.hlaeja.test.isEqualToUuid import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.SoftAssertions import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions 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.WebTestClient
import org.springframework.test.web.reactive.server.expectBody import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer @PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT) @SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class) @ExtendWith(SoftAssertionsExtension::class)
class DeviceEndpoint { 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 java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Identity import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.test.container.PostgresContainer import ltd.hlaeja.test.container.PostgresTestContainer
import ltd.hlaeja.test.isEqualToUuid import ltd.hlaeja.test.isEqualToUuid
import org.assertj.core.api.SoftAssertions import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.InjectSoftAssertions 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.WebTestClient
import org.springframework.test.web.reactive.server.expectBody import org.springframework.test.web.reactive.server.expectBody
@PostgresContainer @PostgresTestContainer
@SpringBootTest(webEnvironment = RANDOM_PORT) @SpringBootTest(webEnvironment = RANDOM_PORT)
@ExtendWith(SoftAssertionsExtension::class) @ExtendWith(SoftAssertionsExtension::class)
class IdentityEndpoint { class IdentityEndpoint {

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,3 @@ spring:
url: r2dbc:postgresql://placeholder url: r2dbc:postgresql://placeholder
username: placeholder username: placeholder
password: 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 package ltd.hlaeja.controller
import io.mockk.every import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -37,7 +37,7 @@ class TypesControllerTest {
@Test @Test
fun `get all types`() = runTest { fun `get all types`() = runTest {
// given // given
every { coEvery {
service.getTypes(any(), any(), any()) service.getTypes(any(), any(), any())
} returns flowOf(TypeEntity(id, timestamp, NAME)) } returns flowOf(TypeEntity(id, timestamp, NAME))
@@ -45,7 +45,7 @@ class TypesControllerTest {
val response = controller.getTypes().single() val response = controller.getTypes().single()
// then // 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.id).isEqualToUuid(NIL_UUID)
assertThat(response.name).isEqualTo(NAME) assertThat(response.name).isEqualTo(NAME)

View File

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