Skip to content

Commit

Permalink
Add support for component data (de)serialization (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
freya022 authored Feb 18, 2025
1 parent 93d9225 commit 2950815
Show file tree
Hide file tree
Showing 43 changed files with 1,341 additions and 481 deletions.
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,11 @@
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jda-ktx</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
package io.github.freya022.botcommands.api.components.annotations

import io.github.freya022.botcommands.api.components.builder.IPersistentActionableComponent
import io.github.freya022.botcommands.api.components.serialization.annotations.SerializableComponentData
import io.github.freya022.botcommands.api.parameters.ParameterResolver
import io.github.freya022.botcommands.api.parameters.resolvers.ComponentParameterResolver

/**
* Sets this parameter as data coming from [IPersistentActionableComponent.bindTo].
*
* The order and types of the passed data must match with the handler parameters.
*
* ### Requirements
* A compatible [ComponentParameterResolver] must exist for the annotated parameter,
* the default supported types can be seen in [ParameterResolver].
*
* If your parameter is a serializable object,
* you can instead use [@SerializableComponentData][SerializableComponentData].
*
* @see JDAButtonListener @JDAButtonListener
* @see JDASelectMenuListener @JDASelectMenuListener
*/
@Target(AnnotationTarget.VALUE_PARAMETER)
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.ANNOTATION_CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ComponentData
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
package io.github.freya022.botcommands.api.components.annotations

import io.github.freya022.botcommands.api.components.builder.IPersistentTimeoutableComponent
import io.github.freya022.botcommands.api.components.serialization.annotations.SerializableTimeoutData
import io.github.freya022.botcommands.api.parameters.ParameterResolver
import io.github.freya022.botcommands.api.parameters.resolvers.TimeoutParameterResolver

/**
* Sets this parameter as data coming from [IPersistentTimeoutableComponent.timeout].
*
* The order and types of the passed data must match with the handler parameters.
*
* ### Requirements
* A compatible [TimeoutParameterResolver] must exist for the annotated parameter,
* the default supported types can be seen in [ParameterResolver].
*
* If your parameter is a serializable object,
* you can instead use [@SerializableTimeoutData][SerializableTimeoutData].
*
* @see ComponentTimeoutHandler @ComponentTimeoutHandler
* @see GroupTimeoutHandler @GroupTimeoutHandler
*/
@Target(AnnotationTarget.VALUE_PARAMETER)
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.ANNOTATION_CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class TimeoutData

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.freya022.botcommands.api.components.serialization

import io.github.freya022.botcommands.api.components.serialization.annotations.SerializableComponentData
import io.github.freya022.botcommands.api.components.serialization.annotations.SerializableTimeoutData
import io.github.freya022.botcommands.api.core.reflect.ParameterWrapper
import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService

/**
* Serializes and deserializes data from parameters annotated with [@SerializableComponentData][SerializableComponentData]
* and [@SerializableTimeoutData][SerializableTimeoutData].
*
* ### Default implementation
* By default, a Jackson-based serializer with the Kotlin module is used.
*
* ### Overriding the default instance
* You can override the default instance by creating a service implementing this interface,
* in which you can use any serialization library you want.
*
* **Tip:** You will generally need to get the type of the to-be-deserialized parameter,
* which you can get from the [ParameterWrapper].
*
* Here's an example with [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization):
*
* ```kt
* @BService
* class KotlinxComponentDataSerializer : GlobalComponentDataSerializer {
*
* // Default instance, you can customize it later
* private val json = Json
*
* override fun deserialize(parameter: ParameterWrapper, data: SerializedComponentData): Any {
* return json.decodeFromString(serializer(parameter.type), data.asString())!!
* }
*
* override fun serialize(parameter: ParameterWrapper, obj: Any): SerializedComponentData {
* val json = json.encodeToString(serializer(parameter.type), obj)
* return SerializedComponentData.fromString(json)
* }
* }
* ```
*/
@InterfacedService(acceptMultiple = false)
interface GlobalComponentDataSerializer {

/**
* Serializes the given object into a [SerializedComponentData].
*
* @param parameter The parameter which this value is serialized for
* @param obj The data to be serialized
*/
fun serialize(parameter: ParameterWrapper, obj: Any): SerializedComponentData

/**
* Deserializes the [data] into an object compatible with the [parameter].
*
* @param parameter The parameter which this value is deserialized for
* @param data The data to be deserialized into a compatible object
*/
fun deserialize(parameter: ParameterWrapper, data: SerializedComponentData): Any
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.github.freya022.botcommands.api.components.serialization

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.github.freya022.botcommands.api.components.serialization.SerializedComponentData.Companion.fromBytes
import io.github.freya022.botcommands.api.components.serialization.SerializedComponentData.Companion.fromString
import io.github.freya022.botcommands.api.core.reflect.KotlinTypeToken
import io.github.freya022.botcommands.api.parameters.resolvers.ComponentParameterResolver

/**
* Contains the serialized data of a component argument.
*
* @see fromString
* @see fromBytes
*
* @see ComponentParameterResolver.serialize
* @see GlobalComponentDataSerializer.serialize
* @see GlobalComponentDataSerializer.deserialize
*/
class SerializedComponentData private constructor(
private val bytes: ByteArray,
) {

/**
* Returns the underlying byte array.
*/
fun asBytes(): ByteArray = bytes.clone()

/**
* Decodes the data as a UTF-8 string.
*/
fun asString() = bytes.decodeToString()

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as SerializedComponentData

return bytes.contentEquals(other.bytes)
}

override fun hashCode(): Int {
return bytes.contentHashCode()
}

override fun toString(): String {
return "SerializedComponentData(bytes=${bytes.contentToString()})"
}

companion object {

/**
* Creates a [SerializedComponentData] from the given bytes.
*/
@JvmStatic
fun fromBytes(bytes: ByteArray): SerializedComponentData {
return SerializedComponentData(bytes.clone())
}

/**
* Creates a [SerializedComponentData] from the given string.
*/
@JvmStatic
fun fromString(string: String): SerializedComponentData {
return SerializedComponentData(string.encodeToByteArray())
}
}
}

/**
* Serializes the given [value] as a [SerializedComponentData] encoded as a UTF-8 string.
*/
fun ObjectMapper.writeValueAsComponentData(value: Any): SerializedComponentData =
fromBytes(writeValueAsBytes(value))

/**
* Deserializes the given UTF-8 encoded JSON object [data] as a [T] instance.
*/
inline fun <reified T : Any> ObjectMapper.readValue(data: SerializedComponentData): T =
readValue(data.asBytes())

/**
* Deserializes the given UTF-8 encoded JSON object [data] as a [T] instance.
*/
fun <T : Any> ObjectMapper.readValue(data: SerializedComponentData, typeToken: KotlinTypeToken<T>): T =
readValue(data.asBytes(), constructType(typeToken.javaType))
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.freya022.botcommands.api.components.serialization.annotations

import io.github.freya022.botcommands.api.components.annotations.ComponentData
import io.github.freya022.botcommands.api.components.serialization.GlobalComponentDataSerializer

/**
* Same as [@ComponentData][ComponentData],
* but also generates a resolver which (de)serializes the value using the [GlobalComponentDataSerializer].
*/
@ComponentData
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.ANNOTATION_CLASS)
annotation class SerializableComponentData
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.freya022.botcommands.api.components.serialization.annotations

import io.github.freya022.botcommands.api.components.annotations.TimeoutData
import io.github.freya022.botcommands.api.components.serialization.GlobalComponentDataSerializer

/**
* Same as [@TimeoutData][TimeoutData],
* but also generates a resolver which (de)serializes the value using the [GlobalComponentDataSerializer].
*/
@TimeoutData
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.ANNOTATION_CLASS)
annotation class SerializableTimeoutData
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.github.freya022.botcommands.api.components.serialization.exceptions

import io.github.freya022.botcommands.api.parameters.resolvers.ComponentParameterResolver
import io.github.freya022.botcommands.api.parameters.resolvers.TimeoutParameterResolver

/**
* An exception thrown when [ComponentParameterResolver.serialize] or [TimeoutParameterResolver.serialize] fails.
*/
class ComponentSerializationException : RuntimeException {

constructor(message: String) : super(message)
constructor(message: String, cause: Throwable) : super(message, cause)
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,9 @@ inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.flatMapTo(destination
destination.addAll(list)
}
return destination
}

inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
val iterator = iterator()
return Array(size) { _ -> transform(iterator.next()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.github.freya022.botcommands.api.commands.application.slash.options.Sla
import io.github.freya022.botcommands.api.commands.text.BaseCommandEvent
import io.github.freya022.botcommands.api.commands.text.options.TextCommandOption
import io.github.freya022.botcommands.api.components.options.ComponentOption
import io.github.freya022.botcommands.api.components.serialization.SerializedComponentData
import io.github.freya022.botcommands.api.components.timeout.options.TimeoutOption
import io.github.freya022.botcommands.api.parameters.resolvers.ComponentParameterResolver
import io.github.freya022.botcommands.api.parameters.resolvers.SlashParameterResolver
Expand Down Expand Up @@ -78,12 +79,14 @@ internal sealed class AbstractEnumResolver<T : AbstractEnumResolver<T, E>, E : E
//endregion

//region Component
override suspend fun resolveSuspend(option: ComponentOption, event: GenericComponentInteractionCreateEvent, arg: String): E? =
getEnumValueOrNull(arg)
override suspend fun resolveSuspend(option: ComponentOption, event: GenericComponentInteractionCreateEvent, data: SerializedComponentData): E? =
getEnumValueOrNull(data.asString())

override fun serialize(obj: E) = SerializedComponentData.fromString(obj.name)
//endregion

//region Timeout
override suspend fun resolveSuspend(option: TimeoutOption, arg: String): E = getEnumValue(arg)
override suspend fun resolveSuspend(option: TimeoutOption, data: SerializedComponentData): E = getEnumValue(data.asString())
//endregion

override fun toString(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,19 @@ import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel
import net.dv8tion.jda.api.entities.emoji.Emoji

/**
* Base class for parameter resolvers used in text commands, application commands, and component callbacks.
* Base class for parameter resolvers,
* needs to be implemented alongside the interface of at least one interaction type:
* - Text commands: [TextParameterResolver] or [QuotableTextParameterResolver]
* - Slash commands: [SlashParameterResolver]
* - Message context commands: [MessageContextParameterResolver]
* - User context commands: [UserContextParameterResolver]
* - Components: [ComponentParameterResolver]
* - Component timeouts: [TimeoutParameterResolver]
* - Modal handlers: [ModalParameterResolver]
* - Custom parameter types: [ICustomResolver]
*
* You need to extend [ClassParameterResolver] or [TypedParameterResolver] instead.
* ### Usage
* As this class is sealed, you need to extend [ClassParameterResolver] or [TypedParameterResolver] instead.
*
* ### Default parameter resolvers
*
Expand Down Expand Up @@ -49,19 +59,8 @@ import net.dv8tion.jda.api.entities.emoji.Emoji
* @param T Type of the implementation
* @param R Type of the returned resolved objects
*
* @see ClassParameterResolver
*
* @see ParameterResolverFactory
*
* @see TextParameterResolver
* @see QuotableTextParameterResolver
* @see ComponentParameterResolver
* @see SlashParameterResolver
* @see MessageContextParameterResolver
* @see UserContextParameterResolver
* @see TimeoutParameterResolver
* @see ICustomResolver
*
* @see Resolvers
*/
@InterfacedService(acceptMultiple = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import io.github.freya022.botcommands.api.components.annotations.JDAButtonListen
import io.github.freya022.botcommands.api.components.annotations.JDASelectMenuListener
import io.github.freya022.botcommands.api.components.builder.IPersistentActionableComponent
import io.github.freya022.botcommands.api.components.options.ComponentOption
import io.github.freya022.botcommands.api.components.serialization.SerializedComponentData
import io.github.freya022.botcommands.api.components.serialization.annotations.SerializableComponentData
import io.github.freya022.botcommands.api.parameters.ParameterResolver
import net.dv8tion.jda.api.events.interaction.component.GenericComponentInteractionCreateEvent
import kotlin.reflect.KParameter
Expand All @@ -15,6 +17,11 @@ import kotlin.reflect.KType
*
* Needs to be implemented alongside a [ParameterResolver] subclass.
*
* ### Use case - Supporting serializable objects
* If you need to pass **serializable** objects to your components,
* you can instead use [@SerializableComponentData][SerializableComponentData]
* and let it generate a resolver for you.
*
* @param T Type of the implementation
* @param R Type of the returned resolved objects
*/
Expand All @@ -31,9 +38,9 @@ interface ComponentParameterResolver<T, R : Any> : IParameterResolver<T>
*
* @param option The option currently being resolved
* @param event The corresponding event
* @param arg One of the data passed by the user in [IPersistentActionableComponent.bindTo]
* @param data A serialized representation of an argument passed in [IPersistentActionableComponent.bindTo]
*/
fun resolve(option: ComponentOption, event: GenericComponentInteractionCreateEvent, arg: String): R? =
fun resolve(option: ComponentOption, event: GenericComponentInteractionCreateEvent, data: SerializedComponentData): R? =
throw NotImplementedError("${this.javaClass.simpleName} must implement the 'resolve' or 'resolveSuspend' method")

/**
Expand All @@ -45,9 +52,17 @@ interface ComponentParameterResolver<T, R : Any> : IParameterResolver<T>
*
* @param option The option currently being resolved
* @param event The corresponding event
* @param arg One of the data passed by the user in [IPersistentActionableComponent.bindTo]
* @param data A serialized representation of an argument passed in [IPersistentActionableComponent.bindTo]
*/
@JvmSynthetic
suspend fun resolveSuspend(option: ComponentOption, event: GenericComponentInteractionCreateEvent, arg: String) =
resolve(option, event, arg)
suspend fun resolveSuspend(option: ComponentOption, event: GenericComponentInteractionCreateEvent, data: SerializedComponentData) =
resolve(option, event, data)

/**
* Serializes an instance of the resolvable object.
*
* The given instance can be serialized in any way you want,
* remember you must be able to deserialize it in [resolve]/[resolveSuspend].
*/
fun serialize(obj: R): SerializedComponentData
}
Loading

0 comments on commit 2950815

Please sign in to comment.