split into controllers

This commit is contained in:
Maxime Duchêne-Savard 2026-07-29 16:41:59 -04:00
parent 4fb57ef61b
commit 470f882b29
7 changed files with 254 additions and 193 deletions

View File

@ -1,210 +1,31 @@
import dev.mduchene.bolts.entity.EntityDefinitionRepository
import dev.mduchene.bolts.persistence.Database import dev.mduchene.bolts.persistence.Database
import dev.mduchene.bolts.persistence.DatabaseConfig import dev.mduchene.bolts.persistence.DatabaseConfig
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 import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.SessionRepository import dev.mduchene.bolts.user.SessionRepository
import dev.mduchene.bolts.user.UserRepository import dev.mduchene.bolts.user.UserRepository
import dev.mduchene.bolts.web.AuthController
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.Javalin
import io.javalin.http.HttpStatus
fun main() { fun main() {
val database = Database(DatabaseConfig.fromEnvironment()) val database = Database(DatabaseConfig.fromEnvironment())
database.initialize(reinitialize = System.getenv("DB_REINITIALIZE").toBoolean()) database.initialize(reinitialize = System.getenv("DB_REINITIALIZE").toBoolean())
val users = UserRepository(database) val users = UserRepository(database)
val loginService = LoginService(users, SessionRepository(database)) val loginService = LoginService(users, SessionRepository(database))
val entities = EntityDefinitionRepository(database)
loginService.initializeAdmin() loginService.initializeAdmin()
val controllers = listOf(
HomeController(),
AuthController(loginService),
UserController(users, loginService),
EntityDefinitionController(EntityDefinitionRepository(database), loginService),
)
Javalin.create { config -> Javalin.create { config ->
config.routes.apply { controllers.forEach { it.register(config.routes) }
get("/") { ctx -> ctx.result("Hello World") }
post("/api/login") { ctx ->
val username = ctx.formParam("username").orEmpty()
val password = ctx.formParam("password").orEmpty()
val session = loginService.authenticate(username, password)
if (session != null) {
ctx.contentType("application/json")
.result(
"""{"username":"${session.user.username.toJsonString()}","role":"${session.user.role.toJsonString()}","token":"${session.token}"}""",
)
} else {
ctx.status(HttpStatus.UNAUTHORIZED)
.contentType("application/json")
.result("""{"message":"Invalid username or password"}""")
}
}
get("/api/users") { ctx ->
val token = ctx.header("Authorization")?.removePrefix("Bearer ")
val currentUser = loginService.userForToken(token)
if (currentUser?.role != "admin") {
ctx.status(HttpStatus.FORBIDDEN)
.contentType("application/json")
.result("""{"message":"Admin access required"}""")
return@get
}
val result = users.findAll().joinToString(prefix = "[", postfix = "]") { user ->
"""{"id":${user.id},"username":"${user.username.toJsonString()}","role":"${user.role.toJsonString()}"}"""
}
ctx.contentType("application/json").result(result)
}
get("/api/entity-definitions") { ctx ->
if (!ctx.requireAdmin(loginService)) return@get
ctx.contentType("application/json").result(entities.findAll().toJson())
}
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).contentType("application/json")
.result(entities.create(name, identifier).toJson())
}
patch("/api/entity-definitions/{entityId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@patch
val id = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name")
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.contentType("application/json").result(entity.toJson())
}
delete("/api/entity-definitions/{entityId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@delete
val id = ctx.pathParam("entityId").toLongOrNull()
if (id == null || !entities.delete(id)) ctx.notFound() else ctx.status(HttpStatus.NO_CONTENT)
}
post("/api/entity-definitions/{entityId}/fields") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post
val entityId = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier")
val type = ctx.formParam("type")?.let(FieldType::from)
val relationship = ctx.relationshipDetails(type, entities)
if (entityId == null || name == null || identifier == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required")
return@post
}
val field = entities.createField(
entityId,
name,
identifier,
type,
relationship.targetEntityId,
relationship.type,
)
if (field == null) ctx.notFound() else {
ctx.status(HttpStatus.CREATED).contentType("application/json").result(field.toJson())
}
}
patch("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@patch
val entityId = ctx.pathParam("entityId").toLongOrNull()
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier")
val type = ctx.formParam("type")?.let(FieldType::from)
val relationship = ctx.relationshipDetails(type, entities)
if (entityId == null || fieldId == null || name == null || identifier == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required")
return@patch
}
val field = entities.updateField(
entityId,
fieldId,
name,
identifier,
type,
relationship.targetEntityId,
relationship.type,
)
if (field == null) ctx.notFound() else ctx.contentType("application/json").result(field.toJson())
}
delete("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@delete
val entityId = ctx.pathParam("entityId").toLongOrNull()
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
if (entityId == null || fieldId == null || !entities.deleteField(entityId, fieldId)) {
ctx.notFound()
} else {
ctx.status(HttpStatus.NO_CONTENT)
}
}
}
}.start(System.getenv("SERVER_PORT")?.toIntOrNull() ?: 7070) }.start(System.getenv("SERVER_PORT")?.toIntOrNull() ?: 7070)
} }
private fun io.javalin.http.Context.requireAdmin(loginService: LoginService): Boolean {
val token = header("Authorization")?.removePrefix("Bearer ")
if (loginService.userForToken(token)?.role == "admin") return true
status(HttpStatus.FORBIDDEN).contentType("application/json").result("""{"message":"Admin access required"}""")
return false
}
private fun io.javalin.http.Context.requiredFormParam(name: String): String? {
val value = formParam(name)?.trim()?.takeIf(String::isNotEmpty)
if (value == null) badRequest("$name is required")
return value
}
private fun io.javalin.http.Context.badRequest(message: String) {
status(HttpStatus.BAD_REQUEST).contentType("application/json")
.result("""{"message":"${message.toJsonString()}"}""")
}
private fun io.javalin.http.Context.notFound() {
status(HttpStatus.NOT_FOUND).contentType("application/json").result("""{"message":"Not found"}""")
}
private data class RelationshipDetails(
val targetEntityId: Long?,
val type: RelationshipType?,
)
private fun io.javalin.http.Context.relationshipDetails(
fieldType: FieldType?,
entities: EntityDefinitionRepository,
): RelationshipDetails? {
if (fieldType != FieldType.RELATIONSHIP) return RelationshipDetails(null, null)
val targetEntityId = formParam("targetEntityId")?.toLongOrNull()
val relationshipType = formParam("relationshipType")?.let(RelationshipType::from)
if (targetEntityId == null || entities.findAll().none { it.id == targetEntityId }) {
badRequest("A valid target entity is required")
return null
}
if (relationshipType == null) {
badRequest("A valid relationship type is required")
return null
}
return RelationshipDetails(targetEntityId, relationshipType)
}
private fun List<EntityDefinition>.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"}}"""
private 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)
}
}
}
}

View File

@ -0,0 +1,24 @@
package dev.mduchene.bolts.web
import dev.mduchene.bolts.user.LoginService
import io.javalin.http.HttpStatus
import io.javalin.router.JavalinDefaultRoutingApi
class AuthController(private val loginService: LoginService) : Controller {
override fun register(routes: JavalinDefaultRoutingApi) {
routes.post("/api/login") { ctx ->
val username = ctx.formParam("username").orEmpty()
val password = ctx.formParam("password").orEmpty()
val session = loginService.authenticate(username, password)
if (session != null) {
ctx.jsonResult(
"""{"username":"${session.user.username.toJsonString()}","role":"${session.user.role.toJsonString()}","token":"${session.token}"}""",
)
} else {
ctx.status(HttpStatus.UNAUTHORIZED)
.jsonResult("""{"message":"Invalid username or password"}""")
}
}
}
}

View File

@ -0,0 +1,7 @@
package dev.mduchene.bolts.web
import io.javalin.router.JavalinDefaultRoutingApi
fun interface Controller {
fun register(routes: JavalinDefaultRoutingApi)
}

View File

@ -0,0 +1,131 @@
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
import io.javalin.http.Context
import io.javalin.http.HttpStatus
import io.javalin.router.JavalinDefaultRoutingApi
class EntityDefinitionController(
private val entities: EntityDefinitionRepository,
private val loginService: LoginService,
) : Controller {
override fun register(routes: JavalinDefaultRoutingApi) {
routes.get("/api/entity-definitions") { ctx ->
if (!ctx.requireAdmin(loginService)) return@get
ctx.jsonResult(entities.findAll().toJson())
}
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())
}
routes.patch("/api/entity-definitions/{entityId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@patch
val id = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name")
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())
}
routes.delete("/api/entity-definitions/{entityId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@delete
val id = ctx.pathParam("entityId").toLongOrNull()
if (id == null || !entities.delete(id)) ctx.notFound() else ctx.status(HttpStatus.NO_CONTENT)
}
routes.post("/api/entity-definitions/{entityId}/fields") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post
val entityId = ctx.pathParam("entityId").toLongOrNull()
val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier")
val type = ctx.formParam("type")?.let(FieldType::from)
val relationship = ctx.relationshipDetails(type)
if (entityId == null || name == null || identifier == null || type == null || relationship == null) {
if (type == null) ctx.badRequest("A valid field type is required")
return@post
}
val field = entities.createField(
entityId,
name,
identifier,
type,
relationship.targetEntityId,
relationship.type,
)
if (field == null) ctx.notFound() else {
ctx.status(HttpStatus.CREATED).jsonResult(field.toJson())
}
}
routes.patch("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@patch
val entityId = ctx.pathParam("entityId").toLongOrNull()
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
val name = ctx.requiredFormParam("name")
val identifier = ctx.requiredFormParam("identifier")
val type = ctx.formParam("type")?.let(FieldType::from)
val relationship = ctx.relationshipDetails(type)
if (
entityId == null || fieldId == null || name == null || identifier == null ||
type == null || relationship == null
) {
if (type == null) ctx.badRequest("A valid field type is required")
return@patch
}
val field = entities.updateField(
entityId,
fieldId,
name,
identifier,
type,
relationship.targetEntityId,
relationship.type,
)
if (field == null) ctx.notFound() else ctx.jsonResult(field.toJson())
}
routes.delete("/api/entity-definitions/{entityId}/fields/{fieldId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@delete
val entityId = ctx.pathParam("entityId").toLongOrNull()
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
if (entityId == null || fieldId == null || !entities.deleteField(entityId, fieldId)) {
ctx.notFound()
} else {
ctx.status(HttpStatus.NO_CONTENT)
}
}
}
private fun Context.relationshipDetails(fieldType: FieldType?): RelationshipDetails? {
if (fieldType != FieldType.RELATIONSHIP) return RelationshipDetails(null, null)
val targetEntityId = formParam("targetEntityId")?.toLongOrNull()
val relationshipType = formParam("relationshipType")?.let(RelationshipType::from)
if (targetEntityId == null || entities.findAll().none { it.id == targetEntityId }) {
badRequest("A valid target entity is required")
return null
}
if (relationshipType == null) {
badRequest("A valid relationship type is required")
return null
}
return RelationshipDetails(targetEntityId, relationshipType)
}
private data class RelationshipDetails(
val targetEntityId: Long?,
val type: RelationshipType?,
)
}
private fun List<EntityDefinition>.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"}}"""

View File

@ -0,0 +1,9 @@
package dev.mduchene.bolts.web
import io.javalin.router.JavalinDefaultRoutingApi
class HomeController : Controller {
override fun register(routes: JavalinDefaultRoutingApi) {
routes.get("/") { ctx -> ctx.result("Hello World") }
}
}

View File

@ -0,0 +1,48 @@
package dev.mduchene.bolts.web
import dev.mduchene.bolts.user.LoginService
import io.javalin.http.Context
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"}""")
return false
}
internal fun Context.requiredFormParam(name: String): String? {
val value = formParam(name)?.trim()?.takeIf(String::isNotEmpty)
if (value == null) badRequest("$name is required")
return value
}
internal fun Context.badRequest(message: String) {
status(HttpStatus.BAD_REQUEST).jsonResult("""{"message":"${message.toJsonString()}"}""")
}
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)
}
}
}
}

View File

@ -0,0 +1,21 @@
package dev.mduchene.bolts.web
import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.UserRepository
import io.javalin.router.JavalinDefaultRoutingApi
class UserController(
private val users: UserRepository,
private val loginService: LoginService,
) : Controller {
override fun register(routes: JavalinDefaultRoutingApi) {
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)
}
}
}