diff --git a/pom.xml b/pom.xml index ec7bd0b..1849d18 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ 2.4.0 MainKt 3.5.2 + 2.21.3 eclipse-temurin:21-jre ${project.artifactId}:${project.version} @@ -94,6 +95,16 @@ javalin 7.2.2 + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} + org.slf4j diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 936f0e6..9a71a90 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -9,6 +9,7 @@ import dev.mduchene.bolts.web.EntityDefinitionController import dev.mduchene.bolts.web.HomeController import dev.mduchene.bolts.web.UserController import io.javalin.Javalin +import io.javalin.json.JavalinJackson fun main() { val database = Database(DatabaseConfig.fromEnvironment()) @@ -26,6 +27,7 @@ fun main() { ) Javalin.create { config -> + config.jsonMapper(JavalinJackson()) controllers.forEach { it.register(config.routes) } }.start(System.getenv("SERVER_PORT")?.toIntOrNull() ?: 7070) } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt b/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt index 4947332..1ff0782 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt @@ -12,12 +12,16 @@ class AuthController(private val loginService: LoginService) : Controller { val session = loginService.authenticate(username, password) if (session != null) { - ctx.jsonResult( - """{"username":"${session.user.username.toJsonString()}","role":"${session.user.role.toJsonString()}","token":"${session.token}"}""", + ctx.json( + LoginResponse( + username = session.user.username, + role = session.user.role, + token = session.token, + ), ) } else { ctx.status(HttpStatus.UNAUTHORIZED) - .jsonResult("""{"message":"Invalid username or password"}""") + .json(ErrorResponse("Invalid username or password")) } } } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt index 86394e5..518ecfc 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt @@ -1,8 +1,6 @@ package dev.mduchene.bolts.web -import dev.mduchene.bolts.entity.EntityDefinition import dev.mduchene.bolts.entity.EntityDefinitionRepository -import dev.mduchene.bolts.entity.EntityField import dev.mduchene.bolts.entity.FieldType import dev.mduchene.bolts.entity.RelationshipType import dev.mduchene.bolts.user.LoginService @@ -17,13 +15,13 @@ class EntityDefinitionController( override fun register(routes: JavalinDefaultRoutingApi) { routes.get("/api/entity-definitions") { ctx -> if (!ctx.requireAdmin(loginService)) return@get - ctx.jsonResult(entities.findAll().toJson()) + ctx.json(entities.findAll()) } routes.post("/api/entity-definitions") { ctx -> if (!ctx.requireAdmin(loginService)) return@post val name = ctx.requiredFormParam("name") ?: return@post val identifier = ctx.requiredFormParam("identifier") ?: return@post - ctx.status(HttpStatus.CREATED).jsonResult(entities.create(name, identifier).toJson()) + ctx.status(HttpStatus.CREATED).json(entities.create(name, identifier)) } routes.patch("/api/entity-definitions/{entityId}") { ctx -> if (!ctx.requireAdmin(loginService)) return@patch @@ -32,7 +30,7 @@ class EntityDefinitionController( val identifier = ctx.requiredFormParam("identifier") if (id == null || name == null || identifier == null) return@patch val entity = entities.update(id, name, identifier) - if (entity == null) ctx.notFound() else ctx.jsonResult(entity.toJson()) + if (entity == null) ctx.notFound() else ctx.json(entity) } routes.delete("/api/entity-definitions/{entityId}") { ctx -> if (!ctx.requireAdmin(loginService)) return@delete @@ -59,7 +57,7 @@ class EntityDefinitionController( relationship.type, ) if (field == null) ctx.notFound() else { - ctx.status(HttpStatus.CREATED).jsonResult(field.toJson()) + ctx.status(HttpStatus.CREATED).json(field) } } routes.patch("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx -> @@ -86,7 +84,7 @@ class EntityDefinitionController( relationship.targetEntityId, relationship.type, ) - if (field == null) ctx.notFound() else ctx.jsonResult(field.toJson()) + if (field == null) ctx.notFound() else ctx.json(field) } routes.delete("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx -> if (!ctx.requireAdmin(loginService)) return@delete @@ -121,11 +119,3 @@ class EntityDefinitionController( val type: RelationshipType?, ) } - -private fun List.toJson() = joinToString(prefix = "[", postfix = "]") { it.toJson() } - -private fun EntityDefinition.toJson() = - """{"id":$id,"name":"${name.toJsonString()}","identifier":"${identifier.toJsonString()}","fields":${fields.joinToString(prefix = "[", postfix = "]") { it.toJson() }}}""" - -private fun EntityField.toJson() = - """{"id":$id,"name":"${name.toJsonString()}","identifier":"${identifier.toJsonString()}","type":"$type","targetEntityId":${targetEntityId ?: "null"},"targetEntityName":${targetEntityName?.let { "\"${it.toJsonString()}\"" } ?: "null"},"relationshipType":${relationshipType?.let { "\"$it\"" } ?: "null"}}""" diff --git a/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt b/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt index fde6e05..c6f8e46 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt @@ -7,7 +7,7 @@ import io.javalin.http.HttpStatus internal fun Context.requireAdmin(loginService: LoginService): Boolean { val token = header("Authorization")?.removePrefix("Bearer ") if (loginService.userForToken(token)?.role == "admin") return true - status(HttpStatus.FORBIDDEN).jsonResult("""{"message":"Admin access required"}""") + status(HttpStatus.FORBIDDEN).json(ErrorResponse("Admin access required")) return false } @@ -18,31 +18,9 @@ internal fun Context.requiredFormParam(name: String): String? { } internal fun Context.badRequest(message: String) { - status(HttpStatus.BAD_REQUEST).jsonResult("""{"message":"${message.toJsonString()}"}""") + status(HttpStatus.BAD_REQUEST).json(ErrorResponse(message)) } internal fun Context.notFound() { - status(HttpStatus.NOT_FOUND).jsonResult("""{"message":"Not found"}""") -} - -internal fun Context.jsonResult(value: String): Context = - contentType("application/json").result(value) - -internal fun String.toJsonString() = buildString { - for (character in this@toJsonString) { - when (character) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\b' -> append("\\b") - '\u000C' -> append("\\f") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> if (character.code < 0x20) { - append("\\u%04x".format(character.code)) - } else { - append(character) - } - } - } + status(HttpStatus.NOT_FOUND).json(ErrorResponse("Not found")) } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt b/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt new file mode 100644 index 0000000..f3216b8 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/Responses.kt @@ -0,0 +1,17 @@ +package dev.mduchene.bolts.web + +data class ErrorResponse( + val message: String, +) + +data class LoginResponse( + val username: String, + val role: String, + val token: String, +) + +data class UserResponse( + val id: Long, + val username: String, + val role: String, +) diff --git a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt index 2b4578e..aedd145 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt @@ -12,10 +12,13 @@ class UserController( routes.get("/api/users") { ctx -> if (!ctx.requireAdmin(loginService)) return@get - val result = users.findAll().joinToString(prefix = "[", postfix = "]") { user -> - """{"id":${user.id},"username":"${user.username.toJsonString()}","role":"${user.role.toJsonString()}"}""" - } - ctx.jsonResult(result) + ctx.json(users.findAll().map { user -> + UserResponse( + id = user.id, + username = user.username, + role = user.role, + ) + }) } } } diff --git a/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt b/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt new file mode 100644 index 0000000..39b7303 --- /dev/null +++ b/src/test/kotlin/dev/mduchene/bolts/web/ResponseSerializationTest.kt @@ -0,0 +1,49 @@ +package dev.mduchene.bolts.web + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import dev.mduchene.bolts.entity.EntityDefinition +import dev.mduchene.bolts.entity.EntityField +import dev.mduchene.bolts.entity.FieldType +import kotlin.test.Test +import kotlin.test.assertEquals + +class ResponseSerializationTest { + private val mapper = ObjectMapper().registerKotlinModule() + + @Test + fun `serializes response data classes`() { + val response = LoginResponse( + username = "admin\"user", + role = "admin", + token = "token", + ) + + assertEquals( + """{"username":"admin\"user","role":"admin","token":"token"}""", + mapper.writeValueAsString(response), + ) + } + + @Test + fun `serializes entity definitions without handwritten json`() { + val entity = EntityDefinition( + id = 1, + name = "Company", + identifier = "company", + fields = listOf( + EntityField( + id = 2, + name = "Annual revenue", + identifier = "annualRevenue", + type = FieldType.NUMBER, + ), + ), + ) + + assertEquals( + """{"id":1,"name":"Company","identifier":"company","fields":[{"id":2,"name":"Annual revenue","identifier":"annualRevenue","type":"NUMBER","targetEntityId":null,"targetEntityName":null,"relationshipType":null}]}""", + mapper.writeValueAsString(entity), + ) + } +}