From bd8032b3274a02fef7dbafb51c8ea6b5970b3931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 27 Feb 2026 19:00:19 -0500 Subject: [PATCH] fix(plugins): add base64 handling for []byte and remove raw=true (#5121) * fix(plugins): add base64 handling for []byte and remove raw=true Go's json.Marshal automatically base64-encodes []byte fields, but Rust's serde_json serializes Vec as a JSON array and Python's json.dumps raises TypeError on bytes. This fixes both directions of plugin communication by adding proper base64 encoding/decoding in generated client code. For Rust templates (client and capability): adds a base64_bytes serde helper module with #[serde(with = "base64_bytes")] on all Vec fields, and adds base64 as a dependency. For Python templates: wraps bytes params with base64.b64encode() and responses with base64.b64decode(). Also removes the raw=true binary framing protocol from all templates, the parser, and the Method type. The raw mechanism added complexity that is no longer needed once []byte works properly over JSON. * fix(plugins): update production code and tests for base64 migration Remove raw=true annotation from SubsonicAPI.CallRaw, delete all raw test fixtures, remove raw-related test cases from parser, generator, and integration tests, and add new test cases validating base64 handling for Rust and Python templates. * fix(plugins): update golden files and regenerate production code Update golden test fixtures for codec and comprehensive services to include base64 handling for []byte fields. Regenerate all production PDK code (Go, Rust, Python) and host wrappers to use standard JSON with base64-encoded byte fields instead of binary framing protocol. * refactor: remove base64 helper duplication from rust template Signed-off-by: Deluan * fix(plugins): add base64 dependency to capabilities' Cargo.toml Signed-off-by: Deluan --------- Signed-off-by: Deluan --- plugins/cmd/ndpgen/integration_test.go | 3 - plugins/cmd/ndpgen/internal/generator.go | 18 ++ plugins/cmd/ndpgen/internal/generator_test.go | 266 +++++------------- plugins/cmd/ndpgen/internal/parser.go | 8 - plugins/cmd/ndpgen/internal/parser_test.go | 113 -------- .../internal/templates/base64_bytes.rs.tmpl | 25 ++ .../internal/templates/capability.rs.tmpl | 4 + .../ndpgen/internal/templates/client.go.tmpl | 27 +- .../ndpgen/internal/templates/client.py.tmpl | 45 ++- .../ndpgen/internal/templates/client.rs.tmpl | 88 +----- .../ndpgen/internal/templates/host.go.tmpl | 50 +--- plugins/cmd/ndpgen/internal/types.go | 57 +++- .../ndpgen/testdata/codec_client_expected.py | 5 +- .../ndpgen/testdata/codec_client_expected.rs | 25 ++ .../testdata/comprehensive_client_expected.py | 5 +- .../testdata/comprehensive_client_expected.rs | 25 ++ .../testdata/raw_client_expected.go.txt | 66 ----- .../ndpgen/testdata/raw_client_expected.py | 63 ----- .../ndpgen/testdata/raw_client_expected.rs | 73 ----- .../cmd/ndpgen/testdata/raw_service.go.txt | 10 - plugins/host/subsonicapi.go | 6 +- plugins/host/subsonicapi_gen.go | 44 +-- plugins/pdk/go/host/nd_host_subsonicapi.go | 37 ++- .../pdk/go/host/nd_host_subsonicapi_stub.go | 4 +- plugins/pdk/python/host/nd_host_cache.py | 5 +- plugins/pdk/python/host/nd_host_http.py | 1 + plugins/pdk/python/host/nd_host_kvstore.py | 5 +- .../pdk/python/host/nd_host_subsonicapi.py | 42 +-- plugins/pdk/python/host/nd_host_websocket.py | 3 +- .../pdk/rust/nd-pdk-capabilities/Cargo.toml | 1 + plugins/pdk/rust/nd-pdk-host/Cargo.toml | 1 + .../pdk/rust/nd-pdk-host/src/nd_host_cache.rs | 25 ++ .../pdk/rust/nd-pdk-host/src/nd_host_http.rs | 25 ++ .../rust/nd-pdk-host/src/nd_host_kvstore.rs | 25 ++ .../nd-pdk-host/src/nd_host_subsonicapi.rs | 90 +++--- .../rust/nd-pdk-host/src/nd_host_websocket.rs | 24 ++ 36 files changed, 460 insertions(+), 854 deletions(-) create mode 100644 plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl delete mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt delete mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.py delete mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.rs delete mode 100644 plugins/cmd/ndpgen/testdata/raw_service.go.txt diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index 13ebe14a..db500c1f 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -282,9 +282,6 @@ type ServiceB interface { Entry("option pattern (value, exists bool)", "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), - - Entry("raw=true binary response", - "raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"), ) It("generates compilable client code for comprehensive service", func() { diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 69e23256..705cd4d3 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -256,6 +256,15 @@ func GenerateClientRust(svc Service) ([]byte, error) { return nil, fmt.Errorf("parsing template: %w", err) } + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + data := templateData{ Service: svc, } @@ -622,6 +631,15 @@ func GenerateCapabilityRust(cap Capability) ([]byte, error) { return nil, fmt.Errorf("parsing template: %w", err) } + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + data := capabilityTemplateData{ Package: cap.Name, Capability: cap, diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index ed15f174..34c2c288 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -264,96 +264,6 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) - It("should generate binary framing for raw=true methods", func() { - svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", - Methods: []Method{ - { - Name: "GetStream", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should include encoding/binary import for raw methods - Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) - - // Should NOT generate a response type for raw methods - Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct")) - - // Should generate request type (request is still JSON) - Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct")) - - // Should build binary frame [0x00][4-byte CT len][CT][data] - Expect(codeStr).To(ContainSubstring("frame[0] = 0x00")) - Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32")) - - // Should have writeRawError helper - Expect(codeStr).To(ContainSubstring("streamWriteRawError")) - - // Should use writeRawError instead of writeError for raw methods - Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack")) - }) - - It("should generate both writeError and writeRawError for mixed services", func() { - svc := Service{ - Name: "API", - Permission: "api", - Interface: "APIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - { - Name: "CallRaw", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should have both helpers - Expect(codeStr).To(ContainSubstring("apiWriteResponse")) - Expect(codeStr).To(ContainSubstring("apiWriteError")) - Expect(codeStr).To(ContainSubstring("apiWriteRawError")) - - // Should generate response type for non-raw method only - Expect(codeStr).To(ContainSubstring("type APICallResponse struct")) - Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct")) - }) - It("should always include json import for JSON protocol", func() { // All services use JSON protocol, so json import is always needed svc := Service{ @@ -717,49 +627,7 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) }) - It("should generate binary frame parsing for raw methods", func() { - svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", - Methods: []Method{ - { - Name: "GetStream", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - Doc: "GetStream returns raw binary stream data.", - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should import Tuple and struct for raw methods - Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple")) - Expect(codeStr).To(ContainSubstring("import struct")) - - // Should return Tuple[str, bytes] - Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:")) - - // Should parse binary frame instead of JSON - Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()")) - Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) - Expect(codeStr).To(ContainSubstring("struct.unpack")) - Expect(codeStr).To(ContainSubstring("return content_type, data")) - - // Should NOT use json.loads for response - Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))")) - }) - - It("should not import Tuple or struct for non-raw services", func() { + It("should not import base64 for non-byte services", func() { svc := Service{ Name: "Test", Permission: "test", @@ -779,8 +647,37 @@ var _ = Describe("Generator", func() { codeStr := string(code) - Expect(codeStr).NotTo(ContainSubstring("Tuple")) - Expect(codeStr).NotTo(ContainSubstring("import struct")) + Expect(codeStr).NotTo(ContainSubstring("import base64")) + }) + + It("should generate base64 encoding/decoding for byte fields", func() { + svc := Service{ + Name: "Codec", + Permission: "codec", + Interface: "CodecService", + Methods: []Method{ + { + Name: "Encode", + HasError: true, + Params: []Param{NewParam("data", "[]byte")}, + Returns: []Param{NewParam("result", "[]byte")}, + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should import base64 + Expect(codeStr).To(ContainSubstring("import base64")) + + // Should base64-encode byte params in request + Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`)) + + // Should base64-decode byte returns in response + Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`)) }) }) @@ -939,46 +836,6 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk")) }) - It("should include encoding/binary import for raw methods", func() { - svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", - Methods: []Method{ - { - Name: "GetStream", - HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - }, - }, - } - - code, err := GenerateClientGo(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should include encoding/binary for raw binary frame parsing - Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) - - // Should NOT generate response type struct for raw methods - Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct")) - - // Should still generate request type - Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct")) - - // Should parse binary frame - Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01")) - Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32")) - - // Should return (string, []byte, error) - Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)")) - }) }) Describe("GenerateClientGoStub", func() { @@ -1748,22 +1605,17 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("Option")) }) - It("should generate raw extern C import and binary frame parsing for raw methods", func() { + It("should generate base64 serde for Vec fields", func() { svc := Service{ - Name: "Stream", - Permission: "stream", - Interface: "StreamService", + Name: "Codec", + Permission: "codec", + Interface: "CodecService", Methods: []Method{ { - Name: "GetStream", + Name: "Encode", HasError: true, - Raw: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{ - NewParam("contentType", "string"), - NewParam("data", "[]byte"), - }, - Doc: "GetStream returns raw binary stream data.", + Params: []Param{NewParam("data", "[]byte")}, + Returns: []Param{NewParam("result", "[]byte")}, }, }, } @@ -1773,24 +1625,36 @@ var _ = Describe("Rust Generation", func() { codeStr := string(code) - // Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost" - Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`)) - Expect(codeStr).To(ContainSubstring(`extern "C"`)) - Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64")) + // Should generate base64_bytes serde module + Expect(codeStr).To(ContainSubstring("mod base64_bytes")) + Expect(codeStr).To(ContainSubstring("use base64::Engine as _")) - // Should NOT generate response type for raw methods - Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse")) + // Should add serde(with = "base64_bytes") on Vec fields + Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) + }) - // Should generate request type (request is still JSON) - Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest")) + It("should not generate base64 module when no byte fields", func() { + svc := Service{ + Name: "Test", + Permission: "test", + Interface: "TestService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + }, + } - // Should return Result<(String, Vec), Error> - Expect(codeStr).To(ContainSubstring("Result<(String, Vec), Error>")) + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) - // Should parse binary frame - Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) - Expect(codeStr).To(ContainSubstring("u32::from_be_bytes")) - Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy")) + codeStr := string(code) + + Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes")) + Expect(codeStr).NotTo(ContainSubstring("use base64")) }) }) }) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index c2d57177..4cb28f8d 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -761,7 +761,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri m := Method{ Name: name, ExportName: annotation["name"], - Raw: annotation["raw"] == "true", Doc: doc, } @@ -800,13 +799,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri } } - // Validate raw=true methods: must return exactly (string, []byte, error) - if m.Raw { - if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" { - return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name) - } - } - return m, nil } diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f2bdbede..f4357839 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -122,119 +122,6 @@ type TestService interface { Expect(services[0].Methods[0].Name).To(Equal("Exported")) }) - It("should parse raw=true annotation", func() { - src := `package host - -import "context" - -//nd:hostservice name=Stream permission=stream -type StreamService interface { - //nd:hostfunc raw=true - GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - m := services[0].Methods[0] - Expect(m.Name).To(Equal("GetStream")) - Expect(m.Raw).To(BeTrue()) - Expect(m.HasError).To(BeTrue()) - Expect(m.Returns).To(HaveLen(2)) - Expect(m.Returns[0].Name).To(Equal("contentType")) - Expect(m.Returns[0].Type).To(Equal("string")) - Expect(m.Returns[1].Name).To(Equal("data")) - Expect(m.Returns[1].Type).To(Equal("[]byte")) - }) - - It("should set Raw=false when raw annotation is absent", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Call(ctx context.Context, uri string) (response string, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services[0].Methods[0].Raw).To(BeFalse()) - }) - - It("should reject raw=true with invalid return signature", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc raw=true - BadRaw(ctx context.Context, uri string) (result string, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - _, err = ParseDirectory(tmpDir) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("raw=true")) - Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)")) - }) - - It("should reject raw=true without error return", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc raw=true - BadRaw(ctx context.Context, uri string) (contentType string, data []byte) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - _, err = ParseDirectory(tmpDir) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("raw=true")) - }) - - It("should parse mixed raw and non-raw methods", func() { - src := `package host - -import "context" - -//nd:hostservice name=API permission=api -type APIService interface { - //nd:hostfunc - Call(ctx context.Context, uri string) (responseJSON string, err error) - - //nd:hostfunc raw=true - CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - Expect(services[0].Methods).To(HaveLen(2)) - Expect(services[0].Methods[0].Raw).To(BeFalse()) - Expect(services[0].Methods[1].Raw).To(BeTrue()) - Expect(services[0].HasRawMethods()).To(BeTrue()) - }) - It("should handle custom export name", func() { src := `package host diff --git a/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl new file mode 100644 index 00000000..929aa8e3 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl @@ -0,0 +1,25 @@ +{{define "base64_bytes_module"}} +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) + } +} +{{- end}} \ No newline at end of file diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 597a1733..790ed93e 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; {{- end}} +{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} // Helper functions for skip_serializing_if with numeric types #[allow(dead_code)] @@ -70,6 +71,9 @@ pub struct {{.Name}} { #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] {{- else}} #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] {{- end}} pub {{rustFieldName .Name}}: {{fieldRustType .}}, {{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index 971ae394..a6ee0444 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -8,9 +8,6 @@ package {{.Package}} import ( -{{- if .Service.HasRawMethods}} - "encoding/binary" -{{- end}} "encoding/json" {{- if .Service.HasErrors}} "errors" @@ -52,7 +49,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if and (not .IsErrorOnly) (not .Raw)}} +{{- if not .IsErrorOnly}} type {{responseType .}} struct { {{- range .Returns}} @@ -98,27 +95,7 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{ // Read the response from memory responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() -{{- if .Raw}} - - // Parse binary-framed response - if len(responseBytes) == 0 { - return "", nil, errors.New("empty response from host") - } - if responseBytes[0] == 0x01 { // error - return "", nil, errors.New(string(responseBytes[1:])) - } - if responseBytes[0] != 0x00 { - return "", nil, errors.New("unknown response status") - } - if len(responseBytes) < 5 { - return "", nil, errors.New("malformed raw response: incomplete header") - } - ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) - if uint32(len(responseBytes)) < 5+ctLen { - return "", nil, errors.New("malformed raw response: content-type overflow") - } - return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil -{{- else if .IsErrorOnly}} +{{- if .IsErrorOnly}} // Parse error-only response var response struct { diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl index 84bff4ab..7ccaa610 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl @@ -8,12 +8,12 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}} +from typing import Any import extism import json -{{- if .Service.HasRawMethods}} -import struct +{{- if .Service.HasByteFields}} +import base64 {{- end}} @@ -32,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int: {{- end}} {{- /* Generate dataclasses for multi-value returns */ -}} {{range .Service.Methods}} -{{- if and .NeedsResultClass (not .Raw)}} +{{- if .NeedsResultClass}} @dataclass @@ -47,7 +47,7 @@ class {{pythonResultType .}}: {{range .Service.Methods}} -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: +def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -56,11 +56,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{.PythonName}}: {{.PythonType}} parameter. {{- end}} {{- end}} -{{- if .Raw}} - - Returns: - Tuple of (content_type, data) with the raw binary response. -{{- else if .HasReturns}} +{{- if .HasReturns}} Returns: {{- if .NeedsResultClass}} @@ -76,7 +72,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{- if .HasParams}} request = { {{- range .Params}} +{{- if .IsByteSlice}} + "{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"), +{{- else}} "{{.JSONName}}": {{.PythonName}}, +{{- end}} {{- end}} } request_bytes = json.dumps(request).encode("utf-8") @@ -86,24 +86,6 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam request_mem = extism.memory.alloc(request_bytes) response_offset = _{{exportName .}}(request_mem.offset) response_mem = extism.memory.find(response_offset) -{{- if .Raw}} - response_bytes = response_mem.bytes() - - if len(response_bytes) == 0: - raise HostFunctionError("empty response from host") - if response_bytes[0] == 0x01: - raise HostFunctionError(response_bytes[1:].decode("utf-8")) - if response_bytes[0] != 0x00: - raise HostFunctionError("unknown response status") - if len(response_bytes) < 5: - raise HostFunctionError("malformed raw response: incomplete header") - ct_len = struct.unpack(">I", response_bytes[1:5])[0] - if len(response_bytes) < 5 + ct_len: - raise HostFunctionError("malformed raw response: content-type overflow") - content_type = response_bytes[5:5 + ct_len].decode("utf-8") - data = response_bytes[5 + ct_len:] - return content_type, data -{{- else}} response = json.loads(extism.memory.string(response_mem)) {{if .HasError}} if response.get("error"): @@ -112,10 +94,17 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{- if .NeedsResultClass}} return {{pythonResultType .}}( {{- range .Returns}} +{{- if .IsByteSlice}} + {{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")), +{{- else}} {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}), +{{- end}} {{- end}} ) {{- else if .HasReturns}} +{{- if (index .Returns 0).IsByteSlice}} + return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", "")) +{{- else}} return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) {{- end}} {{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index 2fb368bf..f8b78684 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -5,6 +5,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} {{- /* Generate struct definitions */ -}} {{- range .Service.Structs}} {{if .Doc}} @@ -16,6 +17,9 @@ pub struct {{.Name}} { {{- range .Fields}} {{- if .NeedsDefault}} #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] {{- end}} pub {{.RustName}}: {{fieldRustType .}}, {{- end}} @@ -29,17 +33,22 @@ pub struct {{.Name}} { #[serde(rename_all = "camelCase")] struct {{requestType .}} { {{- range .Params}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} {{.RustName}}: {{rustType .}}, {{- end}} } {{- end}} -{{- if not .Raw}} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct {{responseType .}} { {{- range .Returns}} #[serde(default)] +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} {{.RustName}}: {{rustType .}}, {{- end}} {{- if .HasError}} @@ -48,92 +57,16 @@ struct {{responseType .}} { {{- end}} } {{- end}} -{{- end}} #[host_fn] extern "ExtismHost" { {{- range .Service.Methods}} -{{- if not .Raw}} fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>; {{- end}} -{{- end}} } -{{- /* Declare raw extern "C" imports for raw methods */ -}} -{{- range .Service.Methods}} -{{- if .Raw}} - -#[link(wasm_import_module = "extism:host/user")] -extern "C" { - fn {{exportName .}}(offset: u64) -> u64; -} -{{- end}} -{{- end}} {{- /* Generate wrapper functions */ -}} {{range .Service.Methods}} -{{- if .Raw}} - -{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} -{{- if .HasParams}} -/// -/// # Arguments -{{- range .Params}} -/// * `{{.RustName}}` - {{rustType .}} parameter. -{{- end}} -{{- end}} -/// -/// # Returns -/// A tuple of (content_type, data) with the raw binary response. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec), Error> { -{{- if .HasParams}} - let req = {{requestType .}} { -{{- range .Params}} - {{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}}, -{{- end}} - }; - let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; -{{- else}} - let input_bytes = b"{}".to_vec(); -{{- end}} - let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; - - let response_offset = unsafe { {{exportName .}}(input_mem.offset()) }; - - let response_mem = Memory::find(response_offset) - .ok_or_else(|| Error::msg("empty response from host"))?; - let response_bytes = response_mem.to_vec(); - - if response_bytes.is_empty() { - return Err(Error::msg("empty response from host")); - } - if response_bytes[0] == 0x01 { - let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); - return Err(Error::msg(msg)); - } - if response_bytes[0] != 0x00 { - return Err(Error::msg("unknown response status")); - } - if response_bytes.len() < 5 { - return Err(Error::msg("malformed raw response: incomplete header")); - } - let ct_len = u32::from_be_bytes([ - response_bytes[1], - response_bytes[2], - response_bytes[3], - response_bytes[4], - ]) as usize; - if ct_len > response_bytes.len() - 5 { - return Err(Error::msg("malformed raw response: content-type overflow")); - } - let ct_end = 5 + ct_len; - let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); - let data = response_bytes[ct_end..].to_vec(); - Ok((content_type, data)) -} -{{- else}} {{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -209,4 +142,3 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName } {{- end}} {{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index 12dd2047..083f7577 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -4,9 +4,6 @@ package {{.Package}} import ( "context" -{{- if .Service.HasRawMethods}} - "encoding/binary" -{{- end}} "encoding/json" extism "github.com/extism/go-sdk" @@ -23,7 +20,6 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if not .Raw}} // {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}. type {{responseType .}} struct { @@ -34,7 +30,6 @@ type {{responseType .}} struct { Error string `json:"error,omitempty"` {{- end}} } -{{- end}} {{end}} // Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions. @@ -56,48 +51,18 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) if err != nil { -{{- if .Raw}} - {{$.Service.Name | lower}}WriteRawError(p, stack, err) -{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) -{{- end}} return } var req {{requestType .}} if err := json.Unmarshal(reqBytes, &req); err != nil { -{{- if .Raw}} - {{$.Service.Name | lower}}WriteRawError(p, stack, err) -{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) -{{- end}} return } {{- end}} // Call the service method -{{- if .Raw}} - {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) - if svcErr != nil { - {{$.Service.Name | lower}}WriteRawError(p, stack, svcErr) - return - } - - // Write binary-framed response to plugin memory: - // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] - ctBytes := []byte({{lower (index .Returns 0).Name}}) - frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}})) - frame[0] = 0x00 // success - binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) - copy(frame[5:5+len(ctBytes)], ctBytes) - copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}}) - - respPtr, err := p.WriteBytes(frame) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -{{- else if .HasReturns}} +{{- if .HasReturns}} {{- if .HasError}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) if svcErr != nil { @@ -162,16 +127,3 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64 respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } -{{- if .Service.HasRawMethods}} - -// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory. -// Format: [0x01][UTF-8 error message] -func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { - errMsg := []byte(err.Error()) - frame := make([]byte, 1+len(errMsg)) - frame[0] = 0x01 // error - copy(frame[1:], errMsg) - respPtr, _ := p.WriteBytes(frame) - stack[0] = respPtr -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 29cf2316..6132dfbc 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -173,16 +173,6 @@ func (s Service) HasErrors() bool { return false } -// HasRawMethods returns true if any method in the service uses raw binary framing. -func (s Service) HasRawMethods() bool { - for _, m := range s.Methods { - if m.Raw { - return true - } - } - return false -} - // Method represents a host function method within a service. type Method struct { Name string // Go method name (e.g., "Call") @@ -191,7 +181,6 @@ type Method struct { Returns []Param // Return values (excluding error) HasError bool // Whether the method returns an error Doc string // Documentation comment for the method - Raw bool // If true, response uses binary framing instead of JSON } // FunctionName returns the Extism host function export name. @@ -343,6 +332,52 @@ type Param struct { JSONName string // JSON field name (camelCase) } +// IsByteSlice returns true if the parameter type is []byte. +func (p Param) IsByteSlice() bool { + return p.Type == "[]byte" +} + +// IsByteSlice returns true if the field type is []byte. +func (f FieldDef) IsByteSlice() bool { + return f.Type == "[]byte" +} + +// HasByteFields returns true if any method params, returns, or struct fields use []byte. +func (s Service) HasByteFields() bool { + for _, m := range s.Methods { + for _, p := range m.Params { + if p.IsByteSlice() { + return true + } + } + for _, r := range m.Returns { + if r.IsByteSlice() { + return true + } + } + } + for _, st := range s.Structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + +// HasByteFields returns true if any capability struct fields use []byte. +func (c Capability) HasByteFields() bool { + for _, st := range c.Structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + // NewParam creates a Param with auto-generated JSON name. func NewParam(name, typ string) Param { return Param{ diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.py b/plugins/cmd/ndpgen/testdata/codec_client_expected.py index e1eb9250..5142ffd0 100644 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.py +++ b/plugins/cmd/ndpgen/testdata/codec_client_expected.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -38,7 +39,7 @@ def codec_encode(data: bytes) -> bytes: HostFunctionError: If the host function returns an error. """ request = { - "data": data, + "data": base64.b64encode(data).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) @@ -49,4 +50,4 @@ def codec_encode(data: bytes) -> bytes: if response.get("error"): raise HostFunctionError(response["error"]) - return response.get("result", b"") + return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.rs b/plugins/cmd/ndpgen/testdata/codec_client_expected.rs index ff61294d..3e229ea8 100644 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/codec_client_expected.rs @@ -5,10 +5,34 @@ use 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) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct CodecEncodeRequest { + #[serde(with = "base64_bytes")] data: Vec, } @@ -16,6 +40,7 @@ struct CodecEncodeRequest { #[serde(rename_all = "camelCase")] struct CodecEncodeResponse { #[serde(default)] + #[serde(with = "base64_bytes")] result: Vec, #[serde(default)] error: Option, diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py index 0fdbfb0f..93370ddc 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -327,7 +328,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes: HostFunctionError: If the host function returns an error. """ request = { - "data": data, + "data": base64.b64encode(data).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) @@ -338,4 +339,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes: if response.get("error"): raise HostFunctionError(response["error"]) - return response.get("result", b"") + return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs index efcc1b8e..08dae290 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs @@ -5,6 +5,29 @@ use 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) + } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -144,6 +167,7 @@ struct ComprehensiveMultipleReturnsResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ComprehensiveByteSliceRequest { + #[serde(with = "base64_bytes")] data: Vec, } @@ -151,6 +175,7 @@ struct ComprehensiveByteSliceRequest { #[serde(rename_all = "camelCase")] struct ComprehensiveByteSliceResponse { #[serde(default)] + #[serde(with = "base64_bytes")] result: Vec, #[serde(default)] error: Option, diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt deleted file mode 100644 index 22d38704..00000000 --- a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Stream host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndpdk - -import ( - "encoding/binary" - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// stream_getstream is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user stream_getstream -func stream_getstream(uint64) uint64 - -type streamGetStreamRequest struct { - Uri string `json:"uri"` -} - -// StreamGetStream calls the stream_getstream host function. -// GetStream returns raw binary stream data with content type. -func StreamGetStream(uri string) (string, []byte, error) { - // Marshal request to JSON - req := streamGetStreamRequest{ - Uri: uri, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := stream_getstream(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse binary-framed response - if len(responseBytes) == 0 { - return "", nil, errors.New("empty response from host") - } - if responseBytes[0] == 0x01 { // error - return "", nil, errors.New(string(responseBytes[1:])) - } - if responseBytes[0] != 0x00 { - return "", nil, errors.New("unknown response status") - } - if len(responseBytes) < 5 { - return "", nil, errors.New("malformed raw response: incomplete header") - } - ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) - if uint32(len(responseBytes)) < 5+ctLen { - return "", nil, errors.New("malformed raw response: content-type overflow") - } - return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil -} diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.py b/plugins/cmd/ndpgen/testdata/raw_client_expected.py deleted file mode 100644 index 45af2b6c..00000000 --- a/plugins/cmd/ndpgen/testdata/raw_client_expected.py +++ /dev/null @@ -1,63 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Stream host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any, Tuple - -import extism -import json -import struct - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "stream_getstream") -def _stream_getstream(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def stream_get_stream(uri: str) -> Tuple[str, bytes]: - """GetStream returns raw binary stream data with content type. - - Args: - uri: str parameter. - - Returns: - Tuple of (content_type, data) with the raw binary response. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _stream_getstream(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response_bytes = response_mem.bytes() - - if len(response_bytes) == 0: - raise HostFunctionError("empty response from host") - if response_bytes[0] == 0x01: - raise HostFunctionError(response_bytes[1:].decode("utf-8")) - if response_bytes[0] != 0x00: - raise HostFunctionError("unknown response status") - if len(response_bytes) < 5: - raise HostFunctionError("malformed raw response: incomplete header") - ct_len = struct.unpack(">I", response_bytes[1:5])[0] - if len(response_bytes) < 5 + ct_len: - raise HostFunctionError("malformed raw response: content-type overflow") - content_type = response_bytes[5:5 + ct_len].decode("utf-8") - data = response_bytes[5 + ct_len:] - return content_type, data diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.rs b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs deleted file mode 100644 index 6de18be8..00000000 --- a/plugins/cmd/ndpgen/testdata/raw_client_expected.rs +++ /dev/null @@ -1,73 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Stream host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct StreamGetStreamRequest { - uri: String, -} - -#[host_fn] -extern "ExtismHost" { -} - -#[link(wasm_import_module = "extism:host/user")] -extern "C" { - fn stream_getstream(offset: u64) -> u64; -} - -/// GetStream returns raw binary stream data with content type. -/// -/// # Arguments -/// * `uri` - String parameter. -/// -/// # Returns -/// A tuple of (content_type, data) with the raw binary response. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_stream(uri: &str) -> Result<(String, Vec), Error> { - let req = StreamGetStreamRequest { - uri: uri.to_owned(), - }; - let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; - let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; - - let response_offset = unsafe { stream_getstream(input_mem.offset()) }; - - let response_mem = Memory::find(response_offset) - .ok_or_else(|| Error::msg("empty response from host"))?; - let response_bytes = response_mem.to_vec(); - - if response_bytes.is_empty() { - return Err(Error::msg("empty response from host")); - } - if response_bytes[0] == 0x01 { - let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); - return Err(Error::msg(msg)); - } - if response_bytes[0] != 0x00 { - return Err(Error::msg("unknown response status")); - } - if response_bytes.len() < 5 { - return Err(Error::msg("malformed raw response: incomplete header")); - } - let ct_len = u32::from_be_bytes([ - response_bytes[1], - response_bytes[2], - response_bytes[3], - response_bytes[4], - ]) as usize; - if ct_len > response_bytes.len() - 5 { - return Err(Error::msg("malformed raw response: content-type overflow")); - } - let ct_end = 5 + ct_len; - let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); - let data = response_bytes[ct_end..].to_vec(); - Ok((content_type, data)) -} diff --git a/plugins/cmd/ndpgen/testdata/raw_service.go.txt b/plugins/cmd/ndpgen/testdata/raw_service.go.txt deleted file mode 100644 index c08332f5..00000000 --- a/plugins/cmd/ndpgen/testdata/raw_service.go.txt +++ /dev/null @@ -1,10 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Stream permission=stream -type StreamService interface { - // GetStream returns raw binary stream data with content type. - //nd:hostfunc raw=true - GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) -} diff --git a/plugins/host/subsonicapi.go b/plugins/host/subsonicapi.go index 117f8abf..32de75e7 100644 --- a/plugins/host/subsonicapi.go +++ b/plugins/host/subsonicapi.go @@ -17,8 +17,8 @@ type SubsonicAPIService interface { Call(ctx context.Context, uri string) (responseJSON string, err error) // CallRaw executes a Subsonic API request and returns the raw binary response. - // Optimized for binary endpoints like getCoverArt and stream that return - // non-JSON data. The response is returned as raw bytes without JSON encoding overhead. - //nd:hostfunc raw=true + // Designed for binary endpoints like getCoverArt and stream that return + // non-JSON data. The data is base64-encoded over JSON on the wire. + //nd:hostfunc CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) } diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go index 438c51c9..52474030 100644 --- a/plugins/host/subsonicapi_gen.go +++ b/plugins/host/subsonicapi_gen.go @@ -4,7 +4,6 @@ package host import ( "context" - "encoding/binary" "encoding/json" extism "github.com/extism/go-sdk" @@ -26,6 +25,13 @@ type SubsonicAPICallRawRequest struct { Uri string `json:"uri"` } +// SubsonicAPICallRawResponse is the response type for SubsonicAPI.CallRaw. +type SubsonicAPICallRawResponse struct { + ContentType string `json:"contentType,omitempty"` + Data []byte `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + // RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions. // The returned host functions should be added to the plugin's configuration. func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction { @@ -76,37 +82,28 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) if err != nil { - subsonicapiWriteRawError(p, stack, err) + subsonicapiWriteError(p, stack, err) return } var req SubsonicAPICallRawRequest if err := json.Unmarshal(reqBytes, &req); err != nil { - subsonicapiWriteRawError(p, stack, err) + subsonicapiWriteError(p, stack, err) return } // Call the service method contenttype, data, svcErr := service.CallRaw(ctx, req.Uri) if svcErr != nil { - subsonicapiWriteRawError(p, stack, svcErr) + subsonicapiWriteError(p, stack, svcErr) return } - // Write binary-framed response to plugin memory: - // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] - ctBytes := []byte(contenttype) - frame := make([]byte, 1+4+len(ctBytes)+len(data)) - frame[0] = 0x00 // success - binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) - copy(frame[5:5+len(ctBytes)], ctBytes) - copy(frame[5+len(ctBytes):], data) - - respPtr, err := p.WriteBytes(frame) - if err != nil { - stack[0] = 0 - return + // Write JSON response to plugin memory + resp := SubsonicAPICallRawResponse{ + ContentType: contenttype, + Data: data, } - stack[0] = respPtr + subsonicapiWriteResponse(p, stack, resp) }, []extism.ValueType{extism.ValueTypePTR}, []extism.ValueType{extism.ValueTypePTR}, @@ -137,14 +134,3 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } - -// subsonicapiWriteRawError writes a binary-framed error response to plugin memory. -// Format: [0x01][UTF-8 error message] -func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { - errMsg := []byte(err.Error()) - frame := make([]byte, 1+len(errMsg)) - frame[0] = 0x01 // error - copy(frame[1:], errMsg) - respPtr, _ := p.WriteBytes(frame) - stack[0] = respPtr -} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi.go b/plugins/pdk/go/host/nd_host_subsonicapi.go index 9bb4f4b1..e6e56ce6 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi.go @@ -8,7 +8,6 @@ package host import ( - "encoding/binary" "encoding/json" "errors" @@ -38,6 +37,12 @@ type subsonicAPICallRawRequest struct { Uri string `json:"uri"` } +type subsonicAPICallRawResponse struct { + ContentType string `json:"contentType,omitempty"` + Data []byte `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + // SubsonicAPICall calls the subsonicapi_call host function. // Call executes a Subsonic API request and returns the JSON response. // @@ -78,8 +83,8 @@ func SubsonicAPICall(uri string) (string, error) { // SubsonicAPICallRaw calls the subsonicapi_callraw host function. // CallRaw executes a Subsonic API request and returns the raw binary response. -// Optimized for binary endpoints like getCoverArt and stream that return -// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +// Designed for binary endpoints like getCoverArt and stream that return +// non-JSON data. The data is base64-encoded over JSON on the wire. func SubsonicAPICallRaw(uri string) (string, []byte, error) { // Marshal request to JSON req := subsonicAPICallRawRequest{ @@ -99,22 +104,16 @@ func SubsonicAPICallRaw(uri string) (string, []byte, error) { responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() - // Parse binary-framed response - if len(responseBytes) == 0 { - return "", nil, errors.New("empty response from host") + // Parse the response + var response subsonicAPICallRawResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return "", nil, err } - if responseBytes[0] == 0x01 { // error - return "", nil, errors.New(string(responseBytes[1:])) + + // Convert Error field to Go error + if response.Error != "" { + return "", nil, errors.New(response.Error) } - if responseBytes[0] != 0x00 { - return "", nil, errors.New("unknown response status") - } - if len(responseBytes) < 5 { - return "", nil, errors.New("malformed raw response: incomplete header") - } - ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) - if uint32(len(responseBytes)) < 5+ctLen { - return "", nil, errors.New("malformed raw response: content-type overflow") - } - return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil + + return response.ContentType, response.Data, nil } diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 95dd4155..2fdaf240 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -42,8 +42,8 @@ func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { // SubsonicAPICallRaw delegates to the mock instance. // CallRaw executes a Subsonic API request and returns the raw binary response. -// Optimized for binary endpoints like getCoverArt and stream that return -// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +// Designed for binary endpoints like getCoverArt and stream that return +// non-JSON data. The data is base64-encoded over JSON on the wire. func SubsonicAPICallRaw(uri string) (string, []byte, error) { return SubsonicAPIMock.CallRaw(uri) } diff --git a/plugins/pdk/python/host/nd_host_cache.py b/plugins/pdk/python/host/nd_host_cache.py index c22f95f6..b24e983c 100644 --- a/plugins/pdk/python/host/nd_host_cache.py +++ b/plugins/pdk/python/host/nd_host_cache.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -337,7 +338,7 @@ Returns an error if the operation fails. """ request = { "key": key, - "value": value, + "value": base64.b64encode(value).decode("ascii"), "ttlSeconds": ttl_seconds, } request_bytes = json.dumps(request).encode("utf-8") @@ -382,7 +383,7 @@ or the stored value is not a byte slice, exists will be false. raise HostFunctionError(response["error"]) return CacheGetBytesResult( - value=response.get("value", b""), + value=base64.b64decode(response.get("value", "")), exists=response.get("exists", False), ) diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py index 8f196f98..a806c845 100644 --- a/plugins/pdk/python/host/nd_host_http.py +++ b/plugins/pdk/python/host/nd_host_http.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py index 5485d2fb..3c3e61f5 100644 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ b/plugins/pdk/python/host/nd_host_kvstore.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -80,7 +81,7 @@ Returns an error if the storage limit would be exceeded or the operation fails. """ request = { "key": key, - "value": value, + "value": base64.b64encode(value).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) @@ -123,7 +124,7 @@ Returns the value and whether the key exists. raise HostFunctionError(response["error"]) return KVStoreGetResult( - value=response.get("value", b""), + value=base64.b64decode(response.get("value", "")), exists=response.get("exists", False), ) diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py index 4da8da77..cf35bc04 100644 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ b/plugins/pdk/python/host/nd_host_subsonicapi.py @@ -8,11 +8,11 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any, Tuple +from typing import Any import extism import json -import struct +import base64 class HostFunctionError(Exception): @@ -32,6 +32,13 @@ def _subsonicapi_callraw(offset: int) -> int: ... +@dataclass +class SubsonicAPICallRawResult: + """Result type for subsonicapi_call_raw.""" + content_type: str + data: bytes + + def subsonicapi_call(uri: str) -> str: """Call executes a Subsonic API request and returns the JSON response. @@ -62,16 +69,16 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. return response.get("responseJson", "") -def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]: +def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult: """CallRaw executes a Subsonic API request and returns the raw binary response. -Optimized for binary endpoints like getCoverArt and stream that return -non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +Designed for binary endpoints like getCoverArt and stream that return +non-JSON data. The data is base64-encoded over JSON on the wire. Args: uri: str parameter. Returns: - Tuple of (content_type, data) with the raw binary response. + SubsonicAPICallRawResult containing content_type, data,. Raises: HostFunctionError: If the host function returns an error. @@ -83,19 +90,12 @@ non-JSON data. The response is returned as raw bytes without JSON encoding overh request_mem = extism.memory.alloc(request_bytes) response_offset = _subsonicapi_callraw(request_mem.offset) response_mem = extism.memory.find(response_offset) - response_bytes = response_mem.bytes() + response = json.loads(extism.memory.string(response_mem)) - if len(response_bytes) == 0: - raise HostFunctionError("empty response from host") - if response_bytes[0] == 0x01: - raise HostFunctionError(response_bytes[1:].decode("utf-8")) - if response_bytes[0] != 0x00: - raise HostFunctionError("unknown response status") - if len(response_bytes) < 5: - raise HostFunctionError("malformed raw response: incomplete header") - ct_len = struct.unpack(">I", response_bytes[1:5])[0] - if len(response_bytes) < 5 + ct_len: - raise HostFunctionError("malformed raw response: content-type overflow") - content_type = response_bytes[5:5 + ct_len].decode("utf-8") - data = response_bytes[5 + ct_len:] - return content_type, data + if response.get("error"): + raise HostFunctionError(response["error"]) + + return SubsonicAPICallRawResult( + content_type=response.get("contentType", ""), + data=base64.b64decode(response.get("data", "")), + ) diff --git a/plugins/pdk/python/host/nd_host_websocket.py b/plugins/pdk/python/host/nd_host_websocket.py index b62ee792..4e882914 100644 --- a/plugins/pdk/python/host/nd_host_websocket.py +++ b/plugins/pdk/python/host/nd_host_websocket.py @@ -12,6 +12,7 @@ from typing import Any import extism import json +import base64 class HostFunctionError(Exception): @@ -134,7 +135,7 @@ Returns an error if the connection is not found or if sending fails. """ request = { "connectionId": connection_id, - "data": data, + "data": base64.b64encode(data).decode("ascii"), } request_bytes = json.dumps(request).encode("utf-8") request_mem = extism.memory.alloc(request_bytes) diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml index 98a91da1..443f19da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" crate-type = ["rlib"] [dependencies] +base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml index 4cb82869..51909611 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] +base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs index 1f3d6929..26765413 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs @@ -5,6 +5,29 @@ use 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) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -106,6 +129,7 @@ struct CacheGetFloatResponse { #[serde(rename_all = "camelCase")] struct CacheSetBytesRequest { key: String, + #[serde(with = "base64_bytes")] value: Vec, ttl_seconds: i64, } @@ -127,6 +151,7 @@ struct CacheGetBytesRequest { #[serde(rename_all = "camelCase")] struct CacheGetBytesResponse { #[serde(default)] + #[serde(with = "base64_bytes")] value: Vec, #[serde(default)] exists: bool, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index b8aaad1e..d3bb2d32 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -5,6 +5,29 @@ use 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) + } +} /// HTTPRequest represents an outbound HTTP request from a plugin. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -15,6 +38,7 @@ pub struct HTTPRequest { #[serde(default)] pub headers: std::collections::HashMap, #[serde(default)] + #[serde(with = "base64_bytes")] pub body: Vec, #[serde(default)] pub timeout_ms: i32, @@ -28,6 +52,7 @@ pub struct HTTPResponse { #[serde(default)] pub headers: std::collections::HashMap, #[serde(default)] + #[serde(with = "base64_bytes")] pub body: Vec, } diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs index 5048f369..20fe18c6 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs @@ -5,11 +5,35 @@ use 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) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct KVStoreSetRequest { key: String, + #[serde(with = "base64_bytes")] value: Vec, } @@ -30,6 +54,7 @@ struct KVStoreGetRequest { #[serde(rename_all = "camelCase")] struct KVStoreGetResponse { #[serde(default)] + #[serde(with = "base64_bytes")] value: Vec, #[serde(default)] exists: bool, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs index 2c9e6545..56ba1066 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs @@ -5,6 +5,29 @@ use 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) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -27,14 +50,22 @@ struct SubsonicAPICallRawRequest { uri: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAPICallRawResponse { + #[serde(default)] + content_type: String, + #[serde(default)] + #[serde(with = "base64_bytes")] + data: Vec, + #[serde(default)] + error: Option, +} + #[host_fn] extern "ExtismHost" { fn subsonicapi_call(input: Json) -> Json; -} - -#[link(wasm_import_module = "extism:host/user")] -extern "C" { - fn subsonicapi_callraw(offset: u64) -> u64; + fn subsonicapi_callraw(input: Json) -> Json; } /// Call executes a Subsonic API request and returns the JSON response. @@ -65,54 +96,27 @@ pub fn call(uri: &str) -> Result { } /// CallRaw executes a Subsonic API request and returns the raw binary response. -/// Optimized for binary endpoints like getCoverArt and stream that return -/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +/// Designed for binary endpoints like getCoverArt and stream that return +/// non-JSON data. The data is base64-encoded over JSON on the wire. /// /// # Arguments /// * `uri` - String parameter. /// /// # Returns -/// A tuple of (content_type, data) with the raw binary response. +/// A tuple of (content_type, data). /// /// # Errors /// Returns an error if the host function call fails. pub fn call_raw(uri: &str) -> Result<(String, Vec), Error> { - let req = SubsonicAPICallRawRequest { - uri: uri.to_owned(), + let response = unsafe { + subsonicapi_callraw(Json(SubsonicAPICallRawRequest { + uri: uri.to_owned(), + }))? }; - let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; - let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; - let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) }; + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } - let response_mem = Memory::find(response_offset) - .ok_or_else(|| Error::msg("empty response from host"))?; - let response_bytes = response_mem.to_vec(); - - if response_bytes.is_empty() { - return Err(Error::msg("empty response from host")); - } - if response_bytes[0] == 0x01 { - let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); - return Err(Error::msg(msg)); - } - if response_bytes[0] != 0x00 { - return Err(Error::msg("unknown response status")); - } - if response_bytes.len() < 5 { - return Err(Error::msg("malformed raw response: incomplete header")); - } - let ct_len = u32::from_be_bytes([ - response_bytes[1], - response_bytes[2], - response_bytes[3], - response_bytes[4], - ]) as usize; - if ct_len > response_bytes.len() - 5 { - return Err(Error::msg("malformed raw response: content-type overflow")); - } - let ct_end = 5 + ct_len; - let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); - let data = response_bytes[ct_end..].to_vec(); - Ok((content_type, data)) + Ok((response.0.content_type, response.0.data)) } diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs index 58a39902..05ceb540 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs @@ -5,6 +5,29 @@ use 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) + } +} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -41,6 +64,7 @@ struct WebSocketSendTextResponse { #[serde(rename_all = "camelCase")] struct WebSocketSendBinaryRequest { connection_id: String, + #[serde(with = "base64_bytes")] data: Vec, }