mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* feat: implement raw binary framing for host function responses Signed-off-by: Deluan <deluan@navidrome.org> * feat: add CallRaw method for Subsonic API to handle binary responses Signed-off-by: Deluan <deluan@navidrome.org> * test: add tests for raw=true methods and binary framing generation Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve error message for malformed raw responses to indicate incomplete header Signed-off-by: Deluan <deluan@navidrome.org> * fix: add wasm_import_module attribute for raw methods and improve content-type handling Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
1801 lines
55 KiB
Go
1801 lines
55 KiB
Go
package internal
|
|
|
|
import (
|
|
"go/format"
|
|
"os"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Generator", func() {
|
|
Describe("GenerateHost", func() {
|
|
It("should generate valid Go code for a simple service with strings", func() {
|
|
// All methods use JSON request/response types
|
|
svc := Service{
|
|
Name: "SubsonicAPI",
|
|
Permission: "subsonicapi",
|
|
Interface: "SubsonicAPIService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Call",
|
|
HasError: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{NewParam("response", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify the code is valid Go
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for generated header
|
|
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
|
|
|
|
// Check for package declaration
|
|
Expect(codeStr).To(ContainSubstring("package host"))
|
|
|
|
// All methods now use request type for JSON protocol
|
|
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct"))
|
|
Expect(codeStr).To(ContainSubstring(`Uri string `))
|
|
|
|
// Response type with error handling
|
|
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct"))
|
|
Expect(codeStr).To(ContainSubstring(`Response string `))
|
|
Expect(codeStr).To(ContainSubstring(`Error string `))
|
|
|
|
// Check for registration function
|
|
Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)"))
|
|
|
|
// Check for host function name
|
|
Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`))
|
|
|
|
// Check for JSON unmarshal (all methods use JSON now)
|
|
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
|
|
})
|
|
|
|
It("should generate code for methods without parameters", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "NoParams",
|
|
HasError: true,
|
|
Returns: []Param{NewParam("result", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
// Methods without params don't need a request type - no params to serialize
|
|
Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
|
|
// But still uses PTR input/output for consistency
|
|
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
|
|
})
|
|
|
|
It("should generate code for methods without return values", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "NoReturn",
|
|
HasError: true,
|
|
Params: []Param{NewParam("input", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("should generate code for multiple methods", func() {
|
|
svc := Service{
|
|
Name: "Scheduler",
|
|
Permission: "scheduler",
|
|
Interface: "SchedulerService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "ScheduleRecurring",
|
|
HasError: true,
|
|
Params: []Param{NewParam("cronExpression", "string")},
|
|
Returns: []Param{NewParam("scheduleID", "string")},
|
|
},
|
|
{
|
|
Name: "ScheduleOneTime",
|
|
HasError: true,
|
|
Params: []Param{NewParam("delaySeconds", "int32")},
|
|
Returns: []Param{NewParam("scheduleID", "string")},
|
|
},
|
|
{
|
|
Name: "CancelSchedule",
|
|
HasError: true,
|
|
Params: []Param{NewParam("scheduleID", "string")},
|
|
Returns: []Param{NewParam("canceled", "bool")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring"))
|
|
Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime"))
|
|
Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule"))
|
|
})
|
|
|
|
It("should handle multiple simple parameters with JSON", func() {
|
|
// All params use JSON - single PTR input
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "MultiParam",
|
|
HasError: true,
|
|
Params: []Param{
|
|
NewParam("name", "string"),
|
|
NewParam("count", "int32"),
|
|
NewParam("enabled", "bool"),
|
|
},
|
|
Returns: []Param{NewParam("result", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
// All methods use request type with JSON protocol
|
|
Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct"))
|
|
// Check for JSON unmarshal (all methods use JSON now)
|
|
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
|
|
// Check that input/output ValueType both use PTR (JSON)
|
|
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
|
|
})
|
|
|
|
It("should use single PTR for mixed simple and complex params", func() {
|
|
// When any param needs JSON, all are bundled into one request struct
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "MixedParam",
|
|
HasError: true,
|
|
Params: []Param{
|
|
NewParam("id", "string"), // simple (PTR for string)
|
|
NewParam("tags", "[]string"), // complex - needs JSON
|
|
},
|
|
Returns: []Param{NewParam("count", "int32")}, // simple
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
// Request type IS needed because of complex param
|
|
Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct"))
|
|
// When using request type, only ONE PTR for input (the JSON request)
|
|
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
|
|
})
|
|
|
|
It("should generate proper JSON tags for complex types", func() {
|
|
// Complex types (structs, slices, maps) need JSON serialization
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Method",
|
|
HasError: true,
|
|
Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON
|
|
Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
// Complex params need request type with JSON tags
|
|
Expect(codeStr).To(ContainSubstring(`json:"inputValue"`))
|
|
// Complex returns need response type with JSON tags
|
|
Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`))
|
|
})
|
|
|
|
It("should include required imports", func() {
|
|
// Service with complex types needs JSON import
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Method",
|
|
HasError: true,
|
|
Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring(`"context"`))
|
|
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
|
|
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
|
})
|
|
|
|
It("should generate binary framing for raw=true methods", func() {
|
|
svc := Service{
|
|
Name: "Stream",
|
|
Permission: "stream",
|
|
Interface: "StreamService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetStream",
|
|
HasError: true,
|
|
Raw: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{
|
|
NewParam("contentType", "string"),
|
|
NewParam("data", "[]byte"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should include encoding/binary import for raw methods
|
|
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
|
|
|
// Should NOT generate a response type for raw methods
|
|
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
|
|
|
|
// Should generate request type (request is still JSON)
|
|
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
|
|
|
|
// Should build binary frame [0x00][4-byte CT len][CT][data]
|
|
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
|
|
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
|
|
|
|
// Should have writeRawError helper
|
|
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
|
|
|
|
// Should use writeRawError instead of writeError for raw methods
|
|
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
|
|
})
|
|
|
|
It("should generate both writeError and writeRawError for mixed services", func() {
|
|
svc := Service{
|
|
Name: "API",
|
|
Permission: "api",
|
|
Interface: "APIService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Call",
|
|
HasError: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{NewParam("response", "string")},
|
|
},
|
|
{
|
|
Name: "CallRaw",
|
|
HasError: true,
|
|
Raw: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{
|
|
NewParam("contentType", "string"),
|
|
NewParam("data", "[]byte"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should have both helpers
|
|
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
|
|
Expect(codeStr).To(ContainSubstring("apiWriteError"))
|
|
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
|
|
|
|
// Should generate response type for non-raw method only
|
|
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
|
|
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
|
|
})
|
|
|
|
It("should always include json import for JSON protocol", func() {
|
|
// All services use JSON protocol, so json import is always needed
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Method",
|
|
Params: []Param{NewParam("count", "int32")},
|
|
Returns: []Param{NewParam("result", "int64")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring(`"context"`))
|
|
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
|
|
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
|
})
|
|
})
|
|
|
|
Describe("toJSONName", func() {
|
|
It("should convert to camelCase matching Rust serde behavior", func() {
|
|
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
|
|
Expect(toJSONName("URI")).To(Equal("uri"))
|
|
Expect(toJSONName("id")).To(Equal("id"))
|
|
Expect(toJSONName("ID")).To(Equal("id"))
|
|
Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
|
|
Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
|
|
Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
|
|
Expect(toJSONName("APIKey")).To(Equal("apiKey"))
|
|
})
|
|
|
|
It("should handle empty string", func() {
|
|
Expect(toJSONName("")).To(Equal(""))
|
|
})
|
|
})
|
|
|
|
Describe("NewParam", func() {
|
|
It("should create param with auto-generated JSON name", func() {
|
|
p := NewParam("MyParam", "string")
|
|
Expect(p.Name).To(Equal("MyParam"))
|
|
Expect(p.Type).To(Equal("string"))
|
|
Expect(p.JSONName).To(Equal("myParam"))
|
|
})
|
|
})
|
|
|
|
Describe("Method.IsOptionPattern", func() {
|
|
It("should return true for (value, exists bool) pattern", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
{Name: "exists", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeTrue())
|
|
})
|
|
|
|
It("should return true for (value, ok bool) pattern", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "int64"},
|
|
{Name: "ok", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeTrue())
|
|
})
|
|
|
|
It("should return true for (value, found bool) pattern", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "data", Type: "[]byte"},
|
|
{Name: "found", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeTrue())
|
|
})
|
|
|
|
It("should be case insensitive for bool name", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
{Name: "EXISTS", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeTrue())
|
|
})
|
|
|
|
It("should return false for single return", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeFalse())
|
|
})
|
|
|
|
It("should return false for more than two returns", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
{Name: "count", Type: "int"},
|
|
{Name: "exists", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeFalse())
|
|
})
|
|
|
|
It("should return false when second return is not bool", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
{Name: "count", Type: "int"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeFalse())
|
|
})
|
|
|
|
It("should return false when bool is not named exists/ok/found", func() {
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
{Name: "success", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeFalse())
|
|
})
|
|
|
|
It("should return false for Has() pattern where first return is bool", func() {
|
|
// Has(key) -> (exists bool) should NOT be treated as Option pattern
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "exists", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeFalse())
|
|
})
|
|
|
|
It("should return false when first return is bool (preserves Has-like methods)", func() {
|
|
// Even with two returns, if first is bool, don't convert to Option<bool>
|
|
m := Method{
|
|
Returns: []Param{
|
|
{Name: "result", Type: "bool"},
|
|
{Name: "exists", Type: "bool"},
|
|
},
|
|
}
|
|
Expect(m.IsOptionPattern()).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("Python type and name helpers", func() {
|
|
Describe("ToPythonType", func() {
|
|
It("should map Go types to Python types", func() {
|
|
Expect(ToPythonType("string")).To(Equal("str"))
|
|
Expect(ToPythonType("int")).To(Equal("int"))
|
|
Expect(ToPythonType("int32")).To(Equal("int"))
|
|
Expect(ToPythonType("int64")).To(Equal("int"))
|
|
Expect(ToPythonType("float32")).To(Equal("float"))
|
|
Expect(ToPythonType("float64")).To(Equal("float"))
|
|
Expect(ToPythonType("bool")).To(Equal("bool"))
|
|
Expect(ToPythonType("[]byte")).To(Equal("bytes"))
|
|
Expect(ToPythonType("unknown")).To(Equal("Any"))
|
|
})
|
|
})
|
|
|
|
Describe("ToSnakeCase", func() {
|
|
It("should convert PascalCase to snake_case", func() {
|
|
Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
|
|
Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
|
|
Expect(ToSnakeCase("simple")).To(Equal("simple"))
|
|
})
|
|
|
|
It("should handle acronyms correctly", func() {
|
|
Expect(ToSnakeCase("ID")).To(Equal("id"))
|
|
Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
|
|
Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
|
|
Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
|
|
Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
|
|
})
|
|
})
|
|
|
|
Describe("Method.PythonFunctionName", func() {
|
|
It("should generate snake_case function name with service prefix", func() {
|
|
m := Method{Name: "GetString"}
|
|
Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
|
|
})
|
|
})
|
|
|
|
Describe("Param.PythonType", func() {
|
|
It("should return Python type for parameter", func() {
|
|
p := NewParam("value", "string")
|
|
Expect(p.PythonType()).To(Equal("str"))
|
|
})
|
|
})
|
|
|
|
Describe("Param.PythonName", func() {
|
|
It("should return snake_case name for parameter", func() {
|
|
p := NewParam("ttlSeconds", "int64")
|
|
Expect(p.PythonName()).To(Equal("ttl_seconds"))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GenerateClientPython", func() {
|
|
It("should generate valid Python code for a simple service", func() {
|
|
svc := Service{
|
|
Name: "SubsonicAPI",
|
|
Permission: "subsonicapi",
|
|
Interface: "SubsonicAPIService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Call",
|
|
HasError: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{NewParam("responseJSON", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for generated header
|
|
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
|
|
|
|
// Check for imports
|
|
Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
|
|
Expect(codeStr).To(ContainSubstring("import extism"))
|
|
Expect(codeStr).To(ContainSubstring("import json"))
|
|
|
|
// Check for exception class
|
|
Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
|
|
|
|
// Check for raw import function
|
|
Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
|
|
Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
|
|
|
|
// Check for wrapper function with type hints
|
|
Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
|
|
|
|
// Check for error handling
|
|
Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
|
|
})
|
|
|
|
It("should generate dataclass for multi-value returns", func() {
|
|
svc := Service{
|
|
Name: "Cache",
|
|
Permission: "cache",
|
|
Interface: "CacheService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetString",
|
|
HasError: true,
|
|
Params: []Param{NewParam("key", "string")},
|
|
Returns: []Param{
|
|
NewParam("value", "string"),
|
|
NewParam("exists", "bool"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for dataclass
|
|
Expect(codeStr).To(ContainSubstring("@dataclass"))
|
|
Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
|
|
Expect(codeStr).To(ContainSubstring("value: str"))
|
|
Expect(codeStr).To(ContainSubstring("exists: bool"))
|
|
|
|
// Check that function returns dataclass
|
|
Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
|
|
Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
|
|
})
|
|
|
|
It("should handle methods with no parameters", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "NoParams",
|
|
HasError: true,
|
|
Returns: []Param{NewParam("result", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Function with no params
|
|
Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
|
|
// Empty request
|
|
Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
|
|
})
|
|
|
|
It("should handle methods with no return values", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "NoReturn",
|
|
HasError: true,
|
|
Params: []Param{NewParam("input", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Function returns None
|
|
Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
|
|
})
|
|
|
|
It("should generate correct Python defaults for different types", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "AllTypes",
|
|
HasError: true,
|
|
Returns: []Param{
|
|
NewParam("strVal", "string"),
|
|
NewParam("intVal", "int64"),
|
|
NewParam("floatVal", "float64"),
|
|
NewParam("boolVal", "bool"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check defaults in response.get() calls
|
|
Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
|
|
Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
|
|
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
|
|
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
|
})
|
|
|
|
It("should generate binary frame parsing for raw methods", func() {
|
|
svc := Service{
|
|
Name: "Stream",
|
|
Permission: "stream",
|
|
Interface: "StreamService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetStream",
|
|
HasError: true,
|
|
Raw: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{
|
|
NewParam("contentType", "string"),
|
|
NewParam("data", "[]byte"),
|
|
},
|
|
Doc: "GetStream returns raw binary stream data.",
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should import Tuple and struct for raw methods
|
|
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
|
|
Expect(codeStr).To(ContainSubstring("import struct"))
|
|
|
|
// Should return Tuple[str, bytes]
|
|
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
|
|
|
|
// Should parse binary frame instead of JSON
|
|
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
|
|
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
|
Expect(codeStr).To(ContainSubstring("struct.unpack"))
|
|
Expect(codeStr).To(ContainSubstring("return content_type, data"))
|
|
|
|
// Should NOT use json.loads for response
|
|
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
|
|
})
|
|
|
|
It("should not import Tuple or struct for non-raw services", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Call",
|
|
HasError: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{NewParam("response", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientPython(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
|
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateGoDoc", func() {
|
|
It("should generate valid doc.go content for multiple services", func() {
|
|
services := []Service{
|
|
{
|
|
Name: "Cache",
|
|
Permission: "cache",
|
|
Interface: "CacheService",
|
|
Doc: "CacheService provides temporary key-value storage with TTL.",
|
|
},
|
|
{
|
|
Name: "Scheduler",
|
|
Permission: "scheduler",
|
|
Interface: "SchedulerService",
|
|
Doc: "SchedulerService manages scheduled tasks.",
|
|
},
|
|
}
|
|
|
|
code, err := GenerateGoDoc(services, "ndpdk")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify it's valid Go code
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for generated header
|
|
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
|
|
|
|
// Check for package declaration
|
|
Expect(codeStr).To(ContainSubstring("package ndpdk"))
|
|
|
|
// Check for package documentation
|
|
Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers"))
|
|
|
|
// Check that services are listed
|
|
Expect(codeStr).To(ContainSubstring("Cache:"))
|
|
Expect(codeStr).To(ContainSubstring("Scheduler:"))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateGoMod", func() {
|
|
It("should generate valid go.mod content", func() {
|
|
code, err := GenerateGoMod()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for module declaration (consolidated PDK path at pdk/go level)
|
|
Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go"))
|
|
// Ensure it's not the old host-specific path
|
|
Expect(codeStr).NotTo(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host"))
|
|
|
|
// Check for Go version
|
|
Expect(codeStr).To(ContainSubstring("go 1.25"))
|
|
|
|
// Check for extism-go-pdk dependency
|
|
Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk"))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateClientGo", func() {
|
|
It("should include errors import when service has methods with errors", func() {
|
|
svc := Service{
|
|
Name: "Cache",
|
|
Permission: "cache",
|
|
Interface: "CacheService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Get",
|
|
HasError: true,
|
|
Params: []Param{NewParam("key", "string")},
|
|
Returns: []Param{NewParam("value", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGo(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify the code is valid Go (can't actually compile without wasip1)
|
|
codeStr := string(code)
|
|
|
|
// Check for errors import when methods have errors
|
|
Expect(codeStr).To(ContainSubstring(`"errors"`))
|
|
Expect(codeStr).To(ContainSubstring("errors.New"))
|
|
})
|
|
|
|
It("should not include errors import when service has no methods with errors", func() {
|
|
svc := Service{
|
|
Name: "Config",
|
|
Permission: "config",
|
|
Interface: "ConfigService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Get",
|
|
HasError: false,
|
|
Params: []Param{NewParam("key", "string")},
|
|
Returns: []Param{NewParam("value", "string"), NewParam("exists", "bool")},
|
|
},
|
|
{
|
|
Name: "List",
|
|
HasError: false,
|
|
Params: []Param{NewParam("prefix", "string")},
|
|
Returns: []Param{NewParam("keys", "[]string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGo(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check that errors is NOT imported when no methods have errors
|
|
Expect(codeStr).NotTo(ContainSubstring(`"errors"`))
|
|
Expect(codeStr).NotTo(ContainSubstring("errors.New"))
|
|
})
|
|
|
|
It("should generate valid Go code structure", func() {
|
|
svc := Service{
|
|
Name: "SubsonicAPI",
|
|
Permission: "subsonicapi",
|
|
Interface: "SubsonicAPIService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Call",
|
|
HasError: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{NewParam("response", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGo(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for generated header
|
|
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
|
|
|
|
// Check for build tag
|
|
Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
|
|
|
|
// Check for package declaration
|
|
Expect(codeStr).To(ContainSubstring("package host"))
|
|
|
|
// Check for wasmimport directive
|
|
Expect(codeStr).To(ContainSubstring("//go:wasmimport extism:host/user"))
|
|
|
|
// Check for PDK import
|
|
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
|
|
})
|
|
|
|
It("should include encoding/binary import for raw methods", func() {
|
|
svc := Service{
|
|
Name: "Stream",
|
|
Permission: "stream",
|
|
Interface: "StreamService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetStream",
|
|
HasError: true,
|
|
Raw: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{
|
|
NewParam("contentType", "string"),
|
|
NewParam("data", "[]byte"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGo(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should include encoding/binary for raw binary frame parsing
|
|
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
|
|
|
// Should NOT generate response type struct for raw methods
|
|
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
|
|
|
|
// Should still generate request type
|
|
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
|
|
|
|
// Should parse binary frame
|
|
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
|
|
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
|
|
|
|
// Should return (string, []byte, error)
|
|
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateClientGoStub", func() {
|
|
It("should generate valid mock code with testify/mock", func() {
|
|
svc := Service{
|
|
Name: "Cache",
|
|
Permission: "cache",
|
|
Interface: "CacheService",
|
|
Doc: "CacheService provides caching capabilities.",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Get",
|
|
Doc: "Get retrieves a value from the cache.",
|
|
Params: []Param{
|
|
{Name: "key", Type: "string"},
|
|
},
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
{Name: "exists", Type: "bool"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGoStub(svc, "ndpdk")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify it's valid Go code
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for build tag (non-WASM)
|
|
Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
|
|
|
|
// Check for package declaration
|
|
Expect(codeStr).To(ContainSubstring("package ndpdk"))
|
|
|
|
// Check for mock comment
|
|
Expect(codeStr).To(ContainSubstring("mock implementations for non-WASM builds"))
|
|
|
|
// Check for testify/mock import
|
|
Expect(codeStr).To(ContainSubstring(`"github.com/stretchr/testify/mock"`))
|
|
|
|
// Check for private mock struct
|
|
Expect(codeStr).To(ContainSubstring("type mockCacheService struct"))
|
|
Expect(codeStr).To(ContainSubstring("mock.Mock"))
|
|
|
|
// Check for exported mock instance
|
|
Expect(codeStr).To(ContainSubstring("var CacheMock = &mockCacheService{}"))
|
|
|
|
// Check for mock method
|
|
Expect(codeStr).To(ContainSubstring("func (m *mockCacheService) Get(key string)"))
|
|
Expect(codeStr).To(ContainSubstring("m.Called(key)"))
|
|
|
|
// Check for wrapper function delegating to mock
|
|
Expect(codeStr).To(ContainSubstring("func CacheGet(key string)"))
|
|
Expect(codeStr).To(ContainSubstring("return CacheMock.Get(key)"))
|
|
|
|
// Stub files should NOT have request/response types (they're not needed)
|
|
Expect(codeStr).NotTo(ContainSubstring("Request struct"))
|
|
Expect(codeStr).NotTo(ContainSubstring("Response struct"))
|
|
})
|
|
|
|
It("should generate correct mock return values for different types", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetString",
|
|
Params: []Param{
|
|
{Name: "key", Type: "string"},
|
|
},
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string"},
|
|
},
|
|
HasError: true,
|
|
},
|
|
{
|
|
Name: "GetInt64",
|
|
Params: []Param{
|
|
{Name: "key", Type: "string"},
|
|
},
|
|
Returns: []Param{
|
|
{Name: "value", Type: "int64"},
|
|
{Name: "exists", Type: "bool"},
|
|
},
|
|
HasError: true,
|
|
},
|
|
{
|
|
Name: "GetBytes",
|
|
Params: []Param{
|
|
{Name: "key", Type: "string"},
|
|
},
|
|
Returns: []Param{
|
|
{Name: "value", Type: "[]byte"},
|
|
},
|
|
HasError: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGoStub(svc, "ndpdk")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify it's valid Go code
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check string return uses args.String(0)
|
|
Expect(codeStr).To(ContainSubstring("args.String(0)"))
|
|
|
|
// Check int64 return uses args.Get(0).(int64)
|
|
Expect(codeStr).To(ContainSubstring("args.Get(0).(int64)"))
|
|
|
|
// Check bool return uses args.Bool(1)
|
|
Expect(codeStr).To(ContainSubstring("args.Bool(1)"))
|
|
|
|
// Check []byte return uses args.Get(0).([]byte)
|
|
Expect(codeStr).To(ContainSubstring("args.Get(0).([]byte)"))
|
|
|
|
// Check error returns use args.Error(N)
|
|
Expect(codeStr).To(ContainSubstring("args.Error("))
|
|
})
|
|
})
|
|
|
|
Describe("Integration", func() {
|
|
It("should generate compilable code from parsed source", func() {
|
|
// This is an integration test that verifies the full pipeline
|
|
src := `package host
|
|
|
|
import "context"
|
|
|
|
// TestService is a test service.
|
|
//nd:hostservice name=Test permission=test
|
|
type TestService interface {
|
|
// DoSomething does something.
|
|
//nd:hostfunc
|
|
DoSomething(ctx context.Context, input string) (output string, err error)
|
|
}
|
|
`
|
|
// Create temporary directory
|
|
tmpDir := GinkgoT().TempDir()
|
|
path := tmpDir + "/test.go"
|
|
err := writeFile(path, src)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Parse
|
|
services, err := ParseDirectory(tmpDir)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(services).To(HaveLen(1))
|
|
|
|
// Generate
|
|
code, err := GenerateHost(services[0], "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Format (validates syntax)
|
|
formatted, err := format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Verify key elements
|
|
codeStr := string(formatted)
|
|
Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
|
|
Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateCapabilityGo", func() {
|
|
It("should generate valid Go code for a non-required capability", func() {
|
|
cap := Capability{
|
|
Name: "metadata",
|
|
Interface: "MetadataAgent",
|
|
Required: false,
|
|
Doc: "MetadataAgent provides metadata retrieval.",
|
|
Methods: []Export{
|
|
{
|
|
Name: "GetArtistBiography",
|
|
ExportName: "nd_get_artist_biography",
|
|
Input: Param{Type: "ArtistInput"},
|
|
Output: Param{Type: "ArtistBiographyOutput"},
|
|
Doc: "Returns artist biography",
|
|
},
|
|
{
|
|
Name: "GetArtistImages",
|
|
ExportName: "nd_get_artist_images",
|
|
Input: Param{Type: "ArtistInput"},
|
|
Output: Param{Type: "ArtistImagesOutput"},
|
|
Doc: "Returns artist images",
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{
|
|
Name: "ArtistInput",
|
|
Fields: []FieldDef{
|
|
{Name: "ID", Type: "string", JSONTag: "id"},
|
|
{Name: "Name", Type: "string", JSONTag: "name"},
|
|
},
|
|
},
|
|
{
|
|
Name: "ArtistBiographyOutput",
|
|
Fields: []FieldDef{
|
|
{Name: "Biography", Type: "string", JSONTag: "biography"},
|
|
},
|
|
},
|
|
{
|
|
Name: "ArtistImagesOutput",
|
|
Fields: []FieldDef{
|
|
{Name: "Images", Type: "[]ImageInfo", JSONTag: "images"},
|
|
},
|
|
},
|
|
{
|
|
Name: "ImageInfo",
|
|
Fields: []FieldDef{
|
|
{Name: "URL", Type: "string", JSONTag: "url"},
|
|
{Name: "Size", Type: "int32", JSONTag: "size"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityGo(cap, "metadata")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for build tag
|
|
Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
|
|
|
|
// Check for package declaration
|
|
Expect(codeStr).To(ContainSubstring("package metadata"))
|
|
|
|
// Check for marker interface (non-required)
|
|
Expect(codeStr).To(ContainSubstring("type Metadata interface{}"))
|
|
|
|
// Check for provider interfaces
|
|
Expect(codeStr).To(ContainSubstring("type ArtistBiographyProvider interface"))
|
|
Expect(codeStr).To(ContainSubstring("type ArtistImagesProvider interface"))
|
|
|
|
// Check for Register function with type assertions
|
|
Expect(codeStr).To(ContainSubstring("func Register(impl Metadata)"))
|
|
Expect(codeStr).To(ContainSubstring("impl.(ArtistBiographyProvider)"))
|
|
|
|
// Check for export wrappers
|
|
Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_get_artist_biography"))
|
|
Expect(codeStr).To(ContainSubstring("func _NdGetArtistBiography()"))
|
|
|
|
// Check for NotImplementedCode handling
|
|
Expect(codeStr).To(ContainSubstring("NotImplementedCode"))
|
|
Expect(codeStr).To(ContainSubstring("return NotImplementedCode"))
|
|
|
|
// Check struct definitions
|
|
Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
|
|
Expect(codeStr).To(ContainSubstring("type ImageInfo struct"))
|
|
})
|
|
|
|
It("should generate valid Go code for a required capability", func() {
|
|
cap := Capability{
|
|
Name: "scrobbler",
|
|
Interface: "Scrobbler",
|
|
Required: true,
|
|
Methods: []Export{
|
|
{
|
|
Name: "IsAuthorized",
|
|
ExportName: "nd_scrobbler_is_authorized",
|
|
Input: Param{Type: "AuthInput"},
|
|
Output: Param{Type: "AuthOutput"},
|
|
},
|
|
{
|
|
Name: "Scrobble",
|
|
ExportName: "nd_scrobbler_scrobble",
|
|
Input: Param{Type: "ScrobbleInput"},
|
|
Output: Param{Type: "ScrobblerOutput"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "AuthInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
|
|
{Name: "AuthOutput", Fields: []FieldDef{{Name: "Authorized", Type: "bool", JSONTag: "authorized"}}},
|
|
{Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
|
|
{Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "Error", Type: "*string", JSONTag: "error", OmitEmpty: true}}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityGo(cap, "scrobbler")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for full interface (required capability)
|
|
Expect(codeStr).To(ContainSubstring("type Scrobbler interface {"))
|
|
Expect(codeStr).To(ContainSubstring("IsAuthorized(AuthInput) (AuthOutput, error)"))
|
|
Expect(codeStr).To(ContainSubstring("Scrobble(ScrobbleInput) (ScrobblerOutput, error)"))
|
|
|
|
// Should NOT have provider interfaces for required capability
|
|
Expect(codeStr).NotTo(ContainSubstring("AuthProvider interface"))
|
|
|
|
// Register should directly assign methods
|
|
Expect(codeStr).To(ContainSubstring("func Register(impl Scrobbler)"))
|
|
Expect(codeStr).To(ContainSubstring("impl.IsAuthorized"))
|
|
})
|
|
|
|
It("should include type aliases and consts", func() {
|
|
cap := Capability{
|
|
Name: "scrobbler",
|
|
Interface: "Scrobbler",
|
|
Required: true,
|
|
Methods: []Export{
|
|
{
|
|
Name: "Scrobble",
|
|
ExportName: "nd_scrobble",
|
|
Input: Param{Type: "ScrobbleInput"},
|
|
Output: Param{Type: "ScrobblerOutput"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
|
|
{Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "ErrorType", Type: "*ScrobblerErrorType", JSONTag: "errorType", OmitEmpty: true}}},
|
|
},
|
|
TypeAliases: []TypeAlias{
|
|
{Name: "ScrobblerErrorType", Type: "string", Doc: "ScrobblerErrorType indicates error handling."},
|
|
},
|
|
Consts: []ConstGroup{
|
|
{
|
|
Type: "ScrobblerErrorType",
|
|
Values: []ConstDef{
|
|
{Name: "ScrobblerErrorNone", Value: `"none"`, Doc: "No error"},
|
|
{Name: "ScrobblerErrorRetry", Value: `"retry"`, Doc: "Retry later"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityGo(cap, "scrobbler")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check type alias
|
|
Expect(codeStr).To(ContainSubstring("type ScrobblerErrorType string"))
|
|
|
|
// Check consts - all consts should have type annotation
|
|
Expect(codeStr).To(ContainSubstring("ScrobblerErrorNone ScrobblerErrorType ="))
|
|
Expect(codeStr).To(ContainSubstring(`"none"`))
|
|
Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType ="))
|
|
Expect(codeStr).To(ContainSubstring(`"retry"`))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateCapabilityGoStub", func() {
|
|
It("should generate valid stub code for non-WASM builds", func() {
|
|
cap := Capability{
|
|
Name: "metadata",
|
|
Interface: "MetadataAgent",
|
|
Required: false,
|
|
Methods: []Export{
|
|
{
|
|
Name: "GetArtistBiography",
|
|
ExportName: "nd_get_artist_biography",
|
|
Input: Param{Type: "ArtistInput"},
|
|
Output: Param{Type: "ArtistBiographyOutput"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
|
{Name: "ArtistBiographyOutput", Fields: []FieldDef{{Name: "Biography", Type: "string", JSONTag: "biography"}}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityGoStub(cap, "metadata")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check for non-WASM build tag
|
|
Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
|
|
|
|
// Check for package declaration
|
|
Expect(codeStr).To(ContainSubstring("package metadata"))
|
|
|
|
// Check for no-op Register
|
|
Expect(codeStr).To(ContainSubstring("func Register(_ Metadata) {}"))
|
|
|
|
// Check struct definitions are present
|
|
Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
|
|
|
|
// Check there are no export wrappers
|
|
Expect(codeStr).NotTo(ContainSubstring("//go:wasmexport"))
|
|
Expect(codeStr).NotTo(ContainSubstring("pdk.InputJSON"))
|
|
})
|
|
})
|
|
|
|
Describe("End-to-end capability generation", func() {
|
|
It("should parse and generate capability code from source", func() {
|
|
src := `package capabilities
|
|
|
|
// Lifecycle provides plugin lifecycle hooks.
|
|
//nd:capability name=lifecycle
|
|
type Lifecycle interface {
|
|
// OnInit is called when the plugin is loaded.
|
|
//nd:export name=nd_on_init
|
|
OnInit(OnInitInput) (OnInitOutput, error)
|
|
}
|
|
|
|
// OnInitInput is the input for OnInit.
|
|
type OnInitInput struct {
|
|
}
|
|
|
|
// OnInitOutput is the output for OnInit.
|
|
type OnInitOutput struct {
|
|
// Error is the error message if initialization failed.
|
|
Error *string ` + "`json:\"error,omitempty\"`" + `
|
|
}
|
|
`
|
|
// Create temporary directory
|
|
tmpDir := GinkgoT().TempDir()
|
|
path := tmpDir + "/lifecycle.go"
|
|
err := writeFile(path, src)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// Parse
|
|
capabilities, err := ParseCapabilities(tmpDir)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(capabilities).To(HaveLen(1))
|
|
|
|
cap := capabilities[0]
|
|
Expect(cap.Name).To(Equal("lifecycle"))
|
|
Expect(cap.Methods).To(HaveLen(1))
|
|
|
|
// Generate WASM code
|
|
code, err := GenerateCapabilityGo(cap, "lifecycle")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_on_init"))
|
|
Expect(codeStr).To(ContainSubstring("type InitProvider interface"))
|
|
|
|
// Generate stub code
|
|
stubCode, err := GenerateCapabilityGoStub(cap, "lifecycle")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
stubStr := string(stubCode)
|
|
Expect(stubStr).To(ContainSubstring("//go:build !wasip1"))
|
|
Expect(stubStr).To(ContainSubstring("func Register(_ Lifecycle) {}"))
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Rust Generation", func() {
|
|
Describe("skipSerializingFunc", func() {
|
|
It("should return Option::is_none for pointer, slice, and map types", func() {
|
|
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
|
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
|
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
|
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
|
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
|
|
})
|
|
|
|
It("should return String::is_empty for string type", func() {
|
|
Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
|
|
})
|
|
|
|
It("should return std::ops::Not::not for bool type", func() {
|
|
Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
|
|
})
|
|
|
|
It("should return is_zero_* functions for numeric types", func() {
|
|
Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
|
|
Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
|
|
Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
|
|
Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
|
|
Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
|
|
Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
|
|
})
|
|
|
|
It("should return Option::is_none for unknown types", func() {
|
|
Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
|
|
})
|
|
})
|
|
|
|
Describe("rustOutputType", func() {
|
|
It("should convert Go primitives to Rust primitives", func() {
|
|
Expect(rustOutputType("bool")).To(Equal("bool"))
|
|
Expect(rustOutputType("string")).To(Equal("String"))
|
|
Expect(rustOutputType("int")).To(Equal("i32"))
|
|
Expect(rustOutputType("int32")).To(Equal("i32"))
|
|
Expect(rustOutputType("int64")).To(Equal("i64"))
|
|
Expect(rustOutputType("float32")).To(Equal("f32"))
|
|
Expect(rustOutputType("float64")).To(Equal("f64"))
|
|
})
|
|
|
|
It("should strip pointer prefix", func() {
|
|
// NOTE: This behavior is incorrect for pointer to primitives.
|
|
// "*string" returns "string" instead of "String", which would generate
|
|
// invalid Rust code. No current capability uses this pattern.
|
|
// See TODO in rustOutputType function.
|
|
Expect(rustOutputType("*string")).To(Equal("string"))
|
|
Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct"))
|
|
})
|
|
|
|
It("should pass through unknown types", func() {
|
|
Expect(rustOutputType("CustomType")).To(Equal("CustomType"))
|
|
Expect(rustOutputType("MyStruct")).To(Equal("MyStruct"))
|
|
})
|
|
})
|
|
|
|
Describe("isPrimitiveRustType", func() {
|
|
It("should return true for primitive Go types", func() {
|
|
Expect(isPrimitiveRustType("bool")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("string")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("int")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("int32")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("int64")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("float32")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("float64")).To(BeTrue())
|
|
})
|
|
|
|
It("should return false for non-primitive types", func() {
|
|
Expect(isPrimitiveRustType("MyStruct")).To(BeFalse())
|
|
Expect(isPrimitiveRustType("CustomType")).To(BeFalse())
|
|
Expect(isPrimitiveRustType("[]string")).To(BeFalse())
|
|
Expect(isPrimitiveRustType("map[string]int")).To(BeFalse())
|
|
})
|
|
|
|
It("should handle pointer types by stripping prefix", func() {
|
|
Expect(isPrimitiveRustType("*string")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("*int64")).To(BeTrue())
|
|
Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("GenerateCapabilityRust", func() {
|
|
It("should generate valid Rust code with primitive output types", func() {
|
|
cap := Capability{
|
|
Name: "test",
|
|
Interface: "TestAgent",
|
|
Required: true,
|
|
SourceFile: "test",
|
|
Methods: []Export{
|
|
{
|
|
Name: "GetBool",
|
|
ExportName: "nd_get_bool",
|
|
Input: Param{Type: "BoolInput"},
|
|
Output: Param{Type: "bool"},
|
|
},
|
|
{
|
|
Name: "GetString",
|
|
ExportName: "nd_get_string",
|
|
Input: Param{Type: "StrInput"},
|
|
Output: Param{Type: "string"},
|
|
},
|
|
{
|
|
Name: "GetInt",
|
|
ExportName: "nd_get_int",
|
|
Input: Param{Type: "IntInput"},
|
|
Output: Param{Type: "int32"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
|
{Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}},
|
|
{Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Check that primitive output types are not prefixed with $crate::
|
|
// The template should use isPrimitiveRust to determine this
|
|
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<bool>>"))
|
|
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<String>>"))
|
|
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<i32>>"))
|
|
|
|
// Verify that primitive output types don't use $crate:: prefix in FnResult
|
|
// The pattern "$crate::test::bool>" would indicate incorrect generation
|
|
Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>"))
|
|
Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>"))
|
|
Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>"))
|
|
})
|
|
|
|
It("should generate valid Rust code with struct output types", func() {
|
|
cap := Capability{
|
|
Name: "metadata",
|
|
Interface: "MetadataAgent",
|
|
Required: true,
|
|
SourceFile: "metadata",
|
|
Methods: []Export{
|
|
{
|
|
Name: "GetArtist",
|
|
ExportName: "nd_get_artist",
|
|
Input: Param{Type: "ArtistInput"},
|
|
Output: Param{Type: "ArtistOutput"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
|
{Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Non-primitive struct types should use $crate:: prefix
|
|
Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput"))
|
|
})
|
|
|
|
It("should generate valid Rust code with pointer output types", func() {
|
|
cap := Capability{
|
|
Name: "test",
|
|
Interface: "TestAgent",
|
|
Required: true,
|
|
SourceFile: "test",
|
|
Methods: []Export{
|
|
{
|
|
Name: "GetOptionalStruct",
|
|
ExportName: "nd_get_optional_struct",
|
|
Input: Param{Type: "Input"},
|
|
Output: Param{Type: "*Output"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
|
{Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Pointer to struct should strip pointer and use struct type with $crate::
|
|
Expect(codeStr).To(ContainSubstring("$crate::test::Output>"))
|
|
// Pointer output types should NOT have Option<> wrapping - Result handles optionality
|
|
Expect(codeStr).NotTo(ContainSubstring("Option<"))
|
|
})
|
|
|
|
It("should include all float types correctly", func() {
|
|
cap := Capability{
|
|
Name: "test",
|
|
Interface: "TestAgent",
|
|
Required: true,
|
|
SourceFile: "test",
|
|
Methods: []Export{
|
|
{
|
|
Name: "GetFloat32",
|
|
ExportName: "nd_get_float32",
|
|
Input: Param{Type: "Input"},
|
|
Output: Param{Type: "float32"},
|
|
},
|
|
{
|
|
Name: "GetFloat64",
|
|
ExportName: "nd_get_float64",
|
|
Input: Param{Type: "Input"},
|
|
Output: Param{Type: "float64"},
|
|
},
|
|
},
|
|
Structs: []StructDef{
|
|
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<f32>>"))
|
|
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<f64>>"))
|
|
})
|
|
})
|
|
|
|
Describe("GenerateClientRust", func() {
|
|
It("should generate Option<T> for (value, exists bool) pattern", func() {
|
|
svc := Service{
|
|
Name: "Config",
|
|
Permission: "config",
|
|
Interface: "ConfigService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Get",
|
|
Params: []Param{
|
|
{Name: "key", Type: "string", JSONName: "key"},
|
|
},
|
|
Returns: []Param{
|
|
{Name: "value", Type: "string", JSONName: "value"},
|
|
{Name: "exists", Type: "bool", JSONName: "exists"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should generate Option<String> return type, not (String, bool)
|
|
Expect(codeStr).To(ContainSubstring("Result<Option<String>, Error>"))
|
|
Expect(codeStr).NotTo(ContainSubstring("Result<(String, bool), Error>"))
|
|
|
|
// Should generate Some/None logic
|
|
Expect(codeStr).To(ContainSubstring("Ok(Some("))
|
|
Expect(codeStr).To(ContainSubstring("Ok(None)"))
|
|
})
|
|
|
|
It("should generate tuple for non-option multi-return", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Permission: "test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetStats",
|
|
Returns: []Param{
|
|
{Name: "count", Type: "int64", JSONName: "count"},
|
|
{Name: "size", Type: "int64", JSONName: "size"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should generate tuple return type
|
|
Expect(codeStr).To(ContainSubstring("Result<(i64, i64), Error>"))
|
|
Expect(codeStr).NotTo(ContainSubstring("Option<"))
|
|
})
|
|
|
|
It("should NOT generate Option for Has() pattern where first return is bool", func() {
|
|
svc := Service{
|
|
Name: "Cache",
|
|
Permission: "cache",
|
|
Interface: "CacheService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Has",
|
|
Params: []Param{
|
|
{Name: "key", Type: "string", JSONName: "key"},
|
|
},
|
|
Returns: []Param{
|
|
{Name: "exists", Type: "bool", JSONName: "exists"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should generate simple bool return, not Option
|
|
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
|
|
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
|
})
|
|
|
|
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
|
svc := Service{
|
|
Name: "Stream",
|
|
Permission: "stream",
|
|
Interface: "StreamService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetStream",
|
|
HasError: true,
|
|
Raw: true,
|
|
Params: []Param{NewParam("uri", "string")},
|
|
Returns: []Param{
|
|
NewParam("contentType", "string"),
|
|
NewParam("data", "[]byte"),
|
|
},
|
|
Doc: "GetStream returns raw binary stream data.",
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
|
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
|
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
|
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
|
|
|
// Should NOT generate response type for raw methods
|
|
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
|
|
|
// Should generate request type (request is still JSON)
|
|
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
|
|
|
// Should return Result<(String, Vec<u8>), Error>
|
|
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
|
|
|
// Should parse binary frame
|
|
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
|
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
|
|
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
|
|
})
|
|
})
|
|
})
|
|
|
|
func writeFile(path, content string) error {
|
|
return os.WriteFile(path, []byte(content), 0600)
|
|
}
|