diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b462e97dd..28d9bdd18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] arrow = "1.2.0" arrowGradle = "0.12.0-rc.5" +exposed = "0.43.0" kotlin = "1.9.10" kotlinx-json = "1.6.0" kotlinx-datetime = "0.4.0" @@ -49,6 +50,12 @@ detekt = "1.23.1" arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-continuations = { module = "io.arrow-kt:arrow-continuations", version.ref = "arrow" } arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } +exposed-java-time = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" } +exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" } +exposed-json = { module = "org.jetbrains.exposed:exposed-json", version.ref = "exposed" } flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } suspendApp-core = { module = "io.arrow-kt:suspendapp", version.ref = "suspendApp" } suspendApp-ktor = { module = "io.arrow-kt:suspendapp-ktor", version.ref = "suspendApp" } @@ -87,6 +94,7 @@ kotest-testcontainers = { module = "io.kotest.extensions:kotest-extensions-testc kotest-assertions-arrow = { module = "io.kotest.extensions:kotest-assertions-arrow", version.ref = "kotest-arrow" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } uuid = { module = "app.softwork:kotlinx-uuid-core", version.ref = "uuid" } klogging = { module = "io.github.oshai:kotlin-logging", version.ref = "klogging" } hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 455bf2bff..9b6ec4453 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -21,6 +21,12 @@ node { } dependencies { + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.kotlin.datetime) + implementation(libs.exposed.java.time) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.json) implementation(libs.flyway.core) implementation(libs.hikari) implementation(libs.klogging) @@ -48,6 +54,15 @@ dependencies { implementation(projects.xefCore) implementation(projects.xefLucene) implementation(projects.xefPostgresql) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testImplementation(libs.kotest.property) + testImplementation(libs.kotest.framework) + testImplementation(libs.kotest.assertions) + testImplementation(libs.kotest.testcontainers) + testImplementation(libs.testcontainers.postgresql) + testRuntimeOnly(libs.kotest.junit5) } tasks.getByName("processResources") { @@ -71,3 +86,7 @@ task("server") { classpath = sourceSets.main.get().runtimeClasspath mainClass.set("com.xebia.functional.xef.server.Server") } + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/OrganizationTable.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/OrganizationTable.kt new file mode 100644 index 000000000..6b307264c --- /dev/null +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/OrganizationTable.kt @@ -0,0 +1,32 @@ +package com.xebia.functional.xef.server.db.tables + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +object OrganizationTable : IntIdTable() { + val name = varchar("name", 20) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp()) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp()) + val ownerId = reference( + name = "owner_id", + refColumn = UsersTable.id, + onDelete = ReferenceOption.CASCADE + ) +} + +class Organization(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(OrganizationTable) + + var name by OrganizationTable.name + var createdAt by OrganizationTable.createdAt + var updatedAt by OrganizationTable.updatedAt + var ownerId by OrganizationTable.ownerId + + var users by User via UsersOrgsTable +} + diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/ProjectsTable.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/ProjectsTable.kt new file mode 100644 index 000000000..f870914b5 --- /dev/null +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/ProjectsTable.kt @@ -0,0 +1,30 @@ +package com.xebia.functional.xef.server.db.tables + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +object ProjectsTable: IntIdTable() { + val name = varchar("name", 20) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp()) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp()) + val orgId = reference( + name = "org_id", + refColumn = OrganizationTable.id, + onDelete = ReferenceOption.CASCADE + ) +} + +class Project(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(ProjectsTable) + + var name by ProjectsTable.name + var createdAt by ProjectsTable.createdAt + var updatedAt by ProjectsTable.updatedAt + var orgId by ProjectsTable.orgId +} + diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/UsersOrgsTable.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/UsersOrgsTable.kt new file mode 100644 index 000000000..84aad728a --- /dev/null +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/UsersOrgsTable.kt @@ -0,0 +1,19 @@ +package com.xebia.functional.xef.server.db.tables + +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.Table + +object UsersOrgsTable : Table() { + val userId = reference( + name = "user_id", + foreign = UsersTable, + onDelete = ReferenceOption.CASCADE + ) + val orgId = reference( + name = "org_id", + foreign = OrganizationTable, + onDelete = ReferenceOption.CASCADE + ) + + override val primaryKey = PrimaryKey(userId, orgId) +} diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/UsersTable.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/UsersTable.kt new file mode 100644 index 000000000..5a98dea59 --- /dev/null +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/UsersTable.kt @@ -0,0 +1,33 @@ +package com.xebia.functional.xef.server.db.tables + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + + +object UsersTable : IntIdTable() { + val name = varchar("name", 20) + val email = varchar("email", 50) + val passwordHash = varchar("password_hash", 50) + val salt = varchar("salt", 20) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp()) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp()) + val authToken = varchar("auth_token", 128) +} + +class User(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(UsersTable) + + var name by UsersTable.name + var email by UsersTable.email + var passwordHash by UsersTable.passwordHash + var salt by UsersTable.salt + var createdAt by UsersTable.createdAt + var updatedAt by UsersTable.updatedAt + var authToken by UsersTable.authToken + + var organizations by Organization via UsersOrgsTable +} diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/XefTokensTable.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/XefTokensTable.kt new file mode 100644 index 000000000..ef471269f --- /dev/null +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/db/tables/XefTokensTable.kt @@ -0,0 +1,55 @@ +package com.xebia.functional.xef.server.db.tables + +import com.xebia.functional.xef.server.models.ProvidersConfig +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.json.jsonb +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +val format = Json { prettyPrint = true } + +data class XefTokens( + @SerialName("user_id") val userId: Int, + @SerialName("project_id") val projectId: Int, + @SerialName("name") val name: String, + @SerialName("created_at") val createdAt: Instant, + @SerialName("updated_at") val updatedAt: Instant, + @SerialName("token") val token: String, + @SerialName("providers_config") val providersConfig: ProvidersConfig +) + +object XefTokensTable : Table() { + val userId = reference( + name = "user_id", + foreign = UsersTable, + onDelete = ReferenceOption.CASCADE) + val projectId = reference( + name = "project_id", + foreign = ProjectsTable, + onDelete = ReferenceOption.CASCADE + ) + val name = varchar("name", 20) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp()) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp()) + val token = varchar("token", 128).uniqueIndex() + val providersConfig = jsonb("providers_config", format) + + override val primaryKey = PrimaryKey(userId, projectId, name) +} + +fun ResultRow.toXefTokens() : XefTokens { + return XefTokens( + userId = this[XefTokensTable.userId].value, + projectId = this[XefTokensTable.projectId].value, + name = this[XefTokensTable.name], + createdAt = this[XefTokensTable.createdAt], + updatedAt = this[XefTokensTable.updatedAt], + token = this[XefTokensTable.token], + providersConfig = this[XefTokensTable.providersConfig] + ) +} diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/models/ProvidersConfig.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/models/ProvidersConfig.kt new file mode 100644 index 000000000..359c52696 --- /dev/null +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/models/ProvidersConfig.kt @@ -0,0 +1,29 @@ +package com.xebia.functional.xef.server.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("open_ai") +data class OpenAIConf( + val name: String, + val token: String, + val url: String +) + +@Serializable +@SerialName("gcp") +data class GCPConf( + val name: String, + val token: String, + val projectId: String, + val location: String +) + +@Serializable +data class ProvidersConfig( + @SerialName("open_ai") + val openAI: OpenAIConf?, + @SerialName("gcp") + val gcp: GCPConf? +) diff --git a/server/src/main/resources/db/migrations/psql/V1__Initial.sql b/server/src/main/resources/db/migrations/psql/V1__Initial.sql index e69de29bb..f10129836 100644 --- a/server/src/main/resources/db/migrations/psql/V1__Initial.sql +++ b/server/src/main/resources/db/migrations/psql/V1__Initial.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS users( + id SERIAL PRIMARY KEY, + name VARCHAR(20) NOT NULL, + email VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(50) NOT NULL, + salt VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + auth_token VARCHAR(128) UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS organizations( + id SERIAL PRIMARY KEY, + name VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + owner_id INT NOT NULL, + + CONSTRAINT fk_user_id + FOREIGN KEY (owner_id) + REFERENCES users(id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS projects( + id SERIAL PRIMARY KEY, + name VARCHAR(20) UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + org_id INT NOT NULL, + + CONSTRAINT fk_org_id + FOREIGN KEY (org_id) + REFERENCES organizations(id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS users_org( + user_id INT, + org_id INT, + + PRIMARY KEY (user_id, org_id), + + CONSTRAINT fk_user_id + FOREIGN KEY (user_id) + REFERENCES users(id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE, + + CONSTRAINT fk_org_id + FOREIGN KEY (org_id) + REFERENCES organizations(id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS xef_tokens( + user_id INT, + project_id INT, + name VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + token VARCHAR(128) UNIQUE, + providers_config JSONB, + + PRIMARY KEY (user_id, project_id, name), + + CONSTRAINT fk_user_id + FOREIGN KEY (user_id) + REFERENCES users(id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE, + + CONSTRAINT fk_project_id + FOREIGN KEY (project_id) + REFERENCES projects(id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE +); diff --git a/server/src/test/kotlin/com/xebia/functional/xef/server/postgresql/DBHelpers.kt b/server/src/test/kotlin/com/xebia/functional/xef/server/postgresql/DBHelpers.kt new file mode 100644 index 000000000..c27ddf35d --- /dev/null +++ b/server/src/test/kotlin/com/xebia/functional/xef/server/postgresql/DBHelpers.kt @@ -0,0 +1,46 @@ +package com.xebia.functional.xef.server.postgresql + +import com.xebia.functional.xef.server.db.tables.Organization +import com.xebia.functional.xef.server.db.tables.Project +import com.xebia.functional.xef.server.db.tables.User +import org.jetbrains.exposed.dao.id.EntityID + +object DBHelpers { + + fun testUser( + fName: String = "test", + fEmail: String = "test@test/com", + fPasswordHash: String = "passwordTest", + fSalt: String = "saltTest", + fAuthToken: String = "authTokenTest" + ): User { + return User.new { + name = fName + email = fEmail + passwordHash = fPasswordHash + salt = fSalt + authToken = fAuthToken + } + } + + fun testOrganization( + fName: String = "testOrg", + fOwnerId: EntityID + ): Organization { + return Organization.new { + name = fName + ownerId = fOwnerId + } + } + + fun testProject( + fName: String = "testProject", + fOrgId: EntityID + ): Project { + return Project.new { + name = fName + orgId = fOrgId + } + } + +} diff --git a/server/src/test/kotlin/com/xebia/functional/xef/server/postgresql/XefDatabaseTest.kt b/server/src/test/kotlin/com/xebia/functional/xef/server/postgresql/XefDatabaseTest.kt new file mode 100644 index 000000000..1821e1dd7 --- /dev/null +++ b/server/src/test/kotlin/com/xebia/functional/xef/server/postgresql/XefDatabaseTest.kt @@ -0,0 +1,156 @@ +package com.xebia.functional.xef.server.postgresql + +import com.xebia.functional.xef.server.db.tables.* +import com.xebia.functional.xef.server.models.OpenAIConf +import com.xebia.functional.xef.server.models.ProvidersConfig +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.testcontainers.containers.PostgreSQLContainer + +object TestDatabase { + private val pgContainer: PostgreSQLContainer = + PostgreSQLContainer("postgres:alpine3.18").apply { + withDatabaseName("xefdb") + withUsername("postgres") + withPassword("postgres") + start() + } + + init { + val config = HikariConfig().apply { + jdbcUrl = pgContainer.jdbcUrl.replace("localhost", "0.0.0.0") + username = pgContainer.username + password = pgContainer.password + driverClassName = "org.postgresql.Driver" + } + + val dataSource = HikariDataSource(config) + + Database.connect(dataSource) + } +} + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class XefDatabaseTest { + @BeforeAll + fun setup() { + TestDatabase + } + + @BeforeEach + fun cleanup() { + transaction { + SchemaUtils.drop(UsersTable, OrganizationTable, ProjectsTable, UsersOrgsTable, XefTokensTable) + SchemaUtils.create(UsersTable, OrganizationTable, ProjectsTable, UsersOrgsTable, XefTokensTable) + } + } + + @Test + fun crudUser() { + transaction { + val newUser = DBHelpers.testUser() + + val retrievedUser = User.findById(newUser.id) + assertEquals("test", retrievedUser?.name) + + retrievedUser?.apply { + name = "test2" + } + + val updatedUser = User.findById(newUser.id) + assertEquals("test2", updatedUser?.name) + + updatedUser?.delete() + val deletedUser = User.findById(newUser.id) + assertNull(deletedUser) + } + } + + @Test + fun crudOrganization() { + transaction { + val ownerUser = DBHelpers.testUser() + val newOrganization = DBHelpers.testOrganization(fOwnerId = ownerUser.id) + + val retrievedOrganization = Organization.findById(newOrganization.id) + assertEquals("testOrg", retrievedOrganization?.name) + assertEquals(ownerUser.id, retrievedOrganization?.ownerId) + + ownerUser.delete() + val deletedOrganization = Organization.find { OrganizationTable.name eq newOrganization.name } + assertEquals(0, deletedOrganization.count()) + + } + + } + + @Test + fun crudProjects() { + transaction { + val ownerUser = DBHelpers.testUser() + val newOrganization = DBHelpers.testOrganization(fOwnerId = ownerUser.id) + val newProject = DBHelpers.testProject(fOrgId = newOrganization.id) + + val retrievedProject = Project.findById(newProject.id) + assertEquals(newProject.name, retrievedProject?.name) + assertEquals(newOrganization.id, retrievedProject?.orgId) + } + } + + @Test + fun organizationsAndUsers() { + transaction { + val ownerUser = DBHelpers.testUser() + val newOrganization = DBHelpers.testOrganization(fOwnerId = ownerUser.id) + ownerUser.organizations = SizedCollection(listOf(newOrganization)) + } + + transaction { + val user = User.all().first() + assertEquals(1, user.organizations.count()) + val newOrganization2 = DBHelpers.testOrganization("testOrg2", fOwnerId = user.id) + val currentOrganizations = user.organizations + user.organizations = SizedCollection(currentOrganizations + newOrganization2) + assertEquals(2, user.organizations.count()) + } + } + + @Test + fun crudXefTokens() { + transaction { + val user = DBHelpers.testUser() + val organization = DBHelpers.testOrganization(fOwnerId = user.id) + user.organizations = SizedCollection(listOf(organization)) + val project = DBHelpers.testProject(fOrgId = organization.id) + + val config = ProvidersConfig( + openAI = OpenAIConf( + name = "dev", + token = "testToken", + url = "testUrl" + ), + gcp = null + ) + XefTokensTable.insert { + it[userId] = user.id.value + it[projectId] = project.id.value + it[name] = "testEnv" + it[token] = "testToken" + it[providersConfig] = config + } + } + transaction { + val tokens = XefTokensTable.selectAll().map { it.toXefTokens() } + assertEquals("testToken", tokens[0].token) + } + } + +}