mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02:00
* fix(plugins): align the Python HTTP example with the repo's host-call pattern
Bind http_send with raw memory offsets like nowplaying-py does, drop
guards for fields the host always sends, and document how plugins
without a PDK call host services and which built-in HTTP APIs are
disabled.
* docs(plugins): document the private-address rules for HTTP requiredHosts
Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can.
* docs(plugins): document the private-address rules for requiredHosts
Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can, for both
HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper.
* feat(plugins): derive Default for Rust host service structs
The ndpgen client.rs template now adds Default to the derive list of host
service structs, as the capability and shared types templates already do.
Plugin authors can now set only the fields they need, for example
HTTPRequest { method, url, ..Default::default() }. The webhook-rs and
discord-rich-presence-rs examples use this form now. The golden files and
the generated nd-pdk-host crate are updated to match.
* feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK
Navidrome no longer enables extism's http_request host function, so a
request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small
deprecation table and writes a Deprecated: paragraph for the listed extism
functions, in both the WASM wrapper and the native stub. Linters and IDEs
now point plugin authors to host.HTTPSend. The PDK example tests used to
teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock.
* docs(plugins): correct requiredHosts rules for websocket and private addresses
Two statements in the plugin docs did not match the code.
The WebSocket section claimed requiredHosts behaves like HTTP. It does not:
host_httpclient.go only consults the allowlist when the list is non-empty and
otherwise falls back to allowing public addresses, while host_websocket.go
always calls isHostInAllowlist, so an absent list blocks every connection.
The HTTP section claimed a named host can never reach a private address.
checkPrivateDial scans the whole requiredHosts list, so a named host does
reach a private address when the same list also holds a covering IP or CIDR.
Reworded both, plus the matching requiredHosts descriptions in
manifest-schema.json, and regenerated manifest_gen.go.
1825 lines
59 KiB
Go
1825 lines
59 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 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"`))
|
|
})
|
|
|
|
It("imports the shared types package when a method references types directly", func() {
|
|
svc := Service{
|
|
Name: "Matcher",
|
|
Interface: "MatcherService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "MatchSongs",
|
|
HasError: true,
|
|
Params: []Param{NewParam("songs", "[]types.SongRef")},
|
|
Returns: []Param{NewParam("results", "[]*types.Track")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`))
|
|
Expect(codeStr).To(ContainSubstring("Songs []types.SongRef"))
|
|
})
|
|
|
|
It("does not import the shared types package when no method references types", func() {
|
|
svc := Service{
|
|
Name: "Test",
|
|
Interface: "TestService",
|
|
Methods: []Method{
|
|
{Name: "Method", Params: []Param{NewParam("count", "int32")}},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateHost(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`))
|
|
})
|
|
})
|
|
|
|
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("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("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"))
|
|
})
|
|
|
|
})
|
|
|
|
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)"))
|
|
|
|
// []byte is nil-able, so it gets a guarded extraction
|
|
Expect(codeStr).To(ContainSubstring("var r0 []byte"))
|
|
Expect(codeStr).To(ContainSubstring("r0 = v.([]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"`))
|
|
})
|
|
|
|
It("emits a deprecated alias and types import for a shared-aliased capability", func() {
|
|
cap := Capability{
|
|
Name: "scrobbler",
|
|
Interface: "Scrobbler",
|
|
Required: true,
|
|
Methods: []Export{{
|
|
Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
|
|
Input: Param{Name: "input", Type: "NowPlayingRequest"},
|
|
}},
|
|
Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
|
|
{Name: "Track", Type: "TrackInfo", JSONTag: "track"},
|
|
}}},
|
|
SharedAliases: []SharedAlias{{
|
|
Name: "TrackInfo", Target: "types.TrackInfo",
|
|
Doc: "Deprecated: use types.TrackInfo.",
|
|
Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}},
|
|
}},
|
|
}
|
|
code, err := GenerateCapabilityGo(cap, "scrobbler")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
|
|
Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo."))
|
|
Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo"))
|
|
Expect(out).NotTo(ContainSubstring("type TrackInfo struct"))
|
|
})
|
|
|
|
It("emits the types import for a direct types.X field with no deprecated alias", func() {
|
|
cap := Capability{
|
|
Name: "scrobbler",
|
|
Interface: "Scrobbler",
|
|
Required: true,
|
|
Methods: []Export{{
|
|
Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
|
|
Input: Param{Name: "input", Type: "NowPlayingRequest"},
|
|
}},
|
|
Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
|
|
{Name: "Song", Type: "types.SongRef", JSONTag: "song"},
|
|
}}},
|
|
// No SharedAliases: the field references the canonical type directly.
|
|
}
|
|
code, err := GenerateCapabilityGo(cap, "scrobbler")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
|
|
Expect(out).To(ContainSubstring("types.SongRef"))
|
|
})
|
|
|
|
It("emits the types import for a direct types.X method input/output", func() {
|
|
cap := Capability{
|
|
Name: "demo",
|
|
Interface: "Demo",
|
|
Required: true,
|
|
Methods: []Export{{
|
|
Name: "Lookup", ExportName: "nd_demo_lookup",
|
|
Input: Param{Name: "input", Type: "types.SongRef"},
|
|
Output: Param{Name: "output", Type: "types.SongRef"},
|
|
}},
|
|
// No structs, no aliases: the method signature references the shared type directly.
|
|
}
|
|
code, err := GenerateCapabilityGo(cap, "demo")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
|
|
Expect(out).To(ContainSubstring("types.SongRef"))
|
|
})
|
|
})
|
|
|
|
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("Shared Types Generation", func() {
|
|
It("emits a Rust types crate root with serde derives", func() {
|
|
structs := []StructDef{
|
|
{Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{
|
|
{Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true},
|
|
{Name: "Name", Type: "string", JSONTag: "name"},
|
|
}},
|
|
}
|
|
code, err := GenerateSharedTypesRust(structs)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};"))
|
|
Expect(out).To(ContainSubstring("pub struct ArtistRef {"))
|
|
Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`))
|
|
Expect(out).To(ContainSubstring("pub name: String,"))
|
|
})
|
|
|
|
It("emits a flat Go types package with no imports", func() {
|
|
structs := []StructDef{
|
|
{Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{
|
|
{Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true},
|
|
{Name: "Name", Type: "string", JSONTag: "name"},
|
|
}},
|
|
}
|
|
code, err := GenerateSharedTypesGo(structs, "types")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
Expect(out).To(ContainSubstring("package types"))
|
|
Expect(out).To(ContainSubstring("type ArtistRef struct {"))
|
|
Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`"))
|
|
Expect(out).To(ContainSubstring("Name string `json:\"name\"`"))
|
|
Expect(out).NotTo(ContainSubstring("import"))
|
|
})
|
|
|
|
It("emits base64 serde for Vec<u8> fields in the Rust types crate", func() {
|
|
structs := []StructDef{
|
|
{Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{
|
|
{Name: "Data", Type: "[]byte", JSONTag: "data"},
|
|
}},
|
|
}
|
|
code, err := GenerateSharedTypesRust(structs)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
Expect(out).To(ContainSubstring("mod base64_bytes"))
|
|
Expect(out).To(ContainSubstring("use base64::Engine as _"))
|
|
Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Rust Generation", func() {
|
|
Describe("skipSerializingFunc", func() {
|
|
It("should return Option::is_none for pointer types", func() {
|
|
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
|
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
|
})
|
|
|
|
It("should return the matching emptiness predicate for slice and map types", func() {
|
|
// The predicate must match the rendered Rust type: []T -> Vec<T>, map[K]V -> HashMap<K,V>.
|
|
Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty"))
|
|
Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty"))
|
|
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
|
|
})
|
|
|
|
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("translates a shared type used directly as a method input/output", func() {
|
|
cap := Capability{
|
|
Name: "demo", Interface: "Demo", Required: true,
|
|
Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo",
|
|
Input: Param{Name: "input", Type: "types.SongRef"},
|
|
Output: Param{Name: "output", Type: "types.SongRef"}}},
|
|
// No structs, no aliases: the method signature references the shared type directly.
|
|
}
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
// The shared type must resolve to the canonical crate path, not pass through
|
|
// as the invalid Go selector `types.SongRef`.
|
|
Expect(out).To(ContainSubstring("nd_pdk_types::SongRef"))
|
|
Expect(out).NotTo(ContainSubstring("types.SongRef"))
|
|
})
|
|
|
|
It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() {
|
|
cap := Capability{
|
|
Name: "demo", Interface: "Demo", Required: true,
|
|
Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo",
|
|
Input: Param{Name: "input", Type: "types.SongRef"},
|
|
Output: Param{Name: "output", Type: "types.SongRef"}}},
|
|
}
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
// Inside the export macro (expanded in the downstream plugin crate, which depends
|
|
// on the umbrella nd-pdk only), the shared type must be reachable via $crate, not
|
|
// by naming the transitive nd_pdk_types crate directly.
|
|
Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>"))
|
|
Expect(out).NotTo(ContainSubstring("extism_pdk::Json<nd_pdk_types::SongRef>"))
|
|
})
|
|
|
|
It("emits a deprecated Rust type alias for shared types", func() {
|
|
cap := Capability{
|
|
Name: "scrobbler", Interface: "Scrobbler", Required: true,
|
|
Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
|
|
Input: Param{Name: "input", Type: "NowPlayingRequest"}}},
|
|
Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
|
|
{Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}},
|
|
SharedAliases: []SharedAlias{{
|
|
Name: "TrackInfo", Target: "types.TrackInfo",
|
|
Doc: "Deprecated: use types.TrackInfo.",
|
|
}},
|
|
}
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
// Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`);
|
|
// the alias target stays the real crate path so it resolves inside nd-pdk-capabilities.
|
|
Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`))
|
|
Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;"))
|
|
})
|
|
|
|
It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() {
|
|
// Wrapper has a field whose type is only in SharedAliases, not Structs.
|
|
// The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical
|
|
// path), not as the local deprecated alias and not as serde_json::Value.
|
|
cap := Capability{
|
|
Name: "test", Interface: "TestAgent", Required: true,
|
|
Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit",
|
|
Input: Param{Name: "req", Type: "Wrapper"}}},
|
|
Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{
|
|
{Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}},
|
|
SharedAliases: []SharedAlias{{
|
|
Name: "TrackInfo", Target: "types.TrackInfo",
|
|
Doc: "Deprecated: use types.TrackInfo.",
|
|
}},
|
|
}
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
// Field must use the canonical nd_pdk_types:: path, not the local alias.
|
|
Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo"))
|
|
Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value"))
|
|
Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,"))
|
|
})
|
|
|
|
It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() {
|
|
// The capability references the shared type by its canonical qualified
|
|
// name (types.Track) while the deprecated alias keeps the old name.
|
|
cap := Capability{
|
|
Name: "test", Interface: "TestAgent", Required: true,
|
|
Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit",
|
|
Input: Param{Name: "req", Type: "Wrapper"}}},
|
|
Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{
|
|
{Name: "Track", Type: "types.Track", JSONTag: "track"}}}},
|
|
SharedAliases: []SharedAlias{{
|
|
Name: "TrackInfo", Target: "types.Track",
|
|
Doc: "Deprecated: use types.Track.",
|
|
}},
|
|
}
|
|
code, err := GenerateCapabilityRust(cap)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
// Field uses the canonical qualified path (resolved from the types. prefix).
|
|
Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,"))
|
|
// The deprecated alias is still re-exported under its kept name.
|
|
Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;"))
|
|
Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value"))
|
|
})
|
|
|
|
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 base64 serde for Vec<u8> fields", func() {
|
|
svc := Service{
|
|
Name: "Codec",
|
|
Permission: "codec",
|
|
Interface: "CodecService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Encode",
|
|
HasError: true,
|
|
Params: []Param{NewParam("data", "[]byte")},
|
|
Returns: []Param{NewParam("result", "[]byte")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
// Should generate base64_bytes serde module
|
|
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
|
|
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
|
|
|
|
// Should add serde(with = "base64_bytes") on Vec<u8> fields
|
|
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
|
|
})
|
|
|
|
It("resolves a shared alias used in a method param/return to its canonical crate path", func() {
|
|
svc := Service{
|
|
Name: "Matcher",
|
|
Permission: "matcher",
|
|
Interface: "MatcherService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "MatchSongs",
|
|
HasError: true,
|
|
Params: []Param{NewParam("query", "string")},
|
|
// Return uses the deprecated alias name directly.
|
|
Returns: []Param{NewParam("matches", "[]Track")},
|
|
},
|
|
},
|
|
SharedAliases: []SharedAlias{{
|
|
Name: "Track", Target: "types.Track",
|
|
Def: StructDef{Name: "Track", Fields: []FieldDef{
|
|
{Name: "Title", Type: "string", JSONTag: "title"}}},
|
|
}},
|
|
}
|
|
|
|
code, err := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
out := string(code)
|
|
// The alias must resolve to the shared crate type; a bare `Track` is undefined
|
|
// in nd-pdk-host and would not compile.
|
|
Expect(out).To(ContainSubstring("nd_pdk_types::Track"))
|
|
Expect(out).NotTo(ContainSubstring("Vec<Track>"))
|
|
})
|
|
|
|
It("should not generate base64 module when no byte fields", 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 := GenerateClientRust(svc)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
|
|
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
|
|
Expect(codeStr).NotTo(ContainSubstring("use base64"))
|
|
})
|
|
})
|
|
|
|
Describe("nil-safe mock accessors", func() {
|
|
Describe("GenerateClientGoStub", func() {
|
|
It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() {
|
|
svc := Service{
|
|
Name: "Paged",
|
|
Interface: "PagedService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "GetPage",
|
|
HasError: true,
|
|
Params: []Param{NewParam("query", "string")},
|
|
Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")},
|
|
},
|
|
{
|
|
Name: "GetLabels",
|
|
HasError: true,
|
|
Params: []Param{NewParam("id", "string")},
|
|
Returns: []Param{NewParam("labels", "map[string]string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGoStub(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring("var r0 []Item"))
|
|
Expect(codeStr).To(ContainSubstring("var r1 *PageOptions"))
|
|
Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {"))
|
|
Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)"))
|
|
Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)"))
|
|
Expect(codeStr).To(ContainSubstring("var r0 map[string]string"))
|
|
Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)"))
|
|
Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)"))
|
|
})
|
|
|
|
It("keeps non-nilable returns as inline accessors", func() {
|
|
svc := Service{
|
|
Name: "Counter",
|
|
Interface: "CounterService",
|
|
Methods: []Method{
|
|
{
|
|
Name: "Count",
|
|
HasError: true,
|
|
Params: []Param{NewParam("id", "string")},
|
|
Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GenerateClientGoStub(svc, "host")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)"))
|
|
Expect(codeStr).NotTo(ContainSubstring("var r0"))
|
|
})
|
|
})
|
|
|
|
Describe("GeneratePDKGoStub", func() {
|
|
It("guards pointer and slice returns", func() {
|
|
symbols := &PDKSymbols{
|
|
Functions: []PDKFunc{
|
|
{
|
|
Name: "NewHTTPRequest",
|
|
Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}},
|
|
Returns: []PDKReturn{{Type: "*HTTPRequest"}},
|
|
},
|
|
{
|
|
Name: "Input",
|
|
Returns: []PDKReturn{{Type: "[]byte"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
code, err := GeneratePDKGoStub(symbols)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
codeStr := string(code)
|
|
Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest"))
|
|
Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)"))
|
|
Expect(codeStr).To(ContainSubstring("var r0 []byte"))
|
|
Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)"))
|
|
})
|
|
})
|
|
|
|
Describe("Deprecated PDK functions", func() {
|
|
symbols := &PDKSymbols{
|
|
Functions: []PDKFunc{
|
|
{
|
|
Name: "NewHTTPRequest",
|
|
Doc: "NewHTTPRequest returns a new `HTTPRequest`.",
|
|
Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}},
|
|
Returns: []PDKReturn{{Type: "*HTTPRequest"}},
|
|
Deprecated: "Use host.HTTPSend instead.",
|
|
},
|
|
},
|
|
}
|
|
|
|
DescribeTable("emits a Deprecated paragraph after the doc line",
|
|
func(generate func(*PDKSymbols) ([]byte, error)) {
|
|
code, err := generate(symbols)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = format.Source(code)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(code)).To(ContainSubstring(
|
|
"// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.\n//\n// Deprecated: Use host.HTTPSend instead.\nfunc NewHTTPRequest("))
|
|
},
|
|
Entry("WASM wrapper", GeneratePDKGo),
|
|
Entry("native stub", GeneratePDKGoStub),
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
func writeFile(path, content string) error {
|
|
return os.WriteFile(path, []byte(content), 0600)
|
|
}
|