diff --git a/plugins/capabilities/websocket_callback.go b/plugins/capabilities/websocket_callback.go index 07db029f..ddfc0fc9 100644 --- a/plugins/capabilities/websocket_callback.go +++ b/plugins/capabilities/websocket_callback.go @@ -38,7 +38,7 @@ type OnBinaryMessageRequest struct { // ConnectionID is the unique identifier for the WebSocket connection that received the message. ConnectionID string `json:"connectionId"` // 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. diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml index 401c77bb..6cd0cff9 100644 --- a/plugins/capabilities/websocket_callback.yaml +++ b/plugins/capabilities/websocket_callback.yaml @@ -30,6 +30,7 @@ components: description: ConnectionID is the unique identifier for the WebSocket connection that received the message. data: type: string + format: byte description: Data is the binary data received from the WebSocket, encoded as base64. required: - connectionId diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index db30262c..cc2a7d0e 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -246,6 +246,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { 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 if strings.HasPrefix(goType, "[]") { elemType := goType[2:] @@ -259,7 +265,7 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { return prop } - // Handle primitive types + // Handle remaining types prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType) return prop } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 5e8a132f..2e28a75d 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -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() { It("should mark pointer to enum as nullable with $ref", func() { capability := Capability{ diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 84b28dd3..74238a42 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -2,7 +2,6 @@ package plugins import ( "context" - "encoding/base64" "errors" "fmt" "maps" @@ -355,7 +354,7 @@ func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connecti func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) { invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{ ConnectionID: connectionID, - Data: base64.StdEncoding.EncodeToString(data), + Data: data, }, "binary message", connectionID) } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index e8cb9f8f..83fca989 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -5,7 +5,7 @@ package plugins import ( "context" "crypto/sha256" - "encoding/base64" + "encoding/hex" "maps" "net/http" @@ -294,11 +294,13 @@ var _ = Describe("WebSocketService", Ordered, func() { var wsServer *httptest.Server var serverConn *websocket.Conn var serverMessages []string + var serverBinaryMessages [][]byte var serverMu sync.Mutex BeforeEach(func() { serverConn = nil serverMessages = nil + serverBinaryMessages = nil upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, @@ -314,12 +316,16 @@ var _ = Describe("WebSocketService", Ordered, func() { // Read and store messages for { - _, msg, err := conn.ReadMessage() + msgType, msg, err := conn.ReadMessage() if err != nil { break } serverMu.Lock() - serverMessages = append(serverMessages, string(msg)) + if msgType == websocket.BinaryMessage { + serverBinaryMessages = append(serverBinaryMessages, msg) + } else { + serverMessages = append(serverMessages, string(msg)) + } serverMu.Unlock() } })) @@ -359,13 +365,12 @@ var _ = Describe("WebSocketService", Ordered, func() { serverMu.Unlock() Expect(err).ToNot(HaveOccurred()) - // Plugin echoes binary data back as text prefixed with "binary_echo:" - expectedEcho := "binary_echo:" + base64.StdEncoding.EncodeToString(binaryData) - Eventually(func() []string { + // Plugin echoes binary data back as a binary message + Eventually(func() [][]byte { serverMu.Lock() defer serverMu.Unlock() - return serverMessages - }).Should(ContainElement(expectedEcho)) + return serverBinaryMessages + }).Should(ContainElement(binaryData)) }) It("should invoke OnClose callback when server closes connection", func() { @@ -609,6 +614,3 @@ func findWebSocketService(m *Manager, pluginName string) *webSocketServiceImpl { } return nil } - -// Ensure base64 import is used -var _ = base64.StdEncoding diff --git a/plugins/pdk/go/websocket/websocket.go b/plugins/pdk/go/websocket/websocket.go index 0ad2cb54..47a53b7b 100644 --- a/plugins/pdk/go/websocket/websocket.go +++ b/plugins/pdk/go/websocket/websocket.go @@ -16,7 +16,7 @@ type OnBinaryMessageRequest struct { // ConnectionID is the unique identifier for the WebSocket connection that received the message. ConnectionID string `json:"connectionId"` // 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. diff --git a/plugins/pdk/go/websocket/websocket_stub.go b/plugins/pdk/go/websocket/websocket_stub.go index 1c808d92..214118a8 100644 --- a/plugins/pdk/go/websocket/websocket_stub.go +++ b/plugins/pdk/go/websocket/websocket_stub.go @@ -13,7 +13,7 @@ type OnBinaryMessageRequest struct { // ConnectionID is the unique identifier for the WebSocket connection that received the message. ConnectionID string `json:"connectionId"` // 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. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs index b077110d..672233e4 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs @@ -4,6 +4,29 @@ // It is intended for use in Navidrome plugins built with extism-pdk. 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(bytes: &Vec, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&BASE64.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, 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 #[allow(dead_code)] @@ -27,7 +50,8 @@ pub struct OnBinaryMessageRequest { pub connection_id: String, /// Data is the binary data received from the WebSocket, encoded as base64. #[serde(default)] - pub data: String, + #[serde(with = "base64_bytes")] + pub data: Vec, } /// OnCloseRequest is the request provided when a WebSocket connection is closed. #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/plugins/testdata/test-websocket/main.go b/plugins/testdata/test-websocket/main.go index b85eaa35..270f36a1 100644 --- a/plugins/testdata/test-websocket/main.go +++ b/plugins/testdata/test-websocket/main.go @@ -3,6 +3,7 @@ package main import ( + "encoding/base64" "errors" "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. -// Echoes the data back as a text message prefixed with "binary_echo:" so tests -// can observe the callback fired. +// Echoes the data back as a binary message so tests can observe the callback fired. func (t *testWebSocket) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - storeReceivedMessage("binary:" + input.Data) - return host.WebSocketSendText(input.ConnectionID, "binary_echo:"+input.Data) + encoded := base64.StdEncoding.EncodeToString(input.Data) + storeReceivedMessage("binary:" + encoded) + return host.WebSocketSendBinary(input.ConnectionID, input.Data) } // OnError is called when an error occurs on a WebSocket connection.