From b1a51f9bbe76248c6e9b4b86f56c00e40dcdec12 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Feb 2026 15:20:55 -0500 Subject: [PATCH] feat(plugins): add raw binary framing support for HTTP endpoint requests and responses Signed-off-by: Deluan --- plugins/capabilities/http_endpoint.go | 6 +- plugins/capabilities/http_endpoint.yaml | 81 +++++++++++ plugins/cmd/ndpgen/internal/generator.go | 25 ++++ plugins/cmd/ndpgen/internal/generator_test.go | 4 + plugins/cmd/ndpgen/internal/parser.go | 1 + plugins/cmd/ndpgen/internal/parser_test.go | 62 ++++++++ .../internal/templates/capability.go.tmpl | 57 ++++++++ .../internal/templates/capability.rs.tmpl | 111 +++++++++++++- .../templates/capability_stub.go.tmpl | 5 + plugins/cmd/ndpgen/internal/types.go | 11 ++ plugins/cmd/ndpgen/internal/xtp_schema.go | 70 ++++++++- .../cmd/ndpgen/internal/xtp_schema_test.go | 135 ++++++++++++++++++ plugins/http_endpoint.go | 14 +- plugins/http_endpoint_test.go | 13 ++ plugins/manager_call.go | 100 +++++++++++++ plugins/pdk/go/go.mod | 7 + plugins/pdk/go/httpendpoint/httpendpoint.go | 43 ++++-- .../pdk/go/httpendpoint/httpendpoint_stub.go | 4 +- .../nd-pdk-capabilities/src/httpendpoint.rs | 50 +++++-- .../test-http-endpoint-native/main.go | 8 +- .../test-http-endpoint-public/main.go | 6 +- plugins/testdata/test-http-endpoint/main.go | 20 ++- 22 files changed, 790 insertions(+), 43 deletions(-) create mode 100644 plugins/capabilities/http_endpoint.yaml diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go index 49e8cb81b..320150f8f 100644 --- a/plugins/capabilities/http_endpoint.go +++ b/plugins/capabilities/http_endpoint.go @@ -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"` } diff --git a/plugins/capabilities/http_endpoint.yaml b/plugins/capabilities/http_endpoint.yaml new file mode 100644 index 000000000..7c6468b09 --- /dev/null +++ b/plugins/capabilities/http_endpoint.yaml @@ -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 diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 6c5022eaf..352f72cda 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -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" } diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 542759ae5..89912d48c 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -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")) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index c2d571779..f7279d941 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -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) diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f2bdbeded..cb9848e7c 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -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"} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index ebcd80739..a9872478b 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -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 diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 597a17338..c8459bbe2 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -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> + {{- end}} + ) -> extism_pdk::FnResult>> { + 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> + {{- end}} + ) -> extism_pdk::FnResult>> { + 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}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index 90f72be93..67417f67a 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -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}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 29cf2316a..13d6a4070 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -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. diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index db30262cc..357a9d40f 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -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 diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 5e8a132f2..debf3d5ed 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -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"), + ) + }) }) diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index cdd1c29d3..d0fc96c49 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -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) } } diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go index 2d88ed1f1..427543b40 100644 --- a/plugins/http_endpoint_test.go +++ b/plugins/http_endpoint_test.go @@ -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"}` diff --git a/plugins/manager_call.go b/plugins/manager_call.go index b5c7536b6..12729767c 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -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) { diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod index 3916cd749..4d5fcddfc 100644 --- a/plugins/pdk/go/go.mod +++ b/plugins/pdk/go/go.mod @@ -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 +) diff --git a/plugins/pdk/go/httpendpoint/httpendpoint.go b/plugins/pdk/go/httpendpoint/httpendpoint.go index 383b3f955..b91b392aa 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint.go @@ -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 } diff --git a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go index 5343b8e78..996dcc5ca 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go @@ -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. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs index 72cec1cb3..5840fdc7c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs @@ -38,8 +38,8 @@ pub struct HTTPHandleRequest { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: std::collections::HashMap>, /// Body is the request body content. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub body: String, + #[serde(skip)] + pub body: Vec, /// User contains the authenticated user information. Nil for auth:"none" endpoints. #[serde(default, skip_serializing_if = "Option::is_none")] pub user: Option, @@ -55,8 +55,8 @@ pub struct HTTPHandleResponse { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: std::collections::HashMap>, /// Body is the response body content. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub body: String, + #[serde(skip)] + pub body: Vec, } /// 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> { + _raw_input: extism_pdk::Raw> + ) -> extism_pdk::FnResult>> { 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)) + } + } } }; } diff --git a/plugins/testdata/test-http-endpoint-native/main.go b/plugins/testdata/test-http-endpoint-native/main.go index 6a4cf6869..edb6c87d0 100644 --- a/plugins/testdata/test-http-endpoint-native/main.go +++ b/plugins/testdata/test-http-endpoint-native/main.go @@ -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 } } diff --git a/plugins/testdata/test-http-endpoint-public/main.go b/plugins/testdata/test-http-endpoint-public/main.go index 55689f6e3..4d82116b1 100644 --- a/plugins/testdata/test-http-endpoint-public/main.go +++ b/plugins/testdata/test-http-endpoint-public/main.go @@ -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 } } diff --git a/plugins/testdata/test-http-endpoint/main.go b/plugins/testdata/test-http-endpoint/main.go index 0b110c5cf..37a308cff 100644 --- a/plugins/testdata/test-http-endpoint/main.go +++ b/plugins/testdata/test-http-endpoint/main.go @@ -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 } }