feat(plugins): add JSONForms-based plugin configuration UI (#4911)
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions

* feat(plugins): add JSONForms schema for plugin configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance error handling by formatting validation errors with field names

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enforce required fields in config validation and improve error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* format JS code

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config schema validation and enhance manifest structure

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: refactor plugin config parsing and add unit tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config validation error message in Portuguese

* feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: resolve React Hooks linting issues in plugin UI components

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* format code

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting

Signed-off-by: Deluan <deluan@navidrome.org>

* address PR comments

Signed-off-by: Deluan <deluan@navidrome.org>

* fix flaky test

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance array layout and configuration handling with AJV defaults

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add error boundary for schema rendering and improve error messages

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: refine non-enum array control logic by utilizing JSONForms schema resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add error styling to ToggleEnabledSwitch for disabled state

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: adjust label positioning and styling in SchemaConfigEditor for improved layout

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: implement outlined input controls renderers to replace custom fragile CSS

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: remove margin from last form control inside array items for better spacing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance AJV error handling to transform required errors for field-level validation

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: set default value for User Tokens in manifest.json to improve user experience

Signed-off-by: Deluan <deluan@navidrome.org>

* format

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add margin to outlined input controls for improved spacing

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: remove redundant margin rule for last form control in array items

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: adjust font size of label elements in SchemaConfigEditor for improved readability

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Deluan Quintão 2026-01-19 20:51:00 -05:00 committed by GitHub
parent 66474fc9f4
commit f1e75c40dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 5430 additions and 2007 deletions

View file

@ -6,7 +6,7 @@ require (
github.com/extism/go-pdk v1.1.3
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/xeipuuv/gojsonschema v1.2.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/tools v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
@ -17,8 +17,6 @@ require (
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect

View file

@ -1,8 +1,9 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
@ -39,8 +40,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@ -51,12 +52,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=

View file

@ -3,10 +3,11 @@ package internal
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/xeipuuv/gojsonschema"
"github.com/santhosh-tekuri/jsonschema/v6"
"gopkg.in/yaml.v3"
)
@ -25,27 +26,61 @@ func ValidateXTPSchema(generatedSchema []byte) error {
return fmt.Errorf("failed to parse generated schema as YAML: %w", err)
}
// Convert to JSON for the validator
jsonBytes, err := json.Marshal(schemaDoc)
if err != nil {
return fmt.Errorf("failed to convert schema to JSON: %w", err)
// Parse the XTP schema JSON
var xtpSchema any
if err := json.Unmarshal([]byte(xtpSchemaJSON), &xtpSchema); err != nil {
return fmt.Errorf("failed to parse XTP schema: %w", err)
}
schemaLoader := gojsonschema.NewStringLoader(xtpSchemaJSON)
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return fmt.Errorf("schema validation failed: %w", err)
// Compile the XTP schema
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("xtp-schema.json", xtpSchema); err != nil {
return fmt.Errorf("failed to add XTP schema resource: %w", err)
}
if !result.Valid() {
var errs []string
for _, desc := range result.Errors() {
errs = append(errs, fmt.Sprintf("- %s", desc))
}
return fmt.Errorf("schema validation errors:\n%s", strings.Join(errs, "\n"))
schema, err := compiler.Compile("xtp-schema.json")
if err != nil {
return fmt.Errorf("failed to compile XTP schema: %w", err)
}
// Validate the generated schema against XTP schema
if err := schema.Validate(schemaDoc); err != nil {
return fmt.Errorf("schema validation errors:\n%s", formatValidationErrors(err))
}
return nil
}
// formatValidationErrors formats jsonschema validation errors into readable strings.
func formatValidationErrors(err error) string {
var validationErr *jsonschema.ValidationError
if !errors.As(err, &validationErr) {
return fmt.Sprintf("- %s", err.Error())
}
var errs []string
collectValidationErrors(validationErr, &errs)
if len(errs) == 0 {
return fmt.Sprintf("- %s", validationErr.Error())
}
return strings.Join(errs, "\n")
}
// collectValidationErrors recursively collects leaf validation errors.
func collectValidationErrors(err *jsonschema.ValidationError, errs *[]string) {
if len(err.Causes) > 0 {
for _, cause := range err.Causes {
collectValidationErrors(cause, errs)
}
return
}
// Leaf error - format with location if available
msg := err.Error()
if len(err.InstanceLocation) > 0 {
location := strings.Join(err.InstanceLocation, "/")
msg = fmt.Sprintf("%s: %s", location, msg)
}
*errs = append(*errs, fmt.Sprintf("- %s", msg))
}

View file

@ -0,0 +1,129 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// ConfigValidationError represents a validation error with field path and message.
type ConfigValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ConfigValidationErrors is a collection of validation errors.
type ConfigValidationErrors struct {
Errors []ConfigValidationError `json:"errors"`
}
func (e *ConfigValidationErrors) Error() string {
if len(e.Errors) == 0 {
return "validation failed"
}
var msgs []string
for _, err := range e.Errors {
if err.Field != "" {
msgs = append(msgs, fmt.Sprintf("%s: %s", err.Field, err.Message))
} else {
msgs = append(msgs, err.Message)
}
}
return strings.Join(msgs, "; ")
}
// ValidateConfig validates a config JSON string against a plugin's config schema.
// If the manifest has no config schema, it returns an error indicating the plugin
// has no configurable options.
// Returns nil if validation passes, ConfigValidationErrors if validation fails.
func ValidateConfig(manifest *Manifest, configJSON string) error {
// If no config schema defined, plugin has no configurable options
if !manifest.HasConfigSchema() {
return fmt.Errorf("plugin has no configurable options")
}
// Parse the config JSON (empty string treated as empty object)
var configData any
if configJSON == "" {
configData = map[string]any{}
} else {
if err := json.Unmarshal([]byte(configJSON), &configData); err != nil {
return &ConfigValidationErrors{
Errors: []ConfigValidationError{{
Message: fmt.Sprintf("invalid JSON: %v", err),
}},
}
}
}
// Compile the schema
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", manifest.Config.Schema); err != nil {
return fmt.Errorf("adding schema resource: %w", err)
}
schema, err := compiler.Compile("schema.json")
if err != nil {
return fmt.Errorf("compiling schema: %w", err)
}
// Validate config against schema
if err := schema.Validate(configData); err != nil {
return convertValidationError(err)
}
return nil
}
// convertValidationError converts jsonschema validation errors to our format.
func convertValidationError(err error) *ConfigValidationErrors {
var validationErr *jsonschema.ValidationError
if !errors.As(err, &validationErr) {
return &ConfigValidationErrors{
Errors: []ConfigValidationError{{
Message: err.Error(),
}},
}
}
var configErrors []ConfigValidationError
collectErrors(validationErr, &configErrors)
if len(configErrors) == 0 {
configErrors = append(configErrors, ConfigValidationError{
Message: validationErr.Error(),
})
}
return &ConfigValidationErrors{Errors: configErrors}
}
// collectErrors recursively collects validation errors from the error tree.
func collectErrors(err *jsonschema.ValidationError, errors *[]ConfigValidationError) {
// If there are child errors, collect from them
if len(err.Causes) > 0 {
for _, cause := range err.Causes {
collectErrors(cause, errors)
}
return
}
// Leaf error - add it
field := ""
if len(err.InstanceLocation) > 0 {
field = strings.Join(err.InstanceLocation, "/")
}
*errors = append(*errors, ConfigValidationError{
Field: field,
Message: err.Error(),
})
}
// HasConfigSchema returns true if the manifest defines a config schema.
func (m *Manifest) HasConfigSchema() bool {
return m.Config != nil && m.Config.Schema != nil
}

View file

@ -0,0 +1,186 @@
//go:build !windows
package plugins
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Config Validation", func() {
Describe("ValidateConfig", func() {
Context("when manifest has no config schema", func() {
It("returns an error", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
}
err := ValidateConfig(manifest, `{"key": "value"}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no configurable options"))
})
})
Context("when manifest has config schema", func() {
var manifest *Manifest
BeforeEach(func() {
manifest = &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"apiKey": map[string]any{
"type": "string",
"description": "API key for the service",
"minLength": float64(1),
},
"timeout": map[string]any{
"type": "integer",
"minimum": float64(1),
"maximum": float64(300),
},
"enabled": map[string]any{
"type": "boolean",
},
},
"required": []any{"apiKey"},
},
},
}
})
It("accepts valid config", func() {
err := ValidateConfig(manifest, `{"apiKey": "secret123", "timeout": 30}`)
Expect(err).ToNot(HaveOccurred())
})
It("rejects empty config when required fields are missing", func() {
err := ValidateConfig(manifest, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
err = ValidateConfig(manifest, "{}")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
})
It("rejects config missing required field", func() {
err := ValidateConfig(manifest, `{"timeout": 30}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
})
It("rejects config with wrong type", func() {
err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": "not a number"}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("timeout"))
})
It("rejects config with value out of range", func() {
err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": 500}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("timeout"))
})
It("rejects config with empty required string", func() {
err := ValidateConfig(manifest, `{"apiKey": ""}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
})
It("rejects invalid JSON", func() {
err := ValidateConfig(manifest, `{invalid json}`)
Expect(err).To(HaveOccurred())
var validationErr *ConfigValidationErrors
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors[0].Message).To(ContainSubstring("invalid JSON"))
})
})
Context("with enum values", func() {
It("accepts valid enum value", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"logLevel": map[string]any{
"type": "string",
"enum": []any{"debug", "info", "warn", "error"},
},
},
},
},
}
err := ValidateConfig(manifest, `{"logLevel": "info"}`)
Expect(err).ToNot(HaveOccurred())
})
It("rejects invalid enum value", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"logLevel": map[string]any{
"type": "string",
"enum": []any{"debug", "info", "warn", "error"},
},
},
},
},
}
err := ValidateConfig(manifest, `{"logLevel": "verbose"}`)
Expect(err).To(HaveOccurred())
})
})
})
Describe("HasConfigSchema", func() {
It("returns false when config is nil", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
}
Expect(manifest.HasConfigSchema()).To(BeFalse())
})
It("returns false when schema is nil", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{},
}
Expect(manifest.HasConfigSchema()).To(BeFalse())
})
It("returns true when schema is present", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
},
},
}
Expect(manifest.HasConfigSchema()).To(BeTrue())
})
})
})

View file

@ -25,6 +25,14 @@ const (
// ID for the reconnection schedule
reconnectScheduleID = "crypto-ticker-reconnect"
// Config keys (must match manifest.json schema property names)
symbolsKey = "symbols"
reconnectDelayKey = "reconnectDelay"
logPricesKey = "logPrices"
// Default values
defaultReconnectDelay = 5
)
// CoinbaseSubscription message structure
@ -74,36 +82,67 @@ var (
func (p *cryptoTickerPlugin) OnInit() error {
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH" // Default tickers
}
tickers := parseTickerSymbols(tickerConfig)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
// Get ticker configuration from JSON schema config
symbols := getSymbols()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols))
// Connect to WebSocket
// Errors won't fail init - reconnect logic will handle it
return connectAndSubscribe(tickers)
return connectAndSubscribe(symbols)
}
// parseTickerSymbols parses a comma-separated list of ticker symbols
func parseTickerSymbols(tickerConfig string) []string {
parts := strings.Split(tickerConfig, ",")
tickers := make([]string, 0, len(parts))
for _, ticker := range parts {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
// Add -USD suffix if not present
if !strings.Contains(ticker, "-") {
ticker = ticker + "-USD"
}
tickers = append(tickers, ticker)
// getSymbols reads the symbols array from config
func getSymbols() []string {
defaultSymbols := []string{"BTC-USD"}
symbolsJSON, ok := pdk.GetConfig(symbolsKey)
if !ok || symbolsJSON == "" {
return defaultSymbols
}
return tickers
var symbols []string
if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err))
return defaultSymbols
}
if len(symbols) == 0 {
return defaultSymbols
}
// Normalize symbols - add -USD suffix if not present
for i, s := range symbols {
s = strings.TrimSpace(s)
if !strings.Contains(s, "-") {
symbols[i] = s + "-USD"
} else {
symbols[i] = s
}
}
return symbols
}
// getReconnectDelay reads the reconnect delay from config
func getReconnectDelay() int32 {
delayStr, ok := pdk.GetConfig(reconnectDelayKey)
if !ok || delayStr == "" {
return defaultReconnectDelay
}
var delay int
if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 {
return defaultReconnectDelay
}
return int32(delay)
}
// shouldLogPrices reads the logPrices setting from config
func shouldLogPrices() bool {
logStr, ok := pdk.GetConfig(logPricesKey)
if !ok || logStr == "" {
return false
}
return logStr == "true"
}
// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers
@ -164,14 +203,16 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
// Calculate 24h change percentage
change := calculatePercentChange(ticker.Open24h, ticker.Price)
// Log ticker information
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
// Log ticker information (only if enabled in config)
if shouldLogPrices() {
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
}
return nil
}
@ -196,10 +237,11 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
// Only attempt reconnect for our connection
if input.ConnectionID == connectionID {
pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...")
delay := getReconnectDelay()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay))
// Schedule a one-time reconnection attempt
_, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
}
@ -218,20 +260,16 @@ func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH"
}
tickers := parseTickerSymbols(tickerConfig)
symbols := getSymbols()
// Try to connect and subscribe
err := connectAndSubscribe(tickers)
err := connectAndSubscribe(symbols)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err))
delay := getReconnectDelay() * 2 // Double delay on failure
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay))
// Schedule another attempt
_, err := host.SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
}

View file

@ -4,6 +4,61 @@
"version": "1.0.0",
"description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
"config": {
"schema": {
"type": "object",
"properties": {
"symbols": {
"type": "array",
"title": "Trading Pairs",
"description": "Cryptocurrency trading pairs to track (default: BTC-USD)",
"items": {
"type": "string",
"title": "Trading Pair",
"pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$",
"description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)"
},
"default": ["BTC-USD"]
},
"reconnectDelay": {
"type": "integer",
"title": "Reconnect Delay",
"description": "Delay in seconds before attempting to reconnect after connection loss",
"default": 5,
"minimum": 1,
"maximum": 60
},
"logPrices": {
"type": "boolean",
"title": "Log Prices",
"description": "Whether to log price updates to the server log",
"default": false
}
}
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/symbols"
},
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/reconnectDelay"
},
{
"type": "Control",
"scope": "#/properties/logPrices"
}
]
}
]
}
},
"permissions": {
"config": {
"reason": "To read ticker symbols configuration"

View file

@ -25,5 +25,74 @@
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"default": [{}],
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}

View file

@ -8,12 +8,9 @@
//!
//! ## Configuration
//!
//! ```toml
//! [PluginConfig.discord-rich-presence-rs]
//! clientid = "YOUR_DISCORD_APPLICATION_ID"
//! "user.username1" = "discord_token1"
//! "user.username2" = "discord_token2"
//! ```
//! Configure this plugin through the Navidrome UI with:
//! - Discord Application Client ID
//! - User tokens array mapping Navidrome usernames to Discord tokens
//!
//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens
//! in configuration files is not secure and may violate Discord's terms of service.
@ -32,6 +29,7 @@ use nd_pdk::websocket::{
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
use serde::Deserialize;
mod rpc;
@ -48,7 +46,7 @@ nd_pdk::register_websocket_close!(DiscordPlugin);
// ============================================================================
const CLIENT_ID_KEY: &str = "clientid";
const USER_KEY_PREFIX: &str = "user.";
const USERS_KEY: &str = "users";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
@ -64,19 +62,31 @@ struct DiscordPlugin;
// Configuration
// ============================================================================
/// User token entry from the config schema
#[derive(Debug, Deserialize)]
struct UserToken {
username: String,
token: String,
}
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
let client_id = config::get(CLIENT_ID_KEY)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
// Get all user keys with the "user." prefix
let user_keys = config::keys(USER_KEY_PREFIX)?;
// Get users array from config (JSON format)
let users_json = config::get(USERS_KEY)?.unwrap_or_default();
let mut users = std::collections::HashMap::new();
for key in user_keys {
let username = key.strip_prefix(USER_KEY_PREFIX).unwrap_or(&key);
if let Some(token) = config::get(&key)?.filter(|s| !s.is_empty()) {
users.insert(username.to_string(), token);
if !users_json.is_empty() {
// Parse JSON array of user tokens
let user_tokens: Vec<UserToken> = serde_json::from_str(&users_json)
.map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?;
for user_token in user_tokens {
if !user_token.username.is_empty() && !user_token.token.is_empty() {
users.insert(user_token.username, user_token.token);
}
}
}

View file

@ -11,6 +11,7 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
@ -24,10 +25,16 @@ import (
// Configuration keys
const (
clientIDKey = "clientid"
userKeyPrefix = "user."
clientIDKey = "clientid"
usersKey = "users"
)
// userToken represents a user-token mapping from the config
type userToken struct {
Username string `json:"username"`
Token string `json:"token"`
}
// discordPlugin implements the scrobbler and scheduler interfaces.
type discordPlugin struct{}
@ -49,24 +56,35 @@ func getConfig() (clientID string, users map[string]string, err error) {
return "", nil, nil
}
// Get all user keys with the "user." prefix
userKeys := host.ConfigKeys(userKeyPrefix)
if len(userKeys) == 0 {
// Get the users array from config
usersJSON, ok := pdk.GetConfig(usersKey)
if !ok || usersJSON == "" {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Parse the JSON array
var userTokens []userToken
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
return clientID, nil, nil
}
if len(userTokens) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Build the users map
users = make(map[string]string)
for _, key := range userKeys {
username := strings.TrimPrefix(key, userKeyPrefix)
token, exists := host.ConfigGet(key)
if exists && token != "" {
users[username] = token
for _, ut := range userTokens {
if ut.Username != "" && ut.Token != "" {
users[ut.Username] = ut.Token
}
}
if len(users) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
pdk.Log(pdk.LogWarn, "no valid users configured")
return clientID, nil, nil
}

View file

@ -29,5 +29,74 @@
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"default": [{}],
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}

View file

@ -20,6 +20,105 @@ import (
. "github.com/onsi/gomega"
)
// testConfigInput is the input for nd_test_config callback.
type testConfigInput struct {
Operation string `json:"operation"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
// testConfigOutput is the output from nd_test_config callback.
type testConfigOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
Keys []string `json:"keys,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
// setupTestConfigPlugin sets up a test environment with the test-config plugin loaded.
// Returns a cleanup function and a helper to call the plugin's nd_test_config function.
func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, testConfigInput) (*testConfigOutput, error)) {
tmpDir, err := os.MkdirTemp("", "config-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-config plugin
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-config",
Path: destPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true,
Config: configJSON,
}})
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// Create and start manager
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
// Helper to call test plugin's exported function
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-config"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
if err != nil {
return nil, err
}
var output testConfigOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
return manager, callTestConfig
}
var _ = Describe("ConfigService", func() {
var service *configServiceImpl
var ctx context.Context
@ -144,59 +243,12 @@ var _ = Describe("ConfigService", func() {
var _ = Describe("ConfigService Integration", Ordered, func() {
var (
manager *Manager
tmpDir string
manager *Manager
callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "config-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-config plugin
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore with pre-enabled plugin and config
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-config",
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: `{"api_key":"test_secret","max_retries":"5","timeout":"30"}`,
}})
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// Create and start manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
manager, callTestConfig = setupTestConfigPlugin(`{"api_key":"test_secret","max_retries":"5","timeout":"30"}`)
})
Describe("Plugin Loading", func() {
@ -205,54 +257,11 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
p, ok := manager.plugins["test-config"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
// Config service doesn't require permission, so Permissions can be nil
// Just verify the plugin loaded
Expect(p.manifest.Name).To(Equal("Test Config Plugin"))
})
})
Describe("Config Operations via Plugin", func() {
type testConfigInput struct {
Operation string `json:"operation"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
type testConfigOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
Keys []string `json:"keys,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
// Helper to call test plugin's exported function
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-config"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
if err != nil {
return nil, err
}
var output testConfigOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
It("should get string value", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
@ -285,7 +294,7 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
It("should return not exists for non-integer value", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get_int",
Key: "api_key", // This is a string, not an integer
Key: "api_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
@ -310,3 +319,64 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
})
})
})
var _ = Describe("Complex Config Values Integration", Ordered, func() {
var callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
BeforeAll(func() {
// Config with arrays and objects - these should be properly serialized as JSON strings
_, callTestConfig = setupTestConfigPlugin(`{"api_key":"secret123","users":[{"username":"admin","token":"tok1"},{"username":"user2","token":"tok2"}],"settings":{"enabled":true,"count":5}}`)
})
Describe("Config Serialization", func() {
It("should make simple string config values accessible to plugin", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
Key: "api_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.StringVal).To(Equal("secret123"))
})
It("should serialize array config values as JSON strings", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
Key: "users",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Array values are serialized as JSON strings - parse to verify structure
var users []map[string]string
Expect(json.Unmarshal([]byte(output.StringVal), &users)).To(Succeed())
Expect(users).To(HaveLen(2))
Expect(users[0]).To(HaveKeyWithValue("username", "admin"))
Expect(users[0]).To(HaveKeyWithValue("token", "tok1"))
Expect(users[1]).To(HaveKeyWithValue("username", "user2"))
Expect(users[1]).To(HaveKeyWithValue("token", "tok2"))
})
It("should serialize object config values as JSON strings", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
Key: "settings",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Object values are serialized as JSON strings - parse to verify structure
var settings map[string]any
Expect(json.Unmarshal([]byte(output.StringVal), &settings)).To(Succeed())
Expect(settings).To(HaveKeyWithValue("enabled", true))
Expect(settings).To(HaveKeyWithValue("count", float64(5)))
})
It("should list all config keys including complex values", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "list",
Prefix: "",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Keys).To(ConsistOf("api_key", "users", "settings"))
})
})
})

View file

@ -381,6 +381,30 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error {
return nil
}
// ValidatePluginConfig validates a config JSON string against the plugin's config schema.
// If the plugin has no config schema defined, it returns an error.
// Returns nil if validation passes, or an error describing the validation failure.
func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
manifest, err := readManifest(plugin.Path)
if err != nil {
return fmt.Errorf("reading manifest: %w", err)
}
return ValidateConfig(manifest, configJSON)
}
// UpdatePluginConfig updates the configuration for a plugin.
// If the plugin is enabled, it will be reloaded with the new config.
func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error {

View file

@ -230,11 +230,9 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
// Parse config from JSON
var pluginConfig map[string]string
if p.Config != "" {
if err := json.Unmarshal([]byte(p.Config), &pluginConfig); err != nil {
return fmt.Errorf("parsing plugin config: %w", err)
}
pluginConfig, err := parsePluginConfig(p.Config)
if err != nil {
return err
}
// Parse users from JSON
@ -379,3 +377,30 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return nil
}
// parsePluginConfig parses a JSON config string into a map of string values.
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
func parsePluginConfig(configJSON string) (map[string]string, error) {
if configJSON == "" {
return nil, nil
}
var rawConfig map[string]any
if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil {
return nil, fmt.Errorf("parsing plugin config: %w", err)
}
pluginConfig := make(map[string]string)
for key, value := range rawConfig {
switch v := value.(type) {
case string:
pluginConfig[key] = v
default:
// Serialize non-string values as JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("serializing config value %q: %w", key, err)
}
pluginConfig[key] = string(jsonBytes)
}
}
return pluginConfig, nil
}

View file

@ -0,0 +1,60 @@
//go:build !windows
package plugins
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parsePluginConfig", func() {
It("returns nil for empty string", func() {
result, err := parsePluginConfig("")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
It("serializes object values as JSON strings", func() {
result, err := parsePluginConfig(`{"settings": {"enabled": true, "count": 5}}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result["settings"]).To(Equal(`{"count":5,"enabled":true}`))
})
It("handles mixed value types", func() {
result, err := parsePluginConfig(`{"api_key": "secret", "timeout": 30, "rate": 1.5, "enabled": true, "tags": ["a", "b"]}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(5))
Expect(result["api_key"]).To(Equal("secret"))
Expect(result["timeout"]).To(Equal("30"))
Expect(result["rate"]).To(Equal("1.5"))
Expect(result["enabled"]).To(Equal("true"))
Expect(result["tags"]).To(Equal(`["a","b"]`))
})
It("returns error for invalid JSON", func() {
_, err := parsePluginConfig(`{invalid json}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
})
It("returns error for non-object JSON", func() {
_, err := parsePluginConfig(`["array", "not", "object"]`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
})
It("handles null values", func() {
result, err := parsePluginConfig(`{"key": null}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result["key"]).To(Equal("null"))
})
It("handles empty object", func() {
result, err := parsePluginConfig(`{}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(0))
Expect(result).ToNot(BeNil())
})
})

View file

@ -36,9 +36,28 @@
},
"experimental": {
"$ref": "#/$defs/Experimental"
},
"config": {
"$ref": "#/$defs/ConfigDefinition"
}
},
"$defs": {
"ConfigDefinition": {
"type": "object",
"description": "Configuration schema for the plugin using JSON Schema (draft-07) and optional JSONForms UI Schema",
"additionalProperties": false,
"required": ["schema"],
"properties": {
"schema": {
"type": "object",
"description": "JSON Schema (draft-07) defining the plugin's configuration options"
},
"uiSchema": {
"type": "object",
"description": "Optional JSONForms UI Schema for customizing form layout"
}
}
},
"Experimental": {
"type": "object",
"description": "Experimental features that may change or be removed in future versions",

View file

@ -3,6 +3,8 @@ package plugins
import (
"encoding/json"
"fmt"
"github.com/santhosh-tekuri/jsonschema/v6"
)
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json
@ -29,6 +31,26 @@ func (m *Manifest) Validate() error {
return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared")
}
}
// Validate config schema if present
if m.Config != nil && m.Config.Schema != nil {
if err := validateConfigSchema(m.Config.Schema); err != nil {
return fmt.Errorf("invalid config schema: %w", err)
}
}
return nil
}
// validateConfigSchema validates that the schema is a valid JSON Schema that can be compiled.
func validateConfigSchema(schema map[string]any) error {
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schema); err != nil {
return fmt.Errorf("invalid schema structure: %w", err)
}
if _, err := compiler.Compile("schema.json"); err != nil {
return err
}
return nil
}

View file

@ -17,6 +17,34 @@ type CachePermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Configuration schema for the plugin using JSON Schema (draft-07) and optional
// JSONForms UI Schema
type ConfigDefinition struct {
// JSON Schema (draft-07) defining the plugin's configuration options
Schema map[string]interface{} `json:"schema" yaml:"schema" mapstructure:"schema"`
// Optional JSONForms UI Schema for customizing form layout
UiSchema map[string]interface{} `json:"uiSchema,omitempty" yaml:"uiSchema,omitempty" mapstructure:"uiSchema,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["schema"]; raw != nil && !ok {
return fmt.Errorf("field schema in ConfigDefinition: required")
}
type Plain ConfigDefinition
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
*j = ConfigDefinition(plain)
return nil
}
// Configuration access permissions for a plugin
type ConfigPermission struct {
// Explanation for why config access is needed
@ -81,6 +109,9 @@ type Manifest struct {
// The author of the plugin
Author string `json:"author" yaml:"author" mapstructure:"author"`
// Config corresponds to the JSON schema field "config".
Config *ConfigDefinition `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"`
// A brief description of what the plugin does
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`

View file

@ -286,6 +286,107 @@ var _ = Describe("Manifest", func() {
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest with valid config schema", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"api_key": map[string]any{
"type": "string",
},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest with complex config schema", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"users": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"username": map[string]any{"type": "string"},
"token": map[string]any{"type": "string"},
},
"required": []any{"username", "token"},
},
},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("returns error for invalid config schema - bad type", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "invalid_type",
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("config schema"))
})
It("returns error for invalid config schema - bad minLength", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"minLength": "not_a_number",
},
},
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("config schema"))
})
It("validates manifest without config", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("ValidateWithCapabilities", func() {

View file

@ -2,5 +2,60 @@
"name": "Test Config Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test plugin for config service integration testing"
"description": "A test plugin for config service integration testing",
"config": {
"schema": {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"minLength": 1
},
"max_retries": {
"type": "string",
"title": "Max Retries"
},
"timeout": {
"type": "string",
"title": "Timeout"
},
"users": {
"type": "array",
"title": "Users",
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Username",
"minLength": 1
},
"token": {
"type": "string",
"title": "Token",
"minLength": 1
}
},
"required": ["username", "token"]
}
},
"settings": {
"type": "object",
"title": "Settings",
"properties": {
"enabled": {
"type": "boolean",
"title": "Enabled"
},
"count": {
"type": "integer",
"title": "Count"
}
}
}
},
"required": ["api_key"]
}
}
}