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<u8> 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<u8> 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 <deluan@navidrome.org> * fix(plugins): add base64 dependency to capabilities' Cargo.toml Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -282,9 +282,6 @@ type ServiceB interface {
|
|||||||
|
|
||||||
Entry("option pattern (value, exists bool)",
|
Entry("option pattern (value, exists bool)",
|
||||||
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
|
"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() {
|
It("generates compilable client code for comprehensive service", func() {
|
||||||
|
|||||||
@@ -256,6 +256,15 @@ func GenerateClientRust(svc Service) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("parsing template: %w", err)
|
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{
|
data := templateData{
|
||||||
Service: svc,
|
Service: svc,
|
||||||
}
|
}
|
||||||
@@ -622,6 +631,15 @@ func GenerateCapabilityRust(cap Capability) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("parsing template: %w", err)
|
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{
|
data := capabilityTemplateData{
|
||||||
Package: cap.Name,
|
Package: cap.Name,
|
||||||
Capability: cap,
|
Capability: cap,
|
||||||
|
|||||||
@@ -264,96 +264,6 @@ var _ = Describe("Generator", func() {
|
|||||||
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
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() {
|
It("should always include json import for JSON protocol", func() {
|
||||||
// All services use JSON protocol, so json import is always needed
|
// All services use JSON protocol, so json import is always needed
|
||||||
svc := Service{
|
svc := Service{
|
||||||
@@ -717,49 +627,7 @@ var _ = Describe("Generator", func() {
|
|||||||
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should generate binary frame parsing for raw methods", func() {
|
It("should not import base64 for non-byte services", 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{
|
svc := Service{
|
||||||
Name: "Test",
|
Name: "Test",
|
||||||
Permission: "test",
|
Permission: "test",
|
||||||
@@ -779,8 +647,37 @@ var _ = Describe("Generator", func() {
|
|||||||
|
|
||||||
codeStr := string(code)
|
codeStr := string(code)
|
||||||
|
|
||||||
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
Expect(codeStr).NotTo(ContainSubstring("import base64"))
|
||||||
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
})
|
||||||
|
|
||||||
|
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"))
|
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() {
|
Describe("GenerateClientGoStub", func() {
|
||||||
@@ -1748,22 +1605,17 @@ var _ = Describe("Rust Generation", func() {
|
|||||||
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
It("should generate base64 serde for Vec<u8> fields", func() {
|
||||||
svc := Service{
|
svc := Service{
|
||||||
Name: "Stream",
|
Name: "Codec",
|
||||||
Permission: "stream",
|
Permission: "codec",
|
||||||
Interface: "StreamService",
|
Interface: "CodecService",
|
||||||
Methods: []Method{
|
Methods: []Method{
|
||||||
{
|
{
|
||||||
Name: "GetStream",
|
Name: "Encode",
|
||||||
HasError: true,
|
HasError: true,
|
||||||
Raw: true,
|
Params: []Param{NewParam("data", "[]byte")},
|
||||||
Params: []Param{NewParam("uri", "string")},
|
Returns: []Param{NewParam("result", "[]byte")},
|
||||||
Returns: []Param{
|
|
||||||
NewParam("contentType", "string"),
|
|
||||||
NewParam("data", "[]byte"),
|
|
||||||
},
|
|
||||||
Doc: "GetStream returns raw binary stream data.",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1773,24 +1625,36 @@ var _ = Describe("Rust Generation", func() {
|
|||||||
|
|
||||||
codeStr := string(code)
|
codeStr := string(code)
|
||||||
|
|
||||||
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
// Should generate base64_bytes serde module
|
||||||
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
|
||||||
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
|
||||||
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
|
||||||
|
|
||||||
// Should NOT generate response type for raw methods
|
// Should add serde(with = "base64_bytes") on Vec<u8> fields
|
||||||
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
|
||||||
|
})
|
||||||
|
|
||||||
// Should generate request type (request is still JSON)
|
It("should not generate base64 module when no byte fields", func() {
|
||||||
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
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<u8>), Error>
|
code, err := GenerateClientRust(svc)
|
||||||
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
// Should parse binary frame
|
codeStr := string(code)
|
||||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
|
||||||
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
|
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
|
||||||
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
|
Expect(codeStr).NotTo(ContainSubstring("use base64"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -761,7 +761,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
|||||||
m := Method{
|
m := Method{
|
||||||
Name: name,
|
Name: name,
|
||||||
ExportName: annotation["name"],
|
ExportName: annotation["name"],
|
||||||
Raw: annotation["raw"] == "true",
|
|
||||||
Doc: doc,
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,119 +122,6 @@ type TestService interface {
|
|||||||
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
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() {
|
It("should handle custom export name", func() {
|
||||||
src := `package host
|
src := `package host
|
||||||
|
|
||||||
|
|||||||
@@ -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<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{- end}}
|
||||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
{{- if hasHashMap .Capability}}
|
{{- if hasHashMap .Capability}}
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
||||||
|
|
||||||
// Helper functions for skip_serializing_if with numeric types
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -70,6 +71,9 @@ pub struct {{.Name}} {
|
|||||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||||
{{- else}}
|
{{- else}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
{{- end}}
|
||||||
|
{{- if .IsByteSlice}}
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
{{- end}}
|
{{- end}}
|
||||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
package {{.Package}}
|
package {{.Package}}
|
||||||
|
|
||||||
import (
|
import (
|
||||||
{{- if .Service.HasRawMethods}}
|
|
||||||
"encoding/binary"
|
|
||||||
{{- end}}
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
{{- if .Service.HasErrors}}
|
{{- if .Service.HasErrors}}
|
||||||
"errors"
|
"errors"
|
||||||
@@ -52,7 +49,7 @@ type {{requestType .}} struct {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if and (not .IsErrorOnly) (not .Raw)}}
|
{{- if not .IsErrorOnly}}
|
||||||
|
|
||||||
type {{responseType .}} struct {
|
type {{responseType .}} struct {
|
||||||
{{- range .Returns}}
|
{{- range .Returns}}
|
||||||
@@ -98,27 +95,7 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
|
|||||||
// Read the response from memory
|
// Read the response from memory
|
||||||
responseMem := pdk.FindMemory(responsePtr)
|
responseMem := pdk.FindMemory(responsePtr)
|
||||||
responseBytes := responseMem.ReadBytes()
|
responseBytes := responseMem.ReadBytes()
|
||||||
{{- if .Raw}}
|
{{- if .IsErrorOnly}}
|
||||||
|
|
||||||
// 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
|
// Parse error-only response
|
||||||
var response struct {
|
var response struct {
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
|
from typing import Any
|
||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
{{- if .Service.HasRawMethods}}
|
{{- if .Service.HasByteFields}}
|
||||||
import struct
|
import base64
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||||
{{range .Service.Methods}}
|
{{range .Service.Methods}}
|
||||||
{{- if and .NeedsResultClass (not .Raw)}}
|
{{- if .NeedsResultClass}}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -47,7 +47,7 @@ class {{pythonResultType .}}:
|
|||||||
{{range .Service.Methods}}
|
{{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 .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||||
{{- if .HasParams}}
|
{{- if .HasParams}}
|
||||||
|
|
||||||
@@ -56,11 +56,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
{{.PythonName}}: {{.PythonType}} parameter.
|
{{.PythonName}}: {{.PythonType}} parameter.
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .Raw}}
|
{{- if .HasReturns}}
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (content_type, data) with the raw binary response.
|
|
||||||
{{- else if .HasReturns}}
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{{- if .NeedsResultClass}}
|
{{- if .NeedsResultClass}}
|
||||||
@@ -76,7 +72,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
{{- if .HasParams}}
|
{{- if .HasParams}}
|
||||||
request = {
|
request = {
|
||||||
{{- range .Params}}
|
{{- range .Params}}
|
||||||
|
{{- if .IsByteSlice}}
|
||||||
|
"{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"),
|
||||||
|
{{- else}}
|
||||||
"{{.JSONName}}": {{.PythonName}},
|
"{{.JSONName}}": {{.PythonName}},
|
||||||
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
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)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
response_offset = _{{exportName .}}(request_mem.offset)
|
response_offset = _{{exportName .}}(request_mem.offset)
|
||||||
response_mem = extism.memory.find(response_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))
|
response = json.loads(extism.memory.string(response_mem))
|
||||||
{{if .HasError}}
|
{{if .HasError}}
|
||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
@@ -112,10 +94,17 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
|||||||
{{- if .NeedsResultClass}}
|
{{- if .NeedsResultClass}}
|
||||||
return {{pythonResultType .}}(
|
return {{pythonResultType .}}(
|
||||||
{{- range .Returns}}
|
{{- range .Returns}}
|
||||||
|
{{- if .IsByteSlice}}
|
||||||
|
{{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")),
|
||||||
|
{{- else}}
|
||||||
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
||||||
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
)
|
)
|
||||||
{{- else if .HasReturns}}
|
{{- 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)}})
|
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
||||||
{{- /* Generate struct definitions */ -}}
|
{{- /* Generate struct definitions */ -}}
|
||||||
{{- range .Service.Structs}}
|
{{- range .Service.Structs}}
|
||||||
{{if .Doc}}
|
{{if .Doc}}
|
||||||
@@ -16,6 +17,9 @@ pub struct {{.Name}} {
|
|||||||
{{- range .Fields}}
|
{{- range .Fields}}
|
||||||
{{- if .NeedsDefault}}
|
{{- if .NeedsDefault}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
{{- end}}
|
||||||
|
{{- if .IsByteSlice}}
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
{{- end}}
|
{{- end}}
|
||||||
pub {{.RustName}}: {{fieldRustType .}},
|
pub {{.RustName}}: {{fieldRustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
@@ -29,17 +33,22 @@ pub struct {{.Name}} {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct {{requestType .}} {
|
struct {{requestType .}} {
|
||||||
{{- range .Params}}
|
{{- range .Params}}
|
||||||
|
{{- if .IsByteSlice}}
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
|
{{- end}}
|
||||||
{{.RustName}}: {{rustType .}},
|
{{.RustName}}: {{rustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if not .Raw}}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct {{responseType .}} {
|
struct {{responseType .}} {
|
||||||
{{- range .Returns}}
|
{{- range .Returns}}
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
{{- if .IsByteSlice}}
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
|
{{- end}}
|
||||||
{{.RustName}}: {{rustType .}},
|
{{.RustName}}: {{rustType .}},
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .HasError}}
|
{{- if .HasError}}
|
||||||
@@ -48,92 +57,16 @@ struct {{responseType .}} {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
|
||||||
|
|
||||||
#[host_fn]
|
#[host_fn]
|
||||||
extern "ExtismHost" {
|
extern "ExtismHost" {
|
||||||
{{- range .Service.Methods}}
|
{{- range .Service.Methods}}
|
||||||
{{- if not .Raw}}
|
|
||||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||||
{{- end}}
|
{{- 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 */ -}}
|
{{- /* Generate wrapper functions */ -}}
|
||||||
{{range .Service.Methods}}
|
{{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<u8>), 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 .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||||
{{- if .HasParams}}
|
{{- if .HasParams}}
|
||||||
@@ -209,4 +142,3 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
|
|||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ package {{.Package}}
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
{{- if .Service.HasRawMethods}}
|
|
||||||
"encoding/binary"
|
|
||||||
{{- end}}
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
extism "github.com/extism/go-sdk"
|
||||||
@@ -23,7 +20,6 @@ type {{requestType .}} struct {
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if not .Raw}}
|
|
||||||
|
|
||||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||||
type {{responseType .}} struct {
|
type {{responseType .}} struct {
|
||||||
@@ -34,7 +30,6 @@ type {{responseType .}} struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
{{- end}}
|
{{- end}}
|
||||||
}
|
}
|
||||||
{{- end}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
// 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
|
// Read JSON request from plugin memory
|
||||||
reqBytes, err := p.ReadBytes(stack[0])
|
reqBytes, err := p.ReadBytes(stack[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
{{- if .Raw}}
|
|
||||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
|
||||||
{{- else}}
|
|
||||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
{{- end}}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req {{requestType .}}
|
var req {{requestType .}}
|
||||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
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)
|
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||||
{{- end}}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
// Call the service method
|
// Call the service method
|
||||||
{{- if .Raw}}
|
{{- if .HasReturns}}
|
||||||
{{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}}
|
{{- if .HasError}}
|
||||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||||
if svcErr != nil {
|
if svcErr != nil {
|
||||||
@@ -162,16 +127,3 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
|
|||||||
respPtr, _ := p.WriteBytes(respBytes)
|
respPtr, _ := p.WriteBytes(respBytes)
|
||||||
stack[0] = respPtr
|
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}}
|
|
||||||
|
|||||||
@@ -173,16 +173,6 @@ func (s Service) HasErrors() bool {
|
|||||||
return false
|
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.
|
// Method represents a host function method within a service.
|
||||||
type Method struct {
|
type Method struct {
|
||||||
Name string // Go method name (e.g., "Call")
|
Name string // Go method name (e.g., "Call")
|
||||||
@@ -191,7 +181,6 @@ type Method struct {
|
|||||||
Returns []Param // Return values (excluding error)
|
Returns []Param // Return values (excluding error)
|
||||||
HasError bool // Whether the method returns an error
|
HasError bool // Whether the method returns an error
|
||||||
Doc string // Documentation comment for the method
|
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.
|
// FunctionName returns the Extism host function export name.
|
||||||
@@ -343,6 +332,52 @@ type Param struct {
|
|||||||
JSONName string // JSON field name (camelCase)
|
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.
|
// NewParam creates a Param with auto-generated JSON name.
|
||||||
func NewParam(name, typ string) Param {
|
func NewParam(name, typ string) Param {
|
||||||
return Param{
|
return Param{
|
||||||
|
|||||||
+3
-2
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -38,7 +39,7 @@ def codec_encode(data: bytes) -> bytes:
|
|||||||
HostFunctionError: If the host function returns an error.
|
HostFunctionError: If the host function returns an error.
|
||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"data": data,
|
"data": base64.b64encode(data).decode("ascii"),
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
@@ -49,4 +50,4 @@ def codec_encode(data: bytes) -> bytes:
|
|||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
raise HostFunctionError(response["error"])
|
raise HostFunctionError(response["error"])
|
||||||
|
|
||||||
return response.get("result", b"")
|
return base64.b64decode(response.get("result", ""))
|
||||||
|
|||||||
@@ -5,10 +5,34 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CodecEncodeRequest {
|
struct CodecEncodeRequest {
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +40,7 @@ struct CodecEncodeRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CodecEncodeResponse {
|
struct CodecEncodeResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
result: Vec<u8>,
|
result: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -327,7 +328,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
|||||||
HostFunctionError: If the host function returns an error.
|
HostFunctionError: If the host function returns an error.
|
||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"data": data,
|
"data": base64.b64encode(data).decode("ascii"),
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
@@ -338,4 +339,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
|||||||
if response.get("error"):
|
if response.get("error"):
|
||||||
raise HostFunctionError(response["error"])
|
raise HostFunctionError(response["error"])
|
||||||
|
|
||||||
return response.get("result", b"")
|
return base64.b64decode(response.get("result", ""))
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -144,6 +167,7 @@ struct ComprehensiveMultipleReturnsResponse {
|
|||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ComprehensiveByteSliceRequest {
|
struct ComprehensiveByteSliceRequest {
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +175,7 @@ struct ComprehensiveByteSliceRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ComprehensiveByteSliceResponse {
|
struct ComprehensiveByteSliceResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
result: Vec<u8>,
|
result: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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<u8>), 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))
|
|
||||||
}
|
|
||||||
-10
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,8 @@ type SubsonicAPIService interface {
|
|||||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||||
|
|
||||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
// Designed for binary endpoints like getCoverArt and stream that return
|
||||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||||
//nd:hostfunc raw=true
|
//nd:hostfunc
|
||||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package host
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
extism "github.com/extism/go-sdk"
|
extism "github.com/extism/go-sdk"
|
||||||
@@ -26,6 +25,13 @@ type SubsonicAPICallRawRequest struct {
|
|||||||
Uri string `json:"uri"`
|
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.
|
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
||||||
// The returned host functions should be added to the plugin's configuration.
|
// The returned host functions should be added to the plugin's configuration.
|
||||||
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
||||||
@@ -76,37 +82,28 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu
|
|||||||
// Read JSON request from plugin memory
|
// Read JSON request from plugin memory
|
||||||
reqBytes, err := p.ReadBytes(stack[0])
|
reqBytes, err := p.ReadBytes(stack[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
subsonicapiWriteRawError(p, stack, err)
|
subsonicapiWriteError(p, stack, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req SubsonicAPICallRawRequest
|
var req SubsonicAPICallRawRequest
|
||||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||||
subsonicapiWriteRawError(p, stack, err)
|
subsonicapiWriteError(p, stack, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the service method
|
// Call the service method
|
||||||
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
||||||
if svcErr != nil {
|
if svcErr != nil {
|
||||||
subsonicapiWriteRawError(p, stack, svcErr)
|
subsonicapiWriteError(p, stack, svcErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write binary-framed response to plugin memory:
|
// Write JSON response to plugin memory
|
||||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
resp := SubsonicAPICallRawResponse{
|
||||||
ctBytes := []byte(contenttype)
|
ContentType: contenttype,
|
||||||
frame := make([]byte, 1+4+len(ctBytes)+len(data))
|
Data: 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
|
subsonicapiWriteResponse(p, stack, resp)
|
||||||
},
|
},
|
||||||
[]extism.ValueType{extism.ValueTypePTR},
|
[]extism.ValueType{extism.ValueTypePTR},
|
||||||
[]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)
|
respPtr, _ := p.WriteBytes(respBytes)
|
||||||
stack[0] = respPtr
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
package host
|
package host
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
@@ -38,6 +37,12 @@ type subsonicAPICallRawRequest struct {
|
|||||||
Uri string `json:"uri"`
|
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.
|
// SubsonicAPICall calls the subsonicapi_call host function.
|
||||||
// Call executes a Subsonic API request and returns the JSON response.
|
// 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.
|
// SubsonicAPICallRaw calls the subsonicapi_callraw host function.
|
||||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
// Designed for binary endpoints like getCoverArt and stream that return
|
||||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||||
// Marshal request to JSON
|
// Marshal request to JSON
|
||||||
req := subsonicAPICallRawRequest{
|
req := subsonicAPICallRawRequest{
|
||||||
@@ -99,22 +104,16 @@ func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
|||||||
responseMem := pdk.FindMemory(responsePtr)
|
responseMem := pdk.FindMemory(responsePtr)
|
||||||
responseBytes := responseMem.ReadBytes()
|
responseBytes := responseMem.ReadBytes()
|
||||||
|
|
||||||
// Parse binary-framed response
|
// Parse the response
|
||||||
if len(responseBytes) == 0 {
|
var response subsonicAPICallRawResponse
|
||||||
return "", nil, errors.New("empty response from host")
|
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")
|
return response.ContentType, response.Data, nil
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
|
|||||||
|
|
||||||
// SubsonicAPICallRaw delegates to the mock instance.
|
// SubsonicAPICallRaw delegates to the mock instance.
|
||||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
// Designed for binary endpoints like getCoverArt and stream that return
|
||||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||||
return SubsonicAPIMock.CallRaw(uri)
|
return SubsonicAPIMock.CallRaw(uri)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -337,7 +338,7 @@ Returns an error if the operation fails.
|
|||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"key": key,
|
"key": key,
|
||||||
"value": value,
|
"value": base64.b64encode(value).decode("ascii"),
|
||||||
"ttlSeconds": ttl_seconds,
|
"ttlSeconds": ttl_seconds,
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
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"])
|
raise HostFunctionError(response["error"])
|
||||||
|
|
||||||
return CacheGetBytesResult(
|
return CacheGetBytesResult(
|
||||||
value=response.get("value", b""),
|
value=base64.b64decode(response.get("value", "")),
|
||||||
exists=response.get("exists", False),
|
exists=response.get("exists", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -80,7 +81,7 @@ Returns an error if the storage limit would be exceeded or the operation fails.
|
|||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"key": key,
|
"key": key,
|
||||||
"value": value,
|
"value": base64.b64encode(value).decode("ascii"),
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
@@ -123,7 +124,7 @@ Returns the value and whether the key exists.
|
|||||||
raise HostFunctionError(response["error"])
|
raise HostFunctionError(response["error"])
|
||||||
|
|
||||||
return KVStoreGetResult(
|
return KVStoreGetResult(
|
||||||
value=response.get("value", b""),
|
value=base64.b64decode(response.get("value", "")),
|
||||||
exists=response.get("exists", False),
|
exists=response.get("exists", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Tuple
|
from typing import Any
|
||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
import struct
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
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:
|
def subsonicapi_call(uri: str) -> str:
|
||||||
"""Call executes a Subsonic API request and returns the JSON response.
|
"""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", "")
|
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.
|
"""CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||||
Optimized for binary endpoints like getCoverArt and stream that return
|
Designed for binary endpoints like getCoverArt and stream that return
|
||||||
non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
uri: str parameter.
|
uri: str parameter.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (content_type, data) with the raw binary response.
|
SubsonicAPICallRawResult containing content_type, data,.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HostFunctionError: If the host function returns an error.
|
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)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
response_offset = _subsonicapi_callraw(request_mem.offset)
|
response_offset = _subsonicapi_callraw(request_mem.offset)
|
||||||
response_mem = extism.memory.find(response_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:
|
if response.get("error"):
|
||||||
raise HostFunctionError("empty response from host")
|
raise HostFunctionError(response["error"])
|
||||||
if response_bytes[0] == 0x01:
|
|
||||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
return SubsonicAPICallRawResult(
|
||||||
if response_bytes[0] != 0x00:
|
content_type=response.get("contentType", ""),
|
||||||
raise HostFunctionError("unknown response status")
|
data=base64.b64decode(response.get("data", "")),
|
||||||
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
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import extism
|
import extism
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class HostFunctionError(Exception):
|
class HostFunctionError(Exception):
|
||||||
@@ -134,7 +135,7 @@ Returns an error if the connection is not found or if sending fails.
|
|||||||
"""
|
"""
|
||||||
request = {
|
request = {
|
||||||
"connectionId": connection_id,
|
"connectionId": connection_id,
|
||||||
"data": data,
|
"data": base64.b64encode(data).decode("ascii"),
|
||||||
}
|
}
|
||||||
request_bytes = json.dumps(request).encode("utf-8")
|
request_bytes = json.dumps(request).encode("utf-8")
|
||||||
request_mem = extism.memory.alloc(request_bytes)
|
request_mem = extism.memory.alloc(request_bytes)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ path = "src/lib.rs"
|
|||||||
crate-type = ["rlib"]
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
extism-pdk = "1.2"
|
extism-pdk = "1.2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ readme = "README.md"
|
|||||||
crate-type = ["rlib"]
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
extism-pdk = "1.2"
|
extism-pdk = "1.2"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -106,6 +129,7 @@ struct CacheGetFloatResponse {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CacheSetBytesRequest {
|
struct CacheSetBytesRequest {
|
||||||
key: String,
|
key: String,
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
ttl_seconds: i64,
|
ttl_seconds: i64,
|
||||||
}
|
}
|
||||||
@@ -127,6 +151,7 @@ struct CacheGetBytesRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct CacheGetBytesResponse {
|
struct CacheGetBytesResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
exists: bool,
|
exists: bool,
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// HTTPRequest represents an outbound HTTP request from a plugin.
|
/// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -15,6 +38,7 @@ pub struct HTTPRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub headers: std::collections::HashMap<String, String>,
|
pub headers: std::collections::HashMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
pub body: Vec<u8>,
|
pub body: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub timeout_ms: i32,
|
pub timeout_ms: i32,
|
||||||
@@ -28,6 +52,7 @@ pub struct HTTPResponse {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub headers: std::collections::HashMap<String, String>,
|
pub headers: std::collections::HashMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
pub body: Vec<u8>,
|
pub body: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,35 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct KVStoreSetRequest {
|
struct KVStoreSetRequest {
|
||||||
key: String,
|
key: String,
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +54,7 @@ struct KVStoreGetRequest {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct KVStoreGetResponse {
|
struct KVStoreGetResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
exists: bool,
|
exists: bool,
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -27,14 +50,22 @@ struct SubsonicAPICallRawRequest {
|
|||||||
uri: String,
|
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<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[host_fn]
|
#[host_fn]
|
||||||
extern "ExtismHost" {
|
extern "ExtismHost" {
|
||||||
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
|
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
|
||||||
}
|
fn subsonicapi_callraw(input: Json<SubsonicAPICallRawRequest>) -> Json<SubsonicAPICallRawResponse>;
|
||||||
|
|
||||||
#[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.
|
/// Call executes a Subsonic API request and returns the JSON response.
|
||||||
@@ -65,54 +96,27 @@ pub fn call(uri: &str) -> Result<String, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// CallRaw executes a Subsonic API request and returns the raw binary response.
|
/// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||||
/// Optimized for binary endpoints like getCoverArt and stream that return
|
/// Designed for binary endpoints like getCoverArt and stream that return
|
||||||
/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
/// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `uri` - String parameter.
|
/// * `uri` - String parameter.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// A tuple of (content_type, data) with the raw binary response.
|
/// A tuple of (content_type, data).
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns an error if the host function call fails.
|
/// Returns an error if the host function call fails.
|
||||||
pub fn call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
pub fn call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||||
let req = SubsonicAPICallRawRequest {
|
let response = unsafe {
|
||||||
uri: uri.to_owned(),
|
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((response.0.content_type, response.0.data))
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
use extism_pdk::*;
|
use extism_pdk::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
mod base64_bytes {
|
||||||
|
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
|
||||||
|
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&BASE64.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -41,6 +64,7 @@ struct WebSocketSendTextResponse {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct WebSocketSendBinaryRequest {
|
struct WebSocketSendBinaryRequest {
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
|
#[serde(with = "base64_bytes")]
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user