mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(plugins): add raw binary framing support for HTTP endpoint requests and responses
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
9a004fd043
commit
b1a51f9bbe
@ -7,7 +7,7 @@ package capabilities
|
||||
//nd:capability name=httpendpoint required=true
|
||||
type HTTPEndpoint interface {
|
||||
// HandleRequest processes an incoming HTTP request and returns a response.
|
||||
//nd:export name=nd_http_handle_request
|
||||
//nd:export name=nd_http_handle_request raw=true
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ type HTTPHandleRequest struct {
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body string `json:"body,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
@ -48,5 +48,5 @@ type HTTPHandleResponse struct {
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body string `json:"body,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
81
plugins/capabilities/http_endpoint.yaml
Normal file
81
plugins/capabilities/http_endpoint.yaml
Normal file
@ -0,0 +1,81 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_http_handle_request:
|
||||
description: HandleRequest processes an incoming HTTP request and returns a response.
|
||||
input:
|
||||
$ref: '#/components/schemas/HTTPHandleRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/HTTPHandleResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
HTTPHandleRequest:
|
||||
description: HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
description: Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
Path is the request path relative to the plugin's base URL.
|
||||
For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
query:
|
||||
type: string
|
||||
description: Query is the raw query string without the leading '?'.
|
||||
headers:
|
||||
type: object
|
||||
description: Headers contains the HTTP request headers.
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
body:
|
||||
type: buffer
|
||||
description: Body is the request body content.
|
||||
user:
|
||||
$ref: '#/components/schemas/HTTPUser'
|
||||
description: User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
nullable: true
|
||||
required:
|
||||
- method
|
||||
- path
|
||||
HTTPHandleResponse:
|
||||
description: HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
headers:
|
||||
type: object
|
||||
description: Headers contains the HTTP response headers to set.
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
body:
|
||||
type: buffer
|
||||
description: Body is the response body content.
|
||||
HTTPUser:
|
||||
description: HTTPUser contains authenticated user information passed to the plugin.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome user ID.
|
||||
username:
|
||||
type: string
|
||||
description: Username is the user's login name.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the user's display name.
|
||||
isAdmin:
|
||||
type: boolean
|
||||
description: IsAdmin indicates whether the user has admin privileges.
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- name
|
||||
- isAdmin
|
||||
@ -364,6 +364,27 @@ func capabilityFuncMap(cap Capability) template.FuncMap {
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"implVar": func(e Export) string { return e.ImplVarName() },
|
||||
"exportFunc": func(e Export) string { return e.ExportFuncName() },
|
||||
"rawFieldName": rawFieldName(cap),
|
||||
}
|
||||
}
|
||||
|
||||
// rawFieldName returns a template function that finds the first []byte field name
|
||||
// in a struct by type name. This is used by raw export templates to generate
|
||||
// field-specific binary frame code.
|
||||
func rawFieldName(cap Capability) func(string) string {
|
||||
structMap := make(map[string]StructDef)
|
||||
for _, s := range cap.Structs {
|
||||
structMap[s.Name] = s
|
||||
}
|
||||
return func(typeName string) string {
|
||||
if s, ok := structMap[typeName]; ok {
|
||||
for _, f := range s.Fields {
|
||||
if f.Type == "[]byte" {
|
||||
return f.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -466,6 +487,7 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap {
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
|
||||
"snakeCase": ToSnakeCase,
|
||||
"rawFieldName": rawFieldName(cap),
|
||||
"indent": func(spaces int, s string) string {
|
||||
indent := strings.Repeat(" ", spaces)
|
||||
lines := strings.Split(s, "\n")
|
||||
@ -560,6 +582,9 @@ func rustConstName(name string) string {
|
||||
|
||||
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
|
||||
func skipSerializingFunc(goType string) string {
|
||||
if goType == "[]byte" {
|
||||
return "Vec::is_empty"
|
||||
}
|
||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") {
|
||||
return "Option::is_none"
|
||||
}
|
||||
|
||||
@ -1432,6 +1432,10 @@ type OnInitOutput struct {
|
||||
|
||||
var _ = Describe("Rust Generation", func() {
|
||||
Describe("skipSerializingFunc", func() {
|
||||
It("should return Vec::is_empty for []byte type", func() {
|
||||
Expect(skipSerializingFunc("[]byte")).To(Equal("Vec::is_empty"))
|
||||
})
|
||||
|
||||
It("should return Option::is_none for pointer and slice types", func() {
|
||||
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
||||
|
||||
@ -269,6 +269,7 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Doc: doc,
|
||||
Raw: annotation["raw"] == "true",
|
||||
}
|
||||
|
||||
// Capability exports have exactly one input parameter (the struct type)
|
||||
|
||||
@ -635,6 +635,68 @@ type Output struct {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseCapabilities raw=true", func() {
|
||||
It("should parse raw=true export annotation", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=httpendpoint required=true
|
||||
type HTTPEndpoint interface {
|
||||
//nd:export name=nd_http_handle_request raw=true
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
}
|
||||
|
||||
type HTTPHandleRequest struct {
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||
}
|
||||
|
||||
type HTTPHandleResponse struct {
|
||||
Status int32 ` + "`json:\"status,omitempty\"`" + `
|
||||
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "http_endpoint.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
Expect(cap.Methods).To(HaveLen(1))
|
||||
Expect(cap.Methods[0].Raw).To(BeTrue())
|
||||
Expect(cap.HasRawMethods()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should default Raw to false for export annotations without raw", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=test required=true
|
||||
type TestCapability interface {
|
||||
//nd:export name=nd_test
|
||||
Test(TestInput) (TestOutput, error)
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
Value string ` + "`json:\"value\"`" + `
|
||||
}
|
||||
|
||||
type TestOutput struct {
|
||||
Result string ` + "`json:\"result\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
Expect(capabilities[0].Methods[0].Raw).To(BeFalse())
|
||||
Expect(capabilities[0].HasRawMethods()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Export helpers", func() {
|
||||
It("should generate correct provider interface name", func() {
|
||||
e := Export{Name: "GetArtistBiography"}
|
||||
|
||||
@ -9,6 +9,10 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
{{- if .Capability.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
{{- end}}
|
||||
)
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
@ -56,6 +60,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@ -68,8 +73,12 @@ type {{.Name}} struct {
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
{{.Name}} {{.Type}} `json:"-"`
|
||||
{{- else}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@ -172,6 +181,53 @@ func {{exportFunc .}}() int32 {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
{{- if .Raw}}
|
||||
{{- /* Raw binary frame input/output */ -}}
|
||||
{{- if .HasInput}}
|
||||
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
raw := pdk.Input()
|
||||
if len(raw) < 4 {
|
||||
pdk.SetErrorString("malformed input frame")
|
||||
return -1
|
||||
}
|
||||
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||
if uint32(len(raw)-4) < jsonLen {
|
||||
pdk.SetErrorString("invalid json length in input frame")
|
||||
return -1
|
||||
}
|
||||
var input {{.Input.Type}}
|
||||
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
input.{{rawFieldName .Input.Type}} = raw[4+jsonLen:]
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
|
||||
output, err := {{implVar .}}(input)
|
||||
if err != nil {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
errMsg := []byte(err.Error())
|
||||
errFrame := make([]byte, 1+len(errMsg))
|
||||
errFrame[0] = 0x01
|
||||
copy(errFrame[1:], errMsg)
|
||||
pdk.Output(errFrame)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
jsonBytes, _ := json.Marshal(output)
|
||||
rawBytes := output.{{rawFieldName .Output.Type}}
|
||||
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||
frame[0] = 0x00
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||
pdk.Output(frame)
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
{{- /* Standard JSON input/output */ -}}
|
||||
{{- if .HasInput}}
|
||||
|
||||
var input {{.Input.Type}}
|
||||
@ -216,6 +272,7 @@ func {{exportFunc .}}() int32 {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
return 0
|
||||
|
||||
@ -52,6 +52,7 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@ -66,13 +67,17 @@ pub struct {{.Name}} {
|
||||
{{- if .Doc}}
|
||||
{{rustDocComment .Doc | indent 4}}
|
||||
{{- end}}
|
||||
{{- if .OmitEmpty}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
#[serde(skip)]
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- else if .OmitEmpty}}
|
||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- else}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@ -124,6 +129,56 @@ pub trait {{agentName .Capability}} {
|
||||
macro_rules! register_{{snakeCase .Package}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- range .Capability.Methods}}
|
||||
{{- if .Raw}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if .HasInput}}
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
match $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
@ -146,6 +201,7 @@ macro_rules! register_{{snakeCase .Package}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- else}}
|
||||
@ -171,6 +227,56 @@ pub trait {{providerInterface .}} {
|
||||
#[macro_export]
|
||||
macro_rules! {{registerMacroName .Name}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- if .Raw}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if .HasInput}}
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
match $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
@ -192,6 +298,7 @@ macro_rules! {{registerMacroName .Name}} {
|
||||
Ok(())
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@ -53,6 +53,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@ -65,8 +66,12 @@ type {{.Name}} struct {
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
{{.Name}} {{.Type}} `json:"-"`
|
||||
{{- else}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
|
||||
@ -48,6 +48,16 @@ type ConstDef struct {
|
||||
Doc string // Documentation comment
|
||||
}
|
||||
|
||||
// HasRawMethods returns true if any export in the capability uses raw binary framing.
|
||||
func (c Capability) HasRawMethods() bool {
|
||||
for _, m := range c.Methods {
|
||||
if m.Raw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownStructs returns a map of struct names defined in this capability.
|
||||
func (c Capability) KnownStructs() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
@ -64,6 +74,7 @@ type Export struct {
|
||||
Input Param // Single input parameter (the struct type)
|
||||
Output Param // Single output return value (the struct type)
|
||||
Doc string // Documentation comment for the method
|
||||
Raw bool // If true, uses binary framing instead of JSON for []byte fields
|
||||
}
|
||||
|
||||
// ProviderInterfaceName returns the optional provider interface name.
|
||||
|
||||
@ -54,6 +54,14 @@ type (
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
Items *xtpProperty `yaml:"items,omitempty"`
|
||||
}
|
||||
|
||||
// xtpMapProperty represents a map property in XTP (type: object with additionalProperties).
|
||||
xtpMapProperty struct {
|
||||
Type string `yaml:"type"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
AdditionalProperties *xtpProperty `yaml:"additionalProperties"`
|
||||
}
|
||||
)
|
||||
|
||||
// GenerateSchema generates an XTP YAML schema from a capability.
|
||||
@ -206,7 +214,12 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema
|
||||
|
||||
for _, field := range st.Fields {
|
||||
propName := getJSONFieldName(field)
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
goType := strings.TrimPrefix(field.Type, "*")
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
addToMap(&schema.Properties, propName, buildMapProperty(goType, field.Doc, strings.HasPrefix(field.Type, "*"), knownTypes))
|
||||
} else {
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
|
||||
schema.Required = append(schema.Required, propName)
|
||||
@ -246,6 +259,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle []byte as buffer type (must be checked before generic slice handling)
|
||||
if goType == "[]byte" {
|
||||
prop.Type = "buffer"
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
elemType := goType[2:]
|
||||
@ -264,6 +283,55 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// buildMapProperty builds an XTP MapProperty for a Go map type.
|
||||
// It parses map[K]V and generates additionalProperties describing V.
|
||||
func buildMapProperty(goType, doc string, isPointer bool, knownTypes map[string]bool) xtpMapProperty {
|
||||
prop := xtpMapProperty{
|
||||
Type: "object",
|
||||
Description: cleanDocForYAML(doc),
|
||||
Nullable: isPointer,
|
||||
}
|
||||
|
||||
// Parse value type from map[K]V
|
||||
valueType := parseMapValueType(goType)
|
||||
|
||||
valProp := &xtpProperty{}
|
||||
if strings.HasPrefix(valueType, "[]") {
|
||||
elemType := valueType[2:]
|
||||
valProp.Type = "array"
|
||||
valProp.Items = &xtpProperty{}
|
||||
if isKnownType(elemType, knownTypes) {
|
||||
valProp.Items.Ref = "#/components/schemas/" + elemType
|
||||
} else {
|
||||
valProp.Items.Type = goTypeToXTPType(elemType)
|
||||
}
|
||||
} else if isKnownType(valueType, knownTypes) {
|
||||
valProp.Ref = "#/components/schemas/" + valueType
|
||||
} else {
|
||||
valProp.Type, valProp.Format = goTypeToXTPTypeAndFormat(valueType)
|
||||
}
|
||||
prop.AdditionalProperties = valProp
|
||||
|
||||
return prop
|
||||
}
|
||||
|
||||
// parseMapValueType extracts the value type from a Go map type string like "map[string][]string".
|
||||
func parseMapValueType(goType string) string {
|
||||
// Find the closing bracket of the key type
|
||||
depth := 0
|
||||
for i, ch := range goType {
|
||||
if ch == '[' {
|
||||
depth++
|
||||
} else if ch == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return goType[i+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "object" // fallback
|
||||
}
|
||||
|
||||
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
|
||||
func addToMap[T any](node *yaml.Node, key string, value T) {
|
||||
var valNode yaml.Node
|
||||
|
||||
@ -719,4 +719,139 @@ var _ = Describe("XTP Schema Generation", func() {
|
||||
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with []byte fields", func() {
|
||||
It("should render []byte as buffer type and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "buffer_test",
|
||||
SourceFile: "buffer_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Name", Type: "string", JSONTag: "name"},
|
||||
{Name: "Data", Type: "[]byte", JSONTag: "data,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Body", Type: "[]byte", JSONTag: "body,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
data := props["data"].(map[string]any)
|
||||
Expect(data["type"]).To(Equal("buffer"))
|
||||
Expect(data).NotTo(HaveKey("items"))
|
||||
Expect(data).NotTo(HaveKey("format"))
|
||||
|
||||
output := schemas["Output"].(map[string]any)
|
||||
outProps := output["properties"].(map[string]any)
|
||||
body := outProps["body"].(map[string]any)
|
||||
Expect(body["type"]).To(Equal("buffer"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with map fields", func() {
|
||||
It("should render map[string][]string as object with additionalProperties and validate", func() {
|
||||
capability := Capability{
|
||||
Name: "map_test",
|
||||
SourceFile: "map_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Headers", Type: "map[string][]string", JSONTag: "headers,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
headers := props["headers"].(map[string]any)
|
||||
Expect(headers).To(HaveKey("additionalProperties"))
|
||||
addlProps := headers["additionalProperties"].(map[string]any)
|
||||
Expect(addlProps["type"]).To(Equal("array"))
|
||||
items := addlProps["items"].(map[string]any)
|
||||
Expect(items["type"]).To(Equal("string"))
|
||||
})
|
||||
|
||||
It("should render map[string]string as object with string additionalProperties", func() {
|
||||
capability := Capability{
|
||||
Name: "map_string_test",
|
||||
SourceFile: "map_string_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Metadata", Type: "map[string]string", JSONTag: "metadata,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
metadata := props["metadata"].(map[string]any)
|
||||
Expect(metadata).To(HaveKey("additionalProperties"))
|
||||
addlProps := metadata["additionalProperties"].(map[string]any)
|
||||
Expect(addlProps["type"]).To(Equal("string"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseMapValueType", func() {
|
||||
DescribeTable("should extract value type from Go map types",
|
||||
func(goType, wantValue string) {
|
||||
Expect(parseMapValueType(goType)).To(Equal(wantValue))
|
||||
},
|
||||
Entry("map[string]string", "map[string]string", "string"),
|
||||
Entry("map[string]int", "map[string]int", "int"),
|
||||
Entry("map[string][]string", "map[string][]string", "[]string"),
|
||||
Entry("map[string][]byte", "map[string][]byte", "[]byte"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -150,13 +150,15 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
|
||||
Path: relPath,
|
||||
Query: r.URL.RawQuery,
|
||||
Headers: r.Header,
|
||||
Body: string(body),
|
||||
Body: body,
|
||||
User: httpUser,
|
||||
}
|
||||
|
||||
// Call the plugin
|
||||
resp, err := callPluginFunction[capabilities.HTTPHandleRequest, capabilities.HTTPHandleResponse](
|
||||
ctx, p, FuncHTTPHandleRequest, pluginReq,
|
||||
// Call the plugin using binary framing for []byte Body fields
|
||||
resp, err := callPluginFunctionRaw(
|
||||
ctx, p, FuncHTTPHandleRequest,
|
||||
pluginReq, pluginReq.Body,
|
||||
func(r *capabilities.HTTPHandleResponse, raw []byte) { r.Body = raw },
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err)
|
||||
@ -183,8 +185,8 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
|
||||
w.WriteHeader(status)
|
||||
|
||||
// Write response body
|
||||
if resp.Body != "" {
|
||||
if _, err := w.Write([]byte(resp.Body)); err != nil {
|
||||
if len(resp.Body) > 0 {
|
||||
if _, err := w.Write(resp.Body); err != nil {
|
||||
log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -446,6 +446,19 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Binary Response", func() {
|
||||
It("returns raw binary data intact", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/binary?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
|
||||
// PNG header bytes
|
||||
Expect(w.Body.Bytes()).To(Equal([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Request body handling", func() {
|
||||
It("passes request body to the plugin", func() {
|
||||
body := `{"event":"push","ref":"refs/heads/main"}`
|
||||
|
||||
@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -95,6 +96,105 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
|
||||
return result, err
|
||||
}
|
||||
|
||||
// callPluginFunctionRaw calls a plugin function using binary framing for []byte fields.
|
||||
// The input is JSON-encoded (with []byte field excluded via json:"-"), followed by raw bytes.
|
||||
// The output frame is: [status:1B][json_len:4B][JSON][raw bytes] for success (0x00),
|
||||
// or [0x01][UTF-8 error message] for errors.
|
||||
func callPluginFunctionRaw[I any, O any](
|
||||
ctx context.Context, plugin *plugin, funcName string,
|
||||
input I, rawInputBytes []byte,
|
||||
setRawOutput func(*O, []byte),
|
||||
) (O, error) {
|
||||
start := time.Now()
|
||||
|
||||
var result O
|
||||
|
||||
p, err := plugin.instance(ctx)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to create plugin: %w", err)
|
||||
}
|
||||
defer p.Close(ctx)
|
||||
|
||||
if !p.FunctionExists(funcName) {
|
||||
log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName)
|
||||
return result, fmt.Errorf("%w: %s", errFunctionNotFound, funcName)
|
||||
}
|
||||
|
||||
// Build input frame: [json_len:4B][JSON][raw bytes]
|
||||
jsonBytes, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes))
|
||||
binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes)))
|
||||
copy(frame[4:4+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[4+len(jsonBytes):], rawInputBytes)
|
||||
|
||||
startCall := time.Now()
|
||||
exit, output, err := p.CallWithContext(ctx, funcName, frame)
|
||||
elapsed := time.Since(startCall)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||
}
|
||||
if exit != 0 {
|
||||
if exit == notImplementedCode {
|
||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||
}
|
||||
|
||||
// Parse output frame
|
||||
if len(output) < 1 {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("empty response from plugin")
|
||||
}
|
||||
|
||||
statusByte := output[0]
|
||||
if statusByte == 0x01 {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
errMsg := string(output[1:])
|
||||
return result, fmt.Errorf("plugin error: %s", errMsg)
|
||||
}
|
||||
|
||||
if statusByte != 0x00 {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("unknown response status byte: 0x%02x", statusByte)
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON][raw bytes]
|
||||
if len(output) < 5 {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("malformed success response from plugin")
|
||||
}
|
||||
|
||||
jsonLen := binary.BigEndian.Uint32(output[1:5])
|
||||
if uint32(len(output)-5) < jsonLen {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("invalid json length in response frame: %d exceeds available %d bytes", jsonLen, len(output)-5)
|
||||
}
|
||||
jsonData := output[5 : 5+jsonLen]
|
||||
rawData := output[5+jsonLen:]
|
||||
|
||||
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
setRawOutput(&result, rawData)
|
||||
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extismLogger is a helper to log messages from Extism plugins
|
||||
func extismLogger(pluginName string) func(level extism.LogLevel, msg string) {
|
||||
return func(level extism.LogLevel, msg string) {
|
||||
|
||||
@ -6,3 +6,10 @@ require (
|
||||
github.com/extism/go-pdk v1.1.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
package httpendpoint
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
@ -24,7 +27,7 @@ type HTTPHandleRequest struct {
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body string `json:"body,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
@ -36,7 +39,7 @@ type HTTPHandleResponse struct {
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body string `json:"body,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
// HTTPUser contains authenticated user information passed to the plugin.
|
||||
@ -80,22 +83,44 @@ func _NdHttpHandleRequest() int32 {
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
raw := pdk.Input()
|
||||
if len(raw) < 4 {
|
||||
pdk.SetErrorString("malformed input frame")
|
||||
return -1
|
||||
}
|
||||
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||
if uint32(len(raw)-4) < jsonLen {
|
||||
pdk.SetErrorString("invalid json length in input frame")
|
||||
return -1
|
||||
}
|
||||
var input HTTPHandleRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
input.Body = raw[4+jsonLen:]
|
||||
|
||||
output, err := handleRequestImpl(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
errMsg := []byte(err.Error())
|
||||
errFrame := make([]byte, 1+len(errMsg))
|
||||
errFrame[0] = 0x01
|
||||
copy(errFrame[1:], errMsg)
|
||||
pdk.Output(errFrame)
|
||||
return 0
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
jsonBytes, _ := json.Marshal(output)
|
||||
rawBytes := output.Body
|
||||
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||
frame[0] = 0x00
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||
pdk.Output(frame)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ type HTTPHandleRequest struct {
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body string `json:"body,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
@ -33,7 +33,7 @@ type HTTPHandleResponse struct {
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body string `json:"body,omitempty"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
// HTTPUser contains authenticated user information passed to the plugin.
|
||||
|
||||
@ -38,8 +38,8 @@ pub struct HTTPHandleRequest {
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub headers: std::collections::HashMap<String, Vec<String>>,
|
||||
/// Body is the request body content.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub body: String,
|
||||
#[serde(skip)]
|
||||
pub body: Vec<u8>,
|
||||
/// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<HTTPUser>,
|
||||
@ -55,8 +55,8 @@ pub struct HTTPHandleResponse {
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub headers: std::collections::HashMap<String, Vec<String>>,
|
||||
/// Body is the response body content.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub body: String,
|
||||
#[serde(skip)]
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
/// HTTPUser contains authenticated user information passed to the plugin.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@ -112,11 +112,45 @@ macro_rules! register_httpendpoint {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_http_handle_request(
|
||||
req: extism_pdk::Json<$crate::httpendpoint::HTTPHandleRequest>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::httpendpoint::HTTPHandleResponse>> {
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::httpendpoint::HTTPHandleRequest = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.body = raw_bytes[4+json_len..].to_vec();
|
||||
match $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.body;
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: "Hello from native auth plugin!",
|
||||
Body: []byte("Hello from native auth plugin!"),
|
||||
}, nil
|
||||
|
||||
case "/echo":
|
||||
@ -31,7 +31,7 @@ func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": req.Query,
|
||||
"body": req.Body,
|
||||
"body": string(req.Body),
|
||||
"hasUser": req.User != nil,
|
||||
"username": userName(req.User),
|
||||
})
|
||||
@ -40,13 +40,13 @@ func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: string(data),
|
||||
Body: data,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: "Not found: " + req.Path,
|
||||
Body: []byte("Not found: " + req.Path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: "webhook received",
|
||||
Body: []byte("webhook received"),
|
||||
}, nil
|
||||
|
||||
case "/check-no-user":
|
||||
@ -31,13 +31,13 @@ func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (
|
||||
}
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Body: "hasUser=" + hasUser,
|
||||
Body: []byte("hasUser=" + hasUser),
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: "Not found: " + req.Path,
|
||||
Body: []byte("Not found: " + req.Path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
20
plugins/testdata/test-http-endpoint/main.go
vendored
20
plugins/testdata/test-http-endpoint/main.go
vendored
@ -22,7 +22,7 @@ func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpen
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: "Hello from plugin!",
|
||||
Body: []byte("Hello from plugin!"),
|
||||
}, nil
|
||||
|
||||
case "/echo":
|
||||
@ -31,7 +31,7 @@ func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpen
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": req.Query,
|
||||
"body": req.Body,
|
||||
"body": string(req.Body),
|
||||
"hasUser": req.User != nil,
|
||||
"username": userName(req.User),
|
||||
})
|
||||
@ -40,19 +40,29 @@ func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpen
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: string(data),
|
||||
Body: data,
|
||||
}, nil
|
||||
|
||||
case "/binary":
|
||||
// Return raw binary data (PNG header)
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"image/png"},
|
||||
},
|
||||
Body: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
|
||||
}, nil
|
||||
|
||||
case "/error":
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 500,
|
||||
Body: "Something went wrong",
|
||||
Body: []byte("Something went wrong"),
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: "Not found: " + req.Path,
|
||||
Body: []byte("Not found: " + req.Path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user