From dc0a59fcf2f1c76a3f1718b475524c9d3bcf7734 Mon Sep 17 00:00:00 2001 From: "gowthaman.b" Date: Sat, 11 Nov 2023 11:38:58 +0530 Subject: [PATCH] more stuff --- api.http | 4 +- build.gradle.kts | 3 +- settings.gradle.kts | 2 +- .../kotlin/com/restapi/AppAccessManager.kt | 15 ++ src/main/kotlin/com/restapi/Main.kt | 149 ++++++------------ .../kotlin/com/restapi/config/AppConfig.kt | 3 + .../com/restapi/controllers/Entities.kt | 119 ++++++++++++++ src/main/kotlin/com/restapi/domain/db.kt | 1 + src/main/kotlin/com/restapi/domain/models.kt | 95 ++++++++++- src/main/kotlin/com/restapi/integ/Jobs.kt | 10 ++ .../kotlin/com/restapi/integ/Scripting.kt | 43 +++++ 11 files changed, 335 insertions(+), 109 deletions(-) create mode 100644 src/main/kotlin/com/restapi/AppAccessManager.kt create mode 100644 src/main/kotlin/com/restapi/controllers/Entities.kt create mode 100644 src/main/kotlin/com/restapi/integ/Jobs.kt create mode 100644 src/main/kotlin/com/restapi/integ/Scripting.kt diff --git a/api.http b/api.http index e10c0a0..d714477 100644 --- a/api.http +++ b/api.http @@ -5,9 +5,9 @@ Authorization: set-auth-token { "data": { - "number": "KA01MU0556" + "number": "TN36BA5009" }, - "uniqueIdentifier": "KA01MU0556" + "uniqueIdentifier": "TN36BA5009" } ### create row, with autogenerated identifier diff --git a/build.gradle.kts b/build.gradle.kts index f0df4ff..8ed7383 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { application } -group = "com.readymixerp" +group = "com.basuvaraj" version = "1.0-SNAPSHOT" repositories { @@ -29,6 +29,7 @@ dependencies { implementation("org.bitbucket.b_c:jose4j:0.9.3") implementation("org.slf4j:slf4j-simple:2.0.7") implementation("redis.clients:jedis:5.0.2") + implementation("org.jetbrains.kotlin:kotlin-scripting-jsr223:1.9.0") 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") diff --git a/settings.gradle.kts b/settings.gradle.kts index 1654b15..7d0599d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,4 +9,4 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" } -rootProject.name = "rmc_modules_api" \ No newline at end of file +rootProject.name = "rest_api" \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/AppAccessManager.kt b/src/main/kotlin/com/restapi/AppAccessManager.kt new file mode 100644 index 0000000..410dc88 --- /dev/null +++ b/src/main/kotlin/com/restapi/AppAccessManager.kt @@ -0,0 +1,15 @@ +package com.restapi + +import io.javalin.http.Context +import io.javalin.http.Handler +import io.javalin.security.AccessManager +import io.javalin.security.RouteRole +import org.slf4j.LoggerFactory + +class AppAccessManager : AccessManager { + private val logger = LoggerFactory.getLogger("Access") + override fun manage(handler: Handler, ctx: Context, routeRoles: Set) { + logger.warn("access {}, {}", ctx.pathParamMap(), routeRoles) + handler.handle(ctx) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/Main.kt b/src/main/kotlin/com/restapi/Main.kt index 35d54ad..1bf9a7e 100644 --- a/src/main/kotlin/com/restapi/Main.kt +++ b/src/main/kotlin/com/restapi/Main.kt @@ -4,26 +4,24 @@ import AuthTokenResponse import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.module.kotlin.readValue import com.restapi.config.AppConfig.Companion.appConfig -import com.restapi.config.Auth import com.restapi.config.Auth.getAuthEndpoint -import com.restapi.config.AuthEndpoint -import com.restapi.domain.DataModel +import com.restapi.config.Auth.parseAuthToken +import com.restapi.controllers.Entities 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 com.restapi.domain.Session.objectMapper import com.restapi.domain.Session.redis import com.restapi.domain.Session.setAuthorizedUser 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.http.util.NaiveRateLimit +import io.javalin.http.util.RateLimitUtil import io.javalin.json.JavalinJackson +import io.javalin.security.AccessManager +import io.javalin.security.RouteRole import org.slf4j.LoggerFactory import java.net.URI import java.net.URLEncoder @@ -32,11 +30,14 @@ import java.net.http.HttpRequest import java.net.http.HttpRequest.BodyPublishers import java.net.http.HttpResponse.BodyHandlers import java.nio.charset.StandardCharsets -import java.time.LocalDateTime +import java.util.concurrent.TimeUnit +import kotlin.jvm.optionals.getOrDefault fun main(args: Array) { val logger = LoggerFactory.getLogger("api") + //ratelimit based on IP Only + RateLimitUtil.keyFunction = { ctx -> ctx.header("X-Forwarded-For")?.split(",")?.get(0) ?: ctx.ip() } Javalin .create { cfg -> cfg.http.generateEtags = true @@ -52,7 +53,8 @@ fun main(args: Array) { } cfg.http.defaultContentType = ContentType.JSON cfg.compression.gzipOnly() - cfg.jsonMapper(JavalinJackson(Session.objectMapper)) + cfg.jsonMapper(JavalinJackson(objectMapper)) + cfg.accessManager(AppAccessManager()) } .routes { @@ -95,110 +97,49 @@ fun main(args: Array) { } } + before("/api/*") { ctx -> //validate, auth token + NaiveRateLimit.requestPerTimeUnit(ctx, appConfig.rateLimit().getOrDefault(30), TimeUnit.MINUTES) // throws if rate limit is exceeded + //allow only alpha, numeric, hypen, underscore, dot in paths val regex = Regex("^[a-zA-Z0-9\\-_\\.]+$") - ctx.path().split("/").dropWhile { it.isEmpty() } + ctx.path().split("/") + .dropWhile { it.isEmpty() } .forEach { if (!it.matches(regex)) { throw IllegalArgumentException() } } - val at = ctx.header("Authorization")?.replace("Bearer ", "")?.replace("Bearer: ", "")?.trim() - ?: throw UnauthorizedResponse() - val pt = Auth.parseAuthToken(authToken = at) + val authToken = ctx.header("Authorization")?.replace("Bearer ", "") + ?.replace("Bearer: ", "") + ?.trim() ?: throw UnauthorizedResponse() - setAuthorizedUser(pt) + logger.warn("authToken = $authToken") + + setAuthorizedUser(parseAuthToken(authToken = authToken)) } path("/api") { - post("/execute/{name}") { - val name = it.pathParam("name") - val params = it.bodyAsClass>() - 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() - val query = database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id")) + post("/execute/{name}", Entities::executeStoredProcedure, Roles(Role.DbOps)) - val querySql = query.data["sql"] as String? ?: throw NotFoundResponse() + get("/{entity}/{id}", Entities::view, Roles(Role.Standard(Action.VIEW))) + post("/{entity}/query/{id}", Entities::sqlQueryId, Roles(Role.Standard(Action.VIEW))) + post("/{entity}/query", Entities::sqlQueryRaw, Roles(Role.Standard(Action.VIEW))) + post("/{entity}", Entities::create, Roles(Role.Standard(Action.CREATE))) - it.json( - database.find(DataModel::class.java) - .setRawSql( - RawSqlBuilder.parse(querySql).create() - ).apply { - sql.params.forEach { (t, u) -> - setParameter(t, u) - } - } - .findList() - ) + put("/{entity}/approve/{id}", Entities::approve, Roles(Role.Standard(Action.APPROVE))) + put("/{entity}/reject/{id}", Entities::reject, Roles(Role.Standard(Action.APPROVE))) + put("/{entity}/{action}/{id}", Entities::action, Roles(Role.Entity)) - } - post("/{entity}/query") { - val sql = it.bodyAsClass() - 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().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>() - e.data.putAll(newData) - e.update() - } - patch("/{entity}/{id}") { - val e = database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id")) - val pv = it.bodyAsClass() - 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() - } + put("/{entity}/{id}", Entities::update, Roles(Role.Standard(Action.UPDATE))) + patch("/{entity}/{id}", Entities::patch, Roles(Role.Standard(Action.UPDATE))) + delete("/{entity}/{id}", Entities::delete, Roles(Role.Standard(Action.DELETE))) } + + } .exception(DuplicateKeyException::class.java) { _, ctx -> ctx.json( @@ -231,10 +172,19 @@ fun main(args: Array) { .start(appConfig.portNumber()) } -data class Query( - val sql: String, - val params: Map -) + +enum class Action { + CREATE, VIEW, UPDATE, DELETE, APPROVE +} + +sealed class Role { + open class Standard(vararg val action: Action) : Role() + data object Entity : Role() + data object DbOps : Role() +} + +open class Roles(vararg val roles: Role) : RouteRole + private fun getFormDataAsString(formData: Map): String { val formBodyBuilder = StringBuilder() @@ -249,4 +199,3 @@ private fun getFormDataAsString(formData: Map): String { return formBodyBuilder.toString() } -data class PatchValue(val key: String, val value: Any) \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/config/AppConfig.kt b/src/main/kotlin/com/restapi/config/AppConfig.kt index b857878..7c0c2fe 100644 --- a/src/main/kotlin/com/restapi/config/AppConfig.kt +++ b/src/main/kotlin/com/restapi/config/AppConfig.kt @@ -63,6 +63,9 @@ interface AppConfig { @Key("app.cache.redis_uri") fun redisUri(): Optional + @Key("app.network.rate_limit") + fun rateLimit(): Optional + companion object { val appConfig: AppConfig = ConfigFactory.builder().build().create(AppConfig::class.java) } diff --git a/src/main/kotlin/com/restapi/controllers/Entities.kt b/src/main/kotlin/com/restapi/controllers/Entities.kt new file mode 100644 index 0000000..9807f7f --- /dev/null +++ b/src/main/kotlin/com/restapi/controllers/Entities.kt @@ -0,0 +1,119 @@ +package com.restapi.controllers + +import com.restapi.domain.DataModel +import com.restapi.domain.Session +import com.restapi.domain.Session.database +import com.restapi.domain.Session.findByEntityAndId +import io.ebean.CallableSql +import io.ebean.RawSqlBuilder +import io.javalin.http.Context +import io.javalin.http.NotFoundResponse +import io.javalin.http.bodyAsClass +import org.slf4j.LoggerFactory +import java.time.LocalDateTime + +data class PatchValue(val key: String, val value: Any) +data class Query( + val sql: String, + val params: Map +) + +object Entities { + private val logger = LoggerFactory.getLogger("Entities") + fun delete(ctx: Context) { + val e = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) + e.deletedBy = Session.currentUser() + e.deletedOn = LocalDateTime.now() + e.update() + e.delete() + } + + fun patch(ctx: Context) { + val e = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) + val pv = ctx.bodyAsClass() + e.data[pv.key] = pv.value; + e.update() + } + + fun update(ctx: Context) { + val e = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) + val newData = ctx.bodyAsClass>() + e.data.putAll(newData) + e.update() + } + + fun action(ctx: Context) {} + fun approve(ctx: Context) {} + fun reject(ctx: Context) {} + + fun executeStoredProcedure(ctx: Context) { + val name = ctx.pathParam("name") + val params = ctx.bodyAsClass>() + val placeholders = (0..params.entries.size + 1).joinToString(",") { "?" } + + val sql = "{call $name($placeholders)}" + val cs: CallableSql = database.createCallableSql(sql) + + params.entries.forEachIndexed { index, entry -> + cs.setParameter(index + 1, entry.value) + } + cs.setParameter(params.entries.size + 1, Session.currentTenant()) + database.execute(cs) + } + + fun sqlQueryRaw(ctx: Context) { + val sql = ctx.bodyAsClass() + ctx.json( + database.find(DataModel::class.java) + .setRawSql( + RawSqlBuilder.parse(sql.sql).create() + ).apply { + sql.params.forEach { (t, u) -> + setParameter(t, u) + } + } + .findList() + ) + + } + + fun sqlQueryId(ctx: Context) { + val sql = ctx.bodyAsClass() + val query = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) + + val querySql = query.data["sql"] as String? ?: throw NotFoundResponse() + + ctx.json( + database.find(DataModel::class.java) + .setRawSql( + RawSqlBuilder.parse(querySql).create() + ).apply { + sql.params.forEach { (t, u) -> + setParameter(t, u) + } + } + .findList() + ) + + } + + fun view(it: Context) { + it.json( + database.findByEntityAndId(it.pathParam("entity"), it.pathParam("id")) + ) + } + + fun create(ctx: Context) { + val entity = ctx.pathParam("entity") + val seqCreated = Session.creatSeq(entity) + logger.debug("sequence created for $entity? = $seqCreated") + database.save( + ctx.bodyAsClass().apply { + this.entityName = entity + if (this.uniqueIdentifier.isEmpty()) { + this.uniqueIdentifier = Session.nextUniqId(entity) + } + } + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/domain/db.kt b/src/main/kotlin/com/restapi/domain/db.kt index 52b71c2..13b1084 100644 --- a/src/main/kotlin/com/restapi/domain/db.kt +++ b/src/main/kotlin/com/restapi/domain/db.kt @@ -46,6 +46,7 @@ object Session { } fun currentUser() = currentUser.get().userName + fun currentTenant() = currentUser.get().tenant fun Database.findByEntityAndId(entity: String, id: String): DataModel { return find(DataModel::class.java) diff --git a/src/main/kotlin/com/restapi/domain/models.kt b/src/main/kotlin/com/restapi/domain/models.kt index 84cfde0..46c764e 100644 --- a/src/main/kotlin/com/restapi/domain/models.kt +++ b/src/main/kotlin/com/restapi/domain/models.kt @@ -16,11 +16,7 @@ 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 +import javax.persistence.* data class Comments(val text: String = "", val by: String = "", val at: LocalDateTime = LocalDateTime.now()) @@ -62,6 +58,95 @@ abstract class BaseModel : Model() { var comments: MutableList = arrayListOf() } +@Entity +open class TenantModel : BaseModel() { + var name: String = "" + var domain: String = "" + var mobile: List = emptyList() + var emails: List = emptyList() + + @DbJsonB + var preferences: MutableMap = hashMapOf() +} + +enum class AuditType { + CREATE, UPDATE, DELETE, VIEW +} + +open class AuditLog : BaseModel() { + var auditType: AuditType = AuditType.CREATE + var entity: String = "" + var uniqueIdentifier: String = "" + + @DbJsonB + @Index(definition = "create index audit_log_values_idx on audit_log using GIN (data) ", platforms = [Platform.POSTGRES]) + var data: Map = hashMapOf() + + @DbJsonB + @Index(definition = "create index audit_log_changes_idx on audit_log using GIN (changes) ", platforms = [Platform.POSTGRES]) + var changes: Map = hashMapOf() +} + +@Entity +open class EntityModel : BaseModel() { + @Index(unique = true) + @JsonDeserialize(using = SafeStringDeserializer::class) + var name: String = "" + + //a kts script that will return true/false along with errors before saving + var preSaveScript: String = "" + + //a kts script that will do something ... returns void + var postSaveScript: String = "" + + //this will create extra actions/roles in keycloak + //the default actions are create, update, view, delete + @DbArray + var actions: List = emptyList() + + //allow only these fields, if this is empty, then all fields are allowed + @DbArray + var allowedFields: List = emptyList() + + //when an entity is saved/updated audit logs will be populated, when this is empty, all fields are logged + @DbArray + var auditLogFields: List = emptyList() + + @DbJsonB + var preferences: MutableMap = hashMapOf() + + //if '0' then its auto saved, no approval steps are required, for further steps, + //a user needs to have ROLE_ENTITY_APPROVE_LEVEL1, ROLE_ENTITY_APPROVE_LEVEL2 roles + var approvalLevels: Int = 0 +} + +enum class JobFrequencyType { + SPECIFIC, EVERY, CRON +} + +enum class JobType { + SCRIPT, DB +} + +@Entity +open class JobModel : BaseModel() { + @Index(unique = true) + var jobName: String = "" + + + @Enumerated(EnumType.STRING) + var jobType: JobType = JobType.SCRIPT + var jobPath: String = "" + + @DbArray + var tenants: List = emptyList() + + @Enumerated(EnumType.STRING) + var jobFrequencyType = JobFrequencyType.EVERY + var frequency: String = "1h" + +} + @Entity @Index(unique = true, name = "entity_unique_id", columnNames = ["entity_name", "unique_identifier", "tenant_id"]) open class DataModel : BaseModel() { diff --git a/src/main/kotlin/com/restapi/integ/Jobs.kt b/src/main/kotlin/com/restapi/integ/Jobs.kt new file mode 100644 index 0000000..49a588b --- /dev/null +++ b/src/main/kotlin/com/restapi/integ/Jobs.kt @@ -0,0 +1,10 @@ +package com.restapi.integ + +object Jobs { + fun runJobs(){ + //wake up every minute + //see jobs that are to be run and run them + //not very accurate so use for simple jobs, + // sometimes they might fail and will not be attempted to re-run + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/integ/Scripting.kt b/src/main/kotlin/com/restapi/integ/Scripting.kt new file mode 100644 index 0000000..6a6f991 --- /dev/null +++ b/src/main/kotlin/com/restapi/integ/Scripting.kt @@ -0,0 +1,43 @@ +package com.restapi.integ + +import javax.script.Invocable +import javax.script.ScriptEngineManager + + +object Scripting { + const val a = "1" + @JvmStatic + fun main(args: Array) { + + + + k() + + + } + + fun k(){ + val engine = ScriptEngineManager().getEngineByExtension("kts")!! + val res1 = engine.eval(""" + fun fn(x: Int) = x + 2 + val obj = object { + fun fn1(x: Int) = x + 3 + } + obj""".trimIndent()) + println(res1) + + val invocator = engine as? Invocable + println(invocator) + + try { + println(invocator!!.invokeFunction("fn1", 3)) + } catch (e: NoSuchMethodException) { + println(e) + } + + val res2 = invocator!!.invokeFunction("fn", 3) + println(res2) + val res3 = invocator.invokeMethod(res1, "fn1", 3) + println(res3) + } +} \ No newline at end of file