feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982)
* feat: implement raw binary framing for host function responses Signed-off-by: Deluan <deluan@navidrome.org> * feat: add CallRaw method for Subsonic API to handle binary responses Signed-off-by: Deluan <deluan@navidrome.org> * test: add tests for raw=true methods and binary framing generation Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve error message for malformed raw responses to indicate incomplete header Signed-off-by: Deluan <deluan@navidrome.org> * fix: add wasm_import_module attribute for raw methods and improve content-type handling Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -264,6 +264,96 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||
})
|
||||
|
||||
It("should generate binary framing for raw=true methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateHost(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should include encoding/binary import for raw methods
|
||||
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||
|
||||
// Should NOT generate a response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
|
||||
|
||||
// Should build binary frame [0x00][4-byte CT len][CT][data]
|
||||
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
|
||||
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
|
||||
|
||||
// Should have writeRawError helper
|
||||
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
|
||||
|
||||
// Should use writeRawError instead of writeError for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
|
||||
})
|
||||
|
||||
It("should generate both writeError and writeRawError for mixed services", func() {
|
||||
svc := Service{
|
||||
Name: "API",
|
||||
Permission: "api",
|
||||
Interface: "APIService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
{
|
||||
Name: "CallRaw",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateHost(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should have both helpers
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteError"))
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
|
||||
|
||||
// Should generate response type for non-raw method only
|
||||
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
|
||||
})
|
||||
|
||||
It("should always include json import for JSON protocol", func() {
|
||||
// All services use JSON protocol, so json import is always needed
|
||||
svc := Service{
|
||||
@@ -626,6 +716,72 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
|
||||
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||
})
|
||||
|
||||
It("should generate binary frame parsing for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should import Tuple and struct for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
|
||||
Expect(codeStr).To(ContainSubstring("import struct"))
|
||||
|
||||
// Should return Tuple[str, bytes]
|
||||
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
|
||||
|
||||
// Should parse binary frame instead of JSON
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("struct.unpack"))
|
||||
Expect(codeStr).To(ContainSubstring("return content_type, data"))
|
||||
|
||||
// Should NOT use json.loads for response
|
||||
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
|
||||
})
|
||||
|
||||
It("should not import Tuple or struct for non-raw services", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
Interface: "TestService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateGoDoc", func() {
|
||||
@@ -782,6 +938,47 @@ var _ = Describe("Generator", func() {
|
||||
// Check for PDK import
|
||||
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
|
||||
})
|
||||
|
||||
It("should include encoding/binary import for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientGo(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should include encoding/binary for raw binary frame parsing
|
||||
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||
|
||||
// Should NOT generate response type struct for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
|
||||
|
||||
// Should still generate request type
|
||||
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
|
||||
|
||||
// Should parse binary frame
|
||||
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
|
||||
|
||||
// Should return (string, []byte, error)
|
||||
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateClientGoStub", func() {
|
||||
@@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() {
|
||||
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
||||
})
|
||||
|
||||
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientRust(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
||||
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
||||
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
||||
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
||||
|
||||
// Should NOT generate response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
||||
|
||||
// Should return Result<(String, Vec<u8>), Error>
|
||||
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
||||
|
||||
// Should parse binary frame
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
|
||||
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
m := Method{
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Raw: annotation["raw"] == "true",
|
||||
Doc: doc,
|
||||
}
|
||||
|
||||
@@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
}
|
||||
}
|
||||
|
||||
// Validate raw=true methods: must return exactly (string, []byte, error)
|
||||
if m.Raw {
|
||||
if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" {
|
||||
return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,119 @@ type TestService interface {
|
||||
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
||||
})
|
||||
|
||||
It("should parse raw=true annotation", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Stream permission=stream
|
||||
type StreamService interface {
|
||||
//nd:hostfunc raw=true
|
||||
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
|
||||
m := services[0].Methods[0]
|
||||
Expect(m.Name).To(Equal("GetStream"))
|
||||
Expect(m.Raw).To(BeTrue())
|
||||
Expect(m.HasError).To(BeTrue())
|
||||
Expect(m.Returns).To(HaveLen(2))
|
||||
Expect(m.Returns[0].Name).To(Equal("contentType"))
|
||||
Expect(m.Returns[0].Type).To(Equal("string"))
|
||||
Expect(m.Returns[1].Name).To(Equal("data"))
|
||||
Expect(m.Returns[1].Type).To(Equal("[]byte"))
|
||||
})
|
||||
|
||||
It("should set Raw=false when raw annotation is absent", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should reject raw=true with invalid return signature", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc raw=true
|
||||
BadRaw(ctx context.Context, uri string) (result string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ParseDirectory(tmpDir)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||
Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)"))
|
||||
})
|
||||
|
||||
It("should reject raw=true without error return", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc raw=true
|
||||
BadRaw(ctx context.Context, uri string) (contentType string, data []byte)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ParseDirectory(tmpDir)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||
})
|
||||
|
||||
It("should parse mixed raw and non-raw methods", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=API permission=api
|
||||
type APIService interface {
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||
|
||||
//nd:hostfunc raw=true
|
||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Methods).To(HaveLen(2))
|
||||
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||
Expect(services[0].Methods[1].Raw).To(BeTrue())
|
||||
Expect(services[0].HasRawMethods()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should handle custom export name", func() {
|
||||
src := `package host
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
{{- if .Service.HasErrors}}
|
||||
"errors"
|
||||
@@ -49,7 +52,7 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .IsErrorOnly}}
|
||||
{{- if and (not .IsErrorOnly) (not .Raw)}}
|
||||
|
||||
type {{responseType .}} struct {
|
||||
{{- range .Returns}}
|
||||
@@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
{{- if .IsErrorOnly}}
|
||||
{{- if .Raw}}
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
{{- else if .IsErrorOnly}}
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
|
||||
|
||||
import extism
|
||||
import json
|
||||
{{- if .Service.HasRawMethods}}
|
||||
import struct
|
||||
{{- end}}
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
|
||||
{{- end}}
|
||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .NeedsResultClass}}
|
||||
{{- if and .NeedsResultClass (not .Raw)}}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,7 +47,7 @@ class {{pythonResultType .}}:
|
||||
{{range .Service.Methods}}
|
||||
|
||||
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
@@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
{{.PythonName}}: {{.PythonType}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .HasReturns}}
|
||||
{{- if .Raw}}
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
{{- else if .HasReturns}}
|
||||
|
||||
Returns:
|
||||
{{- if .NeedsResultClass}}
|
||||
@@ -79,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _{{exportName .}}(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
{{- if .Raw}}
|
||||
response_bytes = response_mem.bytes()
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
{{- else}}
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
{{if .HasError}}
|
||||
if response.get("error"):
|
||||
@@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct {{requestType .}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -47,16 +48,92 @@ struct {{responseType .}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
{{- range .Service.Methods}}
|
||||
{{- if not .Raw}}
|
||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- /* Declare raw extern "C" imports for raw methods */ -}}
|
||||
{{- range .Service.Methods}}
|
||||
{{- if .Raw}}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn {{exportName .}}(offset: u64) -> u64;
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate wrapper functions */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .Raw}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
///
|
||||
/// # Arguments
|
||||
{{- range .Params}}
|
||||
/// * `{{.RustName}}` - {{rustType .}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec<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 .HasParams}}
|
||||
@@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -4,6 +4,9 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -20,6 +23,7 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{responseType .}} struct {
|
||||
@@ -30,6 +34,7 @@ type {{responseType .}} struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||
@@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
{{- if .Raw}}
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||
{{- else}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
var req {{requestType .}}
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
{{- if .Raw}}
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||
{{- else}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
// Call the service method
|
||||
{{- if .HasReturns}}
|
||||
{{- if .Raw}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write binary-framed response to plugin memory:
|
||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||
ctBytes := []byte({{lower (index .Returns 0).Name}})
|
||||
frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}}))
|
||||
frame[0] = 0x00 // success
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||
copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}})
|
||||
|
||||
respPtr, err := p.WriteBytes(frame)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
{{- else if .HasReturns}}
|
||||
{{- if .HasError}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
@@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
{{- else}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
{{- end}}
|
||||
{{- else if .HasError}}
|
||||
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
{{- else}}
|
||||
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
{{- end}}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{
|
||||
@@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
{{- end}}
|
||||
}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- else if .HasError}}
|
||||
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- else}}
|
||||
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- end}}
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
@@ -119,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- if .Service.HasRawMethods}}
|
||||
|
||||
// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory.
|
||||
// Format: [0x01][UTF-8 error message]
|
||||
func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errMsg := []byte(err.Error())
|
||||
frame := make([]byte, 1+len(errMsg))
|
||||
frame[0] = 0x01 // error
|
||||
copy(frame[1:], errMsg)
|
||||
respPtr, _ := p.WriteBytes(frame)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -173,6 +173,16 @@ func (s Service) HasErrors() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasRawMethods returns true if any method in the service uses raw binary framing.
|
||||
func (s Service) HasRawMethods() bool {
|
||||
for _, m := range s.Methods {
|
||||
if m.Raw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Method represents a host function method within a service.
|
||||
type Method struct {
|
||||
Name string // Go method name (e.g., "Call")
|
||||
@@ -181,6 +191,7 @@ type Method struct {
|
||||
Returns []Param // Return values (excluding error)
|
||||
HasError bool // Whether the method returns an error
|
||||
Doc string // Documentation comment for the method
|
||||
Raw bool // If true, response uses binary framing instead of JSON
|
||||
}
|
||||
|
||||
// FunctionName returns the Extism host function export name.
|
||||
|
||||
Reference in New Issue
Block a user