Skip to content

Commit

Permalink
feat: Improve announcements API (#192)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
oSumAtrIX authored Nov 1, 2024
1 parent 50b81fd commit 56a00dd
Show file tree
Hide file tree
Showing 14 changed files with 599 additions and 410 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ kotlin {
}
}

tasks {
test {
useJUnitPlatform()
}
}

repositories {
mavenCentral()
google()
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }
}
}

suspend fun delete(id: Int) = transaction {
val announcement = Announcement.findById(id) ?: return@transaction
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 }

announcement.delete()
latestAnnouncementForTag?.let { latestAnnouncementByTag[tag] = it }
}

// In case the latest announcement was deleted, query the new latest announcement again.
if (latestAnnouncement?.id?.value == id) {
latestAnnouncement = Announcement.all().maxByOrNull { it.id }
suspend fun latest() = transaction {
latestAnnouncement.toApiResponseAnnouncement()
}

// 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!!
}
}
suspend fun latest(tags: Set<Int>) = transaction {
tags.mapNotNull { tag -> latestAnnouncementByTag[tag] }.toApiAnnouncement()
}

fun latest() = latestAnnouncement?.toApi()
fun latestId() = latestAnnouncement?.id?.value.toApiResponseAnnouncementId()

fun latest(channel: String) = latestAnnouncementByChannel[channel]?.toApi()
fun latestId(tags: Set<Int>) =
tags.map { tag -> latestAnnouncementByTag[tag]?.id?.value }.toApiResponseAnnouncementId()

fun latestId() = latest()?.id?.toApi()
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 latestId(channel: String) = latest(channel)?.id?.toApi()
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()
}

suspend fun archive(
id: Int,
archivedAt: LocalDateTime?,
) = transaction {
Announcement.findByIdAndUpdate(id) {
it.archivedAt = archivedAt ?: java.time.LocalDateTime.now().toKotlinLocalDateTime()
}?.also(::updateLatestAnnouncement)
idLessEq() and archivedAtGreaterOrNullOrTrue() and hasTags()
}
}.orderBy(Announcements.id to SortOrder.DESC).limit(count).toApiAnnouncement()
}

suspend fun unarchive(id: Int) = transaction {
Announcement.findByIdAndUpdate(id) {
it.archivedAt = null
}?.also(::updateLatestAnnouncement)
suspend fun get(id: Int) = transaction {
Announcement.findById(id).toApiResponseAnnouncement()
}

suspend fun new(new: APIAnnouncement) = transaction {
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
}
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
}
}.awaitAll()
}.also(::updateLatestAnnouncement)
}
}.let(::updateLatestAnnouncement)
}

suspend fun update(id: Int, new: APIAnnouncement) = transaction {
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
}

// 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
}
}.awaitAll()
}?.also(::updateLatestAnnouncement)
}
}?.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()

// If the deleted announcement is the latest announcement, set the new latest announcement.
if (latestAnnouncement?.id?.value == id) {
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)
}
}

suspend fun tags() = transaction {
Tag.all().toList().toApiTag()
}

private suspend fun <T> transaction(statement: suspend Transaction.() -> T) =
Expand All @@ -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")
Expand All @@ -155,14 +203,27 @@ 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)

var author by Announcements.author
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
Expand All @@ -175,17 +236,32 @@ internal class AnnouncementRepository {
var announcement by Announcement referencedOn Attachments.announcement
}

private fun Announcement.toApi() = APIResponseAnnouncement(
id.value,
author,
title,
content,
attachments.map { it.url },
channel,
createdAt,
archivedAt,
level,
)
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 },
tags.map { it.id.value },
createdAt,
archivedAt,
level,
)
}

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 Int.toApi() = APIResponseAnnouncementId(this)
private fun Iterable<Int?>.toApiResponseAnnouncementId() = map { it.toApiResponseAnnouncementId() }
}
Loading

0 comments on commit 56a00dd

Please sign in to comment.