simple api model

This commit is contained in:
gowthaman.b 2023-11-10 11:06:14 +05:30
parent 203c2b97a7
commit 01e197b0f8
14 changed files with 364 additions and 232 deletions

2
.idea/misc.xml generated
View File

@ -9,7 +9,7 @@
<list /> <list />
</option> </option>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="semeru-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>
</project> </project>

View File

@ -9,22 +9,33 @@ Content-Type: application/json
"uniqueIdentifier": "KA03HD6064" "uniqueIdentifier": "KA03HD6064"
} }
### create row, with autogenerated identifier
POST http://localhost:9001/api/log
Content-Type: application/json
{
"data": {
"user": "gowthaman",
"action": "logout"
}
}
### get row ### get row
GET http://localhost:9001/api/vehicle/1 GET http://localhost:9001/api/vehicle/KA03HD6064
### query row ### query row
POST http://localhost:9001/api/vehicle/query POST http://localhost:9001/api/vehicle/query
Content-Type: application/json Content-Type: application/json
{ {
"sql": "select id, tenant_id, deleted_on, deleted_by, deleted, version, created_at, modified_at, created_by, modified_by, data, tags, comments, unique_identifier, entity_name from data_model where data ->> 'number' = :number", "sql": "select sys_pk, tenant_id, deleted_on, deleted_by, deleted, version, created_at, modified_at, created_by, modified_by, data, tags, comments, unique_identifier, entity_name from data_model where data ->> 'number' = :number",
"params": { "params": {
"number": "KA03HD6064" "number": "KA03HD6064"
} }
} }
### update field ### update field
PATCH http://localhost:9001/api/vehicle/1 PATCH http://localhost:9001/api/vehicle/KA03HD6064
Content-Type: application/json Content-Type: application/json
{ {
@ -34,7 +45,7 @@ Content-Type: application/json
### upate a row ### upate a row
PUT http://localhost:9001/api/vehicle/1 PUT http://localhost:9001/api/vehicle/KA03HD6064
Content-Type: application/json Content-Type: application/json
{ {
@ -44,4 +55,4 @@ Content-Type: application/json
} }
### delete a row ### delete a row
DELETE http://localhost:9001/api/vehicle/1 DELETE http://localhost:9001/api/vehicle/KA03HD6064

7
app-sample.properties Normal file
View File

@ -0,0 +1,7 @@
app.port=9001
app.cors.enabled=true
app.cors.hosts=www.readymixerp.com,app.readymixerp.com
app.db.user=postgres
app.db.pass=postgres
app.db.url=jdbc:postgresql://192.168.64.6/modules_app
app.db.run_migration=true

View File

@ -27,7 +27,8 @@ dependencies {
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.+") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.+")
implementation("org.slf4j:slf4j-simple:2.0.7") implementation("org.slf4j:slf4j-simple:2.0.7")
api ("net.cactusthorn.config:config-core:0.81")
kapt("net.cactusthorn.config:config-compiler:0.81")
kapt("io.ebean:kotlin-querybean-generator:13.23.2") kapt("io.ebean:kotlin-querybean-generator:13.23.2")
} }
@ -40,5 +41,5 @@ kotlin {
} }
application { application {
mainClass.set("com.readymixerp.MainKt") mainClass.set("com.restapi.MainKt")
} }

View File

@ -1,116 +0,0 @@
package com.readymixerp
import com.readymixerp.domain.DataModel
import com.readymixerp.domain.Session
import com.readymixerp.domain.Session.database
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 java.time.LocalDateTime
fun main(args: Array<String>) {
Javalin
.create { cfg ->
cfg.http.generateEtags = true
cfg.plugins.enableCors { container ->
container.add {
it.allowHost(
"http://localhost:5173",
"https://www.readymixerp.com",
"https://app.readymixerp.com"
)
}
}
cfg.http.defaultContentType = ContentType.JSON
cfg.compression.gzipOnly()
cfg.jsonMapper(JavalinJackson(Session.objectMapper))
}
.routes {
before("/*") {
//validate, auth token
}
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.find(DataModel::class.java, it.pathParam("id")) ?: throw NotFoundResponse()
)
}
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}") {
database.save(
it.bodyAsClass<DataModel>().apply {
this.entityName = it.pathParam("entity")
if(this.uniqueIdentifier.isEmpty()) {
//todo: set a counter
}
this.uniqueIdentifier = "${this.entityName}_${this.uniqueIdentifier}"
}
)
}
put("/{entity}/{id}") {
val e = database.find(DataModel::class.java, it.pathParam("id")) ?: throw NotFoundResponse()
val newData = it.bodyAsClass<Map<String,Any>>()
e.data.putAll(newData)
e.update()
}
patch("/{entity}/{id}") {
val e = database.find(DataModel::class.java, it.pathParam("id")) ?: throw NotFoundResponse()
val pv = it.bodyAsClass<PatchValue>()
e.data[pv.key] = pv.value;
e.update()
}
delete("/{entity}/{id}") {
val id = it.pathParam("id")
val e = database.find(DataModel::class.java, id) ?: throw NotFoundResponse()
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)
}
.start(9001)
}
data class Query(
val sql: String,
val params: Map<String, Any>
)
data class PatchValue(val key: String, val value: Any)

View File

@ -1,39 +0,0 @@
package com.readymixerp.domain
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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
data class CurrentUser(
val anon: Boolean = true,
val userId:Long = 0,
val tenantId: Long = 0
)
object Session {
private val currentUser = object: ThreadLocal<CurrentUser>() {
override fun initialValue(): CurrentUser {
return CurrentUser()
}
}
private val sc = DatabaseConfig().apply {
loadFromProperties()
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
}

View 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)

View 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)
}
}

View 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
}

View File

@ -1,11 +1,11 @@
package com.readymixerp.domain package com.restapi.domain
import io.ebean.annotation.Platform import io.ebean.annotation.Platform
import io.ebean.dbmigration.DbMigration import io.ebean.dbmigration.DbMigration
object DBMigration { object DBMigration {
fun create(){ private fun create(){
val dbMigration: DbMigration = DbMigration.create() val dbMigration: DbMigration = DbMigration.create()
dbMigration.setPlatform(Platform.POSTGRES) dbMigration.setPlatform(Platform.POSTGRES)

View File

@ -1,8 +1,14 @@
package com.readymixerp.domain 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.Model
import io.ebean.annotation.DbArray import io.ebean.annotation.DbArray
import io.ebean.annotation.DbJsonB import io.ebean.annotation.DbJsonB
import io.ebean.annotation.Index
import io.ebean.annotation.Platform
import io.ebean.annotation.SoftDelete import io.ebean.annotation.SoftDelete
import io.ebean.annotation.TenantId import io.ebean.annotation.TenantId
import io.ebean.annotation.WhenCreated import io.ebean.annotation.WhenCreated
@ -22,7 +28,7 @@ data class Comments(val text: String = "", val by: String = "", val at: LocalDat
abstract class BaseModel : Model() { abstract class BaseModel : Model() {
@Id @Id
@GeneratedValue @GeneratedValue
var id: Long = 0 var sysPk: Long = 0
@SoftDelete @SoftDelete
var deleted: Boolean = false var deleted: Boolean = false
@ -37,20 +43,17 @@ abstract class BaseModel : Model() {
var modifiedAt: LocalDateTime? = null var modifiedAt: LocalDateTime? = null
@TenantId @TenantId
var tenantId: Long = 0L var tenantId: String = ""
@WhoCreated @WhoCreated
var createdBy: Long = 0L var createdBy: String = ""
@WhoModified @WhoModified
var modifiedBy: Long? = null var modifiedBy: String? = null
var deletedOn: LocalDateTime? = null var deletedOn: LocalDateTime? = null
var deletedBy: Long? = null var deletedBy: String? = null
@DbJsonB
var data: MutableMap<String, Any> = hashMapOf()
@DbArray @DbArray
var tags: MutableList<String> = arrayListOf() var tags: MutableList<String> = arrayListOf()
@ -60,10 +63,29 @@ abstract class BaseModel : Model() {
} }
@Entity @Entity
@Index(unique = true, name = "entity_unique_id", columnNames = ["entity_name", "unique_identifier", "tenant_id"])
open class DataModel : BaseModel() { open class DataModel : BaseModel() {
@JsonDeserialize(using = SafeStringDeserializer::class)
var uniqueIdentifier: String = "" var uniqueIdentifier: String = ""
@JsonDeserialize(using = SafeStringDeserializer::class)
var entityName: String = "" 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
}
}

View File

@ -1,32 +0,0 @@
-- apply changes
create table data_model
(
id bigint generated by default as identity not null,
tenant_id bigint not null,
deleted_on timestamp,
deleted_by bigint,
deleted boolean default false not null,
version integer not null,
created_at timestamp not null,
modified_at timestamp not null,
created_by bigint not null,
modified_by bigint not null,
data jsonb not null,
tags varchar[] not null,
comments jsonb not null,
unique_identifier varchar(255) not null,
entity_name varchar(255) not null,
constraint pk_data_model primary key (id)
);
create unique index data_model_uniq_identifier on data_model (unique_identifier);
create index data_json_jdx on data_model using GIN (data);
create index data_json_meta_data on data_model (tenant_id,
deleted,
version,
created_at,
modified_at,
created_by,
modified_by,
entity_name
);

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<migration xmlns="http://ebean-orm.github.io/xml/ns/dbmigration">
<changeSet type="apply">
<createTable name="data_model" pkName="pk_data_model">
<column name="id" type="bigint" primaryKey="true"/>
<column name="tenant_id" type="bigint" notnull="true"/>
<column name="deleted_on" type="localdatetime"/>
<column name="deleted_by" type="bigint"/>
<column name="data" type="jsonb" notnull="true"/>
<column name="tags" type="varchar[]" notnull="true"/>
<column name="comments" type="jsonb" notnull="true"/>
<column name="unique_identifier" type="varchar" notnull="true"/>
<column name="entity_name" type="varchar" notnull="true"/>
<column name="deleted" type="boolean" defaultValue="false" notnull="true"/>
<column name="version" type="integer" notnull="true"/>
<column name="created_at" type="localdatetime" notnull="true"/>
<column name="modified_at" type="localdatetime" notnull="true"/>
<column name="created_by" type="bigint" notnull="true"/>
<column name="modified_by" type="bigint" notnull="true"/>
</createTable>
</changeSet>
</migration>

View File

@ -1,4 +1,4 @@
entity-packages: com.readymixerp.domain entity-packages: com.restapi.domain
querybean-packages: com.readymixerp.domain querybean-packages: com.restapi.domain
transactional-packages: com.readymixerp transactional-packages: com.restapi
profile-location: true profile-location: true