feat(plugins): change websockets Data field type to []byte for binary support
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
if msgType == websocket.BinaryMessage {
|
||||||
|
serverBinaryMessages = append(serverBinaryMessages, msg)
|
||||||
|
} else {
|
||||||
serverMessages = append(serverMessages, string(msg))
|
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
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user