From e8863ed1476476708371a720d2c2ad5ae6757c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 4 Feb 2026 15:48:08 -0500 Subject: [PATCH] feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982) * feat: implement raw binary framing for host function responses Signed-off-by: Deluan * feat: add CallRaw method for Subsonic API to handle binary responses Signed-off-by: Deluan * test: add tests for raw=true methods and binary framing generation Signed-off-by: Deluan * fix: improve error message for malformed raw responses to indicate incomplete header Signed-off-by: Deluan * fix: add wasm_import_module attribute for raw methods and improve content-type handling Signed-off-by: Deluan --------- Signed-off-by: Deluan --- plugins/cmd/ndpgen/integration_test.go | 3 + plugins/cmd/ndpgen/internal/generator_test.go | 242 ++++++++++++++++++ plugins/cmd/ndpgen/internal/parser.go | 8 + plugins/cmd/ndpgen/internal/parser_test.go | 113 ++++++++ .../ndpgen/internal/templates/client.go.tmpl | 27 +- .../ndpgen/internal/templates/client.py.tmpl | 34 ++- .../ndpgen/internal/templates/client.rs.tmpl | 78 ++++++ .../ndpgen/internal/templates/host.go.tmpl | 74 +++++- plugins/cmd/ndpgen/internal/types.go | 11 + .../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 | 62 +++++ plugins/host_subsonicapi.go | 39 ++- plugins/host_subsonicapi_test.go | 142 +++++++++- plugins/pdk/go/go.mod | 7 - plugins/pdk/go/host/nd_host_subsonicapi.go | 53 ++++ .../pdk/go/host/nd_host_subsonicapi_stub.go | 14 + .../pdk/python/host/nd_host_subsonicapi.py | 48 +++- .../nd-pdk-host/src/nd_host_subsonicapi.rs | 64 +++++ .../testdata/test-subsonicapi-plugin/main.go | 26 ++ 23 files changed, 1223 insertions(+), 40 deletions(-) create mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt create mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.py create mode 100644 plugins/cmd/ndpgen/testdata/raw_client_expected.rs create 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 db500c1f..13ebe14a 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -282,6 +282,9 @@ 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_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 4a189ddd..ed15f174 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -264,6 +264,96 @@ 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{ @@ -626,6 +716,72 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) 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() { + svc := Service{ + Name: "Test", + Permission: "test", + Interface: "TestService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + Expect(codeStr).NotTo(ContainSubstring("Tuple")) + Expect(codeStr).NotTo(ContainSubstring("import struct")) + }) }) Describe("GenerateGoDoc", func() { @@ -782,6 +938,47 @@ var _ = Describe("Generator", func() { // Check for PDK import 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() { @@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring("Result")) Expect(codeStr).NotTo(ContainSubstring("Option")) }) + + It("should generate raw extern C import and 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 := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + + 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 NOT generate response type for raw methods + Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse")) + + // Should generate request type (request is still JSON) + Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest")) + + // Should return Result<(String, Vec), Error> + Expect(codeStr).To(ContainSubstring("Result<(String, Vec), Error>")) + + // 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")) + }) }) }) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d..c2d57177 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri m := Method{ Name: name, ExportName: annotation["name"], + Raw: annotation["raw"] == "true", Doc: doc, } @@ -799,6 +800,13 @@ 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 f4357839..f2bdbede 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -122,6 +122,119 @@ 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/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee0444..971ae394 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -8,6 +8,9 @@ package {{.Package}} import ( +{{- if .Service.HasRawMethods}} + "encoding/binary" +{{- end}} "encoding/json" {{- if .Service.HasErrors}} "errors" @@ -49,7 +52,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if not .IsErrorOnly}} +{{- if and (not .IsErrorOnly) (not .Raw)}} type {{responseType .}} struct { {{- range .Returns}} @@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{ // Read the response from memory responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() -{{- if .IsErrorOnly}} +{{- 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}} // 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 99c5be51..84bff4ab 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl @@ -8,10 +8,13 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any +from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}} import extism import json +{{- if .Service.HasRawMethods}} +import struct +{{- end}} class HostFunctionError(Exception): @@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int: {{- end}} {{- /* Generate dataclasses for multi-value returns */ -}} {{range .Service.Methods}} -{{- if .NeedsResultClass}} +{{- if and .NeedsResultClass (not .Raw)}} @dataclass @@ -44,7 +47,7 @@ class {{pythonResultType .}}: {{range .Service.Methods}} -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}}: +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}}: """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{.PythonName}}: {{.PythonType}} parameter. {{- end}} {{- end}} -{{- if .HasReturns}} +{{- if .Raw}} + + Returns: + Tuple of (content_type, data) with the raw binary response. +{{- else if .HasReturns}} Returns: {{- if .NeedsResultClass}} @@ -79,6 +86,24 @@ 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"): @@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) {{- end}} {{- end}} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index 6dea8098..2fb368bf 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -33,6 +33,7 @@ struct {{requestType .}} { {{- end}} } {{- end}} +{{- if not .Raw}} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -47,16 +48,92 @@ 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}} @@ -132,3 +209,4 @@ 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 d10c01ee..12dd2047 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -4,6 +4,9 @@ package {{.Package}} import ( "context" +{{- if .Service.HasRawMethods}} + "encoding/binary" +{{- end}} "encoding/json" extism "github.com/extism/go-sdk" @@ -20,6 +23,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} +{{- if not .Raw}} // {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}. type {{responseType .}} struct { @@ -30,6 +34,7 @@ type {{responseType .}} struct { Error string `json:"error,omitempty"` {{- end}} } +{{- end}} {{end}} // Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions. @@ -51,18 +56,48 @@ 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 .HasReturns}} +{{- 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 .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 { @@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) {{- else}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) {{- end}} -{{- else if .HasError}} - if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { - {{$.Service.Name | lower}}WriteError(p, stack, svcErr) - return - } -{{- else}} - service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) -{{- end}} // Write JSON response to plugin memory resp := {{responseType .}}{ @@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) {{- end}} } {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- else if .HasError}} + if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { + {{$.Service.Name | lower}}WriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := {{responseType .}}{} + {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- else}} + service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) + + // Write JSON response to plugin memory + resp := {{responseType .}}{} + {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- end}} }, []extism.ValueType{extism.ValueTypePTR}, []extism.ValueType{extism.ValueTypePTR}, @@ -119,3 +162,16 @@ 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 7bd2bcc3..29cf2316 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -173,6 +173,16 @@ 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") @@ -181,6 +191,7 @@ 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. diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt new file mode 100644 index 00000000..22d38704 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt @@ -0,0 +1,66 @@ +// 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 new file mode 100644 index 00000000..45af2b6c --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.py @@ -0,0 +1,63 @@ +# 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 new file mode 100644 index 00000000..6de18be8 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs @@ -0,0 +1,73 @@ +// 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 new file mode 100644 index 00000000..c08332f5 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_service.go.txt @@ -0,0 +1,10 @@ +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 d8fa900d..117f8abf 100644 --- a/plugins/host/subsonicapi.go +++ b/plugins/host/subsonicapi.go @@ -15,4 +15,10 @@ type SubsonicAPIService interface { // e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. //nd:hostfunc 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 + 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 e3c2af7b..438c51c9 100644 --- a/plugins/host/subsonicapi_gen.go +++ b/plugins/host/subsonicapi_gen.go @@ -4,6 +4,7 @@ package host import ( "context" + "encoding/binary" "encoding/json" extism "github.com/extism/go-sdk" @@ -20,11 +21,17 @@ type SubsonicAPICallResponse struct { Error string `json:"error,omitempty"` } +// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw. +type SubsonicAPICallRawRequest struct { + Uri string `json:"uri"` +} + // RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions. // The returned host functions should be added to the plugin's configuration. func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction { return []extism.HostFunction{ newSubsonicAPICallHostFunction(service), + newSubsonicAPICallRawHostFunction(service), } } @@ -62,6 +69,50 @@ func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunct ) } +func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "subsonicapi_callraw", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + subsonicapiWriteRawError(p, stack, err) + return + } + var req SubsonicAPICallRawRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + subsonicapiWriteRawError(p, stack, err) + return + } + + // Call the service method + contenttype, data, svcErr := service.CallRaw(ctx, req.Uri) + if svcErr != nil { + subsonicapiWriteRawError(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 + } + stack[0] = respPtr + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + // subsonicapiWriteResponse writes a JSON response to plugin memory. func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { respBytes, err := json.Marshal(resp) @@ -86,3 +137,14 @@ 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/host_subsonicapi.go b/plugins/host_subsonicapi.go index b7a98fce..01a33c03 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -24,7 +24,7 @@ const subsonicAPIVersion = "1.16.1" // // Authentication: The plugin must provide a valid 'u' (username) parameter in the URL. // URL Format: Only the path and query parameters are used - host/protocol are ignored. -// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format). +// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { pluginID string router SubsonicRouter @@ -50,15 +50,18 @@ func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.Data } } -func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { +// executeRequest handles URL parsing, validation, permission checks, HTTP request creation, +// and router invocation. Shared between Call and CallRaw. +// If setJSON is true, the 'f=json' query parameter is added. +func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) { if s.router == nil { - return "", fmt.Errorf("SubsonicAPI router not available") + return nil, fmt.Errorf("SubsonicAPI router not available") } // Parse the input URL parsedURL, err := url.Parse(uri) if err != nil { - return "", fmt.Errorf("invalid URL format: %w", err) + return nil, fmt.Errorf("invalid URL format: %w", err) } // Extract query parameters @@ -67,18 +70,20 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // Validate that 'u' (username) parameter is present username := query.Get("u") if username == "" { - return "", fmt.Errorf("missing required parameter 'u' (username)") + return nil, fmt.Errorf("missing required parameter 'u' (username)") } if err := s.checkPermissions(ctx, username); err != nil { log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) - return "", err + return nil, err } // Add required Subsonic API parameters query.Set("c", s.pluginID) // Client name (plugin ID) - query.Set("f", "json") // Response format query.Set("v", subsonicAPIVersion) // API version + if setJSON { + query.Set("f", "json") // Response format + } // Extract the endpoint from the path endpoint := path.Base(parsedURL.Path) @@ -96,7 +101,7 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // explicitly added in the next step via request.WithInternalAuth. httpReq, err := http.NewRequest("GET", finalURL.String(), nil) if err != nil { - return "", fmt.Errorf("failed to create HTTP request: %w", err) + return nil, fmt.Errorf("failed to create HTTP request: %w", err) } // Set internal authentication context using the username from the 'u' parameter @@ -109,10 +114,26 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // Call the subsonic router s.router.ServeHTTP(recorder, httpReq) - // Return the response body as JSON + return recorder, nil +} + +func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { + recorder, err := s.executeRequest(ctx, uri, true) + if err != nil { + return "", err + } return recorder.Body.String(), nil } +func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) { + recorder, err := s.executeRequest(ctx, uri, false) + if err != nil { + return "", nil, err + } + contentType := recorder.Header().Get("Content-Type") + return contentType, recorder.Body.Bytes(), nil +} + func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user if s.allUsers { diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 25733210..b0589fa1 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "os" + "path" "path/filepath" "github.com/navidrome/navidrome/conf" @@ -177,6 +178,61 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { Expect(err.Error()).To(ContainSubstring("missing required parameter")) }) }) + + Describe("SubsonicAPI CallRaw", func() { + var plugin *plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-subsonicapi-plugin"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + It("successfully calls getCoverArt and returns binary data", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + // Parse the metadata response from the test plugin + var result map[string]any + err = json.Unmarshal(output, &result) + Expect(err).ToNot(HaveOccurred()) + Expect(result["contentType"]).To(Equal("image/png")) + Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader))) + Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte + }) + + It("does NOT set f=json parameter for raw calls", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + _, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) + Expect(err).ToNot(HaveOccurred()) + + Expect(router.lastRequest).ToNot(BeNil()) + query := router.lastRequest.URL.Query() + Expect(query.Get("f")).To(BeEmpty()) + Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin")) + Expect(query.Get("v")).To(Equal("1.16.1")) + }) + + It("returns error when username is missing", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + }) }) var _ = Describe("SubsonicAPIService", func() { @@ -323,6 +379,66 @@ var _ = Describe("SubsonicAPIService", func() { }) }) + Describe("CallRaw", func() { + It("returns binary data and content-type", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(contentType).To(Equal("image/png")) + Expect(data).To(Equal(fakePNGHeader)) + }) + + It("does not set f=json parameter", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).ToNot(HaveOccurred()) + + Expect(router.lastRequest).ToNot(BeNil()) + query := router.lastRequest.URL.Query() + Expect(query.Get("f")).To(BeEmpty()) + }) + + It("enforces permission checks", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not authorized")) + }) + + It("returns error when username is missing", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + + It("returns error when router is nil", func() { + service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("router not available")) + }) + + It("returns error for invalid URL", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "://invalid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid URL")) + }) + }) + Describe("Router Availability", func() { It("returns error when router is nil", func() { service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) @@ -335,6 +451,9 @@ var _ = Describe("SubsonicAPIService", func() { }) }) +// fakePNGHeader is a minimal PNG file header used in tests. +var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + // fakeSubsonicRouter is a mock Subsonic router that returns predictable responses. type fakeSubsonicRouter struct { lastRequest *http.Request @@ -343,13 +462,20 @@ type fakeSubsonicRouter struct { func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.lastRequest = req - // Return a successful ping response - response := map[string]any{ - "subsonic-response": map[string]any{ - "status": "ok", - "version": "1.16.1", - }, + endpoint := path.Base(req.URL.Path) + switch endpoint { + case "getCoverArt": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNGHeader) + default: + // Return a successful ping response + response := map[string]any{ + "subsonic-response": map[string]any{ + "status": "ok", + "version": "1.16.1", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) } diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod index 4d5fcddf..3916cd74 100644 --- a/plugins/pdk/go/go.mod +++ b/plugins/pdk/go/go.mod @@ -6,10 +6,3 @@ require ( github.com/extism/go-pdk v1.1.3 github.com/stretchr/testify v1.11.1 ) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/plugins/pdk/go/host/nd_host_subsonicapi.go b/plugins/pdk/go/host/nd_host_subsonicapi.go index 87469ce3..9bb4f4b1 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi.go @@ -8,6 +8,7 @@ package host import ( + "encoding/binary" "encoding/json" "errors" @@ -19,6 +20,11 @@ import ( //go:wasmimport extism:host/user subsonicapi_call func subsonicapi_call(uint64) uint64 +// subsonicapi_callraw is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user subsonicapi_callraw +func subsonicapi_callraw(uint64) uint64 + type subsonicAPICallRequest struct { Uri string `json:"uri"` } @@ -28,6 +34,10 @@ type subsonicAPICallResponse struct { Error string `json:"error,omitempty"` } +type subsonicAPICallRawRequest struct { + Uri string `json:"uri"` +} + // SubsonicAPICall calls the subsonicapi_call host function. // Call executes a Subsonic API request and returns the JSON response. // @@ -65,3 +75,46 @@ func SubsonicAPICall(uri string) (string, error) { return response.ResponseJSON, nil } + +// 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. +func SubsonicAPICallRaw(uri string) (string, []byte, error) { + // Marshal request to JSON + req := subsonicAPICallRawRequest{ + 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 := subsonicapi_callraw(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/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index f9d71a9c..95dd4155 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -33,3 +33,17 @@ func (m *mockSubsonicAPIService) Call(uri string) (string, error) { func SubsonicAPICall(uri string) (string, error) { return SubsonicAPIMock.Call(uri) } + +// CallRaw is the mock method for SubsonicAPICallRaw. +func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { + args := m.Called(uri) + return args.String(0), args.Get(1).([]byte), args.Error(2) +} + +// 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. +func SubsonicAPICallRaw(uri string) (string, []byte, error) { + return SubsonicAPIMock.CallRaw(uri) +} diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py index ee6b543f..4da8da77 100644 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ b/plugins/pdk/python/host/nd_host_subsonicapi.py @@ -8,10 +8,11 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any +from typing import Any, Tuple import extism import json +import struct class HostFunctionError(Exception): @@ -25,6 +26,12 @@ def _subsonicapi_call(offset: int) -> int: ... +@extism.import_fn("extism:host/user", "subsonicapi_callraw") +def _subsonicapi_callraw(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + def subsonicapi_call(uri: str) -> str: """Call executes a Subsonic API request and returns the JSON response. @@ -53,3 +60,42 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. raise HostFunctionError(response["error"]) return response.get("responseJson", "") + + +def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]: + """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. + + 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 = _subsonicapi_callraw(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/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs index e32b6d72..2c9e6545 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 @@ -21,11 +21,22 @@ struct SubsonicAPICallResponse { error: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAPICallRawRequest { + uri: String, +} + #[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; +} + /// Call executes a Subsonic API request and returns the JSON response. /// /// The uri parameter should be the Subsonic API path without the server prefix, @@ -52,3 +63,56 @@ pub fn call(uri: &str) -> Result { Ok(response.0.response_json) } + +/// 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. +/// +/// # 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 call_raw(uri: &str) -> Result<(String, Vec), Error> { + let req = 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()) }; + + 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/testdata/test-subsonicapi-plugin/main.go b/plugins/testdata/test-subsonicapi-plugin/main.go index 03b91280..573036b9 100644 --- a/plugins/testdata/test-subsonicapi-plugin/main.go +++ b/plugins/testdata/test-subsonicapi-plugin/main.go @@ -3,6 +3,8 @@ package main import ( + "fmt" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -28,4 +30,28 @@ func callSubsonicAPIExport() int32 { return 0 } +// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function. +// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1") +// Output: JSON with contentType, size, and first bytes of the raw response +// +//go:wasmexport call_subsonic_api_raw +func callSubsonicAPIRawExport() int32 { + uri := pdk.InputString() + + contentType, data, err := host.SubsonicAPICallRaw(uri) + if err != nil { + pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error()) + return 1 + } + + // Return metadata about the raw response as JSON + firstByte := 0 + if len(data) > 0 { + firstByte = int(data[0]) + } + result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte) + pdk.OutputString(result) + return 0 +} + func main() {}