Implement type-safe config for plugins in BE and FE

This commit is contained in:
grimsi
2025-06-03 17:51:17 +02:00
parent 6e390df900
commit 0050ab1f74
30 changed files with 372 additions and 259 deletions
-9
View File
@@ -20,13 +20,4 @@ publishing {
dependencies {
// PF4J (shared)
api("org.pf4j:pf4j:${rootProject.extra["pf4jVersion"]}")
implementation(kotlin("stdlib"))
// Test dependencies
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
@@ -1,9 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
interface Configurable {
val configMetadata: List<PluginConfigElement>
var config: Map<String, String?>
fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
}
@@ -1,17 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
import org.pf4j.PluginWrapper
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
companion object {
lateinit var plugin: ConfigurableGameyfinPlugin
private set
}
init {
plugin = this
}
override var config: Map<String, String?> = emptyMap()
}
@@ -1,8 +0,0 @@
package de.grimsi.gameyfin.pluginapi.core
data class PluginConfigElement(
val key: String,
val name: String,
val description: String,
val isSecret: Boolean = false
)
@@ -0,0 +1,21 @@
package de.grimsi.gameyfin.pluginapi.core.config
import java.io.Serializable
typealias PluginConfigMetadata = List<ConfigMetadata<*>>
data class ConfigMetadata<T : Serializable>(
val key: String,
val type: Class<T>,
val label: String,
val description: String,
val default: T? = null,
val isSecret: Boolean = false,
val isRequired: Boolean = true,
) {
var allowedValues: List<T>? = null
init {
allowedValues = type.enumConstants?.toList()
}
}
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.pluginapi.core.config
import java.io.Serializable
interface Configurable {
val configMetadata: PluginConfigMetadata
fun loadConfig(config: Map<String, String?>)
fun validateConfig(): PluginConfigValidationResult
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
fun <T : Serializable> config(key: String): T
fun <T : Serializable> optionalConfig(key: String): T?
}
@@ -1,3 +1,3 @@
package de.grimsi.gameyfin.pluginapi.core
package de.grimsi.gameyfin.pluginapi.core.config
class PluginConfigError(message: String) : RuntimeException(message)
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.pluginapi.core
package de.grimsi.gameyfin.pluginapi.core.config
data class PluginConfigValidationResult(
val result: PluginConfigValidationResultType,
@@ -0,0 +1,92 @@
package de.grimsi.gameyfin.pluginapi.core.wrapper
import de.grimsi.gameyfin.pluginapi.core.config.ConfigMetadata
import de.grimsi.gameyfin.pluginapi.core.config.Configurable
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigError
import de.grimsi.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import org.pf4j.PluginWrapper
import java.io.Serializable
@Suppress("UNCHECKED_CAST")
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
private var config: Map<String, String?> = emptyMap()
override fun loadConfig(config: Map<String, String?>) {
this.config = config
}
override fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
val errors = mutableMapOf<String, String>()
for (meta in configMetadata) {
val value = resolveValue(meta.key, config)
if (meta.isRequired && value == null) {
errors[meta.key] = "${meta.label} is required"
continue
}
if (value != null) {
try {
castConfigValue(meta, value)
} catch (e: PluginConfigError) {
errors[meta.key] = e.message ?: "Invalid value"
}
}
}
return if (errors.isEmpty()) {
PluginConfigValidationResult.VALID
} else {
PluginConfigValidationResult.INVALID(errors)
}
}
override fun <T : Serializable> optionalConfig(key: String): T? {
val meta = resolveMetadata(key)
val value = resolveValue(key)
if (value == null) return null
return try {
castConfigValue(meta, value) as T
} catch (e: Exception) {
throw PluginConfigError("Failed to cast value for key '$key' to type ${meta.type.simpleName}: ${e.message}")
}
}
private fun castConfigValue(meta: ConfigMetadata<*>, value: Any): Any? {
val expectedType = meta.type
return if (expectedType.isEnum) {
try {
java.lang.Enum.valueOf(expectedType as Class<out Enum<*>>, value.toString())
} catch (_: IllegalArgumentException) {
throw PluginConfigError("Invalid value '${value}', must be one of ${meta.allowedValues!!.joinToString(", ")}")
}
} else {
if (!expectedType.isInstance(value)) {
throw PluginConfigError("Value for key '${meta.key}' is not of type ${expectedType.simpleName}")
}
value
}
}
override fun <T : Serializable> config(key: String): T {
val value = optionalConfig<T>(key)
if (value == null) {
throw PluginConfigError("Required configuration key '$key' is missing or has no value")
}
return value
}
private fun resolveMetadata(key: String): ConfigMetadata<*> {
return configMetadata.find { it.key == key }
?: throw PluginConfigError("Unknown configuration key: $key")
}
private fun resolveValue(key: String, configOverride: Map<String, Serializable?>? = null): Serializable? {
val meta = resolveMetadata(key)
val conf = configOverride ?: config
return conf[key] ?: meta.default
}
}
@@ -1,8 +1,9 @@
package de.grimsi.gameyfin.pluginapi.core
package de.grimsi.gameyfin.pluginapi.core.wrapper
import org.pf4j.Plugin
import org.pf4j.PluginWrapper
@Suppress("DEPRECATION")
abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
companion object {