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),
+ )
+ }
+}