add get and update for Type

- add UpdateType end-to-end
- add updateType to TypeController
- add updateType to TypeService
- add sql 004-create_type_description_data.sql
- update TypesEndpoint to use Types.Response
- update type end-to-end test
  - update TypeEndpoint with CreateType
  - add reset test table
  - add test data
- add getType to TypeController
- add getType to TypeService
- add findTypeWithDescription to TypeRepository
- update type end-to-end test
- update TypeController for changes for adding type
- update type mapping for latest changes in Mapping.kt
- update addType to use TypeDescriptionRepository and return TypeWithDescription in TypeService
- add TypeWithDescription
- add TypeDescriptionRepository
- add TypeDescriptionEntity
- add missing device mapping test
- add type_descriptions sql script for database changes
- update TypesEndpoint
  - update TypesController to use Types.Response
  - add TypeEntity.toTypesResponse to Mapping.kt
This commit is contained in:
2025-03-11 07:51:40 +01:00
parent 53db4408e2
commit d90a716df7
21 changed files with 947 additions and 84 deletions

View File

@@ -1,11 +1,16 @@
package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.util.toTypeEntity
import ltd.hlaeja.util.toTypeResponse
import org.springframework.http.HttpStatus.CREATED
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@RestController
@@ -14,7 +19,19 @@ class TypeController(
) {
@PostMapping("/type")
@ResponseStatus(CREATED)
suspend fun addType(
@RequestBody register: Type.Request,
): Type.Response = service.addType(register.toTypeEntity()).toTypeResponse()
@RequestBody request: Type.Request,
): Type.Response = service.addType(request.name, request.description).toTypeResponse()
@GetMapping("/type-{type}")
suspend fun getType(
@PathVariable type: UUID,
): Type.Response = service.getType(type).toTypeResponse()
@PutMapping("/type-{type}")
suspend fun updateType(
@PathVariable type: UUID,
@RequestBody request: Type.Request,
): Type.Response = service.updateType(type, request.name, request.description).toTypeResponse()
}

View File

@@ -3,9 +3,9 @@ package ltd.hlaeja.controller
import jakarta.validation.constraints.Min
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Types
import ltd.hlaeja.service.TypeService
import ltd.hlaeja.util.toTypeResponse
import ltd.hlaeja.util.toTypesResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
@@ -31,6 +31,6 @@ class TypesController(
@PathVariable(required = false) @Min(1) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(1) show: Int = DEFAULT_SIZE,
@PathVariable(required = false) filter: String? = null,
): Flow<Type.Response> = service.getTypes((page - 1) * show, show, filter)
.map { it.toTypeResponse() }
): Flow<Types.Response> = service.getTypes((page - 1) * show, show, filter)
.map { it.toTypesResponse() }
}

View File

@@ -0,0 +1,11 @@
package ltd.hlaeja.dto
import java.time.ZonedDateTime
import java.util.UUID
data class TypeWithDescription(
val id: UUID,
val timestamp: ZonedDateTime,
val name: String,
val description: String?,
)

View File

@@ -0,0 +1,12 @@
package ltd.hlaeja.entity
import java.util.UUID
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
@Table("type_descriptions")
data class TypeDescriptionEntity(
@Id
val typeId: UUID,
val description: String,
)

View File

@@ -0,0 +1,24 @@
package ltd.hlaeja.repository
import java.util.UUID
import ltd.hlaeja.entity.TypeDescriptionEntity
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 TypeDescriptionRepository : CoroutineCrudRepository<TypeDescriptionEntity, UUID> {
@Query(
"""
INSERT INTO type_descriptions (type_id, description) VALUES (:type_id, :description)
ON CONFLICT (type_id)
DO UPDATE SET description = :description
RETURNING *
""",
)
suspend fun upsert(
@Param("type_id") typeId: UUID,
@Param("description") description: String,
): TypeDescriptionEntity
}

View File

@@ -2,8 +2,8 @@ package ltd.hlaeja.repository
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import ltd.hlaeja.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeEntity
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.repository.query.Param
@@ -24,4 +24,16 @@ interface TypeRepository : CoroutineCrudRepository<TypeEntity, UUID> {
@Param("offset") offset: Int,
@Param("limit") limit: Int,
): Flow<TypeEntity>
@Query(
"""
SELECT t.id, t.timestamp, t.name, td.description
FROM types t
LEFT JOIN type_descriptions td ON t.id = td.type_id
WHERE t.id = :id
""",
)
suspend fun findTypeWithDescription(
@Param("id") id: UUID,
): TypeWithDescription?
}

View File

@@ -1,12 +1,21 @@
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.dto.TypeWithDescription
import ltd.hlaeja.entity.TypeDescriptionEntity
import ltd.hlaeja.entity.TypeEntity
import ltd.hlaeja.repository.TypeDescriptionRepository
import ltd.hlaeja.repository.TypeRepository
import org.springframework.dao.DuplicateKeyException
import org.springframework.http.HttpStatus
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
private val log = KotlinLogging.logger {}
@@ -14,6 +23,7 @@ private val log = KotlinLogging.logger {}
@Service
class TypeService(
private val typeRepository: TypeRepository,
private val typeDescriptionRepository: TypeDescriptionRepository,
) {
fun getTypes(
@@ -25,13 +35,87 @@ class TypeService(
else -> typeRepository.findAll(page, show)
}
@Transactional
suspend fun addType(
entity: TypeEntity,
): TypeEntity = try {
typeRepository.save(entity)
.also { log.debug { "Added new type: $it.id" } }
} catch (e: DuplicateKeyException) {
log.warn { e.localizedMessage }
throw ResponseStatusException(HttpStatus.CONFLICT)
name: String,
description: String,
): TypeWithDescription = try {
val savedType = typeRepository.save(
TypeEntity(timestamp = ZonedDateTime.now(), name = name),
).also { log.debug { "Added new type: ${it.id}" } }
val savedDescription = typeDescriptionRepository.upsert(
savedType.id ?: throw ResponseStatusException(EXPECTATION_FAILED),
description,
).also { log.debug { "Added description for type: ${it.typeId}" } }
TypeWithDescription(
id = savedType.id,
timestamp = savedType.timestamp,
name = savedType.name,
description = savedDescription.description,
)
} catch (e: DataIntegrityViolationException) {
log.warn { "Failed to add type with name '$name': ${e.localizedMessage}" }
throw ResponseStatusException(CONFLICT, "Type with name '$name' already exists")
}
suspend fun getType(
id: UUID,
): TypeWithDescription = typeRepository.findTypeWithDescription(id)
?.also { log.debug { "Retrieved type with description: ${it.id}" } }
?: throw ResponseStatusException(NOT_FOUND, "Type with id '$id' not found")
@Transactional
suspend fun updateType(
id: UUID,
name: String,
description: String,
): TypeWithDescription {
var hasChanges = false
val updatedType = updateType(id, name) { hasChanges = true }
val updatedTypeDescription = updateTypeDescription(id, description) { hasChanges = true }
if (!hasChanges) {
throw ResponseStatusException(ACCEPTED, "No changes for type with id '$id'")
}
return TypeWithDescription(
id = updatedType.id!!,
timestamp = updatedType.timestamp,
name = updatedType.name,
description = updatedTypeDescription.description,
)
}
private suspend fun updateTypeDescription(
id: UUID,
description: String,
onChange: () -> Unit,
): TypeDescriptionEntity {
val existingDescription = typeDescriptionRepository.findById(id)
?: throw ResponseStatusException(NOT_FOUND, "Type description with id '$id' not found")
return if (existingDescription.description == description) {
existingDescription
} else {
onChange()
typeDescriptionRepository.save(existingDescription.copy(description = description))
}
}
private suspend fun updateType(
id: UUID,
name: String,
onChange: () -> Unit,
): TypeEntity {
val existingType = typeRepository.findById(id)
?: throw ResponseStatusException(NOT_FOUND, "Type with id '$id' not found")
return if (existingType.name == name) {
existingType
} else {
onChange()
try {
typeRepository.save(existingType.copy(name = name))
} catch (e: DataIntegrityViolationException) {
log.warn { "Failed to update type with name '$name': ${e.localizedMessage}" }
throw ResponseStatusException(CONFLICT, "Type with name '$name' already exists")
}
}
}
}

View File

@@ -1,22 +1,43 @@
package ltd.hlaeja.util
import java.time.ZonedDateTime
import java.util.UUID
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.jwt.service.PrivateJwtService
import ltd.hlaeja.library.deviceRegistry.Device
import ltd.hlaeja.library.deviceRegistry.Identity
import ltd.hlaeja.library.deviceRegistry.Node
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Types
import org.springframework.http.HttpStatus.EXPECTATION_FAILED
import org.springframework.web.server.ResponseStatusException
fun Type.Request.toTypeEntity(): TypeEntity = TypeEntity(null, ZonedDateTime.now(), name)
fun Type.Request.toTypeEntity(id: UUID): TypeEntity = TypeEntity(
id = id,
timestamp = ZonedDateTime.now(),
name = name,
)
fun TypeEntity.toTypeResponse(): Type.Response = Type.Response(
id ?: throw ResponseStatusException(EXPECTATION_FAILED),
name,
fun Type.Request.toTypeDescriptionEntity(id: UUID): TypeDescriptionEntity = TypeDescriptionEntity(
typeId = id,
description = description,
)
fun TypeWithDescription.toTypeResponse(): Type.Response = Type.Response(
id = id,
timestamp = timestamp,
name = name,
description = description ?: "",
)
fun TypeEntity.toTypesResponse(): Types.Response = Types.Response(
id = id!!,
name = name,
timestamp = timestamp,
)
fun Node.Request.toEntity(): NodeEntity = NodeEntity(