simple api model
This commit is contained in:
177
src/main/kotlin/com/restapi/Main.kt
Normal file
177
src/main/kotlin/com/restapi/Main.kt
Normal file
@@ -0,0 +1,177 @@
|
||||
package com.restapi
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import com.restapi.config.AppConfig.Companion.appConfig
|
||||
import com.restapi.domain.DataModel
|
||||
import com.restapi.domain.DataNotFoundException
|
||||
import com.restapi.domain.Session
|
||||
import com.restapi.domain.Session.creatSeq
|
||||
import com.restapi.domain.Session.database
|
||||
import com.restapi.domain.Session.findByEntityAndId
|
||||
import com.restapi.domain.Session.nextUniqId
|
||||
import io.ebean.CallableSql
|
||||
import io.ebean.DuplicateKeyException
|
||||
import io.ebean.RawSqlBuilder
|
||||
import io.javalin.Javalin
|
||||
import io.javalin.apibuilder.ApiBuilder.*
|
||||
import io.javalin.http.*
|
||||
import io.javalin.json.JavalinJackson
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.LocalDateTime
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val logger = LoggerFactory.getLogger("api")
|
||||
Javalin
|
||||
.create { cfg ->
|
||||
cfg.http.generateEtags = true
|
||||
if (appConfig.corsEnabled()) {
|
||||
cfg.plugins.enableCors { container ->
|
||||
container.add {
|
||||
it.allowHost(
|
||||
"http://localhost:5173",
|
||||
*appConfig.corsHosts().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.http.defaultContentType = ContentType.JSON
|
||||
cfg.compression.gzipOnly()
|
||||
cfg.jsonMapper(JavalinJackson(Session.objectMapper))
|
||||
}
|
||||
.routes {
|
||||
before("/*") { ctx ->
|
||||
//validate, auth token
|
||||
|
||||
//allow only alpha, numeric, hypen, underscore, dot in paths
|
||||
val regex = Regex("^[a-zA-Z0-9\\-_\\.]+$")
|
||||
|
||||
ctx.path().split("/").dropWhile { it.isEmpty() }
|
||||
.forEach {
|
||||
if (!it.matches(regex)) {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
}
|
||||
path("/api") {
|
||||
post("/execute/{name}") {
|
||||
val name = it.pathParam("name")
|
||||
val params = it.bodyAsClass<Map<String, Any>>()
|
||||
val placeholders = (0..params.entries.size).joinToString(",") { "?" }
|
||||
val sql = "{call $name($placeholders)}"
|
||||
val cs: CallableSql = database.createCallableSql(sql)
|
||||
params.entries.forEachIndexed { index, entry ->
|
||||
cs.setParameter(index + 1, entry.value)
|
||||
}
|
||||
database.execute(cs)
|
||||
}
|
||||
get("/{entity}/{id}") {
|
||||
it.json(
|
||||
database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id"))
|
||||
)
|
||||
}
|
||||
post("/{entity}/query/{id}") {
|
||||
val sql = it.bodyAsClass<Query>()
|
||||
val query = database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id"))
|
||||
|
||||
val querySql = query.data["sql"] as String? ?: throw NotFoundResponse()
|
||||
|
||||
it.json(
|
||||
database.find(DataModel::class.java)
|
||||
.setRawSql(
|
||||
RawSqlBuilder.parse(querySql).create()
|
||||
).apply {
|
||||
sql.params.forEach { (t, u) ->
|
||||
setParameter(t, u)
|
||||
}
|
||||
}
|
||||
.findList()
|
||||
)
|
||||
|
||||
}
|
||||
post("/{entity}/query") {
|
||||
val sql = it.bodyAsClass<Query>()
|
||||
it.json(
|
||||
database.find(DataModel::class.java)
|
||||
.setRawSql(
|
||||
RawSqlBuilder.parse(sql.sql).create()
|
||||
).apply {
|
||||
sql.params.forEach { (t, u) ->
|
||||
setParameter(t, u)
|
||||
}
|
||||
}
|
||||
.findList()
|
||||
)
|
||||
|
||||
}
|
||||
post("/{entity}") {
|
||||
val entity = it.pathParam("entity")
|
||||
val seqCreated = creatSeq(entity)
|
||||
logger.debug("sequence created for $entity? = $seqCreated")
|
||||
database.save(
|
||||
it.bodyAsClass<DataModel>().apply {
|
||||
this.entityName = entity
|
||||
if (this.uniqueIdentifier.isEmpty()) {
|
||||
this.uniqueIdentifier = nextUniqId(entity)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
put("/{entity}/{id}") {
|
||||
val e = database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id"))
|
||||
val newData = it.bodyAsClass<Map<String, Any>>()
|
||||
e.data.putAll(newData)
|
||||
e.update()
|
||||
}
|
||||
patch("/{entity}/{id}") {
|
||||
val e = database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id"))
|
||||
val pv = it.bodyAsClass<PatchValue>()
|
||||
e.data[pv.key] = pv.value;
|
||||
e.update()
|
||||
}
|
||||
delete("/{entity}/{id}") {
|
||||
val id = it.pathParam("id")
|
||||
val e = database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id"))
|
||||
e.deletedBy = Session.currentUser()
|
||||
e.deletedOn = LocalDateTime.now()
|
||||
e.update()
|
||||
e.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
.exception(DuplicateKeyException::class.java) { _, ctx ->
|
||||
ctx.json(
|
||||
mapOf(
|
||||
"error" to "Duplicate Data"
|
||||
)
|
||||
).status(HttpStatus.CONFLICT)
|
||||
}
|
||||
.exception(DataNotFoundException::class.java) { _, ctx ->
|
||||
ctx.json(
|
||||
mapOf(
|
||||
"error" to "Data Not Found"
|
||||
)
|
||||
).status(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
.exception(IllegalArgumentException::class.java) { _, ctx ->
|
||||
ctx.json(
|
||||
mapOf(
|
||||
"error" to "Incorrect Data"
|
||||
)
|
||||
).status(HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
.exception(JsonMappingException::class.java) { _, ctx ->
|
||||
ctx.json(
|
||||
mapOf(
|
||||
"error" to "Incorrect Data"
|
||||
)
|
||||
).status(HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
.start(appConfig.portNumber())
|
||||
}
|
||||
|
||||
data class Query(
|
||||
val sql: String,
|
||||
val params: Map<String, Any>
|
||||
)
|
||||
|
||||
data class PatchValue(val key: String, val value: Any)
|
||||
48
src/main/kotlin/com/restapi/config/AppConfig.kt
Normal file
48
src/main/kotlin/com/restapi/config/AppConfig.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.restapi.config
|
||||
|
||||
import net.cactusthorn.config.core.Config
|
||||
import net.cactusthorn.config.core.Default
|
||||
import net.cactusthorn.config.core.Key
|
||||
import net.cactusthorn.config.core.factory.ConfigFactory
|
||||
import net.cactusthorn.config.core.loader.LoadStrategy
|
||||
|
||||
const val INITIAL_ROLES_JSON = """{
|
||||
"roles": []
|
||||
}"""
|
||||
|
||||
@Config(
|
||||
sources = [
|
||||
"file:~/app.properties", "system:env"
|
||||
],
|
||||
loadStrategy = LoadStrategy.FIRST_KEYCASEINSENSITIVE
|
||||
)
|
||||
interface AppConfig {
|
||||
@Key("app.cors.enabled")
|
||||
@Default("false")
|
||||
fun corsEnabled(): Boolean
|
||||
|
||||
@Key("app.port")
|
||||
@Default("9001")
|
||||
fun portNumber(): Int
|
||||
|
||||
@Key("app.cors.hosts")
|
||||
@Default("*")
|
||||
fun corsHosts(): List<String>
|
||||
|
||||
@Key("app.db.user")
|
||||
fun dbUser(): String
|
||||
|
||||
@Key("app.db.pass")
|
||||
fun dbPass(): String
|
||||
|
||||
@Key("app.db.url")
|
||||
fun dbUrl(): String
|
||||
|
||||
@Key("app.db.run_migration")
|
||||
fun dbRunMigration(): Boolean
|
||||
|
||||
|
||||
companion object {
|
||||
val appConfig: AppConfig = ConfigFactory.builder().build().create(AppConfig::class.java)
|
||||
}
|
||||
}
|
||||
75
src/main/kotlin/com/restapi/domain/db.kt
Normal file
75
src/main/kotlin/com/restapi/domain/db.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.restapi.domain
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.Module
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.restapi.config.AppConfig.Companion.appConfig
|
||||
import io.ebean.Database
|
||||
import io.ebean.DatabaseFactory
|
||||
import io.ebean.config.CurrentTenantProvider
|
||||
import io.ebean.config.CurrentUserProvider
|
||||
import io.ebean.config.DatabaseConfig
|
||||
import io.ebean.config.TenantMode
|
||||
import java.util.*
|
||||
|
||||
data class CurrentUser(
|
||||
val anon: Boolean = true,
|
||||
val userId: String = "",
|
||||
val tenantId: String = ""
|
||||
)
|
||||
|
||||
|
||||
object Session {
|
||||
private val currentUser = object : ThreadLocal<CurrentUser>() {
|
||||
override fun initialValue(): CurrentUser {
|
||||
return CurrentUser()
|
||||
}
|
||||
}
|
||||
private val sc = DatabaseConfig().apply {
|
||||
loadFromProperties(Properties().apply {
|
||||
setProperty("datasource.db.username", appConfig.dbUser())
|
||||
setProperty("datasource.db.password", appConfig.dbPass())
|
||||
setProperty("datasource.db.url", appConfig.dbUrl())
|
||||
setProperty("ebean.migration.run", appConfig.dbRunMigration().toString())
|
||||
})
|
||||
tenantMode = TenantMode.PARTITION
|
||||
currentTenantProvider = CurrentTenantProvider { currentUser.get().tenantId }
|
||||
currentUserProvider = CurrentUserProvider { currentUser.get().userId }
|
||||
}
|
||||
|
||||
val database: Database = DatabaseFactory.create(sc)
|
||||
val objectMapper = jacksonObjectMapper().apply {
|
||||
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
||||
findAndRegisterModules()
|
||||
}
|
||||
|
||||
fun currentUser() = currentUser.get().userId
|
||||
|
||||
fun Database.findByEntityAndId(entity: String, id: String): DataModel {
|
||||
return find(DataModel::class.java)
|
||||
.where()
|
||||
.eq("uniqueIdentifier", id)
|
||||
.eq("entityName", entity)
|
||||
.findOne() ?: throw DataNotFoundException
|
||||
}
|
||||
|
||||
private fun seqName(entity: String) = "sequence_$entity"
|
||||
fun creatSeq(entity: String): Int {
|
||||
return database.sqlUpdate("CREATE SEQUENCE IF NOT EXISTS ${seqName(entity)} START 1;").execute()
|
||||
}
|
||||
|
||||
fun nextUniqId(entity: String): String {
|
||||
val s = database
|
||||
.sqlQuery("SELECT nextval('${seqName(entity)}');")
|
||||
.findOne()?.getLong("nextval") ?: throw DataNotFoundException
|
||||
return String.format("%s-%s", entity, "$s".padStart(10, '0'))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object DataNotFoundException : Exception() {
|
||||
private fun readResolve(): Any = DataNotFoundException
|
||||
}
|
||||
19
src/main/kotlin/com/restapi/domain/migration.kt
Normal file
19
src/main/kotlin/com/restapi/domain/migration.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.restapi.domain
|
||||
|
||||
import io.ebean.annotation.Platform
|
||||
import io.ebean.dbmigration.DbMigration
|
||||
|
||||
|
||||
object DBMigration {
|
||||
private fun create(){
|
||||
val dbMigration: DbMigration = DbMigration.create()
|
||||
dbMigration.setPlatform(Platform.POSTGRES)
|
||||
|
||||
dbMigration.generateMigration()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
create()
|
||||
}
|
||||
}
|
||||
91
src/main/kotlin/com/restapi/domain/models.kt
Normal file
91
src/main/kotlin/com/restapi/domain/models.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
package com.restapi.domain
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||
import io.ebean.Model
|
||||
import io.ebean.annotation.DbArray
|
||||
import io.ebean.annotation.DbJsonB
|
||||
import io.ebean.annotation.Index
|
||||
import io.ebean.annotation.Platform
|
||||
import io.ebean.annotation.SoftDelete
|
||||
import io.ebean.annotation.TenantId
|
||||
import io.ebean.annotation.WhenCreated
|
||||
import io.ebean.annotation.WhenModified
|
||||
import io.ebean.annotation.WhoCreated
|
||||
import io.ebean.annotation.WhoModified
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.GeneratedValue
|
||||
import javax.persistence.Id
|
||||
import javax.persistence.MappedSuperclass
|
||||
import javax.persistence.Version
|
||||
|
||||
data class Comments(val text: String = "", val by: String = "", val at: LocalDateTime = LocalDateTime.now())
|
||||
|
||||
@MappedSuperclass
|
||||
abstract class BaseModel : Model() {
|
||||
@Id
|
||||
@GeneratedValue
|
||||
var sysPk: Long = 0
|
||||
|
||||
@SoftDelete
|
||||
var deleted: Boolean = false
|
||||
|
||||
@Version
|
||||
var version: Int = 0
|
||||
|
||||
@WhenCreated
|
||||
var createdAt: LocalDateTime = LocalDateTime.now()
|
||||
|
||||
@WhenModified
|
||||
var modifiedAt: LocalDateTime? = null
|
||||
|
||||
@TenantId
|
||||
var tenantId: String = ""
|
||||
|
||||
@WhoCreated
|
||||
var createdBy: String = ""
|
||||
|
||||
@WhoModified
|
||||
var modifiedBy: String? = null
|
||||
|
||||
var deletedOn: LocalDateTime? = null
|
||||
|
||||
var deletedBy: String? = null
|
||||
|
||||
@DbArray
|
||||
var tags: MutableList<String> = arrayListOf()
|
||||
|
||||
@DbJsonB
|
||||
var comments: MutableList<Comments> = arrayListOf()
|
||||
}
|
||||
|
||||
@Entity
|
||||
@Index(unique = true, name = "entity_unique_id", columnNames = ["entity_name", "unique_identifier", "tenant_id"])
|
||||
open class DataModel : BaseModel() {
|
||||
|
||||
@JsonDeserialize(using = SafeStringDeserializer::class)
|
||||
var uniqueIdentifier: String = ""
|
||||
|
||||
@JsonDeserialize(using = SafeStringDeserializer::class)
|
||||
var entityName: String = ""
|
||||
|
||||
@Index(definition = "create index data_jsonb_idx on data_model using GIN (data) ", platforms = [Platform.POSTGRES])
|
||||
@DbJsonB
|
||||
var data: MutableMap<String, Any> = hashMapOf()
|
||||
|
||||
}
|
||||
|
||||
class SafeStringDeserializer : JsonDeserializer<String>() {
|
||||
private val regex = Regex("^[a-zA-Z0-9\\-_\\.]+$")
|
||||
|
||||
override fun deserialize(p: JsonParser, ctxt: DeserializationContext?): String {
|
||||
|
||||
val text = p.text
|
||||
if (!regex.matches(text)) throw IllegalArgumentException()
|
||||
return text
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user