Skip to content

Commit 344e8aa

Browse files
committed
Support custom integration routes, add WebUntis exam calendars
1 parent 7a40b12 commit 344e8aa

File tree

32 files changed

+329
-81
lines changed

32 files changed

+329
-81
lines changed

api/build.gradle.kts

+3
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ dependencies {
1414
implementation(database.exposed.core)
1515
api(libraries.jobrunr)
1616
api(log.kotlinLogging)
17+
implementation(ktor.server.core)
18+
implementation(ktor.server.auth)
19+
implementation(ktor.server.auth.jwt)
1720
}

src/main/kotlin/me/snoty/backend/server/handler/HttpStatusException.kt api/src/main/kotlin/me/snoty/backend/utils/HttpStatusException.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package me.snoty.backend.server.handler
1+
package me.snoty.backend.utils
22

33
import io.ktor.http.*
44
import kotlinx.serialization.KSerializer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package me.snoty.backend.utils
2+
3+
import io.ktor.server.application.*
4+
import io.ktor.server.auth.*
5+
import io.ktor.server.auth.jwt.*
6+
import me.snoty.backend.User
7+
import java.util.*
8+
9+
fun ApplicationCall.getUser(): User =
10+
getUserOrNull() ?: throw UnauthorizedException("User not authenticated")
11+
12+
fun ApplicationCall.getUserOrNull(): User? {
13+
val principal = authentication.principal<JWTPrincipal>() ?: return null
14+
val claims = principal.payload.claims
15+
return User(
16+
id = claims["sub"]?.`as`(UUID::class.java) ?: NULL_UUID,
17+
name = claims["preferred_username"]?.asString() ?: "unknown",
18+
email = claims["email"]?.asString() ?: "unknown"
19+
)
20+
}

api/src/main/kotlin/me/snoty/backend/utils/SqlUtils.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import org.jetbrains.exposed.sql.Function
1111
* 3. The value to return if the condition is false
1212
* 4. The Type of the results
1313
*/
14-
class When<T>(
14+
class When<T : Any>(
1515
private val condition: Expression<*>,
1616
private val ifTrue: Expression<T>,
1717
private val ifFalse: Expression<T>?,
18-
columnType: IColumnType
18+
columnType: IColumnType<T>
1919
) : Function<T>(columnType) {
2020
override fun toQueryBuilder(queryBuilder: QueryBuilder) {
2121
queryBuilder {

integrations/api/build.gradle.kts

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ dependencies {
1919
api(ktor.client.contentNegotiation)
2020
api(ktor.serialization.kotlinx.json)
2121
api(projects.api)
22+
api(ktor.server.core)
23+
api(ktor.server.auth)
24+
api(libraries.jackson.core)
25+
api(libraries.jackson.kotlin)
2226
testImplementation(kotlin("test"))
2327
}
2428

integrations/api/src/main/kotlin/me/snoty/integration/common/AbstractIntegration.kt

+7-5
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package me.snoty.integration.common
22

33
import io.micrometer.core.instrument.MeterRegistry
44
import me.snoty.backend.User
5-
import me.snoty.integration.common.diff.EntityDiffMetrics
6-
import me.snoty.integration.common.diff.EntityStateTable
75
import me.snoty.backend.scheduling.JobRequest
86
import me.snoty.backend.scheduling.Scheduler
7+
import me.snoty.integration.common.diff.EntityDiffMetrics
8+
import me.snoty.integration.common.diff.EntityStateTable
99
import org.jetbrains.exposed.sql.Database
1010
import org.jetbrains.exposed.sql.SchemaUtils
1111
import org.jetbrains.exposed.sql.transactions.transaction
@@ -52,21 +52,23 @@ abstract class AbstractIntegration<S : IntegrationSettings, R : JobRequest>(
5252
scheduleAll()
5353
}
5454

55+
override fun setup(user: User, settings: IntegrationSettings)
56+
= IntegrationConfigTable.create(user.id, name, settings)
57+
5558
private fun scheduleAll() {
5659
val allSettings = IntegrationConfigTable.getAllIntegrationConfigs<S>(name)
5760
allSettings.forEach(::schedule)
5861
}
5962

6063
abstract fun createRequest(config: IntegrationConfig<S>): JobRequest
61-
abstract fun getInstanceId(config: IntegrationConfig<S>): Int
6264

63-
override fun schedule(user: User, settings: Any) {
65+
override fun schedule(user: User, settings: IntegrationSettings) {
6466
@Suppress("UNCHECKED_CAST")
6567
val integrationConfig = IntegrationConfig(user.id, settings as S)
6668
schedule(integrationConfig)
6769
}
6870

6971
private fun schedule(config: IntegrationConfig<S>) {
70-
scheduler.scheduleJob(listOf(getInstanceId(config), config.user), createRequest(config))
72+
scheduler.scheduleJob(listOf(config.settings.instanceId, config.user), createRequest(config))
7173
}
7274
}

integrations/api/src/main/kotlin/me/snoty/integration/common/Integration.kt

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
package me.snoty.integration.common
22

3+
import io.ktor.server.routing.*
34
import me.snoty.backend.User
45
import kotlin.reflect.KClass
56

67

78
interface Integration {
89
val name: String
9-
val settingsType: KClass<out Any>
10+
val settingsType: KClass<out IntegrationSettings>
1011
val fetcher: IntegrationFetcher<*>
1112

1213
fun start()
13-
fun schedule(user: User, settings: Any)
14+
fun schedule(user: User, settings: IntegrationSettings)
15+
fun setup(user: User, settings: IntegrationSettings): Long
16+
17+
fun routes(routing: Route) {
18+
// default implementation does nothing
19+
}
1420
}
1521

1622
interface IntegrationFactory {

integrations/api/src/main/kotlin/me/snoty/integration/common/IntegrationConfigTable.kt

+28-5
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
package me.snoty.integration.common
22

3-
import kotlinx.serialization.json.Json
4-
import org.jetbrains.exposed.dao.id.IdTable
3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
4+
import com.fasterxml.jackson.module.kotlin.readValue
5+
import org.jetbrains.exposed.dao.id.LongIdTable
56
import org.jetbrains.exposed.sql.SchemaUtils
7+
import org.jetbrains.exposed.sql.and
8+
import org.jetbrains.exposed.sql.insertAndGetId
69
import org.jetbrains.exposed.sql.json.jsonb
710
import org.jetbrains.exposed.sql.transactions.transaction
811
import java.util.*
912

1013
data class IntegrationConfig<S>(val user: UUID, val settings: S)
1114

12-
object IntegrationConfigTable : IdTable<Long>() {
13-
override val id = long("id").entityId()
15+
val mapper = jacksonObjectMapper()
1416

17+
object IntegrationConfigTable : LongIdTable() {
1518
val user = uuid("user")
1619
private val integrationType = varchar("integration_type", 255)
17-
private val settings = jsonb<IntegrationSettings>("config", Json)
20+
private val settings = jsonb<IntegrationSettings>(
21+
"config",
22+
{ mapper.writeValueAsString(it) },
23+
{ mapper.readValue(it) }
24+
)
1825

1926
init {
2027
transaction {
@@ -30,4 +37,20 @@ object IntegrationConfigTable : IdTable<Long>() {
3037
IntegrationConfig(row[user], row[settings] as S)
3138
}
3239
}
40+
41+
fun <S> get(id: Long, integrationType: String) = transaction {
42+
@Suppress("UNCHECKED_CAST")
43+
select(settings)
44+
.where { IntegrationConfigTable.id eq id and (IntegrationConfigTable.integrationType eq integrationType) }
45+
.firstOrNull()
46+
?.get(settings) as S?
47+
}
48+
49+
fun <S : IntegrationSettings> create(userId: UUID, integrationType: String, settings: S) = transaction {
50+
insertAndGetId {
51+
it[user] = userId
52+
it[this.integrationType] = integrationType
53+
it[this.settings] = settings
54+
}.value
55+
}
3356
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package me.snoty.integration.common
22

3-
import kotlinx.serialization.Serializable
3+
import com.fasterxml.jackson.annotation.JsonTypeInfo
44

5-
@Serializable
6-
open class IntegrationSettings
5+
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
6+
interface IntegrationSettings {
7+
val instanceId: Int
8+
}

integrations/api/src/main/kotlin/me/snoty/integration/common/diff/EntityStateTable.kt

+7-4
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
88
import org.jetbrains.exposed.sql.SqlExpressionBuilder.neq
99
import org.jetbrains.exposed.sql.json.jsonb
1010
import org.jetbrains.exposed.sql.transactions.transaction
11+
import java.util.UUID
1112

1213
const val ID = "id"
1314
abstract class EntityStateTable<ID>(
1415
json: Json = Json
1516
) : Table() {
1617
abstract val id: Column<ID>
17-
private val instanceId = integer("instance_id")
18+
val instanceId = integer("instance_id")
1819

1920
/**
2021
* Override this with `buildPrimaryKey()` to define the primary key
@@ -23,9 +24,10 @@ abstract class EntityStateTable<ID>(
2324
abstract override val primaryKey: PrimaryKey
2425
fun buildPrimaryKey() = PrimaryKey(id, instanceId)
2526

26-
private val type = varchar("type", 255)
27-
private val state = jsonb<Fields>("state", json)
27+
val type = varchar("type", 255)
28+
val state = jsonb<Fields>("state", json)
2829
private val checksum = long("checksum")
30+
val userId = uuid("user_id").nullable()
2931

3032
private fun deleteIdentifier(instanceId: InstanceId, entity: IUpdatableEntity<ID>): EntityStateTable<ID>.(ISqlExpressionBuilder) -> Op<Boolean> = {
3133
(id eq entity.id) and (type eq entity.type) and (this.instanceId eq instanceId)
@@ -53,7 +55,7 @@ abstract class EntityStateTable<ID>(
5355
return entity.diff(result[stateQuery])
5456
}
5557

56-
fun compareAndUpdateState(instanceId: InstanceId, entity: IUpdatableEntity<ID>): DiffResult = transaction {
58+
fun compareAndUpdateState(entity: IUpdatableEntity<ID>, instanceId: InstanceId, userId: UUID): DiffResult = transaction {
5759
val diffResult = compareState(instanceId, entity)
5860
when (diffResult) {
5961
// entity has changed since last time
@@ -67,6 +69,7 @@ abstract class EntityStateTable<ID>(
6769
is DiffResult.Created -> {
6870
insert {
6971
it[id] = entity.id
72+
it[this.userId] = userId
7073
it[this.instanceId] = instanceId
7174
it[type] = entity.type
7275
it[state] = entity.fields

integrations/api/src/main/kotlin/me/snoty/integration/common/diff/Updatable.kt

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package me.snoty.integration.common.diff
22

3-
import kotlinx.serialization.json.JsonObject
3+
import kotlinx.serialization.json.*
44

55
data class OldNew<T>(val old: T, val new: T)
66
typealias Diff = Map<String, OldNew<Any>>
77

88
typealias Fields = JsonObject
99
fun Fields.checksum() = hashCode().toLong()
1010

11+
fun Fields.getString(key: String): String = get(key)!!.jsonPrimitive.content
12+
fun Fields.getInt(key: String): Int = get(key)!!.jsonPrimitive.int
13+
fun Fields.getLong(key: String): Long = get(key)!!.jsonPrimitive.long
14+
fun Fields.getJsonArray(key: String): JsonArray = get(key)!!.jsonArray
15+
1116
sealed class DiffResult {
1217
data object Unchanged : DiffResult()
1318
data class Created(val checksum: Long, val fields: Fields) : DiffResult()

integrations/moodle/src/main/kotlin/me/snoty/integration/moodle/MoodleFetcher.kt

+6-5
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,25 @@ import me.snoty.integration.common.IntegrationFetcherFactory
88
import me.snoty.integration.common.diff.EntityDiffMetrics
99
import me.snoty.integration.common.diff.IUpdatableEntity
1010
import me.snoty.integration.moodle.request.getCalendarUpcoming
11+
import java.util.UUID
1112

1213
open class MoodleFetcher(
1314
private val entityDiffMetrics: EntityDiffMetrics,
1415
private val moodleAPI: MoodleAPI = MoodleAPIImpl()
1516
) : IntegrationFetcher<MoodleJobRequest> {
1617
private val logger = KotlinLogging.logger {}
1718

18-
private fun updateStates(instanceId: InstanceId, elements: List<IUpdatableEntity<Long>>) {
19+
private fun updateStates(elements: List<IUpdatableEntity<Long>>, instanceId: InstanceId, userId: UUID) {
1920
elements.forEach {
20-
val result = MoodleEntityStateTable.compareAndUpdateState(instanceId, it)
21+
val result = MoodleEntityStateTable.compareAndUpdateState(it, instanceId, userId)
2122
entityDiffMetrics.process(result)
2223
}
2324
}
2425

25-
private suspend fun fetchAssignments(moodleSettings: MoodleSettings) {
26+
private suspend fun fetchAssignments(moodleSettings: MoodleSettings, userId: UUID) {
2627
val instanceId = moodleSettings.baseUrl.hashCode()
2728
val assignments = moodleAPI.getCalendarUpcoming(moodleSettings)
28-
updateStates(instanceId, assignments)
29+
updateStates(assignments, instanceId, userId)
2930
logger.info { "Fetched ${assignments.size} assignments for ${moodleSettings.username}" }
3031
// TODO: send update events
3132
}
@@ -35,6 +36,6 @@ open class MoodleFetcher(
3536
}
3637

3738
override fun run(jobRequest: MoodleJobRequest) = runBlocking {
38-
fetchAssignments(jobRequest.settings)
39+
fetchAssignments(jobRequest.settings, jobRequest.userId)
3940
}
4041
}

integrations/moodle/src/main/kotlin/me/snoty/integration/moodle/MoodleIntegration.kt

+3-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ data class MoodleSettings(
1313
val baseUrl: String,
1414
val username: String,
1515
val appSecret: String
16-
) : IntegrationSettings()
16+
) : IntegrationSettings {
17+
override val instanceId: Int = baseUrl.hashCode()
18+
}
1719

1820
object MoodleEntityStateTable : EntityStateTable<Long>() {
1921
override val id: Column<Long> = long(ID)
@@ -34,9 +36,6 @@ class MoodleIntegration(
3436
const val INTEGRATION_NAME = "moodle"
3537
}
3638

37-
override fun getInstanceId(config: IntegrationConfig<MoodleSettings>) =
38-
config.settings.baseUrl.hashCode()
39-
4039
override fun createRequest(config: IntegrationConfig<MoodleSettings>): JobRequest =
4140
MoodleJobRequest(config.user, config.settings)
4241

integrations/webuntis/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ repositories {
99

1010
dependencies {
1111
compileOnly(projects.integrations.api)
12+
implementation("org.mnode.ical4j:ical4j:4.0.0-rc6")
1213

1314
testImplementation(tests.junit.api)
1415
testImplementation(libraries.kotlinx.serialization)

integrations/webuntis/src/main/kotlin/me/snoty/integration/untis/WebUntisFetcher.kt

+6-5
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,25 @@ import me.snoty.integration.common.IntegrationFetcherFactory
88
import me.snoty.integration.common.diff.EntityDiffMetrics
99
import me.snoty.integration.common.diff.IUpdatableEntity
1010
import me.snoty.integration.untis.request.getExams
11+
import java.util.UUID
1112

1213
class WebUntisFetcher(
1314
private val entityDiffMetrics: EntityDiffMetrics,
1415
private val untis: WebUntisAPI = WebUntisAPIImpl()
1516
) : IntegrationFetcher<WebUntisJobRequest> {
1617
private val logger = KotlinLogging.logger {}
1718

18-
private fun updateStates(instanceId: InstanceId, elements: List<IUpdatableEntity<Int>>) {
19+
private fun updateStates(instanceId: InstanceId, elements: List<IUpdatableEntity<Int>>, userId: UUID) {
1920
elements.forEach {
20-
val result = WebUntisEntityStateTable.compareAndUpdateState(instanceId, it)
21+
val result = WebUntisEntityStateTable.compareAndUpdateState(it, instanceId, userId)
2122
entityDiffMetrics.process(result)
2223
}
2324
}
2425

25-
private suspend fun fetchExams(untisSettings: WebUntisSettings) {
26+
private suspend fun fetchExams(untisSettings: WebUntisSettings, userId: UUID) {
2627
val instanceId = untisSettings.baseUrl.hashCode()
2728
val exams = untis.getExams(untisSettings)
28-
updateStates(instanceId, exams)
29+
updateStates(instanceId, exams, userId)
2930
logger.info { "Fetched ${exams.size} exams for ${untisSettings.username}" }
3031
}
3132

@@ -34,6 +35,6 @@ class WebUntisFetcher(
3435
}
3536

3637
override fun run(jobRequest: WebUntisJobRequest) = runBlocking {
37-
fetchExams(jobRequest.settings)
38+
fetchExams(jobRequest.settings, jobRequest.userId)
3839
}
3940
}

0 commit comments

Comments
 (0)