Skip to content

Commit

Permalink
feature: Barebones bot and document rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
LichtHund committed Oct 15, 2023
1 parent a180a17 commit adec03b
Show file tree
Hide file tree
Showing 26 changed files with 885 additions and 128 deletions.
1 change: 0 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@ dependencies {
implementation(libs.bundles.logger)

implementation(libs.bundles.database)
implementation("io.ktor:ktor-client-logging-jvm:2.3.4")
}
8 changes: 6 additions & 2 deletions app/src/main/kotlin/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ package dev.triumphteam.docsly
import com.zaxxer.hikari.HikariDataSource
import dev.triumphteam.docsly.config.createOrGetConfig
import dev.triumphteam.docsly.controller.apiGuild
import dev.triumphteam.docsly.database.entity.DocsTable
import dev.triumphteam.docsly.database.entity.DocumentsTable
import dev.triumphteam.docsly.database.entity.ProjectsTable
import dev.triumphteam.docsly.defaults.Defaults
import dev.triumphteam.docsly.meilisearch.Meili
import dev.triumphteam.docsly.project.Projects
Expand Down Expand Up @@ -56,7 +57,10 @@ public fun Application.module() {
Database.connect(HikariDataSource(config.postgres.toHikariConfig()))

transaction {
SchemaUtils.create(DocsTable)
SchemaUtils.create(
ProjectsTable,
DocumentsTable,
)
}

/*install(CORS) {
Expand Down
63 changes: 37 additions & 26 deletions app/src/main/kotlin/controller/ApiGuildController.kt
Original file line number Diff line number Diff line change
@@ -1,48 +1,59 @@
package dev.triumphteam.docsly.controller

import dev.triumphteam.docsly.api.GuildSetupRequest
import dev.triumphteam.docsly.database.entity.DocumentEntity
import dev.triumphteam.docsly.defaults.Defaults
import dev.triumphteam.docsly.elements.DocElement
import dev.triumphteam.docsly.meilisearch.Meili
import dev.triumphteam.docsly.project.DocumentSearchResult
import dev.triumphteam.docsly.project.IndexDocument
import dev.triumphteam.docsly.project.Projects
import dev.triumphteam.docsly.resource.GuildApi
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.plugin
import io.ktor.server.request.receive
import io.ktor.server.resources.get
import io.ktor.server.resources.post
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import org.jetbrains.exposed.sql.transactions.transaction

public fun Routing.apiGuild() {
val defaults = plugin(Defaults)
val projects = plugin(Projects)
val meili = plugin(Meili)

post<GuildApi.Guild.Setup> { setup ->
val guild = setup.parent.guild
val setupDefaults = call.receive<GuildSetupRequest>().defaults

val defaultProjects = defaults.defaultProjects()

// Validate data
setupDefaults.forEach { (project, versions) ->
val defaultVersions = defaultProjects[project] ?: run {
call.respond(HttpStatusCode.BadRequest, "Invalid default project '$project'.")
return@post
}

versions.forEach { version ->
// TODO: Figure better way to get version, and not use folder name
val replaced = version.replace(".", "_")
if (replaced !in defaultVersions) {
call.respond(HttpStatusCode.BadRequest, "Invalid default project version '$version' for project '$project'.")
return@post
}
}
}

post<GuildApi.Guild.Setup> { api ->
// If it goes well nothing will throw and it'll work well!
projects.setupProjects(guild, setupDefaults)
projects.setup(api.parent.guild, defaults)

// So we return "accepted"
call.respond(HttpStatusCode.Accepted)
}

get<GuildApi.Guild.Projects> { api ->
call.respond(projects.getProjects(api.parent.guild))
}

get<GuildApi.Guild.Search> { api ->
val project = projects.getProject(api.parent.guild, api.project, api.version) ?: run {
call.respond(HttpStatusCode.BadRequest)
return@get
}

val index = meili.client.index(Projects.indexKeyFor(api.parent.guild, project))

val result = index.search<IndexDocument>(api.query, null)
.map { DocumentSearchResult(it.references.first(), it.id) }
.take(20)

call.respond(result)
}

get<GuildApi.Guild.Document> { api ->
val document = transaction {
DocumentEntity[api.id]
}

call.respond<DocElement>(document.document)
}
}
31 changes: 20 additions & 11 deletions app/src/main/kotlin/database/entity/DocsEntity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.triumphteam.docsly.database.entity

import dev.triumphteam.docsly.database.entity.DocumentsTable.document
import dev.triumphteam.docsly.database.entity.DocumentsTable.projectId
import dev.triumphteam.docsly.database.exposed.serializable
import dev.triumphteam.docsly.elements.DocElement
import org.jetbrains.exposed.dao.LongEntity
Expand All @@ -8,19 +10,26 @@ import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.Column

public object DocsTable : LongIdTable() {
/**
* Represents a table that stores documents associated with a project.
*
* This class extends the `LongIdTable` class and provides columns to store
* the project ID and document contents.
*
* @property projectId The column to store the ID of the project associated with the document.
* @property document The column to store the document contents.
*/
public object DocumentsTable : LongIdTable("docsly_documents") {

public val guild: Column<String> = text("guild_id")
public val project: Column<String> = text("project")
public val version: Column<String> = text("version")
public val doc: Column<DocElement> = serializable("doc")
public val projectId: Column<Int> = integer("project_id").references(ProjectsTable.id)
public val document: Column<DocElement> = serializable("document")
}

public class DocEntity(entityId: EntityID<Long>) : LongEntity(entityId) {
public companion object : LongEntityClass<DocEntity>(DocsTable)
/** Document entity referencing the table [DocumentsTable]. */
public class DocumentEntity(entityId: EntityID<Long>) : LongEntity(entityId) {

public var guild: String by DocsTable.guild
public var project: String by DocsTable.project
public var version: String by DocsTable.version
public var doc: DocElement by DocsTable.doc
public companion object : LongEntityClass<DocumentEntity>(DocumentsTable)

public var projectId: Int by DocumentsTable.projectId
public var document: DocElement by DocumentsTable.document
}
40 changes: 40 additions & 0 deletions app/src/main/kotlin/database/entity/ProjectEntity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package dev.triumphteam.docsly.database.entity

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.Column

/**
* A database table for storing projects information.
*
* The ProjectsTable class extends the IntIdTable class to inherit the primary key column 'id'.
* It has columns to store the guild id, name, version, and a boolean flag for the latest version.
* The table has a unique constraint on the combination of guild, name, and version columns.
*/
public object ProjectsTable : IntIdTable("docsly_projects") {

public val guild: Column<String> = text("guild_id")
public val name: Column<String> = text("name")
public val version: Column<String> = text("version")
public val latest: Column<Boolean> = bool("latest")

init {
uniqueIndex(
columns = arrayOf(guild, name, version),
customIndexName = "docsly_guild_name_version_uq"
)
}
}

/** Project entity referencing the table [ProjectsTable]. */
public class ProjectEntity(entityId: EntityID<Int>) : IntEntity(entityId) {

public companion object : IntEntityClass<ProjectEntity>(ProjectsTable)

public var guild: String by ProjectsTable.guild
public var name: String by ProjectsTable.name
public var version: String by ProjectsTable.version
public var latest: Boolean by ProjectsTable.latest
}
14 changes: 14 additions & 0 deletions app/src/main/kotlin/database/exposed/Entity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.triumphteam.docsly.database.exposed

import org.jetbrains.exposed.dao.Entity
import org.jetbrains.exposed.dao.EntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IdTable

public abstract class StringEntity(id: EntityID<String>) : Entity<String>(id)

public abstract class StringEntityClass<out E : StringEntity>(
table: IdTable<String>,
entityType: Class<E>? = null,
entityCtor: ((EntityID<String>) -> E)? = null
) : EntityClass<String, E>(table, entityType, entityCtor)
69 changes: 69 additions & 0 deletions app/src/main/kotlin/database/exposed/ListColumn.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* MIT License
*
* Copyright (c) 2019-2022 TriumphTeam and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package dev.triumphteam.docsly.database.exposed

import com.impossibl.postgres.jdbc.PGArray
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.ColumnType
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi
import org.jetbrains.exposed.sql.statements.jdbc.JdbcPreparedStatementImpl
import org.jetbrains.exposed.sql.vendors.currentDialect

public fun Table.stringSet(name: String): Column<Set<String>> =
registerColumn(name, StringListColumnType())

public class StringListColumnType : ColumnType() {

override fun sqlType(): String = "${currentDialect.dataTypeProvider.textType()}[]"

/** When writing the value, it can either be a full on list, or individual values. */
override fun notNullValueToDB(value: Any): Any = when (value) {
is Collection<*> -> value
is String -> setOf(value)
else -> error("$value of ${value::class.qualifiedName} is not valid for string set")
}

/** When getting the value it can be more than just [PGArray]. */
override fun valueFromDB(value: Any): Any = when (value) {
is PGArray -> (value.array as Array<*>).toSet()
is Set<*> -> value
is Array<*> -> value.toSet()
is Collection<*> -> value.toSet()
else -> error("Got unexpected array value of type: ${value::class.qualifiedName} ($value)")
}

override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) {
if (value == null) {
stmt.setNull(index, this)
} else {
val preparedStatement = stmt as? JdbcPreparedStatementImpl ?: error("Currently only JDBC is supported")
val array = preparedStatement.statement.connection.createArrayOf(
currentDialect.dataTypeProvider.integerType(),
(value as Collection<*>).toTypedArray()
)
stmt[index] = array
}
}
}
69 changes: 41 additions & 28 deletions app/src/main/kotlin/defaults/Defaults.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,18 @@ package dev.triumphteam.docsly.defaults
import io.ktor.server.application.Application
import io.ktor.server.application.BaseApplicationPlugin
import io.ktor.util.AttributeKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileNotFoundException
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.isDirectory
import kotlin.io.path.notExists
import kotlin.io.path.name

public class Defaults {

// Move to separate repo
private val defaultsFolder: Path = Path("data/defaults")
private val defaults: Map<String, Set<String>> = defaultsFolder.toFile().listFiles()?.mapNotNull { project ->
if (!project.isDirectory) return@mapNotNull null

val versions = project.listFiles()?.mapNotNull { version ->
if (!version.isDirectory) null else version.name
}?.toSet() ?: emptySet()

project.name to versions
}?.toMap() ?: emptyMap()

public fun defaultProjects(): Map<String, Set<String>> {
return defaults
}

public fun resolve(project: String, version: String): File {
val folder = defaultsFolder.resolve("$project/$version")
if (folder.notExists() || !folder.isDirectory()) {
throw FileNotFoundException("Could not find file with path '${"$project/$version"}'")
}

return folder.toFile().listFiles()?.find { it.name.endsWith(".json") }
?: throw FileNotFoundException("Could not find json file for '${"$project/$version"}'")
}

public companion object Plugin : BaseApplicationPlugin<Application, Defaults, Defaults> {

override val key: AttributeKey<Defaults> = AttributeKey("defaults")
Expand All @@ -46,4 +23,40 @@ public class Defaults {
return Defaults()
}
}

private val logger: Logger = LoggerFactory.getLogger(Defaults::class.java)

// Move to separate repo
private val defaultsFolder: Path = Path("data/defaults")
public val defaults: Map<FileProjectVersion, File> = defaultsFolder.toFile().listFiles()?.flatMap { project ->
if (!project.isDirectory) return@flatMap emptyList()

project.listFiles()?.mapNotNull inner@{ version ->
if (!version.isDirectory) return@inner null

val files = version.listFiles() ?: return@inner run {
logger.warn("Ignoring folder ${version.name}, as it is empty.")
null
}

val versionFile = files.find { it.name == "version.json" } ?: return@inner run {
logger.warn("Ignoring folder ${version.name}, as it does not have a version.json file.")
null
}

val documentsFile = files.find { it.name == "documents.json" } ?: return@inner run {
logger.warn("Ignoring folder ${version.name}, as it does not have a documents.json file.")
null
}

Json.decodeFromString<FileProjectVersion>(versionFile.readText()) to documentsFile
} ?: emptyList()
}?.toMap() ?: emptyMap()

@Serializable
public data class FileProjectVersion(
public val project: String,
public val version: String,
public val latest: Boolean,
)
}
Loading

0 comments on commit adec03b

Please sign in to comment.