Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@

{
"permissions": {
"allow": [
"Bash(./gradlew compileKotlin:*)",
"Bash(./gradlew test:*)",
"Bash(./src/kotlin/gradlew:*)",
"Bash(docker compose build:*)",
"Bash(docker compose up:*)",
"Bash(docker compose run:*)",
"Bash(./gradlew tasks:*)",
"Bash(docker compose config:*)",
"Bash(curl:*)",
"Bash(pip3 install:*)",
"Bash(schemathesis run:*)",
"Bash(python3:*)",
"Bash(pkill:*)"
]
}
}
1 change: 1 addition & 0 deletions src/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dependencies {
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.junit.jupiter:junit-jupiter:5.9.3")
testImplementation("io.ktor:ktor-client-content-negotiation")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}

tasks.test {
Expand Down
2 changes: 1 addition & 1 deletion src/kotlin/build/reports/jacoco/test/jacocoTestReport.xml

Large diffs are not rendered by default.

36 changes: 28 additions & 8 deletions src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.lampcontrol.api.models.Lamp
import com.lampcontrol.api.models.LampCreate
import com.lampcontrol.api.models.LampUpdate
import com.lampcontrol.api.models.ListLamps200Response
import com.lampcontrol.api.models.Error
import com.lampcontrol.service.LampService

fun Route.DefaultApi(lampService: LampService) {
Expand Down Expand Up @@ -65,14 +66,33 @@ fun Route.DefaultApi(lampService: LampService) {
}

get<Paths.listLamps> {
val lamps = lampService.getAllLamps()
// Construct response object matching OpenAPI schema: { data: [...], hasMore: boolean, nextCursor: string? }
val response = ListLamps200Response(
data = lamps,
hasMore = false,
nextCursor = null
)
call.respond(HttpStatusCode.OK, response)
try {
// Validate pageSize parameter if provided
val pageSize = it.pageSize
if (pageSize != null) {
if (pageSize < 1 || pageSize > 100) {
call.respond(
HttpStatusCode.BadRequest,
Error(error = "Invalid numeric parameter")
)
return@get
}
}

val lamps = lampService.getAllLamps()
// Construct response object matching OpenAPI schema: { data: [...], hasMore: boolean, nextCursor: string? }
val response = ListLamps200Response(
data = lamps,
hasMore = false,
nextCursor = null
)
call.respond(HttpStatusCode.OK, response)
} catch (e: Exception) {
call.respond(
HttpStatusCode.BadRequest,
Error(error = "Invalid numeric parameter")
)
}
}

put<Paths.updateLamp> {
Expand Down
46 changes: 18 additions & 28 deletions src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lampcontrol.plugins

import com.lampcontrol.api.models.Error
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
Expand All @@ -12,25 +13,17 @@ fun Application.configureStatusPages() {
// Map numeric parse errors to 400 so malformed numeric query params (e.g. pageSize=null)
// don't surface as 500 Internal Server Error. This lets the client know the input
// was invalid while preserving `pageSize: kotlin.Int?` in generated `Paths`.
// Use respondText with explicit JSON and Content-Type so a body is always sent even
// when serialization/content-negotiation isn't available at the time the handler runs.
fun jsonError(error: String, message: String?): String {
val safeMessage = message?.replace("\"", "\\\"") ?: ""
return "{\"error\":\"$error\",\"message\":\"$safeMessage\"}"
}

exception<NumberFormatException> { call, cause ->
call.respondText(
jsonError("Invalid numeric parameter", cause.message),
ContentType.Application.Json,
HttpStatusCode.BadRequest
call.respond(
HttpStatusCode.BadRequest,
Error(error = "Invalid numeric parameter")
)
}

exception<SerializationException> { call, cause ->
call.respondText(
jsonError("Invalid JSON format", cause.message),
ContentType.Application.Json,
HttpStatusCode.BadRequest
call.respond(
HttpStatusCode.BadRequest,
Error(error = "Invalid JSON format")
)
}

Expand All @@ -40,31 +33,28 @@ fun Application.configureStatusPages() {
exception<BadRequestException> { call, cause ->
val nf = cause.cause
if (nf is NumberFormatException) {
call.respondText(
jsonError("Invalid numeric parameter", nf.message),
ContentType.Application.Json,
HttpStatusCode.BadRequest
call.respond(
HttpStatusCode.BadRequest,
Error(error = "Invalid numeric parameter")
)
} else {
// Fallback to the generic handler below by rethrowing so it is caught by the
// broader Exception mapping, which logs and returns a 500.
throw cause
}
}

exception<IllegalArgumentException> { call, cause ->
call.respondText(
jsonError("Invalid argument", cause.message),
ContentType.Application.Json,
HttpStatusCode.BadRequest
call.respond(
HttpStatusCode.BadRequest,
Error(error = "Invalid argument")
)
}

exception<Exception> { call, cause ->
call.respondText(
jsonError("Internal server error", "An unexpected error occurred"),
ContentType.Application.Json,
HttpStatusCode.InternalServerError
call.respond(
HttpStatusCode.InternalServerError,
Error(error = "Internal server error")
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import java.util.*
* Works with domain entities to maintain separation from API models.
*/
interface LampRepository {
fun getAllLamps(): List<LampEntity>
fun getLampById(id: UUID): LampEntity?
fun createLamp(entity: LampEntity): LampEntity
fun updateLamp(entity: LampEntity): LampEntity?
fun deleteLamp(id: UUID): Boolean
fun lampExists(id: UUID): Boolean
suspend fun getAllLamps(): List<LampEntity>
suspend fun getLampById(id: UUID): LampEntity?
suspend fun createLamp(entity: LampEntity): LampEntity
suspend fun updateLamp(entity: LampEntity): LampEntity?
suspend fun deleteLamp(id: UUID): Boolean
suspend fun lampExists(id: UUID): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,45 +16,45 @@ class InMemoryLampRepository : LampRepository {
/**
* Get all lamps
*/
override fun getAllLamps(): List<LampEntity> {
override suspend fun getAllLamps(): List<LampEntity> {
return lamps.values.toList()
}

/**
* Get a lamp by ID
*/
override fun getLampById(id: UUID): LampEntity? {
override suspend fun getLampById(id: UUID): LampEntity? {
return lamps[id]
}

/**
* Create a new lamp
*/
override fun createLamp(entity: LampEntity): LampEntity {
override suspend fun createLamp(entity: LampEntity): LampEntity {
lamps[entity.id] = entity
return entity
}

/**
* Update an existing lamp
*/
override fun updateLamp(entity: LampEntity): LampEntity? {
override suspend fun updateLamp(entity: LampEntity): LampEntity? {
val existingLamp = lamps[entity.id] ?: return null
lamps[entity.id] = entity
return entity
}

/**
* Delete a lamp by ID
*/
override fun deleteLamp(id: UUID): Boolean {
override suspend fun deleteLamp(id: UUID): Boolean {
return lamps.remove(id) != null
}

/**
* Check if a lamp exists
*/
override fun lampExists(id: UUID): Boolean {
override suspend fun lampExists(id: UUID): Boolean {
return lamps.containsKey(id)
}
}
32 changes: 16 additions & 16 deletions src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,77 +16,77 @@ class LampService(
private val lampRepository: LampRepository,
private val lampMapper: LampMapper
) {

/**
* Get all lamps as API models
*/
fun getAllLamps(): List<Lamp> {
suspend fun getAllLamps(): List<Lamp> {
return lampRepository.getAllLamps()
.map { lampMapper.toApiModel(it) }
}

/**
* Get a lamp by string ID and return as API model
*/
fun getLampById(lampId: String): Lamp? {
suspend fun getLampById(lampId: String): Lamp? {
val uuid = try {
UUID.fromString(lampId)
} catch (e: IllegalArgumentException) {
return null
}

return lampRepository.getLampById(uuid)
?.let { lampMapper.toApiModel(it) }
}

/**
* Create a new lamp from API model
*/
fun createLamp(lampCreate: LampCreate): Lamp {
suspend fun createLamp(lampCreate: LampCreate): Lamp {
val domainEntity = lampMapper.toDomainEntityCreate(lampCreate)
val savedEntity = lampRepository.createLamp(domainEntity)
return lampMapper.toApiModel(savedEntity)
}

/**
* Update a lamp by string ID with API update model
*/
fun updateLamp(lampId: String, lampUpdate: LampUpdate): Lamp? {
suspend fun updateLamp(lampId: String, lampUpdate: LampUpdate): Lamp? {
val uuid = try {
UUID.fromString(lampId)
} catch (e: IllegalArgumentException) {
return null
}

val existingEntity = lampRepository.getLampById(uuid) ?: return null
val updatedEntity = lampMapper.updateDomainEntity(existingEntity, lampUpdate)
val savedEntity = lampRepository.updateLamp(updatedEntity) ?: return null
return lampMapper.toApiModel(savedEntity)
}

/**
* Delete a lamp by string ID
*/
fun deleteLamp(lampId: String): Boolean {
suspend fun deleteLamp(lampId: String): Boolean {
val uuid = try {
UUID.fromString(lampId)
} catch (e: IllegalArgumentException) {
return false
}

return lampRepository.deleteLamp(uuid)
}

/**
* Check if a lamp exists by string ID
*/
fun lampExists(lampId: String): Boolean {
suspend fun lampExists(lampId: String): Boolean {
val uuid = try {
UUID.fromString(lampId)
} catch (e: IllegalArgumentException) {
return false
}

return lampRepository.lampExists(uuid)
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.lampcontrol.service

import com.lampcontrol.entity.LampEntity
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.test.*

class InMemoryLampRepositoryTest {
private val repo = InMemoryLampRepository()

@Test
fun `create, retrieve, update and delete lamp lifecycle`() {
fun `create, retrieve, update and delete lamp lifecycle`() = runTest {
val entity = LampEntity.create(true)
val created = repo.createLamp(entity)

Expand Down
Loading