feat(plugins): change websockets Data field type to []byte for binary support

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2026-03-02 16:38:00 -05:00
parent 30df004d4d
commit 6fd044fb09
10 changed files with 94 additions and 22 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ type OnBinaryMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message. // ConnectionID is the unique identifier for the WebSocket connection that received the message.
ConnectionID string `json:"connectionId"` ConnectionID string `json:"connectionId"`
// Data is the binary data received from the WebSocket, encoded as base64. // Data is the binary data received from the WebSocket, encoded as base64.
Data string `json:"data"` Data []byte `json:"data"`
} }
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection. // OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
@@ -30,6 +30,7 @@ components:
description: ConnectionID is the unique identifier for the WebSocket connection that received the message. description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
data: data:
type: string type: string
format: byte
description: Data is the binary data received from the WebSocket, encoded as base64. description: Data is the binary data received from the WebSocket, encoded as base64.
required: required:
- connectionId - connectionId
+7 -1
View File
@@ -246,6 +246,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
return prop return prop
} }
// Handle primitive types (including []byte which maps to string/byte, not array)
if isPrimitiveGoType(goType) {
prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType)
return prop
}
// Handle slice types // Handle slice types
if strings.HasPrefix(goType, "[]") { if strings.HasPrefix(goType, "[]") {
elemType := goType[2:] elemType := goType[2:]
@@ -259,7 +265,7 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
return prop return prop
} }
// Handle primitive types // Handle remaining types
prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType) prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType)
return prop return prop
} }
@@ -303,6 +303,45 @@ var _ = Describe("XTP Schema Generation", func() {
}) })
}) })
Context("capability with []byte field", func() {
It("should map []byte to string with byte format, not array", func() {
capability := Capability{
Name: "byte_test",
SourceFile: "byte_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Data", Type: "[]byte", JSONTag: "data"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(ValidateXTPSchema(schema)).To(Succeed())
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
data := props["data"].(map[string]any)
Expect(data["type"]).To(Equal("string"))
Expect(data["format"]).To(Equal("byte"))
Expect(data).NotTo(HaveKey("items"))
})
})
Context("capability with nullable ref", func() { Context("capability with nullable ref", func() {
It("should mark pointer to enum as nullable with $ref", func() { It("should mark pointer to enum as nullable with $ref", func() {
capability := Capability{ capability := Capability{
+1 -2
View File
@@ -2,7 +2,6 @@ package plugins
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
@@ -355,7 +354,7 @@ func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connecti
func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) { func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) {
invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{ invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{
ConnectionID: connectionID, ConnectionID: connectionID,
Data: base64.StdEncoding.EncodeToString(data), Data: data,
}, "binary message", connectionID) }, "binary message", connectionID)
} }
+13 -11
View File
@@ -5,7 +5,7 @@ package plugins
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"maps" "maps"
"net/http" "net/http"
@@ -294,11 +294,13 @@ var _ = Describe("WebSocketService", Ordered, func() {
var wsServer *httptest.Server var wsServer *httptest.Server
var serverConn *websocket.Conn var serverConn *websocket.Conn
var serverMessages []string var serverMessages []string
var serverBinaryMessages [][]byte
var serverMu sync.Mutex var serverMu sync.Mutex
BeforeEach(func() { BeforeEach(func() {
serverConn = nil serverConn = nil
serverMessages = nil serverMessages = nil
serverBinaryMessages = nil
upgrader := websocket.Upgrader{ upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
@@ -314,12 +316,16 @@ var _ = Describe("WebSocketService", Ordered, func() {
// Read and store messages // Read and store messages
for { for {
_, msg, err := conn.ReadMessage() msgType, msg, err := conn.ReadMessage()
if err != nil { if err != nil {
break break
} }
serverMu.Lock() serverMu.Lock()
serverMessages = append(serverMessages, string(msg)) if msgType == websocket.BinaryMessage {
serverBinaryMessages = append(serverBinaryMessages, msg)
} else {
serverMessages = append(serverMessages, string(msg))
}
serverMu.Unlock() serverMu.Unlock()
} }
})) }))
@@ -359,13 +365,12 @@ var _ = Describe("WebSocketService", Ordered, func() {
serverMu.Unlock() serverMu.Unlock()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
// Plugin echoes binary data back as text prefixed with "binary_echo:" // Plugin echoes binary data back as a binary message
expectedEcho := "binary_echo:" + base64.StdEncoding.EncodeToString(binaryData) Eventually(func() [][]byte {
Eventually(func() []string {
serverMu.Lock() serverMu.Lock()
defer serverMu.Unlock() defer serverMu.Unlock()
return serverMessages return serverBinaryMessages
}).Should(ContainElement(expectedEcho)) }).Should(ContainElement(binaryData))
}) })
It("should invoke OnClose callback when server closes connection", func() { It("should invoke OnClose callback when server closes connection", func() {
@@ -609,6 +614,3 @@ func findWebSocketService(m *Manager, pluginName string) *webSocketServiceImpl {
} }
return nil return nil
} }
// Ensure base64 import is used
var _ = base64.StdEncoding
+1 -1
View File
@@ -16,7 +16,7 @@ type OnBinaryMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message. // ConnectionID is the unique identifier for the WebSocket connection that received the message.
ConnectionID string `json:"connectionId"` ConnectionID string `json:"connectionId"`
// Data is the binary data received from the WebSocket, encoded as base64. // Data is the binary data received from the WebSocket, encoded as base64.
Data string `json:"data"` Data []byte `json:"data"`
} }
// OnCloseRequest is the request provided when a WebSocket connection is closed. // OnCloseRequest is the request provided when a WebSocket connection is closed.
+1 -1
View File
@@ -13,7 +13,7 @@ type OnBinaryMessageRequest struct {
// ConnectionID is the unique identifier for the WebSocket connection that received the message. // ConnectionID is the unique identifier for the WebSocket connection that received the message.
ConnectionID string `json:"connectionId"` ConnectionID string `json:"connectionId"`
// Data is the binary data received from the WebSocket, encoded as base64. // Data is the binary data received from the WebSocket, encoded as base64.
Data string `json:"data"` Data []byte `json:"data"`
} }
// OnCloseRequest is the request provided when a WebSocket connection is closed. // OnCloseRequest is the request provided when a WebSocket connection is closed.
@@ -4,6 +4,29 @@
// It is intended for use in Navidrome plugins built with extism-pdk. // It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
mod base64_bytes {
use serde::{self, Deserialize, Deserializer, Serializer};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64.decode(&s).map_err(serde::de::Error::custom)
}
}
// Helper functions for skip_serializing_if with numeric types // Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)] #[allow(dead_code)]
@@ -27,7 +50,8 @@ pub struct OnBinaryMessageRequest {
pub connection_id: String, pub connection_id: String,
/// Data is the binary data received from the WebSocket, encoded as base64. /// Data is the binary data received from the WebSocket, encoded as base64.
#[serde(default)] #[serde(default)]
pub data: String, #[serde(with = "base64_bytes")]
pub data: Vec<u8>,
} }
/// OnCloseRequest is the request provided when a WebSocket connection is closed. /// OnCloseRequest is the request provided when a WebSocket connection is closed.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
+5 -4
View File
@@ -3,6 +3,7 @@
package main package main
import ( import (
"encoding/base64"
"errors" "errors"
"github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/host"
@@ -45,11 +46,11 @@ func (t *testWebSocket) OnTextMessage(input websocket.OnTextMessageRequest) erro
} }
// OnBinaryMessage is called when a binary message is received. // OnBinaryMessage is called when a binary message is received.
// Echoes the data back as a text message prefixed with "binary_echo:" so tests // Echoes the data back as a binary message so tests can observe the callback fired.
// can observe the callback fired.
func (t *testWebSocket) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { func (t *testWebSocket) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
storeReceivedMessage("binary:" + input.Data) encoded := base64.StdEncoding.EncodeToString(input.Data)
return host.WebSocketSendText(input.ConnectionID, "binary_echo:"+input.Data) storeReceivedMessage("binary:" + encoded)
return host.WebSocketSendBinary(input.ConnectionID, input.Data)
} }
// OnError is called when an error occurs on a WebSocket connection. // OnError is called when an error occurs on a WebSocket connection.