diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 9055869..936f0e6 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,210 +1,31 @@ +import dev.mduchene.bolts.entity.EntityDefinitionRepository import dev.mduchene.bolts.persistence.Database 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.SessionRepository 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.http.HttpStatus fun main() { val database = Database(DatabaseConfig.fromEnvironment()) database.initialize(reinitialize = System.getenv("DB_REINITIALIZE").toBoolean()) + val users = UserRepository(database) val loginService = LoginService(users, SessionRepository(database)) - val entities = EntityDefinitionRepository(database) loginService.initializeAdmin() + val controllers = listOf( + HomeController(), + AuthController(loginService), + UserController(users, loginService), + EntityDefinitionController(EntityDefinitionRepository(database), loginService), + ) + Javalin.create { config -> - config.routes.apply { - 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) - } - } - } + controllers.forEach { it.register(config.routes) } }.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.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) - } - } - } -} diff --git a/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt b/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt new file mode 100644 index 0000000..4947332 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt @@ -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"}""") + } + } + } +} diff --git a/src/main/kotlin/dev/mduchene/bolts/web/Controller.kt b/src/main/kotlin/dev/mduchene/bolts/web/Controller.kt new file mode 100644 index 0000000..98561a9 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/Controller.kt @@ -0,0 +1,7 @@ +package dev.mduchene.bolts.web + +import io.javalin.router.JavalinDefaultRoutingApi + +fun interface Controller { + fun register(routes: JavalinDefaultRoutingApi) +} diff --git a/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt new file mode 100644 index 0000000..86394e5 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/EntityDefinitionController.kt @@ -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.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/HomeController.kt b/src/main/kotlin/dev/mduchene/bolts/web/HomeController.kt new file mode 100644 index 0000000..35c4f98 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/HomeController.kt @@ -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") } + } +} diff --git a/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt b/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt new file mode 100644 index 0000000..fde6e05 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt @@ -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) + } + } + } +} diff --git a/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt new file mode 100644 index 0000000..2b4578e --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/web/UserController.kt @@ -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) + } + } +}