mirror of
https://github.com/revanced/revanced-api.git
synced 2025-04-29 14:14:29 +02:00
feat: Improve announcements API (#192)
Announcements can have tags now instead of being grouped into a single channel. You can get an announcement using its ID. You can page announcements and filter them by tags and whether they are archived. You can see a list of all available tags. Some route API overhaul.
This commit is contained in:
parent
50b81fd337
commit
56a00ddb85
@ -75,7 +75,7 @@ with updates and ReVanced Patches.
|
||||
|
||||
Some of the features ReVanced API include:
|
||||
|
||||
- 📢 **Announcements**: Post and get announcements grouped by channels
|
||||
- 📢 **Announcements**: Post and get announcements
|
||||
- ℹ️ **About**: Get more information such as a description, ways to donate to,
|
||||
and links of the hoster of ReVanced API
|
||||
- 🧩 **Patches**: Get the latest updates of ReVanced Patches, directly from ReVanced API
|
||||
|
@ -48,6 +48,12 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
tasks {
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
@ -98,6 +104,8 @@ dependencies {
|
||||
implementation(libs.caffeine)
|
||||
implementation(libs.bouncy.castle.provider)
|
||||
implementation(libs.bouncy.castle.pgp)
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
// The maven-publish plugin is necessary to make signing work.
|
||||
|
@ -1,10 +1,10 @@
|
||||
package app.revanced.api.configuration.repository
|
||||
|
||||
import app.revanced.api.configuration.schema.APIAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
|
||||
import app.revanced.api.configuration.schema.ApiAnnouncement
|
||||
import app.revanced.api.configuration.schema.ApiAnnouncementTag
|
||||
import app.revanced.api.configuration.schema.ApiResponseAnnouncement
|
||||
import app.revanced.api.configuration.schema.ApiResponseAnnouncementId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.*
|
||||
import org.jetbrains.exposed.dao.IntEntity
|
||||
@ -15,126 +15,175 @@ import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync
|
||||
import java.time.LocalDateTime
|
||||
|
||||
internal class AnnouncementRepository {
|
||||
// This is better than doing a maxByOrNull { it.id }.
|
||||
// This is better than doing a maxByOrNull { it.id } on every request.
|
||||
private var latestAnnouncement: Announcement? = null
|
||||
private val latestAnnouncementByChannel = mutableMapOf<String, Announcement>()
|
||||
|
||||
private fun updateLatestAnnouncement(new: Announcement) {
|
||||
if (latestAnnouncement?.id?.value == new.id.value) {
|
||||
latestAnnouncement = new
|
||||
latestAnnouncementByChannel[new.channel ?: return] = new
|
||||
}
|
||||
}
|
||||
private val latestAnnouncementByTag = mutableMapOf<Int, Announcement>()
|
||||
|
||||
init {
|
||||
runBlocking {
|
||||
transaction {
|
||||
SchemaUtils.create(Announcements, Attachments)
|
||||
SchemaUtils.create(
|
||||
Announcements,
|
||||
Attachments,
|
||||
Tags,
|
||||
AnnouncementTags,
|
||||
)
|
||||
|
||||
// Initialize the latest announcement.
|
||||
latestAnnouncement = Announcement.all().onEach {
|
||||
latestAnnouncementByChannel[it.channel ?: return@onEach] = it
|
||||
}.maxByOrNull { it.id } ?: return@transaction
|
||||
initializeLatestAnnouncements()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun all() = transaction {
|
||||
Announcement.all().map { it.toApi() }
|
||||
private fun initializeLatestAnnouncements() {
|
||||
latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
|
||||
|
||||
Tag.all().map { it.id.value }.forEach(::updateLatestAnnouncementForTag)
|
||||
}
|
||||
|
||||
suspend fun all(channel: String) = transaction {
|
||||
Announcement.find { Announcements.channel eq channel }.map { it.toApi() }
|
||||
private fun updateLatestAnnouncement(new: Announcement) {
|
||||
if (latestAnnouncement == null || latestAnnouncement!!.id.value <= new.id.value) {
|
||||
latestAnnouncement = new
|
||||
new.tags.forEach { tag -> latestAnnouncementByTag[tag.id.value] = new }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLatestAnnouncementForTag(tag: Int) {
|
||||
val latestAnnouncementForTag = AnnouncementTags.select(AnnouncementTags.announcement)
|
||||
.where { AnnouncementTags.tag eq tag }
|
||||
.map { it[AnnouncementTags.announcement] }
|
||||
.mapNotNull { Announcement.findById(it) }
|
||||
.maxByOrNull { it.id }
|
||||
|
||||
latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
|
||||
}
|
||||
|
||||
suspend fun latest() = transaction {
|
||||
latestAnnouncement.toApiResponseAnnouncement()
|
||||
}
|
||||
|
||||
suspend fun latest(tags: Set<Int>) = transaction {
|
||||
tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement()
|
||||
}
|
||||
|
||||
fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId()
|
||||
|
||||
fun latestId(tags: Set<Int>) =
|
||||
tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()
|
||||
|
||||
suspend fun paged(cursor: Int, count: Int, tags: Set<Int>?, archived: Boolean) = transaction {
|
||||
Announcement.find {
|
||||
fun idLessEq() = Announcements.id lessEq cursor
|
||||
fun archivedAtIsNull() = Announcements.archivedAt.isNull()
|
||||
fun archivedAtGreaterNow() = Announcements.archivedAt greater LocalDateTime.now().toKotlinLocalDateTime()
|
||||
|
||||
if (tags == null) {
|
||||
if (archived) {
|
||||
idLessEq()
|
||||
} else {
|
||||
idLessEq() and (archivedAtIsNull() or archivedAtGreaterNow())
|
||||
}
|
||||
} else {
|
||||
fun archivedAtGreaterOrNullOrTrue() = if (archived) {
|
||||
Op.TRUE
|
||||
} else {
|
||||
archivedAtIsNull() or archivedAtGreaterNow()
|
||||
}
|
||||
|
||||
fun hasTags() = tags.mapNotNull { Tag.findById(it)?.id }.let { tags ->
|
||||
Announcements.id inSubQuery Announcements.leftJoin(AnnouncementTags)
|
||||
.select(AnnouncementTags.announcement)
|
||||
.where { AnnouncementTags.tag inList tags }
|
||||
.withDistinct()
|
||||
}
|
||||
|
||||
idLessEq() and archivedAtGreaterOrNullOrTrue() and hasTags()
|
||||
}
|
||||
}.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
|
||||
}
|
||||
|
||||
suspend fun get(id: Int) = transaction {
|
||||
Announcement.findById(id).toApiResponseAnnouncement()
|
||||
}
|
||||
|
||||
suspend fun new(new: ApiAnnouncement) = transaction {
|
||||
Announcement.new {
|
||||
author = new.author
|
||||
title = new.title
|
||||
content = new.content
|
||||
archivedAt = new.archivedAt
|
||||
level = new.level
|
||||
tags = SizedCollection(
|
||||
new.tags.map { tag -> Tag.find { Tags.name eq tag }.firstOrNull() ?: Tag.new { name = tag } },
|
||||
)
|
||||
}.apply {
|
||||
new.attachments.map { attachmentUrl ->
|
||||
Attachment.new {
|
||||
url = attachmentUrl
|
||||
announcement = this@apply
|
||||
}
|
||||
}
|
||||
}.let(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun update(id: Int, new: ApiAnnouncement) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.author = new.author
|
||||
it.title = new.title
|
||||
it.content = new.content
|
||||
it.archivedAt = new.archivedAt
|
||||
it.level = new.level
|
||||
|
||||
// Get the old tags, create new tags if they don't exist,
|
||||
// and delete tags that are not in the new tags, after updating the announcement.
|
||||
val oldTags = it.tags.toList()
|
||||
val updatedTags = new.tags.map { name ->
|
||||
Tag.find { Tags.name eq name }.firstOrNull() ?: Tag.new { this.name = name }
|
||||
}
|
||||
it.tags = SizedCollection(updatedTags)
|
||||
oldTags.forEach { tag ->
|
||||
if (tag in updatedTags || !tag.announcements.empty()) return@forEach
|
||||
tag.delete()
|
||||
}
|
||||
|
||||
// Delete old attachments and create new attachments.
|
||||
it.attachments.forEach { attachment -> attachment.delete() }
|
||||
new.attachments.map { attachment ->
|
||||
Attachment.new {
|
||||
url = attachment
|
||||
announcement = it
|
||||
}
|
||||
}
|
||||
}?.let(::updateLatestAnnouncement) ?: Unit
|
||||
}
|
||||
|
||||
suspend fun delete(id: Int) = transaction {
|
||||
val announcement = Announcement.findById(id) ?: return@transaction
|
||||
|
||||
// Delete the tag if no other announcements are referencing it.
|
||||
// One count means that the announcement is the only one referencing the tag.
|
||||
announcement.tags.filter { tag -> tag.announcements.count() == 1L }.forEach { tag ->
|
||||
latestAnnouncementByTag -= tag.id.value
|
||||
tag.delete()
|
||||
}
|
||||
|
||||
announcement.delete()
|
||||
|
||||
// In case the latest announcement was deleted, query the new latest announcement again.
|
||||
// If the deleted announcement is the latest announcement, set the new latest announcement.
|
||||
if (latestAnnouncement?.id?.value == id) {
|
||||
latestAnnouncement = Announcement.all().maxByOrNull { it.id }
|
||||
|
||||
// If no latest announcement was found, remove it from the channel map.
|
||||
if (latestAnnouncement == null) {
|
||||
latestAnnouncementByChannel.remove(announcement.channel)
|
||||
} else {
|
||||
latestAnnouncementByChannel[latestAnnouncement!!.channel ?: return@transaction] = latestAnnouncement!!
|
||||
latestAnnouncement = Announcement.all().orderBy(Announcements.id to SortOrder.DESC).firstOrNull()
|
||||
}
|
||||
|
||||
// The new announcement may be the latest for a specific tag. Set the new latest announcement for that tag.
|
||||
latestAnnouncementByTag.keys.forEach { tag ->
|
||||
updateLatestAnnouncementForTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
fun latest() = latestAnnouncement?.toApi()
|
||||
|
||||
fun latest(channel: String) = latestAnnouncementByChannel[channel]?.toApi()
|
||||
|
||||
fun latestId() = latest()?.id?.toApi()
|
||||
|
||||
fun latestId(channel: String) = latest(channel)?.id?.toApi()
|
||||
|
||||
suspend fun archive(
|
||||
id: Int,
|
||||
archivedAt: LocalDateTime?,
|
||||
) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime()
|
||||
}?.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun unarchive(id: Int) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.archivedAt = null
|
||||
}?.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun new(new: APIAnnouncement) = transaction {
|
||||
Announcement.new {
|
||||
author = new.author
|
||||
title = new.title
|
||||
content = new.content
|
||||
channel = new.channel
|
||||
archivedAt = new.archivedAt
|
||||
level = new.level
|
||||
}.also { newAnnouncement ->
|
||||
new.attachmentUrls.map { newUrl ->
|
||||
suspendedTransactionAsync {
|
||||
Attachment.new {
|
||||
url = newUrl
|
||||
announcement = newAnnouncement
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}.also(::updateLatestAnnouncement)
|
||||
}
|
||||
|
||||
suspend fun update(id: Int, new: APIAnnouncement) = transaction {
|
||||
Announcement.findByIdAndUpdate(id) {
|
||||
it.author = new.author
|
||||
it.title = new.title
|
||||
it.content = new.content
|
||||
it.channel = new.channel
|
||||
it.archivedAt = new.archivedAt
|
||||
it.level = new.level
|
||||
}?.also { newAnnouncement ->
|
||||
newAnnouncement.attachments.map {
|
||||
suspendedTransactionAsync {
|
||||
it.delete()
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
new.attachmentUrls.map { newUrl ->
|
||||
suspendedTransactionAsync {
|
||||
Attachment.new {
|
||||
url = newUrl
|
||||
announcement = newAnnouncement
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}?.also(::updateLatestAnnouncement)
|
||||
suspend fun tags() = transaction {
|
||||
Tag.all().toList().toApiTag()
|
||||
}
|
||||
|
||||
private suspend fun <T> transaction(statement: suspend Transaction.() -> T) =
|
||||
@ -144,7 +193,6 @@ internal class AnnouncementRepository {
|
||||
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").defaultExpression(CurrentDateTime)
|
||||
val archivedAt = datetime("archivedAt").nullable()
|
||||
val level = integer("level")
|
||||
@ -155,6 +203,19 @@ internal class AnnouncementRepository {
|
||||
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
|
||||
}
|
||||
|
||||
private object Tags : IntIdTable() {
|
||||
val name = varchar("name", 16).uniqueIndex()
|
||||
}
|
||||
|
||||
private object AnnouncementTags : Table() {
|
||||
val tag = reference("tag", Tags, onDelete = ReferenceOption.CASCADE)
|
||||
val announcement = reference("announcement", Announcements, onDelete = ReferenceOption.CASCADE)
|
||||
|
||||
init {
|
||||
uniqueIndex(tag, announcement)
|
||||
}
|
||||
}
|
||||
|
||||
class Announcement(id: EntityID<Int>) : IntEntity(id) {
|
||||
companion object : IntEntityClass<Announcement>(Announcements)
|
||||
|
||||
@ -162,7 +223,7 @@ internal class AnnouncementRepository {
|
||||
var title by Announcements.title
|
||||
var content by Announcements.content
|
||||
val attachments by Attachment referrersOn Attachments.announcement
|
||||
var channel by Announcements.channel
|
||||
var tags by Tag via AnnouncementTags
|
||||
var createdAt by Announcements.createdAt
|
||||
var archivedAt by Announcements.archivedAt
|
||||
var level by Announcements.level
|
||||
@ -175,17 +236,32 @@ internal class AnnouncementRepository {
|
||||
var announcement by Announcement referencedOn Attachments.announcement
|
||||
}
|
||||
|
||||
private fun Announcement.toApi() = APIResponseAnnouncement(
|
||||
class Tag(id: EntityID<Int>) : IntEntity(id) {
|
||||
companion object : IntEntityClass<Tag>(Tags)
|
||||
|
||||
var name by Tags.name
|
||||
var announcements by Announcement via AnnouncementTags
|
||||
}
|
||||
|
||||
private fun Announcement?.toApiResponseAnnouncement() = this?.let {
|
||||
ApiResponseAnnouncement(
|
||||
id.value,
|
||||
author,
|
||||
title,
|
||||
content,
|
||||
attachments.map { it.url },
|
||||
channel,
|
||||
tags.map { it.id.value },
|
||||
createdAt,
|
||||
archivedAt,
|
||||
level,
|
||||
)
|
||||
|
||||
private fun Int.toApi() = APIResponseAnnouncementId(this)
|
||||
}
|
||||
|
||||
private fun Iterable<Announcement>.toApiAnnouncement() = map { it.toApiResponseAnnouncement()!! }
|
||||
|
||||
private fun Iterable<Tag>.toApiTag() = map { ApiAnnouncementTag(it.id.value, it.name) }
|
||||
|
||||
private fun Int?.toApiResponseAnnouncementId() = this?.let { ApiResponseAnnouncementId(this) }
|
||||
|
||||
private fun Iterable<Int?>.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() }
|
||||
}
|
||||
|
@ -4,10 +4,9 @@ import app.revanced.api.configuration.canRespondUnauthorized
|
||||
import app.revanced.api.configuration.installCache
|
||||
import app.revanced.api.configuration.installNotarizedRoute
|
||||
import app.revanced.api.configuration.respondOrNotFound
|
||||
import app.revanced.api.configuration.schema.APIAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIAnnouncementArchivedAt
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
|
||||
import app.revanced.api.configuration.schema.ApiAnnouncement
|
||||
import app.revanced.api.configuration.schema.ApiResponseAnnouncement
|
||||
import app.revanced.api.configuration.schema.ApiResponseAnnouncementId
|
||||
import app.revanced.api.configuration.services.AnnouncementService
|
||||
import io.bkbn.kompendium.core.metadata.*
|
||||
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
|
||||
@ -16,7 +15,6 @@ import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
@ -32,76 +30,63 @@ internal fun Route.announcementsRoute() = route("announcements") {
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
get {
|
||||
call.respond(announcementService.all())
|
||||
val cursor = call.parameters["cursor"]?.toInt() ?: Int.MAX_VALUE
|
||||
val count = call.parameters["count"]?.toInt() ?: 16
|
||||
val tags = call.parameters.getAll("tag")
|
||||
val archived = call.parameters["archived"]?.toBoolean() ?: true
|
||||
|
||||
call.respond(announcementService.paged(cursor, count, tags?.map { it.toInt() }?.toSet(), archived))
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("{channel}/latest") {
|
||||
installLatestChannelAnnouncementRouteDocumentation()
|
||||
rateLimit(RateLimitName("weak")) {
|
||||
authenticate("jwt") {
|
||||
post<ApiAnnouncement> { announcement ->
|
||||
announcementService.new(announcement)
|
||||
|
||||
get {
|
||||
val channel: String by call.parameters
|
||||
|
||||
call.respondOrNotFound(announcementService.latest(channel))
|
||||
}
|
||||
|
||||
route("id") {
|
||||
installLatestChannelAnnouncementIdRouteDocumentation()
|
||||
|
||||
get {
|
||||
val channel: String by call.parameters
|
||||
|
||||
call.respondOrNotFound(announcementService.latestId(channel))
|
||||
}
|
||||
}
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("{channel}") {
|
||||
installChannelAnnouncementsRouteDocumentation()
|
||||
|
||||
get {
|
||||
val channel: String by call.parameters
|
||||
|
||||
call.respond(announcementService.all(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
route("latest") {
|
||||
installLatestAnnouncementRouteDocumentation()
|
||||
installAnnouncementsLatestRouteDocumentation()
|
||||
|
||||
get {
|
||||
val tags = call.parameters.getAll("tag")
|
||||
|
||||
if (tags?.isNotEmpty() == true) {
|
||||
call.respond(announcementService.latest(tags.map { it.toInt() }.toSet()))
|
||||
} else {
|
||||
call.respondOrNotFound(announcementService.latest())
|
||||
}
|
||||
}
|
||||
|
||||
route("id") {
|
||||
installLatestAnnouncementIdRouteDocumentation()
|
||||
installAnnouncementsLatestIdRouteDocumentation()
|
||||
|
||||
get {
|
||||
val tags = call.parameters.getAll("tag")
|
||||
|
||||
if (tags?.isNotEmpty() == true) {
|
||||
call.respond(announcementService.latestId(tags.map { it.toInt() }.toSet()))
|
||||
} else {
|
||||
call.respondOrNotFound(announcementService.latestId())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rateLimit(RateLimitName("strong")) {
|
||||
authenticate("jwt") {
|
||||
installAnnouncementRouteDocumentation()
|
||||
route("{id}") {
|
||||
installAnnouncementsIdRouteDocumentation()
|
||||
|
||||
post<APIAnnouncement> { announcement ->
|
||||
announcementService.new(announcement)
|
||||
get {
|
||||
val id: Int by call.parameters
|
||||
|
||||
call.respond(HttpStatusCode.OK)
|
||||
call.respondOrNotFound(announcementService.get(id))
|
||||
}
|
||||
|
||||
route("{id}") {
|
||||
installAnnouncementIdRouteDocumentation()
|
||||
|
||||
patch<APIAnnouncement> { announcement ->
|
||||
authenticate("jwt") {
|
||||
patch<ApiAnnouncement> { announcement ->
|
||||
val id: Int by call.parameters
|
||||
|
||||
announcementService.update(id, announcement)
|
||||
@ -116,31 +101,14 @@ internal fun Route.announcementsRoute() = route("announcements") {
|
||||
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
|
||||
route("archive") {
|
||||
installAnnouncementArchiveRouteDocumentation()
|
||||
|
||||
post {
|
||||
val id: Int by call.parameters
|
||||
val archivedAt = call.receiveNullable<APIAnnouncementArchivedAt>()?.archivedAt
|
||||
|
||||
announcementService.archive(id, archivedAt)
|
||||
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
route("unarchive") {
|
||||
installAnnouncementUnarchiveRouteDocumentation()
|
||||
route("tags") {
|
||||
installAnnouncementsTagsRouteDocumentation()
|
||||
|
||||
post {
|
||||
val id: Int by call.parameters
|
||||
|
||||
announcementService.unarchive(id)
|
||||
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
get {
|
||||
call.respond(announcementService.tags())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -154,16 +122,56 @@ private val authHeaderParameter = Parameter(
|
||||
examples = mapOf("Bearer authentication" to Parameter.Example("Bearer abc123")),
|
||||
)
|
||||
|
||||
private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRoute {
|
||||
private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(authHeaderParameter)
|
||||
get = GetInfo.builder {
|
||||
description("Get a page of announcements")
|
||||
summary("Get announcements")
|
||||
parameters(
|
||||
Parameter(
|
||||
name = "cursor",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The offset of the announcements. Default is Int.MAX_VALUE (Newest first)",
|
||||
required = false,
|
||||
),
|
||||
Parameter(
|
||||
name = "count",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The count of the announcements. Default is 16",
|
||||
required = false,
|
||||
),
|
||||
Parameter(
|
||||
name = "tag",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The tag IDs to filter the announcements by. Default is all tags",
|
||||
required = false,
|
||||
),
|
||||
Parameter(
|
||||
name = "archived",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.BOOLEAN,
|
||||
description = "Whether to include archived announcements. Default is true",
|
||||
required = false,
|
||||
),
|
||||
)
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The announcements")
|
||||
responseType<Set<ApiResponseAnnouncement>>()
|
||||
}
|
||||
}
|
||||
|
||||
post = PostInfo.builder {
|
||||
description("Create a new announcement")
|
||||
summary("Create announcement")
|
||||
parameters(authHeaderParameter)
|
||||
request {
|
||||
requestType<APIAnnouncement>()
|
||||
requestType<ApiAnnouncement>()
|
||||
description("The new announcement")
|
||||
}
|
||||
response {
|
||||
@ -175,17 +183,32 @@ private fun Route.installAnnouncementRouteDocumentation() = installNotarizedRout
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestAnnouncementRouteDocumentation() = installNotarizedRoute {
|
||||
private fun Route.installAnnouncementsLatestRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the latest announcement")
|
||||
summary("Get latest announcement")
|
||||
parameters(
|
||||
Parameter(
|
||||
name = "tag",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The tag IDs to filter the latest announcements by",
|
||||
required = false,
|
||||
),
|
||||
)
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The latest announcement")
|
||||
responseType<APIResponseAnnouncement>()
|
||||
responseType<ApiResponseAnnouncement>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The latest announcements")
|
||||
responseType<Set<ApiResponseAnnouncement>>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
@ -195,85 +218,42 @@ private fun Route.installLatestAnnouncementRouteDocumentation() = installNotariz
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestAnnouncementIdRouteDocumentation() = installNotarizedRoute {
|
||||
private fun Route.installAnnouncementsLatestIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the id of the latest announcement")
|
||||
summary("Get id of latest announcement")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The id of the latest announcement")
|
||||
responseType<APIResponseAnnouncementId>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("No announcement exists")
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installChannelAnnouncementsRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
description("Get the ID of the latest announcement")
|
||||
summary("Get ID of latest announcement")
|
||||
parameters(
|
||||
Parameter(
|
||||
name = "channel",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The channel to get the announcements from",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the announcements from a channel")
|
||||
summary("Get announcements from channel")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The announcements in the channel")
|
||||
responseType<Set<APIResponseAnnouncement>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementArchiveRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "id",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The id of the announcement to archive",
|
||||
required = true,
|
||||
),
|
||||
Parameter(
|
||||
name = "archivedAt",
|
||||
name = "tag",
|
||||
`in` = Parameter.Location.query,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The date and time the announcement to be archived",
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The tag IDs to filter the latest announcements by",
|
||||
required = false,
|
||||
),
|
||||
authHeaderParameter,
|
||||
)
|
||||
|
||||
post = PostInfo.builder {
|
||||
description("Archive an announcement")
|
||||
summary("Archive announcement")
|
||||
response {
|
||||
description("The announcement is archived")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The ID of the latest announcement")
|
||||
responseType<ApiResponseAnnouncementId>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The IDs of the latest announcements")
|
||||
responseType<Set<ApiResponseAnnouncement>>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("No announcement exists")
|
||||
responseType<Unit>()
|
||||
}
|
||||
canRespondUnauthorized()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementUnarchiveRouteDocumentation() = installNotarizedRoute {
|
||||
private fun Route.installAnnouncementsIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
@ -281,43 +261,32 @@ private fun Route.installAnnouncementUnarchiveRouteDocumentation() = installNota
|
||||
name = "id",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The id of the announcement to unarchive",
|
||||
description = "The ID of the announcement to update",
|
||||
required = true,
|
||||
),
|
||||
authHeaderParameter,
|
||||
)
|
||||
|
||||
post = PostInfo.builder {
|
||||
description("Unarchive an announcement")
|
||||
summary("Unarchive announcement")
|
||||
get = GetInfo.builder {
|
||||
description("Get an announcement")
|
||||
summary("Get announcement")
|
||||
response {
|
||||
description("The announcement is unarchived")
|
||||
description("The announcement")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<ApiResponseAnnouncement>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("The announcement does not exist")
|
||||
responseType<Unit>()
|
||||
}
|
||||
canRespondUnauthorized()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "id",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.INT,
|
||||
description = "The id of the announcement to update",
|
||||
required = true,
|
||||
),
|
||||
authHeaderParameter,
|
||||
)
|
||||
|
||||
patch = PatchInfo.builder {
|
||||
description("Update an announcement")
|
||||
summary("Update announcement")
|
||||
request {
|
||||
requestType<APIAnnouncement>()
|
||||
requestType<ApiAnnouncement>()
|
||||
description("The new announcement")
|
||||
}
|
||||
response {
|
||||
@ -340,77 +309,17 @@ private fun Route.installAnnouncementIdRouteDocumentation() = installNotarizedRo
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installAnnouncementsRouteDocumentation() = installNotarizedRoute {
|
||||
private fun Route.installAnnouncementsTagsRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the announcements")
|
||||
summary("Get announcements")
|
||||
description("Get all announcement tags")
|
||||
summary("Get announcement tags")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The announcements")
|
||||
responseType<Set<APIResponseAnnouncement>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestChannelAnnouncementRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "channel",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The channel to get the latest announcement from",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the latest announcement from a channel")
|
||||
summary("Get latest channel announcement")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The latest announcement in the channel")
|
||||
responseType<APIResponseAnnouncement>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("The channel does not exist")
|
||||
responseType<Unit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.installLatestChannelAnnouncementIdRouteDocumentation() = installNotarizedRoute {
|
||||
tags = setOf("Announcements")
|
||||
|
||||
parameters = listOf(
|
||||
Parameter(
|
||||
name = "channel",
|
||||
`in` = Parameter.Location.path,
|
||||
schema = TypeDefinition.STRING,
|
||||
description = "The channel to get the latest announcement id from",
|
||||
required = true,
|
||||
),
|
||||
)
|
||||
|
||||
get = GetInfo.builder {
|
||||
description("Get the id of the latest announcement from a channel")
|
||||
summary("Get id of latest announcement from channel")
|
||||
response {
|
||||
responseCode(HttpStatusCode.OK)
|
||||
mediaTypes("application/json")
|
||||
description("The id of the latest announcement from the channel")
|
||||
responseType<APIResponseAnnouncementId>()
|
||||
}
|
||||
canRespond {
|
||||
responseCode(HttpStatusCode.NotFound)
|
||||
description("The channel does not exist")
|
||||
responseType<Unit>()
|
||||
description("The announcement tags")
|
||||
responseType<Set<String>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ private fun Route.installRateLimitRouteDocumentation() = installNotarizedRoute {
|
||||
description("The rate limit of the backend")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIRateLimit>()
|
||||
responseType<ApiRateLimit>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -144,7 +144,7 @@ private fun Route.installTeamRouteDocumentation() = installNotarizedRoute {
|
||||
description("The list of team members")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<Set<APIMember>>()
|
||||
responseType<Set<ApiMember>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,7 +195,7 @@ private fun Route.installTokenRouteDocumentation() = installNotarizedRoute {
|
||||
description("The authorization token")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIToken>()
|
||||
responseType<ApiToken>()
|
||||
}
|
||||
canRespondUnauthorized()
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
package app.revanced.api.configuration.routes
|
||||
|
||||
import app.revanced.api.configuration.installNotarizedRoute
|
||||
import app.revanced.api.configuration.schema.APIManagerAsset
|
||||
import app.revanced.api.configuration.schema.APIRelease
|
||||
import app.revanced.api.configuration.schema.APIReleaseVersion
|
||||
import app.revanced.api.configuration.schema.ApiManagerAsset
|
||||
import app.revanced.api.configuration.schema.ApiRelease
|
||||
import app.revanced.api.configuration.schema.ApiReleaseVersion
|
||||
import app.revanced.api.configuration.services.ManagerService
|
||||
import io.bkbn.kompendium.core.metadata.GetInfo
|
||||
import io.ktor.http.*
|
||||
@ -53,7 +53,7 @@ private fun Route.installManagerRouteDocumentation(deprecated: Boolean) = instal
|
||||
description("The latest manager release")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIRelease<APIManagerAsset>>()
|
||||
responseType<ApiRelease<ApiManagerAsset>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -69,7 +69,7 @@ private fun Route.installManagerVersionRouteDocumentation(deprecated: Boolean) =
|
||||
description("The current manager release version")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIReleaseVersion>()
|
||||
responseType<ApiReleaseVersion>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,10 +2,10 @@ package app.revanced.api.configuration.routes
|
||||
|
||||
import app.revanced.api.configuration.installCache
|
||||
import app.revanced.api.configuration.installNotarizedRoute
|
||||
import app.revanced.api.configuration.schema.APIAssetPublicKeys
|
||||
import app.revanced.api.configuration.schema.APIPatchesAsset
|
||||
import app.revanced.api.configuration.schema.APIRelease
|
||||
import app.revanced.api.configuration.schema.APIReleaseVersion
|
||||
import app.revanced.api.configuration.schema.ApiAssetPublicKeys
|
||||
import app.revanced.api.configuration.schema.ApiPatchesAsset
|
||||
import app.revanced.api.configuration.schema.ApiRelease
|
||||
import app.revanced.api.configuration.schema.ApiReleaseVersion
|
||||
import app.revanced.api.configuration.services.PatchesService
|
||||
import io.bkbn.kompendium.core.metadata.GetInfo
|
||||
import io.ktor.http.*
|
||||
@ -78,7 +78,7 @@ private fun Route.installPatchesRouteDocumentation(deprecated: Boolean) = instal
|
||||
description("The current patches release")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIRelease<APIPatchesAsset>>()
|
||||
responseType<ApiRelease<ApiPatchesAsset>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,7 +94,7 @@ private fun Route.installPatchesVersionRouteDocumentation(deprecated: Boolean) =
|
||||
description("The current patches release version")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIReleaseVersion>()
|
||||
responseType<ApiReleaseVersion>()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -126,7 +126,7 @@ private fun Route.installPatchesPublicKeyRouteDocumentation(deprecated: Boolean)
|
||||
description("The public keys")
|
||||
mediaTypes("application/json")
|
||||
responseCode(HttpStatusCode.OK)
|
||||
responseType<APIAssetPublicKeys>()
|
||||
responseType<ApiAssetPublicKeys>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,44 +3,44 @@ package app.revanced.api.configuration.schema
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface APIUser {
|
||||
interface ApiUser {
|
||||
val name: String
|
||||
val avatarUrl: String
|
||||
val url: String
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class APIMember(
|
||||
class ApiMember(
|
||||
override val name: String,
|
||||
override val avatarUrl: String,
|
||||
override val url: String,
|
||||
val bio: String?,
|
||||
val gpgKey: APIGpgKey?,
|
||||
) : APIUser
|
||||
val gpgKey: ApiGpgKey?,
|
||||
) : ApiUser
|
||||
|
||||
@Serializable
|
||||
class APIGpgKey(
|
||||
class ApiGpgKey(
|
||||
val id: String,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIContributor(
|
||||
class ApiContributor(
|
||||
override val name: String,
|
||||
override val avatarUrl: String,
|
||||
override val url: String,
|
||||
val contributions: Int,
|
||||
) : APIUser
|
||||
) : ApiUser
|
||||
|
||||
@Serializable
|
||||
class APIContributable(
|
||||
val name: String,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val contributors: List<APIContributor>,
|
||||
val contributors: List<ApiContributor>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIRelease<T>(
|
||||
class ApiRelease<T>(
|
||||
val version: String,
|
||||
val createdAt: LocalDateTime,
|
||||
val description: String,
|
||||
@ -49,74 +49,82 @@ class APIRelease<T>(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIManagerAsset(
|
||||
class ApiManagerAsset(
|
||||
val downloadUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIPatchesAsset(
|
||||
class ApiPatchesAsset(
|
||||
val downloadUrl: String,
|
||||
val signatureDownloadUrl: String,
|
||||
// TODO: Remove this eventually when integrations are merged into patches.
|
||||
val name: APIAssetName,
|
||||
val name: ApiAssetName,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class APIAssetName {
|
||||
enum class ApiAssetName {
|
||||
PATCHES,
|
||||
INTEGRATION,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class APIReleaseVersion(
|
||||
class ApiReleaseVersion(
|
||||
val version: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAnnouncement(
|
||||
class ApiAnnouncement(
|
||||
val author: String? = null,
|
||||
val title: String,
|
||||
val content: String? = null,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val attachmentUrls: List<String> = emptyList(),
|
||||
val channel: String? = null,
|
||||
val attachments: List<String> = emptyList(),
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val tags: List<String> = emptyList(),
|
||||
val archivedAt: LocalDateTime? = null,
|
||||
val level: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIResponseAnnouncement(
|
||||
class ApiResponseAnnouncement(
|
||||
val id: Int,
|
||||
val author: String? = null,
|
||||
val title: String,
|
||||
val content: String? = null,
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val attachmentUrls: List<String> = emptyList(),
|
||||
val channel: String? = null,
|
||||
val attachments: List<String> = emptyList(),
|
||||
// Using a list instead of a set because set semantics are unnecessary here.
|
||||
val tags: List<Int> = emptyList(),
|
||||
val createdAt: LocalDateTime,
|
||||
val archivedAt: LocalDateTime? = null,
|
||||
val level: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIResponseAnnouncementId(
|
||||
class ApiResponseAnnouncementId(
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAnnouncementArchivedAt(
|
||||
class ApiAnnouncementArchivedAt(
|
||||
val archivedAt: LocalDateTime,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIRateLimit(
|
||||
class ApiAnnouncementTag(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiRateLimit(
|
||||
val limit: Int,
|
||||
val remaining: Int,
|
||||
val reset: LocalDateTime,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class APIAssetPublicKeys(
|
||||
class ApiAssetPublicKeys(
|
||||
val patchesPublicKey: String,
|
||||
val integrationsPublicKey: String,
|
||||
)
|
||||
@ -174,4 +182,4 @@ class APIAbout(
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class APIToken(val token: String)
|
||||
class ApiToken(val token: String)
|
||||
|
@ -1,35 +1,29 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import app.revanced.api.configuration.repository.AnnouncementRepository
|
||||
import app.revanced.api.configuration.schema.APIAnnouncement
|
||||
import app.revanced.api.configuration.schema.APIResponseAnnouncementId
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import app.revanced.api.configuration.schema.ApiAnnouncement
|
||||
|
||||
internal class AnnouncementService(
|
||||
private val announcementRepository: AnnouncementRepository,
|
||||
) {
|
||||
fun latestId(channel: String): APIResponseAnnouncementId? = announcementRepository.latestId(channel)
|
||||
fun latestId(): APIResponseAnnouncementId? = announcementRepository.latestId()
|
||||
suspend fun latest(tags: Set<Int>) = announcementRepository.latest(tags)
|
||||
|
||||
fun latest(channel: String) = announcementRepository.latest(channel)
|
||||
fun latest() = announcementRepository.latest()
|
||||
suspend fun latest() = announcementRepository.latest()
|
||||
|
||||
suspend fun all(channel: String) = announcementRepository.all(channel)
|
||||
suspend fun all() = announcementRepository.all()
|
||||
fun latestId(tags: Set<Int>) = announcementRepository.latestId(tags)
|
||||
|
||||
suspend fun new(new: APIAnnouncement) {
|
||||
announcementRepository.new(new)
|
||||
}
|
||||
suspend fun archive(id: Int, archivedAt: LocalDateTime?) {
|
||||
announcementRepository.archive(id, archivedAt)
|
||||
}
|
||||
suspend fun unarchive(id: Int) {
|
||||
announcementRepository.unarchive(id)
|
||||
}
|
||||
suspend fun update(id: Int, new: APIAnnouncement) {
|
||||
announcementRepository.update(id, new)
|
||||
}
|
||||
suspend fun delete(id: Int) {
|
||||
announcementRepository.delete(id)
|
||||
}
|
||||
fun latestId() = announcementRepository.latestId()
|
||||
|
||||
suspend fun paged(cursor: Int, limit: Int, tags: Set<Int>?, archived: Boolean) =
|
||||
announcementRepository.paged(cursor, limit, tags, archived)
|
||||
|
||||
suspend fun get(id: Int) = announcementRepository.get(id)
|
||||
|
||||
suspend fun update(id: Int, new: ApiAnnouncement) = announcementRepository.update(id, new)
|
||||
|
||||
suspend fun delete(id: Int) = announcementRepository.delete(id)
|
||||
|
||||
suspend fun new(new: ApiAnnouncement) = announcementRepository.new(new)
|
||||
|
||||
suspend fun tags() = announcementRepository.tags()
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ internal class ApiService(
|
||||
APIContributable(
|
||||
it,
|
||||
backendRepository.contributors(configurationRepository.organization, it).map {
|
||||
APIContributor(it.name, it.avatarUrl, it.url, it.contributions)
|
||||
ApiContributor(it.name, it.avatarUrl, it.url, it.contributions)
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -29,13 +29,13 @@ internal class ApiService(
|
||||
}.awaitAll()
|
||||
|
||||
suspend fun team() = backendRepository.members(configurationRepository.organization).map { member ->
|
||||
APIMember(
|
||||
ApiMember(
|
||||
member.name,
|
||||
member.avatarUrl,
|
||||
member.url,
|
||||
member.bio,
|
||||
if (member.gpgKeys.ids.isNotEmpty()) {
|
||||
APIGpgKey(
|
||||
ApiGpgKey(
|
||||
// Must choose one of the GPG keys, because it does not make sense to have multiple GPG keys for the API.
|
||||
member.gpgKeys.ids.first(),
|
||||
member.gpgKeys.url,
|
||||
@ -47,6 +47,6 @@ internal class ApiService(
|
||||
}
|
||||
|
||||
suspend fun rateLimit() = backendRepository.rateLimit()?.let {
|
||||
APIRateLimit(it.limit, it.remaining, it.reset)
|
||||
ApiRateLimit(it.limit, it.remaining, it.reset)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import app.revanced.api.configuration.schema.APIToken
|
||||
import app.revanced.api.configuration.schema.ApiToken
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import io.ktor.server.auth.*
|
||||
@ -43,7 +43,7 @@ internal class AuthenticationService private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun newToken() = APIToken(
|
||||
fun newToken() = ApiToken(
|
||||
JWT.create()
|
||||
.withIssuer(issuer)
|
||||
.withExpiresAt(Instant.now().plus(validityInMin, ChronoUnit.MINUTES))
|
||||
|
@ -9,17 +9,17 @@ internal class ManagerService(
|
||||
private val backendRepository: BackendRepository,
|
||||
private val configurationRepository: ConfigurationRepository,
|
||||
) {
|
||||
suspend fun latestRelease(): APIRelease<APIManagerAsset> {
|
||||
suspend fun latestRelease(): ApiRelease<ApiManagerAsset> {
|
||||
val managerRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.manager.repository,
|
||||
)
|
||||
|
||||
val managerAsset = APIManagerAsset(
|
||||
val managerAsset = ApiManagerAsset(
|
||||
managerRelease.assets.first(configurationRepository.manager.assetRegex).downloadUrl,
|
||||
)
|
||||
|
||||
return APIRelease(
|
||||
return ApiRelease(
|
||||
managerRelease.tag,
|
||||
managerRelease.createdAt,
|
||||
managerRelease.releaseNote,
|
||||
@ -27,12 +27,12 @@ internal class ManagerService(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun latestVersion(): APIReleaseVersion {
|
||||
suspend fun latestVersion(): ApiReleaseVersion {
|
||||
val managerRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.manager.repository,
|
||||
)
|
||||
|
||||
return APIReleaseVersion(managerRelease.tag)
|
||||
return ApiReleaseVersion(managerRelease.tag)
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ internal class PatchesService(
|
||||
private val backendRepository: BackendRepository,
|
||||
private val configurationRepository: ConfigurationRepository,
|
||||
) {
|
||||
suspend fun latestRelease(): APIRelease<APIPatchesAsset> {
|
||||
suspend fun latestRelease(): ApiRelease<ApiPatchesAsset> {
|
||||
val patchesRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.patches.repository,
|
||||
@ -30,8 +30,8 @@ internal class PatchesService(
|
||||
|
||||
fun ConfigurationRepository.SignedAssetConfiguration.asset(
|
||||
release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease,
|
||||
assetName: APIAssetName,
|
||||
) = APIPatchesAsset(
|
||||
assetName: ApiAssetName,
|
||||
) = ApiPatchesAsset(
|
||||
release.assets.first(assetRegex).downloadUrl,
|
||||
release.assets.first(signatureAssetRegex).downloadUrl,
|
||||
assetName,
|
||||
@ -39,14 +39,14 @@ internal class PatchesService(
|
||||
|
||||
val patchesAsset = configurationRepository.patches.asset(
|
||||
patchesRelease,
|
||||
APIAssetName.PATCHES,
|
||||
ApiAssetName.PATCHES,
|
||||
)
|
||||
val integrationsAsset = configurationRepository.integrations.asset(
|
||||
integrationsRelease,
|
||||
APIAssetName.INTEGRATION,
|
||||
ApiAssetName.INTEGRATION,
|
||||
)
|
||||
|
||||
return APIRelease(
|
||||
return ApiRelease(
|
||||
patchesRelease.tag,
|
||||
patchesRelease.createdAt,
|
||||
patchesRelease.releaseNote,
|
||||
@ -54,13 +54,13 @@ internal class PatchesService(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun latestVersion(): APIReleaseVersion {
|
||||
suspend fun latestVersion(): ApiReleaseVersion {
|
||||
val patchesRelease = backendRepository.release(
|
||||
configurationRepository.organization,
|
||||
configurationRepository.patches.repository,
|
||||
)
|
||||
|
||||
return APIReleaseVersion(patchesRelease.tag)
|
||||
return ApiReleaseVersion(patchesRelease.tag)
|
||||
}
|
||||
|
||||
private val patchesListCache = Caffeine
|
||||
@ -111,12 +111,12 @@ internal class PatchesService(
|
||||
}
|
||||
}
|
||||
|
||||
fun publicKeys(): APIAssetPublicKeys {
|
||||
fun publicKeys(): ApiAssetPublicKeys {
|
||||
fun readPublicKey(
|
||||
getSignedAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.SignedAssetConfiguration,
|
||||
) = configurationRepository.getSignedAssetConfiguration().publicKeyFile.readText()
|
||||
|
||||
return APIAssetPublicKeys(
|
||||
return ApiAssetPublicKeys(
|
||||
readPublicKey { patches },
|
||||
readPublicKey { integrations },
|
||||
)
|
||||
|
@ -0,0 +1,194 @@
|
||||
package app.revanced.api.configuration.services
|
||||
|
||||
import app.revanced.api.configuration.repository.AnnouncementRepository
|
||||
import app.revanced.api.configuration.schema.ApiAnnouncement
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.toKotlinLocalDateTime
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.transactions.TransactionManager
|
||||
import org.junit.jupiter.api.*
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
private object AnnouncementServiceTest {
|
||||
private lateinit var announcementService: AnnouncementService
|
||||
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun setUp() {
|
||||
TransactionManager.defaultDatabase =
|
||||
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false")
|
||||
|
||||
announcementService = AnnouncementService(AnnouncementRepository())
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun clear() {
|
||||
runBlocking {
|
||||
while (true) {
|
||||
val latestId = announcementService.latestId() ?: break
|
||||
announcementService.delete(latestId.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can do basic crud`(): Unit = runBlocking {
|
||||
announcementService.new(ApiAnnouncement(title = "title"))
|
||||
|
||||
val latestId = announcementService.latestId()!!.id
|
||||
|
||||
announcementService.update(latestId, ApiAnnouncement(title = "new title"))
|
||||
assert(announcementService.get(latestId)?.title == "new title")
|
||||
|
||||
announcementService.delete(latestId)
|
||||
assertNull(announcementService.get(latestId))
|
||||
assertNull(announcementService.latestId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archiving works properly`() = runBlocking {
|
||||
announcementService.new(ApiAnnouncement(title = "title"))
|
||||
|
||||
val latest = announcementService.latest()!!
|
||||
assertNull(announcementService.get(latest.id)?.archivedAt)
|
||||
|
||||
val updated = ApiAnnouncement(
|
||||
title = latest.title,
|
||||
archivedAt = LocalDateTime.now().toKotlinLocalDateTime(),
|
||||
)
|
||||
|
||||
announcementService.update(latest.id, updated)
|
||||
assertNotNull(announcementService.get(latest.id)?.archivedAt)
|
||||
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest works properly`() = runBlocking {
|
||||
announcementService.new(ApiAnnouncement(title = "title"))
|
||||
announcementService.new(ApiAnnouncement(title = "title2"))
|
||||
|
||||
var latest = announcementService.latest()
|
||||
assert(latest?.title == "title2")
|
||||
|
||||
announcementService.delete(latest!!.id)
|
||||
|
||||
latest = announcementService.latest()
|
||||
assert(latest?.title == "title")
|
||||
|
||||
announcementService.delete(latest!!.id)
|
||||
assertNull(announcementService.latest())
|
||||
|
||||
announcementService.new(ApiAnnouncement(title = "1", tags = listOf("tag1", "tag2")))
|
||||
announcementService.new(ApiAnnouncement(title = "2", tags = listOf("tag1", "tag3")))
|
||||
announcementService.new(ApiAnnouncement(title = "3", tags = listOf("tag1", "tag4")))
|
||||
|
||||
val tag2 = announcementService.tags().find { it.name == "tag2" }!!.id
|
||||
assert(announcementService.latest(setOf(tag2)).first().title == "1")
|
||||
|
||||
val tag3 = announcementService.tags().find { it.name == "tag3" }!!.id
|
||||
assert(announcementService.latest(setOf(tag3)).last().title == "2")
|
||||
|
||||
val tag1and3 =
|
||||
announcementService.tags().filter { it.name == "tag1" || it.name == "tag3" }.map { it.id }.toSet()
|
||||
val announcement2and3 = announcementService.latest(tag1and3)
|
||||
assert(announcement2and3.size == 2)
|
||||
assert(announcement2and3.any { it.title == "2" })
|
||||
assert(announcement2and3.any { it.title == "3" })
|
||||
|
||||
announcementService.delete(announcementService.latestId()!!.id)
|
||||
assert(announcementService.latest(tag1and3).first().title == "2")
|
||||
|
||||
announcementService.delete(announcementService.latestId()!!.id)
|
||||
assert(announcementService.latest(tag1and3).first().title == "1")
|
||||
|
||||
announcementService.delete(announcementService.latestId()!!.id)
|
||||
assert(announcementService.latest(tag1and3).isEmpty())
|
||||
assert(announcementService.tags().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags work properly`() = runBlocking {
|
||||
announcementService.new(ApiAnnouncement(title = "title", tags = listOf("tag1", "tag2")))
|
||||
announcementService.new(ApiAnnouncement(title = "title2", tags = listOf("tag1", "tag3")))
|
||||
|
||||
val tags = announcementService.tags()
|
||||
assertEquals(3, tags.size)
|
||||
assert(tags.any { it.name == "tag1" })
|
||||
assert(tags.any { it.name == "tag2" })
|
||||
assert(tags.any { it.name == "tag3" })
|
||||
|
||||
announcementService.delete(announcementService.latestId()!!.id)
|
||||
assertEquals(2, announcementService.tags().size)
|
||||
|
||||
announcementService.update(
|
||||
announcementService.latestId()!!.id,
|
||||
ApiAnnouncement(title = "title", tags = listOf("tag1", "tag3")),
|
||||
)
|
||||
|
||||
assertEquals(2, announcementService.tags().size)
|
||||
assert(announcementService.tags().any { it.name == "tag3" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attachments work properly`() = runBlocking {
|
||||
announcementService.new(ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment2")))
|
||||
|
||||
val latestAnnouncement = announcementService.latest()!!
|
||||
val latestId = latestAnnouncement.id
|
||||
|
||||
val attachments = latestAnnouncement.attachments
|
||||
assertEquals(2, attachments.size)
|
||||
assert(attachments.any { it == "attachment1" })
|
||||
assert(attachments.any { it == "attachment2" })
|
||||
|
||||
announcementService.update(
|
||||
latestId,
|
||||
ApiAnnouncement(title = "title", attachments = listOf("attachment1", "attachment3")),
|
||||
)
|
||||
assert(announcementService.get(latestId)!!.attachments.any { it == "attachment3" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paging works correctly`() = runBlocking {
|
||||
repeat(10) {
|
||||
announcementService.new(ApiAnnouncement(title = "title$it"))
|
||||
}
|
||||
|
||||
val announcements = announcementService.paged(Int.MAX_VALUE, 5, null, true)
|
||||
assertEquals(5, announcements.size, "Returns correct number of announcements")
|
||||
assertEquals("title9", announcements.first().title, "Starts from the latest announcement")
|
||||
|
||||
val announcements2 = announcementService.paged(5, 5, null, true)
|
||||
assertEquals(5, announcements2.size, "Returns correct number of announcements when starting from the cursor")
|
||||
assertEquals("title4", announcements2.first().title, "Starts from the cursor")
|
||||
|
||||
(0..4).forEach { id ->
|
||||
announcementService.update(
|
||||
id,
|
||||
ApiAnnouncement(
|
||||
title = "title$id",
|
||||
tags = (0..id).map { "tag$it" },
|
||||
archivedAt = if (id % 2 == 0) {
|
||||
// Only two announcements will be archived.
|
||||
LocalDateTime.now().plusDays(2).minusDays(id.toLong()).toKotlinLocalDateTime()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val tags = announcementService.tags()
|
||||
assertEquals(5, tags.size, "Returns correct number of newly created tags")
|
||||
|
||||
val announcements3 = announcementService.paged(5, 5, setOf(tags[1].id), true)
|
||||
assertEquals(4, announcements3.size, "Filters announcements by tag")
|
||||
|
||||
val announcements4 = announcementService.paged(Int.MAX_VALUE, 10, null, false)
|
||||
assertEquals(8, announcements4.size, "Filters out archived announcements")
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user