feat: Add announcements API

This commit is contained in:
oSumAtrIX 2024-01-31 03:01:07 +01:00
parent af0b0865f4
commit 42f731854d
No known key found for this signature in database
GPG Key ID: A9B3094ACDB604B4
21 changed files with 509 additions and 286 deletions

View File

@ -1,2 +1,13 @@
GITHUB_TOKEN=
CONFIG_FILE_PATH=
CONFIG_FILE_PATH=configuration.toml
DB_URL=jdbc:h2:./api.db
DB_USER=
DB_PASSWORD=
JWT_SECRET=
JWT_ISSUER=
JWT_VALIDITY_IN_MIN=
BASIC_USERNAME=
BASIC_PASSWORD=

View File

@ -45,10 +45,13 @@ dependencies {
implementation(libs.logback.classic)
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.dao)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.dotenv.kotlin)
implementation(libs.ktoml.core)
implementation(libs.ktoml.file)
implementation(libs.picocli)
implementation(libs.kotlinx.datetime)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.kotlin.test.junit)

View File

@ -2,4 +2,4 @@ organization = "org"
patches-repository = "patches"
integrations-repositories = ["integrations"]
contributors-repositories = ["patches", "integrations"]
api-version = 1
api-version = 1

View File

@ -1,13 +1,14 @@
[versions]
kotlin="1.9.22"
logback="1.4.14"
exposed="0.41.1"
h2="2.1.214"
koin="3.5.3"
dotenv="6.4.1"
kotlin = "1.9.22"
logback = "1.4.14"
exposed = "0.41.1"
h2 = "2.2.224"
koin = "3.5.3"
dotenv = "6.4.1"
ktor = "2.3.7"
ktoml = "0.5.1"
picocli = "4.7.3"
datetime = "0.5.0"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core" }
@ -31,12 +32,15 @@ h2 = { module = "com.h2database:h2", version.ref = "h2" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
ktor-server-tests = { module = "io.ktor:ktor-server-tests" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
ktoml-core = { module = "com.akuleshov7:ktoml-core", version.ref = "ktoml" }
ktoml-file = { module = "com.akuleshov7:ktoml-file", version.ref = "ktoml" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
[plugins]
serilization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

View File

@ -2,6 +2,7 @@ package app.revanced.api.backend
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
/**
@ -89,7 +90,7 @@ abstract class Backend(
class BackendRelease(
val tag: String,
val releaseNote: String,
val createdAt: String,
val createdAt: LocalDateTime,
val assets: Set<BackendAsset>
) {
/**

View File

@ -26,6 +26,8 @@ import kotlinx.coroutines.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import org.koin.dsl.bind
import org.koin.dsl.module
@OptIn(ExperimentalSerializationApi::class)
class GitHubBackend(token: String? = null) : Backend({

View File

@ -1,5 +1,6 @@
package app.revanced.api.backend.github.api
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -40,7 +41,7 @@ class Response {
class GitHubRelease(
val tagName: String,
val assets: Set<GitHubAsset>,
val createdAt: String,
val createdAt: LocalDateTime,
val body: String
) {
@Serializable

View File

@ -1,6 +1,6 @@
package app.revanced.api.command
import app.revanced.api.plugins.*
import app.revanced.api.modules.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import picocli.CommandLine
@ -30,11 +30,10 @@ internal object StartAPICommand : Runnable {
workerGroupSize = 1
callGroupSize = 1
}) {
configureDependencies()
configureHTTP()
configureSerialization()
configureDatabases()
configureSecurity()
configureDependencies()
configureRouting()
}.start(wait = true)
}

View File

@ -0,0 +1,160 @@
package app.revanced.api.modules
import app.revanced.api.modules.AnnouncementService.Attachments.announcement
import app.revanced.api.schema.APIResponseAnnouncement
import app.revanced.api.schema.APIAnnouncement
import app.revanced.api.schema.APILatestAnnouncement
import kotlinx.datetime.*
import org.jetbrains.exposed.sql.transactions.transaction
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.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
class AnnouncementService(private val database: Database) {
private object Announcements : IntIdTable() {
val author = varchar("author", 32).nullable()
val title = varchar("title", 64)
val content = text("content").nullable()
val channel = varchar("channel", 16).nullable()
val createdAt = datetime("createdAt")
val archivedAt = datetime("archivedAt").nullable()
val level = integer("level")
}
private object Attachments : IntIdTable() {
val url = varchar("url", 256)
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
}
class Announcement(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Announcement>(Announcements)
var author by Announcements.author
var title by Announcements.title
var content by Announcements.content
val attachments by Attachment referrersOn announcement
var channel by Announcements.channel
var createdAt by Announcements.createdAt
var archivedAt by Announcements.archivedAt
var level by Announcements.level
fun api() = APIResponseAnnouncement(
id.value,
author,
title,
content,
attachments.map(Attachment::url).toSet(),
channel,
createdAt,
archivedAt,
level
)
}
class Attachment(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Attachment>(Attachments)
var url by Attachments.url
var announcement by Announcement referencedOn Attachments.announcement
}
init {
transaction {
SchemaUtils.create(Announcements, Attachments)
}
}
private fun <T> transaction(block: Transaction.() -> T) = transaction(database, block)
fun read() = transaction {
Announcement.all().map { it.api() }.toSet()
}
fun read(channel: String) = transaction {
Announcement.find { Announcements.channel eq channel }.map { it.api() }.toSet()
}
fun delete(id: Int) = transaction {
val announcement = Announcement.findById(id) ?: return@transaction
announcement.delete()
}
fun latest() = transaction {
Announcement.all().maxByOrNull { it.createdAt }?.api()
}
fun latest(channel: String) = transaction {
Announcement.find { Announcements.channel eq channel }.maxByOrNull { it.createdAt }?.api()
}
fun latestId() = transaction {
Announcement.all().maxByOrNull { it.createdAt }?.id?.value?.let {
APILatestAnnouncement(it)
}
}
fun latestId(channel: String) = transaction {
Announcement.find { Announcements.channel eq channel }.maxByOrNull { it.createdAt }?.id?.value?.let {
APILatestAnnouncement(it)
}
}
fun archive(
id: Int,
archivedAt: LocalDateTime?
) = transaction {
Announcement.findById(id)?.apply {
this.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime()
}
}
fun unarchive(id: Int) = transaction {
Announcement.findById(id)?.apply {
archivedAt = null
}
}
fun new(new: APIAnnouncement) = transaction {
Announcement.new announcement@{
author = new.author
title = new.title
content = new.content
channel = new.channel
createdAt = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
archivedAt = new.archivedAt
level = new.level
}.also { newAnnouncement ->
new.attachmentUrls.map {
Attachment.new {
url = it
announcement = newAnnouncement
}
}
}
}
fun update(id: Int, new: APIAnnouncement) = transaction {
Announcement.findById(id)?.apply {
author = new.author
title = new.title
content = new.content
channel = new.channel
archivedAt = new.archivedAt
level = new.level
attachments.forEach(Attachment::delete)
new.attachmentUrls.map {
Attachment.new {
url = it
announcement = this@apply
}
}
}
}
}

View File

@ -0,0 +1,73 @@
package app.revanced.api.modules
import app.revanced.api.backend.Backend
import app.revanced.api.backend.github.GitHubBackend
import app.revanced.api.schema.APIConfiguration
import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.source.decodeFromStream
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import java.io.File
fun Application.configureDependencies() {
install(Koin) {
modules(
globalModule,
gitHubBackendModule,
databaseModule,
authModule
)
}
}
val globalModule = module {
single {
Dotenv.load()
}
single {
val configFilePath = get<Dotenv>().get("CONFIG_FILE_PATH")!!
Toml.decodeFromStream<APIConfiguration>(File(configFilePath).inputStream())
}
}
val gitHubBackendModule = module {
single {
val token = get<Dotenv>().get("GITHUB_TOKEN")
GitHubBackend(token)
} bind Backend::class
}
val databaseModule = module {
single {
val dotenv = get<Dotenv>()
Database.connect(
url = dotenv.get("DB_URL"),
user = dotenv.get("DB_USER"),
password = dotenv.get("DB_PASSWORD"),
driver = "org.h2.Driver"
)
}
factory<AnnouncementService> {
AnnouncementService(get())
}
}
val authModule = module {
single {
val dotenv = get<Dotenv>()
val jwtSecret = dotenv.get("JWT_SECRET")!!
val issuer = dotenv.get("JWT_ISSUER")!!
val validityInMin = dotenv.get("JWT_VALIDITY_IN_MIN")!!.toInt()
val basicUsername = dotenv.get("BASIC_USERNAME")!!
val basicPassword = dotenv.get("BASIC_PASSWORD")!!
AuthService(issuer, validityInMin, jwtSecret, basicUsername, basicPassword)
}
}

View File

@ -1,4 +1,4 @@
package app.revanced.api.plugins
package app.revanced.api.modules
import io.ktor.http.*
import io.ktor.http.content.*

View File

@ -0,0 +1,158 @@
package app.revanced.api.modules
import app.revanced.api.backend.Backend
import app.revanced.api.schema.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.http.content.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.datetime.toKotlinLocalDateTime
import java.time.LocalDateTime
import org.koin.ktor.ext.get as koinGet
fun Application.configureRouting() {
val backend: Backend = koinGet()
val configuration: APIConfiguration = koinGet()
val announcementService: AnnouncementService = koinGet()
val authService: AuthService = koinGet()
routing {
route("/v${configuration.apiVersion}") {
route("/announcements") {
suspend fun PipelineContext<*, ApplicationCall>.announcement(
block: AnnouncementService.() -> APIResponseAnnouncement?
) = announcementService.block()?.let { call.respond(it) }
?: call.respond(HttpStatusCode.NotFound)
suspend fun PipelineContext<*, ApplicationCall>.announcementId(
block: AnnouncementService.() -> APILatestAnnouncement?
) = announcementService.block()?.let { call.respond(it) }
?: call.respond(HttpStatusCode.NotFound)
suspend fun PipelineContext<*, ApplicationCall>.channel(block: suspend (String) -> Unit) =
block(call.parameters["channel"]!!)
announcementService.new(
APIAnnouncement(
"author",
"title",
"content",
setOf("https://example.com"),
"channel",
LocalDateTime.now().toKotlinLocalDateTime(),
)
)
route("/{channel}/latest") {
get("/id") { channel { announcementId { latestId(it) } } }
get { channel { announcement { latest(it) } } }
}
get("/{channel}") { channel { call.respond(announcementService.read(it)) } }
route("/latest") {
get("/id") { announcementId { latestId() } }
get { announcement { latest() } }
}
get { call.respond(announcementService.read()) }
authenticate("jwt") {
suspend fun PipelineContext<*, ApplicationCall>.id(block: suspend (Int) -> Unit) =
call.parameters["id"]!!.toIntOrNull()?.let { block(it) }
?: call.respond(HttpStatusCode.BadRequest)
post { announcementService.new(call.receive<APIAnnouncement>()) }
post("/{id}/archive") {
id {
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
announcementService.archive(it, archivedAt)
}
}
post("/{id}/unarchive") { id { announcementService.unarchive(it) } }
patch("/{id}") { id { announcementService.update(it, call.receive<APIAnnouncement>()) } }
delete("/{id}") { id { announcementService.delete(it) } }
}
}
route("/patches") {
route("latest") {
get {
val patches = backend.getRelease(configuration.organization, configuration.patchesRepository)
val integrations = configuration.integrationsRepositoryNames.map {
async { backend.getRelease(configuration.organization, it) }
}.awaitAll()
val assets = (patches.assets + integrations.flatMap { it.assets }).filter {
it.downloadUrl.endsWith(".apk") || it.downloadUrl.endsWith(".jar")
}.map { APIAsset(it.downloadUrl) }.toSet()
val release = APIRelease(
patches.tag,
patches.createdAt,
patches.releaseNote,
assets
)
call.respond(release)
}
get("/version") {
val patches = backend.getRelease(configuration.organization, configuration.patchesRepository)
val release = APIReleaseVersion(patches.tag)
call.respond(release)
}
}
}
staticResources("/", "/static/api") {
contentType { ContentType.Application.Json }
extensions("json")
}
get("/contributors") {
val contributors = configuration.contributorsRepositoryNames.map {
async {
APIContributable(
it,
backend.getContributors(configuration.organization, it).map {
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
}.toSet()
)
}
}.awaitAll()
call.respond(contributors)
}
get("/team") {
val team = backend.getMembers(configuration.organization).map {
APIMember(it.name, it.avatarUrl, it.url, it.gpgKeysUrl)
}
call.respond(team)
}
route("/ping") {
handle {
call.respond(HttpStatusCode.NoContent)
}
}
authenticate("basic") { get("/token") { call.respond(authService.newToken()) } }
}
}
}

View File

@ -0,0 +1,53 @@
package app.revanced.api.modules
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import org.koin.ktor.ext.get
import java.util.*
import kotlin.time.Duration.Companion.minutes
class AuthService(
private val issuer: String,
private val validityInMin: Int,
private val jwtSecret: String,
private val basicUsername: String,
private val basicPassword: String,
) {
val configureSecurity: Application.() -> Unit = {
install(Authentication) {
jwt("jwt") {
verifier(
JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer(issuer)
.build()
)
validate { credential -> JWTPrincipal(credential.payload) }
}
basic("basic") {
validate { credentials ->
if (credentials.name == basicUsername && credentials.password == basicPassword) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
}
}
fun newToken(): String {
return JWT.create()
.withIssuer(issuer)
.withExpiresAt(Date(System.currentTimeMillis() + validityInMin.minutes.inWholeMilliseconds))
.sign(Algorithm.HMAC256(jwtSecret))
}
}
fun Application.configureSecurity() {
val configureSecurity = get<AuthService>().configureSecurity
configureSecurity()
}

View File

@ -1,4 +1,4 @@
package app.revanced.api.plugins
package app.revanced.api.modules
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*

View File

@ -1,49 +0,0 @@
package app.revanced.api.plugins
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
fun Application.configureDatabases() {
val database = Database.connect(
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
user = "root",
driver = "org.h2.Driver",
password = ""
)
val userService = UserService(database)
routing {
// Create user
post("/users") {
val user = call.receive<ExposedUser>()
val id = userService.create(user)
call.respond(HttpStatusCode.Created, id)
}
// Read user
get("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = userService.read(id)
if (user != null) {
call.respond(HttpStatusCode.OK, user)
} else {
call.respond(HttpStatusCode.NotFound)
}
}
// Update user
put("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
val user = call.receive<ExposedUser>()
userService.update(id, user)
call.respond(HttpStatusCode.OK)
}
// Delete user
delete("/users/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalArgumentException("Invalid ID")
userService.delete(id)
call.respond(HttpStatusCode.OK)
}
}
}

View File

@ -1,33 +0,0 @@
package app.revanced.api.plugins
import app.revanced.api.schema.APIConfiguration
import app.revanced.api.backend.github.GitHubBackend
import com.akuleshov7.ktoml.Toml
import com.akuleshov7.ktoml.source.decodeFromStream
import io.github.cdimascio.dotenv.Dotenv
import io.ktor.server.application.*
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import java.io.File
fun Application.configureDependencies() {
install(Koin) {
modules(
module {
single {
Dotenv.load()
}
single {
val configFilePath = get<Dotenv>().get("CONFIG_FILE_PATH")!!
Toml.decodeFromStream<APIConfiguration>(File(configFilePath).inputStream())
}
single {
val token = get<Dotenv>().get("GITHUB_TOKEN")
GitHubBackend(token)
}
}
)
}
}

View File

@ -1,88 +0,0 @@
package app.revanced.api.plugins
import app.revanced.api.backend.github.GitHubBackend
import app.revanced.api.schema.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import org.koin.ktor.ext.inject
fun Application.configureRouting() {
val backend by inject<GitHubBackend>()
val configuration by inject<APIConfiguration>()
routing {
route("/v${configuration.apiVersion}") {
route("/patches") {
route("latest") {
get {
val patches = backend.getRelease(configuration.organization, configuration.patchesRepository)
val integrations = configuration.integrationsRepositoryNames.map {
async { backend.getRelease(configuration.organization, it) }
}.awaitAll()
val assets = (patches.assets + integrations.flatMap { it.assets }).filter {
it.downloadUrl.endsWith(".apk") || it.downloadUrl.endsWith(".jar")
}.map { APIAsset(it.downloadUrl) }.toSet()
val release = APIRelease(
patches.tag,
patches.createdAt,
patches.releaseNote,
assets
)
call.respond(release)
}
get("/version") {
val patches = backend.getRelease(configuration.organization, configuration.patchesRepository)
val release = APIReleaseVersion(patches.tag)
call.respond(release)
}
}
}
get("/contributors") {
val contributors = configuration.contributorsRepositoryNames.map {
async {
APIContributable(
it,
backend.getContributors(configuration.organization, it).map {
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
}.toSet()
)
}
}.awaitAll()
call.respond(contributors)
}
get("/members") {
val members = backend.getMembers(configuration.organization).map {
APIMember(it.name, it.avatarUrl, it.url, it.gpgKeysUrl)
}
call.respond(members)
}
route("/ping") {
handle {
call.respond(HttpStatusCode.NoContent)
}
}
staticResources("/", "/static/api") {
contentType { ContentType.Application.Json }
extensions("json")
}
}
}
}

View File

@ -1,30 +0,0 @@
package app.revanced.api.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
fun Application.configureSecurity() {
// Please read the jwt property from the config file if you are using EngineMain
val jwtAudience = "jwt-audience"
val jwtDomain = "https://jwt-provider-domain/"
val jwtRealm = "ktor sample app"
val jwtSecret = "secret"
authentication {
jwt {
realm = jwtRealm
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtDomain)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
}
}
}
}

View File

@ -1,59 +0,0 @@
package app.revanced.api.plugins
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import kotlinx.serialization.Serializable
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
@Serializable
data class ExposedUser(val name: String, val age: Int)
class UserService(private val database: Database) {
object Users : Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", length = 50)
val age = integer("age")
override val primaryKey = PrimaryKey(id)
}
init {
transaction(database) {
SchemaUtils.create(Users)
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun create(user: ExposedUser): Int = dbQuery {
Users.insert {
it[name] = user.name
it[age] = user.age
}[Users.id]
}
suspend fun read(id: Int): ExposedUser? {
return dbQuery {
Users.select { Users.id eq id }
.map { ExposedUser(it[Users.name], it[Users.age]) }
.singleOrNull()
}
}
suspend fun update(id: Int, user: ExposedUser) {
dbQuery {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[age] = user.age
}
}
}
suspend fun delete(id: Int) {
dbQuery {
Users.deleteWhere { Users.id.eq(id) }
}
}
}

View File

@ -1,11 +1,12 @@
package app.revanced.api.schema
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
class APIRelease(
val version: String,
val createdAt: String,
val createdAt: LocalDateTime,
val changelog: String,
val assets: Set<APIAsset>
)
@ -56,18 +57,34 @@ class APIReleaseVersion(
@Serializable
class APIAnnouncement(
val id: Int,
val author: APIUser?,
val author: String? = null,
val title: String,
val content: APIAnnouncementContent,
val channel: String,
val createdAt: String,
val archivedAt: String?,
val level: Int,
val content: String? = null,
val attachmentUrls: Set<String> = emptySet(),
val channel: String? = null,
val archivedAt: LocalDateTime? = null,
val level: Int = 0
)
@Serializable
class APIAnnouncementContent(
val message: String,
val attachmentUrls: Set<String>
class APIResponseAnnouncement(
val id: Int,
val author: String? = null,
val title: String,
val content: String? = null,
val attachmentUrls: Set<String> = emptySet(),
val channel: String? = null,
val createdAt: LocalDateTime,
val archivedAt: LocalDateTime? = null,
val level: Int = 0
)
@Serializable
class APILatestAnnouncement(
val id: Int
)
@Serializable
class APIAnnouncementArchivedAt(
val archivedAt: LocalDateTime
)

View File

@ -1,6 +1,6 @@
package app.revanced
import app.revanced.api.plugins.configureRouting
import app.revanced.api.modules.configureRouting
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*